diff --git a/docs/user-guide/commands/render.md b/docs/user-guide/commands/render.md index fd0275e8..422b23df 100644 --- a/docs/user-guide/commands/render.md +++ b/docs/user-guide/commands/render.md @@ -202,6 +202,34 @@ The render command generates artifacts in the specified output directory: - **Docker Compose** - Complete service stack definition - **Configuration files** - All service configurations rendered +### JSON Output + +Use `--output-format json` (or `-o json`) to get machine-readable output. Progress messages go to stderr; the JSON result goes to stdout. + +```bash +torrust-tracker-deployer render \ + --env-file envs/my-environment.json \ + --instance-ip 192.168.1.100 \ + --output-dir /tmp/build/my-environment \ + --output-format json 2>/dev/null +``` + +```json +{ + "environment_name": "my-environment", + "config_source": "Config file: envs/my-environment.json", + "target_ip": "192.168.1.100", + "output_dir": "/tmp/build/my-environment" +} +``` + +| Field | Type | Description | +| ------------------ | ------ | ----------------------------------------------------------------- | +| `environment_name` | string | Name of the environment whose artifacts were generated | +| `config_source` | string | Description of the configuration source (env name or config file) | +| `target_ip` | string | IP address used in artifact generation | +| `output_dir` | string | Path to the directory containing generated artifacts | + ## Use Cases ### 1. Preview Before Provisioning diff --git a/src/presentation/cli/controllers/render/handler.rs b/src/presentation/cli/controllers/render/handler.rs index 738f447b..46955bd3 100644 --- a/src/presentation/cli/controllers/render/handler.rs +++ b/src/presentation/cli/controllers/render/handler.rs @@ -10,9 +10,13 @@ use std::sync::Arc; use parking_lot::ReentrantMutex; -use crate::application::command_handlers::render::{RenderCommandHandler, RenderInputMode}; +use crate::application::command_handlers::render::{ + RenderCommandHandler, RenderInputMode, RenderResult, +}; use crate::domain::environment::repository::EnvironmentRepository; use crate::domain::EnvironmentName; +use crate::presentation::cli::input::cli::OutputFormat; +use crate::presentation::cli::views::commands::render::{JsonView, RenderDetailsData, TextView}; use crate::presentation::cli::views::progress::ProgressReporter; use crate::presentation::cli::views::UserOutput; @@ -70,7 +74,6 @@ impl RenderStep { pub struct RenderCommandController { handler: RenderCommandHandler, progress: ProgressReporter, - user_output: Arc>>, } impl RenderCommandController { @@ -85,8 +88,7 @@ impl RenderCommandController { ) -> Self { Self { handler: RenderCommandHandler::new(repository), - progress: ProgressReporter::new(Arc::clone(&user_output), RenderStep::count()), - user_output, + progress: ProgressReporter::new(user_output, RenderStep::count()), } } @@ -102,6 +104,7 @@ impl RenderCommandController { /// * `output_dir` - Output directory for generated artifacts (required) /// * `force` - Whether to overwrite existing output directory /// * `working_dir` - Working directory for environment data (from --working-dir global arg) + /// * `output_format` - Output format (text or JSON) /// /// # Returns /// @@ -117,6 +120,7 @@ impl RenderCommandController { /// - Config file doesn't exist /// - Environment not found /// - Template rendering fails + #[allow(clippy::too_many_arguments)] // Required parameters for render workflow - all are necessary pub async fn execute( &mut self, env_name: Option<&str>, @@ -125,6 +129,7 @@ impl RenderCommandController { output_dir: &Path, force: bool, working_dir: &Path, + output_format: OutputFormat, ) -> Result<(), RenderCommandError> { // Step 1: Validate input self.progress @@ -136,7 +141,7 @@ impl RenderCommandController { .map_err(|_| RenderCommandError::InvalidIpAddress { ip: ip.to_string() })?; // Determine input mode and prepare handler parameters - let (input_mode, source_desc) = match (env_name, env_file) { + let input_mode = match (env_name, env_file) { (Some(name), None) => { let env_name = EnvironmentName::new(name).map_err(|e| { RenderCommandError::InvalidEnvironmentName { @@ -144,10 +149,7 @@ impl RenderCommandController { reason: e.to_string(), } })?; - ( - RenderInputMode::EnvironmentName(env_name.clone()), - format!("Environment: {env_name}"), - ) + RenderInputMode::EnvironmentName(env_name) } (None, Some(path)) => { // Validate file exists @@ -156,10 +158,7 @@ impl RenderCommandController { path: path.to_path_buf(), }); } - ( - RenderInputMode::ConfigFile(path.to_path_buf()), - format!("Config file: {}", path.display()), - ) + RenderInputMode::ConfigFile(path.to_path_buf()) } (None, None) => return Err(RenderCommandError::NoInputMode), (Some(_), Some(_)) => unreachable!("Clap ensures mutual exclusivity"), @@ -189,31 +188,33 @@ impl RenderCommandController { self.progress.complete_step(None)?; - // Show success message - self.show_success( - &source_desc, - &result.target_ip.to_string(), - &result.output_dir, - ); + // Render and display results + self.complete_workflow(&result, output_format)?; Ok(()) } - /// Show success message to user - fn show_success(&mut self, source: &str, target_ip: &str, output_dir: &Path) { - let output = self.user_output.lock(); - let mut output_ref = output.borrow_mut(); - - output_ref.success(&format!( - "\nDeployment artifacts generated successfully!\n\n \ - Source: {source}\n \ - Target IP: {target_ip}\n \ - Output: {}\n\n\ - Next steps:\n \ - - Review artifacts in the output directory\n \ - - Use 'provision' command to deploy infrastructure\n \ - - Or use artifacts manually with your deployment tools", - output_dir.display() - )); + /// Complete the workflow with render details output + /// + /// Renders the artifact generation summary using the chosen output format + /// (text or JSON) and displays it to the user. + fn complete_workflow( + &mut self, + result: &RenderResult, + output_format: OutputFormat, + ) -> Result<(), RenderCommandError> { + let data = RenderDetailsData::from_result(result); + + match output_format { + OutputFormat::Text => { + self.progress.blank_line()?; + 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 44783f02..bbf4799c 100644 --- a/src/presentation/cli/dispatch/router.rs +++ b/src/presentation/cli/dispatch/router.rs @@ -193,6 +193,7 @@ pub async fn route_command( output_dir, force, } => { + let output_format = context.output_format(); context .container() .create_render_controller() @@ -203,6 +204,7 @@ pub async fn route_command( output_dir.as_path(), force, context.working_dir(), + output_format, ) .await?; Ok(()) diff --git a/src/presentation/cli/views/commands/mod.rs b/src/presentation/cli/views/commands/mod.rs index e695f14a..e57ed1be 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 release; +pub mod render; pub mod run; pub mod shared; pub mod show; diff --git a/src/presentation/cli/views/commands/render/mod.rs b/src/presentation/cli/views/commands/render/mod.rs new file mode 100644 index 00000000..cc1c7edb --- /dev/null +++ b/src/presentation/cli/views/commands/render/mod.rs @@ -0,0 +1,52 @@ +//! Views for Render Command +//! +//! This module contains view components for rendering render command output. +//! +//! # Architecture +//! +//! This module follows the Strategy Pattern for rendering: +//! - `RenderDetailsData`: 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 +//! - `render_details.rs`: Main DTO with render 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: &RenderDetailsData) -> String` +//! 3. Export it from this module +//! 4. No need to modify existing views or the DTO + +pub mod view_data { + pub mod render_details; + + // Re-export main types for convenience + pub use render_details::RenderDetailsData; +} + +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::RenderDetailsData; +pub use views::{JsonView, TextView}; diff --git a/src/presentation/cli/views/commands/render/view_data/render_details.rs b/src/presentation/cli/views/commands/render/view_data/render_details.rs new file mode 100644 index 00000000..499175f9 --- /dev/null +++ b/src/presentation/cli/views/commands/render/view_data/render_details.rs @@ -0,0 +1,144 @@ +//! Render Details Data Transfer Object +//! +//! This module contains the presentation DTO for render 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::application::command_handlers::render::RenderResult; + +/// Render details data for rendering +/// +/// This struct holds all the data needed to render render 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 vs `From` +/// +/// A named constructor `from_result` is used because it provides a clear API +/// and `RenderResult` is a single input (unlike DTOs combining multiple sources). +#[derive(Debug, Clone, PartialEq, Serialize)] +pub struct RenderDetailsData { + /// Name of the environment whose artifacts were generated + pub environment_name: String, + /// Description of the configuration source (env name or config file) + pub config_source: String, + /// IP address used in artifact generation + pub target_ip: String, + /// Absolute path to the directory containing generated artifacts + pub output_dir: String, +} + +impl RenderDetailsData { + /// Construct a `RenderDetailsData` from a render result + /// + /// # Arguments + /// + /// * `result` - Successful render result from the application layer + /// + /// # Examples + /// + /// ```rust + /// use std::net::IpAddr; + /// use std::path::PathBuf; + /// use torrust_tracker_deployer_lib::application::command_handlers::render::RenderResult; + /// use torrust_tracker_deployer_lib::presentation::cli::views::commands::render::RenderDetailsData; + /// + /// let result = RenderResult { + /// environment_name: "my-env".to_string(), + /// config_source: "Config file: envs/my-env.json".to_string(), + /// target_ip: "192.168.1.100".parse::().unwrap(), + /// output_dir: PathBuf::from("/tmp/build/my-env"), + /// }; + /// + /// let data = RenderDetailsData::from_result(&result); + /// + /// assert_eq!(data.environment_name, "my-env"); + /// assert_eq!(data.target_ip, "192.168.1.100"); + /// ``` + #[must_use] + pub fn from_result(result: &RenderResult) -> Self { + Self { + environment_name: result.environment_name.clone(), + config_source: result.config_source.clone(), + target_ip: result.target_ip.to_string(), + output_dir: result.output_dir.display().to_string(), + } + } +} + +#[cfg(test)] +mod tests { + use std::net::IpAddr; + use std::path::PathBuf; + + use super::*; + + fn create_sample_result() -> RenderResult { + RenderResult { + environment_name: "test-env".to_string(), + config_source: "Config file: envs/test-env.json".to_string(), + target_ip: "192.168.1.100".parse::().unwrap(), + output_dir: PathBuf::from("/tmp/build/test-env"), + } + } + + #[test] + fn it_should_build_dto_from_result() { + // Arrange + let result = create_sample_result(); + + // Act + let data = RenderDetailsData::from_result(&result); + + // Assert + assert_eq!(data.environment_name, "test-env"); + assert_eq!(data.config_source, "Config file: envs/test-env.json"); + assert_eq!(data.target_ip, "192.168.1.100"); + assert_eq!(data.output_dir, "/tmp/build/test-env"); + } + + #[test] + fn it_should_convert_ip_to_string() { + // Arrange + let result = create_sample_result(); + + // Act + let data = RenderDetailsData::from_result(&result); + + // Assert - IpAddr is converted to string + assert_eq!(data.target_ip, "192.168.1.100"); + } + + #[test] + fn it_should_convert_output_dir_to_string() { + // Arrange + let result = create_sample_result(); + + // Act + let data = RenderDetailsData::from_result(&result); + + // Assert - PathBuf is converted to string via display() + assert_eq!(data.output_dir, "/tmp/build/test-env"); + } +} diff --git a/src/presentation/cli/views/commands/render/views/json_view.rs b/src/presentation/cli/views/commands/render/views/json_view.rs new file mode 100644 index 00000000..2ccc38af --- /dev/null +++ b/src/presentation/cli/views/commands/render/views/json_view.rs @@ -0,0 +1,224 @@ +//! JSON View for Render Command +//! +//! This module provides JSON-based rendering for the render command. +//! It follows the Strategy Pattern, providing a machine-readable output format +//! for the same underlying data (`RenderDetailsData` DTO). +//! +//! # Design +//! +//! The `JsonView` serializes render result information to JSON using `serde_json`. +//! The output includes the environment name, configuration source, target IP, +//! and output directory for the generated artifacts. + +use crate::presentation::cli::views::commands::render::RenderDetailsData; + +/// View for rendering render details as JSON +/// +/// This view provides machine-readable JSON output for automation workflows +/// and AI agents. It serializes the render details without any transformations, +/// preserving all field names and structure from the DTO. +/// +/// # Examples +/// +/// ```rust +/// use torrust_tracker_deployer_lib::presentation::cli::views::commands::render::{ +/// RenderDetailsData, JsonView, +/// }; +/// +/// let data = RenderDetailsData { +/// environment_name: "my-env".to_string(), +/// config_source: "Config file: envs/my-env.json".to_string(), +/// target_ip: "192.168.1.100".to_string(), +/// output_dir: "/tmp/build/my-env".to_string(), +/// }; +/// +/// 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["target_ip"], "192.168.1.100"); +/// ``` +pub struct JsonView; + +impl JsonView { + /// Render render details as JSON + /// + /// Serializes the render details to pretty-printed JSON format. + /// The JSON structure matches the DTO structure exactly: + /// - `environment_name`: Name of the environment + /// - `config_source`: Description of the configuration source + /// - `target_ip`: IP address used in artifact generation + /// - `output_dir`: Path to the generated artifacts directory + /// + /// # Arguments + /// + /// * `data` - Render details to render + /// + /// # Returns + /// + /// A JSON string containing the serialized render 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::render::{ + /// RenderDetailsData, JsonView, + /// }; + /// + /// let data = RenderDetailsData { + /// environment_name: "prod-tracker".to_string(), + /// config_source: "Config file: envs/prod-tracker.json".to_string(), + /// target_ip: "10.0.0.1".to_string(), + /// output_dir: "/tmp/build/prod-tracker".to_string(), + /// }; + /// + /// let json = JsonView::render(&data); + /// + /// assert!(json.contains("\"environment_name\": \"prod-tracker\"")); + /// assert!(json.contains("\"target_ip\": \"10.0.0.1\"")); + /// ``` + #[must_use] + pub fn render(data: &RenderDetailsData) -> String { + serde_json::to_string_pretty(data).unwrap_or_else(|e| { + format!( + r#"{{ + "error": "Failed to serialize render details", + "message": "{e}" +}}"# + ) + }) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + // Test fixtures + + fn create_test_data() -> RenderDetailsData { + RenderDetailsData { + environment_name: "test-env".to_string(), + config_source: "Config file: envs/test-env.json".to_string(), + target_ip: "192.168.1.100".to_string(), + output_dir: "/tmp/build/test-env".to_string(), + } + } + + /// Helper to assert JSON fields match expected string values + fn assert_json_str_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_render_details_as_valid_json() { + // Arrange + let data = create_test_data(); + + // Act + let json = JsonView::render(&data); + + // Assert - verify it's valid JSON with expected string field values + assert_json_str_fields_eq( + &json, + &[ + ("environment_name", "test-env"), + ("config_source", "Config file: envs/test-env.json"), + ("target_ip", "192.168.1.100"), + ("output_dir", "/tmp/build/test-env"), + ], + ); + } + + #[test] + fn it_should_render_all_required_fields() { + // Arrange + let data = create_test_data(); + + // Act + let json = JsonView::render(&data); + + // Assert - every documented field must be present + assert_json_has_fields( + &json, + &[ + "environment_name", + "config_source", + "target_ip", + "output_dir", + ], + ); + } + + #[test] + fn it_should_produce_valid_json() { + // Arrange + let data = create_test_data(); + + // Act + let json = JsonView::render(&data); + + // Assert + let result = serde_json::from_str::(&json); + assert!(result.is_ok(), "Output should be valid JSON, got: {json}"); + } + + #[test] + fn it_should_render_env_name_source_via_env_name() { + // Arrange - simulate env-name based source + let data = RenderDetailsData { + environment_name: "my-env".to_string(), + config_source: "Environment: my-env".to_string(), + target_ip: "10.0.0.1".to_string(), + output_dir: "/tmp/build/my-env".to_string(), + }; + + // Act + let json = JsonView::render(&data); + + // Assert + assert_json_str_fields_eq(&json, &[("config_source", "Environment: my-env")]); + } + + #[test] + fn it_should_render_pretty_printed_json() { + // Arrange + let data = create_test_data(); + + // Act + let json = JsonView::render(&data); + + // Assert - pretty-printed JSON has newlines and indentation + assert!( + json.contains('\n'), + "Pretty-printed JSON should contain newlines" + ); + assert!( + json.contains(" "), + "Pretty-printed JSON should contain indentation" + ); + } +} diff --git a/src/presentation/cli/views/commands/render/views/text_view.rs b/src/presentation/cli/views/commands/render/views/text_view.rs new file mode 100644 index 00000000..5eb71560 --- /dev/null +++ b/src/presentation/cli/views/commands/render/views/text_view.rs @@ -0,0 +1,213 @@ +//! Text View for Render Command +//! +//! This module provides text-based rendering for the render command. +//! It follows the Strategy Pattern, providing a human-readable output format +//! for the same underlying data (`RenderDetailsData` DTO). +//! +//! # Design +//! +//! The `TextView` formats render 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::render::RenderDetailsData; + +/// View for rendering render details as human-readable text +/// +/// This view produces formatted text output suitable for terminal display +/// and human consumption. It presents artifact generation details +/// in a clear, readable format including next steps for the user. +/// +/// 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::render::{ +/// RenderDetailsData, TextView, +/// }; +/// +/// let data = RenderDetailsData { +/// environment_name: "my-env".to_string(), +/// config_source: "Config file: envs/my-env.json".to_string(), +/// target_ip: "192.168.1.100".to_string(), +/// output_dir: "/tmp/build/my-env".to_string(), +/// }; +/// +/// let output = TextView::render(&data); +/// assert!(output.contains("Deployment artifacts generated successfully!")); +/// assert!(output.contains("192.168.1.100")); +/// assert!(output.contains("/tmp/build/my-env")); +/// ``` +pub struct TextView; + +impl TextView { + /// Render render details as human-readable formatted text + /// + /// Takes render details and produces a human-readable output + /// intended to be wrapped by `ProgressReporter::complete()`. + /// + /// # Arguments + /// + /// * `data` - Render details to render + /// + /// # Returns + /// + /// A formatted string containing: + /// - "Deployment artifacts generated successfully!" + /// - Source, Target IP, and Output path + /// - Next steps section with guidance + /// + /// # Format + /// + /// The output follows this structure: + /// + /// ```text + /// Deployment artifacts generated successfully! + /// + /// Source: + /// Target IP: + /// Output: + /// + /// Next steps: + /// - Review artifacts in the output directory + /// - Use 'provision' command to deploy infrastructure + /// - Or use artifacts manually with your deployment tools + /// ``` + /// + /// # Examples + /// + /// ```rust + /// use torrust_tracker_deployer_lib::presentation::cli::views::commands::render::{ + /// RenderDetailsData, TextView, + /// }; + /// + /// let data = RenderDetailsData { + /// environment_name: "prod-tracker".to_string(), + /// config_source: "Config file: envs/prod-tracker.json".to_string(), + /// target_ip: "10.0.0.1".to_string(), + /// output_dir: "/tmp/build/prod-tracker".to_string(), + /// }; + /// + /// let text = TextView::render(&data); + /// + /// assert!(text.contains("Deployment artifacts generated successfully!")); + /// assert!(text.contains("10.0.0.1")); + /// assert!(text.contains("Next steps:")); + /// ``` + #[must_use] + pub fn render(data: &RenderDetailsData) -> String { + format!( + "Deployment artifacts generated successfully!\n\n\ + \x20\x20Source: {}\n\ + \x20\x20Target IP: {}\n\ + \x20\x20Output: {}\n\n\ + Next steps:\n\ + \x20\x20- Review artifacts in the output directory\n\ + \x20\x20- Use 'provision' command to deploy infrastructure\n\ + \x20\x20- Or use artifacts manually with your deployment tools", + data.config_source, data.target_ip, data.output_dir, + ) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + // Test fixtures + + fn create_test_data() -> RenderDetailsData { + RenderDetailsData { + environment_name: "test-env".to_string(), + config_source: "Config file: envs/test-env.json".to_string(), + target_ip: "192.168.1.100".to_string(), + output_dir: "/tmp/build/test-env".to_string(), + } + } + + /// 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_render_details_as_formatted_text() { + // Arrange + let data = create_test_data(); + + // Act + let text = TextView::render(&data); + + // Assert + assert_contains_all( + &text, + &[ + "Deployment artifacts generated successfully!", + "Config file: envs/test-env.json", + "192.168.1.100", + "/tmp/build/test-env", + "Next steps:", + ], + ); + } + + #[test] + fn it_should_include_all_result_fields() { + // Arrange + let data = create_test_data(); + + // Act + let text = TextView::render(&data); + + // Assert - each significant data point appears in the output + assert_contains_all( + &text, + &[ + "Config file: envs/test-env.json", + "192.168.1.100", + "/tmp/build/test-env", + ], + ); + } + + #[test] + fn it_should_include_next_steps_guidance() { + // Arrange + let data = create_test_data(); + + // Act + let text = TextView::render(&data); + + // Assert - next steps section is present + assert_contains_all(&text, &["Next steps:", "Review artifacts", "provision"]); + } + + #[test] + fn it_should_render_env_name_source_when_used() { + // Arrange - simulate env-name based source + let data = RenderDetailsData { + environment_name: "my-env".to_string(), + config_source: "Environment: my-env".to_string(), + target_ip: "10.0.0.1".to_string(), + output_dir: "/tmp/build/my-env".to_string(), + }; + + // Act + let text = TextView::render(&data); + + // Assert + assert_contains_all( + &text, + &["Environment: my-env", "10.0.0.1", "/tmp/build/my-env"], + ); + } +}