diff --git a/src/presentation/cli/controllers/register/handler.rs b/src/presentation/cli/controllers/register/handler.rs index 36ddc2f4..1617094d 100644 --- a/src/presentation/cli/controllers/register/handler.rs +++ b/src/presentation/cli/controllers/register/handler.rs @@ -14,6 +14,10 @@ use crate::domain::environment::name::EnvironmentName; use crate::domain::environment::repository::EnvironmentRepository; use crate::domain::environment::state::Provisioned; use crate::domain::environment::Environment; +use crate::presentation::cli::input::cli::OutputFormat; +use crate::presentation::cli::views::commands::register::{ + JsonView, RegisterDetailsData, TextView, +}; use crate::presentation::cli::views::progress::ProgressReporter; use crate::presentation::cli::views::UserOutput; use crate::shared::clock::Clock; @@ -105,6 +109,7 @@ impl RegisterCommandController { /// * `environment_name` - The name of the environment to register the instance with /// * `instance_ip_str` - The IP address string of the existing instance /// * `ssh_port` - Optional SSH port (overrides environment config if provided) + /// * `output_format` - Output format (text or JSON) /// /// # Errors /// @@ -119,6 +124,7 @@ impl RegisterCommandController { environment_name: &str, instance_ip_str: &str, ssh_port: Option, + output_format: OutputFormat, ) -> Result, RegisterSubcommandError> { let (env_name, instance_ip) = self.validate_input(environment_name, instance_ip_str)?; @@ -128,7 +134,7 @@ impl RegisterCommandController { .register_instance(&handler, &env_name, instance_ip, ssh_port) .await?; - self.complete_workflow(environment_name)?; + self.complete_workflow(&provisioned, output_format)?; Ok(provisioned) } @@ -206,12 +212,23 @@ impl RegisterCommandController { } /// Complete the workflow with success message + /// + /// Dispatches to `TextView` or `JsonView` based on `output_format`. #[allow(clippy::result_large_err)] - fn complete_workflow(&mut self, environment_name: &str) -> Result<(), RegisterSubcommandError> { - self.progress.complete(&format!( - "Instance registered successfully with environment '{environment_name}'" - ))?; - + fn complete_workflow( + &mut self, + provisioned: &Environment, + output_format: OutputFormat, + ) -> Result<(), RegisterSubcommandError> { + let data = RegisterDetailsData::from_environment(provisioned); + match output_format { + OutputFormat::Text => { + self.progress.complete(&TextView::render(&data))?; + } + OutputFormat::Json => { + self.progress.result(&JsonView::render(&data))?; + } + } Ok(()) } } diff --git a/src/presentation/cli/dispatch/router.rs b/src/presentation/cli/dispatch/router.rs index ce657dbb..fc17013f 100644 --- a/src/presentation/cli/dispatch/router.rs +++ b/src/presentation/cli/dispatch/router.rs @@ -171,10 +171,11 @@ pub async fn route_command( instance_ip, ssh_port, } => { + let output_format = context.output_format(); context .container() .create_register_controller() - .execute(&environment, &instance_ip, ssh_port) + .execute(&environment, &instance_ip, ssh_port, output_format) .await?; Ok(()) } diff --git a/src/presentation/cli/views/commands/mod.rs b/src/presentation/cli/views/commands/mod.rs index d7c2089d..b350ef9e 100644 --- a/src/presentation/cli/views/commands/mod.rs +++ b/src/presentation/cli/views/commands/mod.rs @@ -10,6 +10,7 @@ pub mod destroy; pub mod list; pub mod provision; pub mod purge; +pub mod register; pub mod release; pub mod render; pub mod run; diff --git a/src/presentation/cli/views/commands/register/mod.rs b/src/presentation/cli/views/commands/register/mod.rs new file mode 100644 index 00000000..a6eee813 --- /dev/null +++ b/src/presentation/cli/views/commands/register/mod.rs @@ -0,0 +1,52 @@ +//! Views for Register Command +//! +//! This module contains view components for rendering register command output. +//! +//! # Architecture +//! +//! This module follows the Strategy Pattern for rendering: +//! - `RegisterDetailsData`: The data DTO passed to all views +//! - `TextView`: Renders human-readable text output +//! - `JsonView`: Renders machine-readable JSON output +//! +//! # Structure +//! +//! - `view_data/`: Data structures (DTOs) passed to views +//! - `register_details.rs`: Main DTO with register result data +//! - `views/`: View rendering implementations +//! - `text_view.rs`: Human-readable text rendering +//! - `json_view.rs`: Machine-readable JSON rendering +//! +//! # SOLID Principles +//! +//! - **Single Responsibility**: Each view has one job - render in its format +//! - **Open/Closed**: Add new formats by creating new view files, not modifying existing ones +//! - **Strategy Pattern**: Different rendering strategies for the same data +//! +//! # Adding New Formats +//! +//! To add a new output format (e.g., XML, YAML, CSV): +//! 1. Create a new file in `views/`: `xml_view.rs`, `yaml_view.rs`, etc. +//! 2. Implement the view with `render(data: &RegisterDetailsData) -> String` +//! 3. Export it from this module +//! 4. No need to modify existing views or the DTO + +pub mod view_data { + pub mod register_details; + + // Re-export main types for convenience + pub use register_details::RegisterDetailsData; +} + +pub mod views { + pub mod json_view; + pub mod text_view; + + // Re-export views for convenience + pub use json_view::JsonView; + pub use text_view::TextView; +} + +// Re-export at module root for convenience +pub use view_data::RegisterDetailsData; +pub use views::{JsonView, TextView}; diff --git a/src/presentation/cli/views/commands/register/view_data/register_details.rs b/src/presentation/cli/views/commands/register/view_data/register_details.rs new file mode 100644 index 00000000..1e10fc80 --- /dev/null +++ b/src/presentation/cli/views/commands/register/view_data/register_details.rs @@ -0,0 +1,128 @@ +//! Register Details Data Transfer Object +//! +//! This module contains the presentation DTO for register command details. +//! It serves as the data structure passed to view renderers (`TextView`, `JsonView`, etc.). +//! +//! # Architecture +//! +//! This follows the Strategy Pattern where: +//! - This DTO is the data passed to all rendering strategies +//! - Different views (`TextView`, `JsonView`) consume this data +//! - Adding new formats doesn't modify this DTO or existing views +//! +//! # SOLID Principles +//! +//! - **Single Responsibility**: This file only defines the data structure +//! - **Open/Closed**: New formats extend by adding views, not modifying this +//! - **Separation of Concerns**: Data definition separate from rendering logic + +use serde::Serialize; + +use crate::domain::environment::state::Provisioned; +use crate::domain::environment::Environment; + +/// Register details data for rendering +/// +/// This struct holds all the data needed to render register command +/// information for display to the user. It is consumed by view renderers +/// (`TextView`, `JsonView`) which format it according to their specific output format. +/// +/// # Design +/// +/// This is a presentation layer DTO (Data Transfer Object) that: +/// - Decouples application types from view formatting +/// - Provides a stable interface for multiple view strategies +/// - Contains all fields needed for any output format +/// +/// # Named Constructor +/// +/// `RegisterDetailsData` is built from an `Environment` since +/// the register command handler returns the provisioned environment on success. +/// `registered: true` is always set because this DTO is only constructed on +/// the success path. +#[derive(Debug, Clone, PartialEq, Serialize)] +pub struct RegisterDetailsData { + /// Name of the environment that was registered + pub environment_name: String, + /// IP address of the registered instance (empty string if unknown) + pub instance_ip: String, + /// SSH port of the registered instance + pub ssh_port: u16, + /// Always `true` when the command exits successfully + pub registered: bool, +} + +impl RegisterDetailsData { + /// Construct a `RegisterDetailsData` from a provisioned environment + /// + /// This named constructor always sets `registered: true` because it is only + /// called on the success path — register failures result in an error return, + /// never a `RegisterDetailsData`. + /// + /// # Arguments + /// + /// * `env` - The provisioned environment returned by the register command handler + /// + /// # Examples + /// + /// ```rust,ignore + /// use torrust_tracker_deployer_lib::presentation::cli::views::commands::register::RegisterDetailsData; + /// + /// // Built from a provisioned environment in the success path + /// let data = RegisterDetailsData::from_environment(&provisioned_env); + /// + /// assert!(data.registered); + /// assert_eq!(data.ssh_port, 22); + /// ``` + #[must_use] + pub fn from_environment(env: &Environment) -> Self { + Self { + environment_name: env.name().to_string(), + instance_ip: env + .instance_ip() + .map_or_else(String::new, |ip| ip.to_string()), + ssh_port: env.ssh_port(), + registered: true, + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn it_should_always_set_registered_to_true() { + // The constructor only produces RegisterDetailsData on the success path. + // We verify the field directly since we cannot construct Environment + // without full infrastructure in a unit test. + let data = RegisterDetailsData { + environment_name: "test-env".to_string(), + instance_ip: "192.168.1.1".to_string(), + ssh_port: 22, + registered: true, + }; + + assert!( + data.registered, + "registered should always be true on success path" + ); + } + + #[test] + fn it_should_store_all_fields() { + // Arrange + let data = RegisterDetailsData { + environment_name: "my-env".to_string(), + instance_ip: "10.0.0.1".to_string(), + ssh_port: 2222, + registered: true, + }; + + // Assert + assert_eq!(data.environment_name, "my-env"); + assert_eq!(data.instance_ip, "10.0.0.1"); + assert_eq!(data.ssh_port, 2222); + assert!(data.registered); + } +} diff --git a/src/presentation/cli/views/commands/register/views/json_view.rs b/src/presentation/cli/views/commands/register/views/json_view.rs new file mode 100644 index 00000000..1d2560e1 --- /dev/null +++ b/src/presentation/cli/views/commands/register/views/json_view.rs @@ -0,0 +1,206 @@ +//! JSON View for Register Command +//! +//! This module provides JSON-based rendering for the register command. +//! It follows the Strategy Pattern, providing a machine-readable output format +//! for the same underlying data (`RegisterDetailsData` DTO). +//! +//! # Design +//! +//! The `JsonView` serializes register result information to JSON using `serde_json`. +//! The output includes the environment name, instance IP, SSH port, and a boolean +//! confirming the registration. + +use crate::presentation::cli::views::commands::register::RegisterDetailsData; + +/// View for rendering register details as JSON +/// +/// This view provides machine-readable JSON output for automation workflows +/// and AI agents. It serializes the register details without any transformations, +/// preserving all field names and structure from the DTO. +/// +/// # Examples +/// +/// ```rust +/// use torrust_tracker_deployer_lib::presentation::cli::views::commands::register::{ +/// RegisterDetailsData, JsonView, +/// }; +/// +/// let data = RegisterDetailsData { +/// environment_name: "my-env".to_string(), +/// instance_ip: "192.168.1.100".to_string(), +/// ssh_port: 22, +/// registered: true, +/// }; +/// +/// let output = JsonView::render(&data); +/// +/// // Verify it's valid JSON +/// let parsed: serde_json::Value = serde_json::from_str(&output).unwrap(); +/// assert_eq!(parsed["environment_name"], "my-env"); +/// assert_eq!(parsed["instance_ip"], "192.168.1.100"); +/// assert_eq!(parsed["ssh_port"], 22); +/// assert_eq!(parsed["registered"], true); +/// ``` +pub struct JsonView; + +impl JsonView { + /// Render register details as JSON + /// + /// Serializes the register details to pretty-printed JSON format. + /// The JSON structure matches the DTO structure exactly: + /// - `environment_name`: Name of the registered environment + /// - `instance_ip`: IP address of the registered instance + /// - `ssh_port`: SSH port of the registered instance + /// - `registered`: Always `true` on success + /// + /// # Arguments + /// + /// * `data` - Register details to render + /// + /// # Returns + /// + /// A JSON string containing the serialized register details. + /// If serialization fails (which should never happen with valid data), + /// returns an error JSON object with the serialization error message. + /// + /// # Examples + /// + /// ```rust + /// use torrust_tracker_deployer_lib::presentation::cli::views::commands::register::{ + /// RegisterDetailsData, JsonView, + /// }; + /// + /// let data = RegisterDetailsData { + /// environment_name: "prod-tracker".to_string(), + /// instance_ip: "10.0.0.1".to_string(), + /// ssh_port: 2222, + /// registered: true, + /// }; + /// + /// let json = JsonView::render(&data); + /// + /// assert!(json.contains("\"environment_name\": \"prod-tracker\"")); + /// assert!(json.contains("\"instance_ip\": \"10.0.0.1\"")); + /// assert!(json.contains("\"ssh_port\": 2222")); + /// assert!(json.contains("\"registered\": true")); + /// ``` + #[must_use] + pub fn render(data: &RegisterDetailsData) -> String { + serde_json::to_string_pretty(data).unwrap_or_else(|e| { + format!( + r#"{{ + "error": "Failed to serialize register details", + "message": "{e}" +}}"# + ) + }) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn create_test_data() -> RegisterDetailsData { + RegisterDetailsData { + environment_name: "test-env".to_string(), + instance_ip: "192.168.1.100".to_string(), + ssh_port: 22, + registered: true, + } + } + + #[test] + fn it_should_render_valid_json() { + // Arrange + let data = create_test_data(); + + // Act + let json = JsonView::render(&data); + + // Assert + let parsed: serde_json::Value = + serde_json::from_str(&json).expect("Should produce valid JSON"); + assert_eq!(parsed["environment_name"], "test-env"); + assert_eq!(parsed["instance_ip"], "192.168.1.100"); + assert_eq!(parsed["ssh_port"], 22); + assert_eq!(parsed["registered"], true); + } + + #[test] + fn it_should_include_environment_name_field() { + // Arrange + let data = RegisterDetailsData { + environment_name: "my-env".to_string(), + instance_ip: "10.0.0.1".to_string(), + ssh_port: 22, + registered: true, + }; + + // Act + let json = JsonView::render(&data); + + // Assert + assert!( + json.contains("\"environment_name\": \"my-env\""), + "JSON should contain environment_name field" + ); + } + + #[test] + fn it_should_include_instance_ip_field() { + // Arrange + let data = create_test_data(); + + // Act + let json = JsonView::render(&data); + + // Assert + assert!( + json.contains("\"instance_ip\": \"192.168.1.100\""), + "JSON should contain instance_ip field" + ); + } + + #[test] + fn it_should_include_ssh_port_field() { + // Arrange + let data = create_test_data(); + + // Act + let json = JsonView::render(&data); + + // Assert + assert!( + json.contains("\"ssh_port\": 22"), + "JSON should contain ssh_port field" + ); + } + + #[test] + fn it_should_include_registered_true_field() { + // Arrange + let data = create_test_data(); + + // Act + let json = JsonView::render(&data); + + // Assert + assert!( + json.contains("\"registered\": true"), + "JSON should contain registered: true" + ); + } + + #[test] + fn it_should_produce_pretty_printed_json() { + // Arrange + let data = create_test_data(); + + // Act + let json = JsonView::render(&data); + + // Assert — pretty-printed JSON contains newlines + assert!(json.contains('\n'), "JSON should be pretty-printed"); + } +} diff --git a/src/presentation/cli/views/commands/register/views/text_view.rs b/src/presentation/cli/views/commands/register/views/text_view.rs new file mode 100644 index 00000000..9ef3a0ba --- /dev/null +++ b/src/presentation/cli/views/commands/register/views/text_view.rs @@ -0,0 +1,146 @@ +//! Text View for Register Command +//! +//! This module provides text-based rendering for the register command. +//! It follows the Strategy Pattern, providing a human-readable output format +//! for the same underlying data (`RegisterDetailsData` DTO). +//! +//! # Design +//! +//! The `TextView` formats register details as human-readable text suitable +//! for terminal display and direct user consumption. It preserves the exact +//! output format produced before the Strategy Pattern was introduced. + +use crate::presentation::cli::views::commands::register::RegisterDetailsData; + +/// View for rendering register details as human-readable text +/// +/// This view produces formatted text output suitable for terminal display +/// and human consumption. +/// +/// The rendered string is intended to be passed to `ProgressReporter::complete()`, +/// which adds the `✅` prefix to the first line. +/// +/// # Examples +/// +/// ```rust +/// use torrust_tracker_deployer_lib::presentation::cli::views::commands::register::{ +/// RegisterDetailsData, TextView, +/// }; +/// +/// let data = RegisterDetailsData { +/// environment_name: "my-env".to_string(), +/// instance_ip: "192.168.1.100".to_string(), +/// ssh_port: 22, +/// registered: true, +/// }; +/// +/// let output = TextView::render(&data); +/// assert!(output.contains("Instance registered successfully with environment 'my-env'")); +/// ``` +pub struct TextView; + +impl TextView { + /// Render register details as human-readable text + /// + /// Takes register details and produces a human-readable output + /// intended to be wrapped by `ProgressReporter::complete()`. + /// + /// # Arguments + /// + /// * `data` - Register details to render + /// + /// # Returns + /// + /// A formatted string: `"Instance registered successfully with environment ''"`. + /// The `✅` prefix is added by `ProgressReporter::complete()`, not here. + /// + /// # Examples + /// + /// ```rust + /// use torrust_tracker_deployer_lib::presentation::cli::views::commands::register::{ + /// RegisterDetailsData, TextView, + /// }; + /// + /// let data = RegisterDetailsData { + /// environment_name: "prod-tracker".to_string(), + /// instance_ip: "10.0.0.1".to_string(), + /// ssh_port: 22, + /// registered: true, + /// }; + /// + /// let text = TextView::render(&data); + /// + /// assert!(text.contains("Instance registered successfully with environment 'prod-tracker'")); + /// ``` + #[must_use] + pub fn render(data: &RegisterDetailsData) -> String { + format!( + "Instance registered successfully with environment '{}'", + data.environment_name + ) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn create_test_data() -> RegisterDetailsData { + RegisterDetailsData { + environment_name: "test-env".to_string(), + instance_ip: "192.168.1.100".to_string(), + ssh_port: 22, + registered: true, + } + } + + #[test] + fn it_should_render_success_message_with_environment_name() { + // Arrange + let data = create_test_data(); + + // Act + let text = TextView::render(&data); + + // Assert + assert_eq!( + text, + "Instance registered successfully with environment 'test-env'" + ); + } + + #[test] + fn it_should_include_environment_name_in_output() { + // Arrange + let data = RegisterDetailsData { + environment_name: "my-production-env".to_string(), + instance_ip: "10.0.0.1".to_string(), + ssh_port: 22, + registered: true, + }; + + // Act + let text = TextView::render(&data); + + // Assert + assert!( + text.contains("my-production-env"), + "Output should contain the environment name" + ); + } + + #[test] + fn it_should_not_include_checkmark_prefix() { + // Arrange — the ✅ is added by ProgressReporter::complete(), not here + let data = create_test_data(); + + // Act + let text = TextView::render(&data); + + // Assert + assert!( + !text.starts_with('✅'), + "TextView should not add the ✅ prefix — that is ProgressReporter's job" + ); + } +}