diff --git a/src/presentation/controllers/release/handler.rs b/src/presentation/controllers/release/handler.rs index 7fd71a20..b500798e 100644 --- a/src/presentation/controllers/release/handler.rs +++ b/src/presentation/controllers/release/handler.rs @@ -12,6 +12,10 @@ use tracing::info; use crate::application::command_handlers::release::ReleaseCommandHandler; use crate::domain::environment::name::EnvironmentName; use crate::domain::environment::repository::EnvironmentRepository; +use crate::domain::environment::state::Released; +use crate::domain::environment::Environment; +use crate::presentation::input::cli::OutputFormat; +use crate::presentation::views::commands::release::{JsonView, ReleaseDetailsData, TextView}; use crate::presentation::views::progress::{ProgressReporter, VerboseProgressListener}; use crate::presentation::views::UserOutput; use crate::shared::clock::Clock; @@ -109,12 +113,16 @@ impl ReleaseCommandController { /// /// Returns `Ok(())` on success, or a `ReleaseSubcommandError` if any step fails. #[allow(clippy::result_large_err)] - pub async fn execute(&mut self, environment_name: &str) -> Result<(), ReleaseSubcommandError> { + pub async fn execute( + &mut self, + environment_name: &str, + output_format: OutputFormat, + ) -> Result<(), ReleaseSubcommandError> { let env_name = self.validate_environment_name(environment_name)?; - self.release_application(&env_name).await?; + let released_env = self.release_application(&env_name).await?; - self.complete_workflow(environment_name)?; + self.complete_workflow(&released_env, output_format)?; Ok(()) } @@ -151,7 +159,7 @@ impl ReleaseCommandController { async fn release_application( &mut self, env_name: &EnvironmentName, - ) -> Result<(), ReleaseSubcommandError> { + ) -> Result, ReleaseSubcommandError> { self.progress .start_step(ReleaseStep::ReleaseApplication.description())?; @@ -162,7 +170,7 @@ impl ReleaseCommandController { // user-facing detail messages via UserOutput's verbosity filter. let listener = VerboseProgressListener::new(self.progress.output().clone()); - let _released_env = handler + let released_env = handler .execute(env_name, Some(&listener)) .await .map_err(|source| ReleaseSubcommandError::ApplicationLayerError { source })?; @@ -176,17 +184,28 @@ impl ReleaseCommandController { self.progress .complete_step(Some("Application released successfully"))?; - Ok(()) + Ok(released_env) } - /// Complete the workflow with success message + /// Complete the workflow with environment details output /// - /// Shows final success message to the user with workflow summary. + /// Renders the released environment details using the chosen output format + /// (text or JSON) and displays them to the user. #[allow(clippy::result_large_err)] - fn complete_workflow(&mut self, name: &str) -> Result<(), ReleaseSubcommandError> { - self.progress.complete(&format!( - "Release command completed successfully for '{name}'" - ))?; + fn complete_workflow( + &mut self, + released_env: &Environment, + output_format: OutputFormat, + ) -> Result<(), ReleaseSubcommandError> { + let details = ReleaseDetailsData::from(released_env); + + let output = match output_format { + OutputFormat::Text => TextView::render(&details), + OutputFormat::Json => JsonView::render(&details), + }; + + self.progress.result(&output)?; + Ok(()) } } @@ -228,7 +247,7 @@ mod tests { // Test with invalid environment name (contains underscore) let result = ReleaseCommandController::new(repository, clock, user_output.clone()) - .execute("invalid_name") + .execute("invalid_name", OutputFormat::Text) .await; assert!(result.is_err()); @@ -247,7 +266,7 @@ mod tests { let (user_output, repository, clock) = create_test_dependencies(&temp_dir); let result = ReleaseCommandController::new(repository, clock, user_output.clone()) - .execute("") + .execute("", OutputFormat::Text) .await; assert!(result.is_err()); @@ -267,7 +286,7 @@ mod tests { // Valid environment name but environment doesn't exist let result = ReleaseCommandController::new(repository, clock, user_output.clone()) - .execute("test-env") + .execute("test-env", OutputFormat::Text) .await; // Should fail because environment doesn't exist diff --git a/src/presentation/controllers/release/tests.rs b/src/presentation/controllers/release/tests.rs index 01890bb3..01e8da81 100644 --- a/src/presentation/controllers/release/tests.rs +++ b/src/presentation/controllers/release/tests.rs @@ -13,6 +13,7 @@ use crate::domain::environment::repository::EnvironmentRepository; use crate::infrastructure::persistence::repository_factory::RepositoryFactory; use crate::presentation::controllers::constants::DEFAULT_LOCK_TIMEOUT; use crate::presentation::controllers::release::handler::ReleaseCommandController; +use crate::presentation::input::cli::OutputFormat; use crate::presentation::views::testing::TestUserOutput; use crate::presentation::views::{UserOutput, VerbosityLevel}; use crate::shared::clock::Clock; @@ -46,7 +47,7 @@ mod environment_name_validation { let (user_output, repository, clock) = create_test_dependencies(&temp_dir); let result = ReleaseCommandController::new(repository, clock, user_output) - .execute("invalid_name") + .execute("invalid_name", OutputFormat::Text) .await; assert!(matches!( @@ -61,7 +62,7 @@ mod environment_name_validation { let (user_output, repository, clock) = create_test_dependencies(&temp_dir); let result = ReleaseCommandController::new(repository, clock, user_output) - .execute("") + .execute("", OutputFormat::Text) .await; assert!(matches!( @@ -76,7 +77,7 @@ mod environment_name_validation { let (user_output, repository, clock) = create_test_dependencies(&temp_dir); let result = ReleaseCommandController::new(repository, clock, user_output) - .execute("-invalid") + .execute("-invalid", OutputFormat::Text) .await; assert!(matches!( @@ -97,7 +98,7 @@ mod workflow_errors { // Valid name but environment doesn't exist let result = ReleaseCommandController::new(repository, clock, user_output) - .execute("production") + .execute("production", OutputFormat::Text) .await; // Should fail with ApplicationLayerError because environment doesn't exist @@ -116,7 +117,7 @@ mod workflow_errors { let (user_output, repository, clock) = create_test_dependencies(&temp_dir); let result = ReleaseCommandController::new(repository, clock, user_output) - .execute("my-test-env") + .execute("my-test-env", OutputFormat::Text) .await; // Should fail with ApplicationLayerError because environment doesn't exist diff --git a/src/presentation/dispatch/router.rs b/src/presentation/dispatch/router.rs index d1a31523..fc165156 100644 --- a/src/presentation/dispatch/router.rs +++ b/src/presentation/dispatch/router.rs @@ -175,10 +175,11 @@ pub async fn route_command( Ok(()) } Commands::Release { environment } => { + let output_format = context.output_format(); context .container() .create_release_controller() - .execute(&environment) + .execute(&environment, output_format) .await?; Ok(()) } diff --git a/src/presentation/views/commands/mod.rs b/src/presentation/views/commands/mod.rs index 3a1234ee..d947a9c7 100644 --- a/src/presentation/views/commands/mod.rs +++ b/src/presentation/views/commands/mod.rs @@ -8,6 +8,7 @@ pub mod configure; pub mod create; pub mod list; pub mod provision; +pub mod release; pub mod run; pub mod shared; pub mod show; diff --git a/src/presentation/views/commands/release/mod.rs b/src/presentation/views/commands/release/mod.rs new file mode 100644 index 00000000..fd9cdc9a --- /dev/null +++ b/src/presentation/views/commands/release/mod.rs @@ -0,0 +1,52 @@ +//! Views for Release Command +//! +//! This module contains view components for rendering release command output. +//! +//! # Architecture +//! +//! This module follows the Strategy Pattern for rendering: +//! - `ReleaseDetailsData`: 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 +//! - `release_details.rs`: Main DTO with environment release 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: &ReleaseDetailsData) -> String` +//! 3. Export it from this module +//! 4. No need to modify existing views or the DTO + +pub mod view_data { + pub mod release_details; + + // Re-export main types for convenience + pub use release_details::ReleaseDetailsData; +} + +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::ReleaseDetailsData; +pub use views::{JsonView, TextView}; diff --git a/src/presentation/views/commands/release/view_data/release_details.rs b/src/presentation/views/commands/release/view_data/release_details.rs new file mode 100644 index 00000000..48ad27a9 --- /dev/null +++ b/src/presentation/views/commands/release/view_data/release_details.rs @@ -0,0 +1,184 @@ +//! Release Details Data Transfer Object +//! +//! This module contains the presentation DTO for release 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 chrono::{DateTime, Utc}; +use serde::Serialize; +use std::net::IpAddr; + +use crate::domain::environment::state::Released; +use crate::domain::environment::Environment; + +/// Release details data for rendering +/// +/// This struct holds all the data needed to render release 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 domain models from view formatting +/// - Provides a stable interface for multiple view strategies +/// - Contains all fields needed for any output format +#[derive(Debug, Clone, PartialEq, Serialize)] +pub struct ReleaseDetailsData { + /// Name of the released environment + pub environment_name: String, + /// Name of the released instance + pub instance_name: String, + /// Infrastructure provider (lowercase: "lxd", "hetzner", etc.) + pub provider: String, + /// State name (always "Released" for this command) + pub state: String, + /// IP address of the instance (nullable) + pub instance_ip: Option, + /// Timestamp when the environment was created (ISO 8601 format in JSON) + pub created_at: DateTime, +} + +/// Conversion from domain model to presentation DTO +/// +/// This `From` trait implementation is placed in the presentation layer +/// (not in the domain layer) to maintain proper DDD layering: +/// +/// - Domain layer should not depend on presentation layer DTOs +/// - Presentation layer can depend on domain models (allowed) +/// - This keeps the domain clean and focused on business logic +/// +/// Alternative approaches considered: +/// - Adding method to `Environment`: Would violate DDD by making +/// domain depend on presentation DTOs +/// - Keeping mapping in controller: Works but less idiomatic than `From` trait +impl From<&Environment> for ReleaseDetailsData { + fn from(env: &Environment) -> Self { + Self { + environment_name: env.name().as_str().to_string(), + instance_name: env.instance_name().as_str().to_string(), + provider: env.provider_config().provider_name().to_string(), + state: "Released".to_string(), + instance_ip: env.instance_ip(), + created_at: env.created_at(), + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::adapters::ssh::SshCredentials; + use crate::domain::environment::runtime_outputs::ProvisionMethod; + use crate::domain::environment::EnvironmentName; + use crate::domain::provider::{LxdConfig, ProviderConfig}; + use crate::domain::ProfileName; + use crate::shared::Username; + use chrono::{DateTime, TimeZone, Utc}; + use std::net::{IpAddr, Ipv4Addr}; + use std::path::PathBuf; + + // Test fixtures and helpers + + fn create_test_ssh_credentials() -> SshCredentials { + let ssh_username = Username::new("deployer".to_string()).unwrap(); + SshCredentials::new( + PathBuf::from("./keys/test_rsa"), + PathBuf::from("./keys/test_rsa.pub"), + ssh_username, + ) + } + + fn create_test_provider_config() -> ProviderConfig { + ProviderConfig::Lxd(LxdConfig { + profile_name: ProfileName::new("lxd-test-env".to_string()).unwrap(), + }) + } + + fn create_test_timestamp() -> DateTime { + Utc.with_ymd_and_hms(2026, 2, 23, 10, 0, 0).unwrap() + } + + fn create_released_environment_with_ip(ip: IpAddr) -> Environment { + let env_name = EnvironmentName::new("test-env".to_string()).unwrap(); + let ssh_credentials = create_test_ssh_credentials(); + let provider_config = create_test_provider_config(); + let created_at = create_test_timestamp(); + + Environment::new(env_name, provider_config, ssh_credentials, 22, created_at) + .start_provisioning() + .provisioned(ip, ProvisionMethod::Provisioned) + .start_configuring() + .configured() + .start_releasing() + .released() + } + + fn create_test_ip() -> IpAddr { + IpAddr::V4(Ipv4Addr::new(10, 140, 190, 39)) + } + + fn create_expected_dto(ip: IpAddr) -> ReleaseDetailsData { + ReleaseDetailsData { + environment_name: "test-env".to_string(), + instance_name: "torrust-tracker-vm-test-env".to_string(), + provider: "lxd".to_string(), + state: "Released".to_string(), + instance_ip: Some(ip), + created_at: create_test_timestamp(), + } + } + + // Tests + + #[test] + fn it_should_convert_released_environment_to_dto() { + // Arrange + let test_ip = create_test_ip(); + let env = create_released_environment_with_ip(test_ip); + let expected = create_expected_dto(test_ip); + + // Act + let dto = ReleaseDetailsData::from(&env); + + // Assert + assert_eq!(dto, expected); + } + + #[test] + fn it_should_have_released_state_string() { + // Arrange + let env = create_released_environment_with_ip(create_test_ip()); + + // Act + let dto = ReleaseDetailsData::from(&env); + + // Assert + assert_eq!(dto.state, "Released"); + } + + #[test] + fn it_should_have_instance_ip_present_for_provisioned_environment() { + // Arrange - create environment with IP + let env = create_released_environment_with_ip(create_test_ip()); + + // Act + let dto = ReleaseDetailsData::from(&env); + + // Assert - in a real released environment, IP should always be present + // This test documents that released environments have IP addresses + assert!(dto.instance_ip.is_some()); + } +} diff --git a/src/presentation/views/commands/release/views/json_view.rs b/src/presentation/views/commands/release/views/json_view.rs new file mode 100644 index 00000000..fdec8cfa --- /dev/null +++ b/src/presentation/views/commands/release/views/json_view.rs @@ -0,0 +1,213 @@ +//! JSON View for Release Command +//! +//! This module provides JSON-based rendering for the release command. +//! It follows the Strategy Pattern, providing a machine-readable output format +//! for the same underlying data (`ReleaseDetailsData` DTO). +//! +//! # Design +//! +//! The `JsonView` serializes release command information to JSON using `serde_json`. +//! The output includes environment details and release state. + +use crate::presentation::views::commands::release::ReleaseDetailsData; + +/// View for rendering release details as JSON +/// +/// This view provides machine-readable JSON output for automation workflows +/// and AI agents. It serializes the release details without any transformations, +/// preserving all field names and structure from the DTO. +/// +/// # Examples +/// +/// ```rust +/// use torrust_tracker_deployer_lib::presentation::views::commands::release::{ +/// ReleaseDetailsData, JsonView, +/// }; +/// use chrono::{TimeZone, Utc}; +/// use std::net::{IpAddr, Ipv4Addr}; +/// +/// let details = ReleaseDetailsData { +/// environment_name: "my-env".to_string(), +/// instance_name: "torrust-tracker-vm-my-env".to_string(), +/// provider: "lxd".to_string(), +/// state: "Released".to_string(), +/// instance_ip: Some(IpAddr::V4(Ipv4Addr::new(10, 140, 190, 39))), +/// created_at: Utc.with_ymd_and_hms(2026, 2, 20, 10, 0, 0).unwrap(), +/// }; +/// +/// let output = JsonView::render(&details); +/// +/// // 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["state"], "Released"); +/// ``` +pub struct JsonView; + +impl JsonView { + /// Render release details as JSON + /// + /// Serializes the release details to pretty-printed JSON format. + /// The JSON structure matches the DTO structure exactly: + /// - `environment_name`: Name of the environment + /// - `instance_name`: VM instance name + /// - `provider`: Infrastructure provider + /// - `state`: Always "Released" on success + /// - `instance_ip`: IP address (nullable) + /// - `created_at`: ISO 8601 UTC timestamp + /// + /// # Arguments + /// + /// * `data` - Release details to render + /// + /// # Returns + /// + /// A JSON string containing the serialized release 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::views::commands::release::{ + /// ReleaseDetailsData, JsonView, + /// }; + /// use chrono::{TimeZone, Utc}; + /// use std::net::{IpAddr, Ipv4Addr}; + /// + /// let details = ReleaseDetailsData { + /// environment_name: "prod-tracker".to_string(), + /// instance_name: "torrust-tracker-vm-prod-tracker".to_string(), + /// provider: "lxd".to_string(), + /// state: "Released".to_string(), + /// instance_ip: Some(IpAddr::V4(Ipv4Addr::new(192, 168, 1, 100))), + /// created_at: Utc.with_ymd_and_hms(2026, 1, 5, 10, 30, 0).unwrap(), + /// }; + /// + /// let json = JsonView::render(&details); + /// + /// assert!(json.contains("\"environment_name\": \"prod-tracker\"")); + /// assert!(json.contains("\"state\": \"Released\"")); + /// ``` + #[must_use] + pub fn render(data: &ReleaseDetailsData) -> String { + serde_json::to_string_pretty(data).unwrap_or_else(|e| { + format!( + r#"{{ + "error": "Failed to serialize release details", + "message": "{e}" +}}"# + ) + }) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use chrono::{DateTime, TimeZone, Utc}; + use std::net::{IpAddr, Ipv4Addr}; + + // Test fixtures and helpers + + fn create_test_timestamp() -> DateTime { + Utc.with_ymd_and_hms(2026, 2, 23, 10, 0, 0).unwrap() + } + + fn create_test_ip() -> IpAddr { + IpAddr::V4(Ipv4Addr::new(10, 140, 190, 39)) + } + + fn create_test_details_with_ip(ip: Option) -> ReleaseDetailsData { + ReleaseDetailsData { + environment_name: "test-env".to_string(), + instance_name: "torrust-tracker-vm-test-env".to_string(), + provider: "lxd".to_string(), + state: "Released".to_string(), + instance_ip: ip, + created_at: create_test_timestamp(), + } + } + + /// Helper to assert JSON fields match expected values + fn assert_json_fields_eq(json: &str, expected_fields: &[(&str, &str)]) { + let parsed: serde_json::Value = serde_json::from_str(json).expect("Should be valid JSON"); + for (field, expected_value) in expected_fields { + assert_eq!( + parsed[field].as_str().unwrap_or(""), + *expected_value, + "Field '{field}' should be '{expected_value}'" + ); + } + } + + /// Helper to assert JSON contains all required field names + fn assert_json_has_fields(json: &str, field_names: &[&str]) { + let parsed: serde_json::Value = serde_json::from_str(json).expect("Should be valid JSON"); + for field_name in field_names { + assert!( + parsed.get(field_name).is_some(), + "Expected JSON to have field '{field_name}' but it didn't.\nActual JSON:\n{json}" + ); + } + } + + // Tests + + #[test] + fn it_should_render_release_details_as_valid_json() { + // Arrange + let details = create_test_details_with_ip(Some(create_test_ip())); + + // Act + let json = JsonView::render(&details); + + // Assert - verify it's valid JSON with expected field values + assert_json_fields_eq( + &json, + &[ + ("environment_name", "test-env"), + ("instance_name", "torrust-tracker-vm-test-env"), + ("provider", "lxd"), + ("state", "Released"), + ("instance_ip", "10.140.190.39"), + ("created_at", "2026-02-23T10:00:00Z"), + ], + ); + } + + #[test] + fn it_should_render_null_instance_ip_as_json_null() { + // Arrange + let details = create_test_details_with_ip(None); + + // Act + let json = JsonView::render(&details); + + // Assert + let parsed: serde_json::Value = serde_json::from_str(&json).expect("Should be valid JSON"); + assert!(parsed["instance_ip"].is_null()); + } + + #[test] + fn it_should_include_all_required_fields() { + // Arrange + let details = create_test_details_with_ip(Some(IpAddr::V4(Ipv4Addr::new(192, 168, 1, 42)))); + + // Act + let json = JsonView::render(&details); + + // Assert - check all required fields are present + assert_json_has_fields( + &json, + &[ + "environment_name", + "instance_name", + "provider", + "state", + "instance_ip", + "created_at", + ], + ); + } +} diff --git a/src/presentation/views/commands/release/views/text_view.rs b/src/presentation/views/commands/release/views/text_view.rs new file mode 100644 index 00000000..78b7268c --- /dev/null +++ b/src/presentation/views/commands/release/views/text_view.rs @@ -0,0 +1,223 @@ +//! Text View for Release Command +//! +//! This module provides text-based rendering for the release command. +//! It follows the Strategy Pattern, providing a human-readable output format +//! for the same underlying data (`ReleaseDetailsData` DTO). +//! +//! # Design +//! +//! The `TextView` formats release details as human-readable text suitable +//! for terminal display and direct user consumption. + +use crate::presentation::views::commands::release::ReleaseDetailsData; + +/// View for rendering release details as human-readable text +/// +/// This view produces formatted text output suitable for terminal display +/// and human consumption. It presents environment release details +/// in a clear, readable format. +/// +/// # Examples +/// +/// ```rust +/// use torrust_tracker_deployer_lib::presentation::views::commands::release::{ +/// ReleaseDetailsData, TextView, +/// }; +/// use chrono::{TimeZone, Utc}; +/// use std::net::{IpAddr, Ipv4Addr}; +/// +/// let details = ReleaseDetailsData { +/// environment_name: "my-env".to_string(), +/// instance_name: "torrust-tracker-vm-my-env".to_string(), +/// provider: "lxd".to_string(), +/// state: "Released".to_string(), +/// instance_ip: Some(IpAddr::V4(Ipv4Addr::new(10, 140, 190, 39))), +/// created_at: Utc.with_ymd_and_hms(2026, 2, 20, 10, 0, 0).unwrap(), +/// }; +/// +/// let output = TextView::render(&details); +/// assert!(output.contains("Environment Details:")); +/// assert!(output.contains("my-env")); +/// ``` +pub struct TextView; + +impl TextView { + /// Render release details as human-readable formatted text + /// + /// Takes release details and produces a human-readable output + /// suitable for displaying to users via stdout. + /// + /// # Arguments + /// + /// * `data` - Release details to render + /// + /// # Returns + /// + /// A formatted string containing: + /// - Environment Details section with name, instance, provider, state + /// - Instance IP (if available) + /// - Creation timestamp + /// + /// # Format + /// + /// The output follows this structure: + /// ```text + /// Environment Details: + /// Name: + /// Instance: + /// Provider: + /// State: + /// Instance IP: + /// Created: + /// ``` + /// + /// # Examples + /// + /// ```rust + /// use torrust_tracker_deployer_lib::presentation::views::commands::release::{ + /// ReleaseDetailsData, TextView, + /// }; + /// use chrono::{TimeZone, Utc}; + /// use std::net::{IpAddr, Ipv4Addr}; + /// + /// let details = ReleaseDetailsData { + /// environment_name: "prod-tracker".to_string(), + /// instance_name: "torrust-tracker-vm-prod-tracker".to_string(), + /// provider: "lxd".to_string(), + /// state: "Released".to_string(), + /// instance_ip: Some(IpAddr::V4(Ipv4Addr::new(192, 168, 1, 100))), + /// created_at: Utc.with_ymd_and_hms(2026, 1, 5, 10, 30, 0).unwrap(), + /// }; + /// + /// let text = TextView::render(&details); + /// + /// assert!(text.contains("Name:")); + /// assert!(text.contains("prod-tracker")); + /// assert!(text.contains("Released")); + /// ``` + #[must_use] + pub fn render(data: &ReleaseDetailsData) -> String { + let instance_ip = data + .instance_ip + .map_or_else(|| "Not available".to_string(), |ip| ip.to_string()); + + format!( + r"Environment Details: + Name: {} + Instance: {} + Provider: {} + State: {} + Instance IP: {} + Created: {}", + data.environment_name, + data.instance_name, + data.provider, + data.state, + instance_ip, + data.created_at.format("%Y-%m-%d %H:%M:%S UTC") + ) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use chrono::{DateTime, TimeZone, Utc}; + use std::net::{IpAddr, Ipv4Addr}; + + // Test fixtures and helpers + + fn create_test_timestamp() -> DateTime { + Utc.with_ymd_and_hms(2026, 2, 23, 10, 0, 0).unwrap() + } + + fn create_test_ip() -> IpAddr { + IpAddr::V4(Ipv4Addr::new(10, 140, 190, 39)) + } + + fn create_test_details_with_ip(ip: Option) -> ReleaseDetailsData { + ReleaseDetailsData { + environment_name: "test-env".to_string(), + instance_name: "torrust-tracker-vm-test-env".to_string(), + provider: "lxd".to_string(), + state: "Released".to_string(), + instance_ip: ip, + created_at: create_test_timestamp(), + } + } + + /// Helper to assert text contains all expected substrings + fn assert_contains_all(text: &str, expected: &[&str]) { + for substring in expected { + assert!( + text.contains(substring), + "Expected text to contain '{substring}' but it didn't.\nActual text:\n{text}" + ); + } + } + + // Tests + + #[test] + fn it_should_render_release_details_as_formatted_text() { + // Arrange + let details = create_test_details_with_ip(Some(create_test_ip())); + + // Act + let text = TextView::render(&details); + + // Assert + assert_contains_all( + &text, + &[ + "Environment Details:", + "Name:", + "test-env", + "Instance:", + "torrust-tracker-vm-test-env", + "Provider:", + "lxd", + "State:", + "Released", + "Instance IP:", + "10.140.190.39", + "Created:", + "2026-02-23 10:00:00 UTC", + ], + ); + } + + #[test] + fn it_should_display_not_available_when_instance_ip_is_none() { + // Arrange + let details = create_test_details_with_ip(None); + + // Act + let text = TextView::render(&details); + + // Assert + assert!(text.contains("Instance IP: Not available")); + } + + #[test] + fn it_should_include_all_required_sections() { + // Arrange + let details = create_test_details_with_ip(Some(IpAddr::V4(Ipv4Addr::new(192, 168, 1, 42)))); + + // Act + let text = TextView::render(&details); + + // Assert - check all sections are present + assert_contains_all( + &text, + &[ + "Name:", + "Instance:", + "Provider:", + "State:", + "Instance IP:", + "Created:", + ], + ); + } +}