From fa8c9982b2eb1388f6a3d5c620c238489a205d40 Mon Sep 17 00:00:00 2001 From: Jose Celano Date: Fri, 27 Feb 2026 08:08:04 +0000 Subject: [PATCH] feat: add JSON output to purge command (closes #394) - Add PurgeDetailsData DTO with from_environment_name constructor - Add JsonView and TextView for purge command (Strategy Pattern) - Add output_format param to PurgeCommandController::execute() - Dispatch to TextView or JsonView in complete_workflow() - Update router to pass context.output_format() to purge controller - Add unit tests for DTO, JsonView, and TextView --- .../394-add-json-output-to-purge-command.md | 65 ++++---- docs/user-guide/commands/purge.md | 46 ++++++ project-words.txt | 1 + .../cli/controllers/purge/errors.rs | 3 +- .../cli/controllers/purge/handler.rs | 25 ++- src/presentation/cli/controllers/purge/mod.rs | 6 +- src/presentation/cli/dispatch/router.rs | 3 +- src/presentation/cli/views/commands/mod.rs | 1 + .../cli/views/commands/purge/mod.rs | 52 +++++++ .../commands/purge/view_data/purge_details.rs | 101 ++++++++++++ .../views/commands/purge/views/json_view.rs | 147 ++++++++++++++++++ .../views/commands/purge/views/text_view.rs | 119 ++++++++++++++ 12 files changed, 527 insertions(+), 42 deletions(-) create mode 100644 src/presentation/cli/views/commands/purge/mod.rs create mode 100644 src/presentation/cli/views/commands/purge/view_data/purge_details.rs create mode 100644 src/presentation/cli/views/commands/purge/views/json_view.rs create mode 100644 src/presentation/cli/views/commands/purge/views/text_view.rs diff --git a/docs/issues/394-add-json-output-to-purge-command.md b/docs/issues/394-add-json-output-to-purge-command.md index 4e13cda7..1409a83b 100644 --- a/docs/issues/394-add-json-output-to-purge-command.md +++ b/docs/issues/394-add-json-output-to-purge-command.md @@ -21,13 +21,13 @@ The application-layer `PurgeCommandHandler::execute()` returns `()` on success ## Goals -- [ ] Add `output_format: OutputFormat` parameter to `PurgeCommandController::execute()` -- [ ] Add a `PurgeDetailsData` view DTO -- [ ] Implement `JsonView` for the purge command — `render()` returns `String` using the list-command pattern (inline `unwrap_or_else` fallback) -- [ ] Implement `TextView` to present the same data in human-readable format (preserving current `complete_workflow` output) -- [ ] Handle `OutputFormat::Json` and `OutputFormat::Text` branches in `complete_workflow()` using Strategy Pattern -- [ ] Update router to pass `output_format` from context to controller -- [ ] Add unit tests for `JsonView` and `TextView` +- [x] Add `output_format: OutputFormat` parameter to `PurgeCommandController::execute()` +- [x] Add a `PurgeDetailsData` view DTO +- [x] Implement `JsonView` for the purge command — `render()` returns `String` using the list-command pattern (inline `unwrap_or_else` fallback) +- [x] Implement `TextView` to present the same data in human-readable format (preserving current `complete_workflow` output) +- [x] Handle `OutputFormat::Json` and `OutputFormat::Text` branches in `complete_workflow()` using Strategy Pattern +- [x] Update router to pass `output_format` from context to controller +- [x] Add unit tests for `JsonView` and `TextView` ## 🏗️ Architecture Requirements @@ -46,18 +46,18 @@ The application-layer `PurgeCommandHandler::execute()` returns `()` on success ### Module Structure Requirements -- [ ] Follow the existing view module structure established in `render/` and `validate/` (has `view_data/`) -- [ ] `PurgeDetailsData` is a plain presentation DTO deriving `Serialize`, `PartialEq` (not `Deserialize`) with a named constructor `from_environment_name` — built from just the environment name string since purge returns no result struct -- [ ] `JsonView::render()` returns `String` — serialization errors handled inline via `unwrap_or_else` -- [ ] `TextView::render()` formats the same data as human-readable text and also returns `String` (preserving existing output) -- [ ] Follow module organization conventions (`docs/contributing/module-organization.md`) +- [x] Follow the existing view module structure established in `render/` and `validate/` (has `view_data/`) +- [x] `PurgeDetailsData` is a plain presentation DTO deriving `Serialize`, `PartialEq` (not `Deserialize`) with a named constructor `from_environment_name` — built from just the environment name string since purge returns no result struct +- [x] `JsonView::render()` returns `String` — serialization errors handled inline via `unwrap_or_else` +- [x] `TextView::render()` formats the same data as human-readable text and also returns `String` (preserving existing output) +- [x] Follow module organization conventions (`docs/contributing/module-organization.md`) ### Architectural Constraints -- [ ] No business logic in the presentation layer — only rendering -- [ ] Error handling follows project conventions (`docs/contributing/error-handling.md`) -- [ ] Output must go through `UserOutput` methods — never `println!` or `eprintln!` directly (`docs/contributing/output-handling.md`) -- [ ] The `PurgeDetailsData` DTO must derive `serde::Serialize` (output-only — no `Deserialize` needed) +- [x] No business logic in the presentation layer — only rendering +- [x] Error handling follows project conventions (`docs/contributing/error-handling.md`) +- [x] Output must go through `UserOutput` methods — never `println!` or `eprintln!` directly (`docs/contributing/output-handling.md`) +- [x] The `PurgeDetailsData` DTO must derive `serde::Serialize` (output-only — no `Deserialize` needed) ### Anti-Patterns to Avoid @@ -182,29 +182,28 @@ Commands::Purge { environment, force } => { ### Phase 1 — View Module -- [ ] Create `src/presentation/cli/views/commands/purge/mod.rs` -- [ ] Create `src/presentation/cli/views/commands/purge/view_data/purge_details.rs` with `PurgeDetailsData` -- [ ] Create `src/presentation/cli/views/commands/purge/views/mod.rs` -- [ ] Create `src/presentation/cli/views/commands/purge/views/json_view.rs` with `JsonView` -- [ ] Create `src/presentation/cli/views/commands/purge/views/text_view.rs` with `TextView` -- [ ] Register `purge` in `src/presentation/cli/views/commands/mod.rs` +- [x] Create `src/presentation/cli/views/commands/purge/mod.rs` +- [x] Create `src/presentation/cli/views/commands/purge/view_data/purge_details.rs` with `PurgeDetailsData` +- [x] Create `src/presentation/cli/views/commands/purge/views/json_view.rs` with `JsonView` +- [x] Create `src/presentation/cli/views/commands/purge/views/text_view.rs` with `TextView` +- [x] Register `purge` in `src/presentation/cli/views/commands/mod.rs` ### Phase 2 — Controller Changes -- [ ] Add `output_format: OutputFormat` parameter to `PurgeCommandController::execute()` -- [ ] Update `complete_workflow()` to accept and dispatch on `output_format` -- [ ] Import and use `PurgeDetailsData`, `JsonView`, `TextView`, `OutputFormat` +- [x] Add `output_format: OutputFormat` parameter to `PurgeCommandController::execute()` +- [x] Update `complete_workflow()` to accept and dispatch on `output_format` +- [x] Import and use `PurgeDetailsData`, `JsonView`, `TextView`, `OutputFormat` ### Phase 3 — Router Changes -- [ ] Add `let output_format = context.output_format();` in `Commands::Purge` arm -- [ ] Pass `output_format` as new argument to `execute()` +- [x] Add `let output_format = context.output_format();` in `Commands::Purge` arm +- [x] Pass `output_format` as new argument to `execute()` ### Phase 4 — Tests -- [ ] Unit tests for `JsonView::render()` — assert serialized JSON matches expected -- [ ] Unit tests for `TextView::render()` — assert formatted string matches expected -- [ ] Verify existing tests still pass (`cargo test`) +- [x] Unit tests for `JsonView::render()` — assert serialized JSON matches expected +- [x] Unit tests for `TextView::render()` — assert formatted string matches expected +- [x] Verify existing tests still pass (`cargo test`) ### Phase 5 — Manual Output Verification @@ -243,9 +242,9 @@ Expected: ### Phase 6 — Lint & Documentation -- [ ] `cargo run --bin linter all` passes (stable + nightly) -- [ ] `cargo machete` passes (no unused dependencies) -- [ ] Update `docs/user-guide/commands/purge.md` — add JSON output section +- [x] `cargo run --bin linter all` passes (stable + nightly) +- [x] `cargo machete` passes (no unused dependencies) +- [x] Update `docs/user-guide/commands/purge.md` — add JSON output section ## Testing Strategy diff --git a/docs/user-guide/commands/purge.md b/docs/user-guide/commands/purge.md index 448fc13b..b6a237ac 100644 --- a/docs/user-guide/commands/purge.md +++ b/docs/user-guide/commands/purge.md @@ -79,6 +79,52 @@ See detailed progress during purge: torrust-tracker-deployer purge my-environment --force --log-output file-and-stderr ``` +## JSON Output + +The purge command supports machine-readable JSON output via the `--output-format json` flag. This is useful for automation, scripts, and AI agent workflows. + +### JSON Format + +```bash +torrust-tracker-deployer purge my-environment --force --output-format json +``` + +**Output** (stdout): + +```json +{ + "environment_name": "my-environment", + "purged": true +} +``` + +| Field | Type | Description | +| ------------------ | ------- | ------------------------------------------------- | +| `environment_name` | string | Name of the environment that was purged | +| `purged` | boolean | Always `true` when the command exits successfully | + +### Notes on JSON Mode + +- `purged` is always `true` on the success path — purge failures exit with a non-zero code and produce no JSON +- Progress lines (step counters) are written to stderr; only the JSON result is written to stdout +- When using JSON mode in automation, always pass `--force` to skip the interactive confirmation prompt: + + ```bash + torrust-tracker-deployer purge my-environment --force --output-format json + ``` + +### Using JSON Output in Scripts + +Extract the environment name from the JSON output: + +```bash +result=$(torrust-tracker-deployer purge my-environment --force --output-format json 2>/dev/null) +env_name=$(echo "$result" | jq -r '.environment_name') +purged=$(echo "$result" | jq -r '.purged') + +echo "Purged: $env_name (success=$purged)" +``` + ## What Gets Purged The purge command removes: diff --git a/project-words.txt b/project-words.txt index 1dc8baf1..f4bd511f 100644 --- a/project-words.txt +++ b/project-words.txt @@ -152,6 +152,7 @@ certbot certonly chatbots chdir +checkmark checkmarks checkpointed checkpointing diff --git a/src/presentation/cli/controllers/purge/errors.rs b/src/presentation/cli/controllers/purge/errors.rs index cb21464e..8b15ecb9 100644 --- a/src/presentation/cli/controllers/purge/errors.rs +++ b/src/presentation/cli/controllers/purge/errors.rs @@ -121,6 +121,7 @@ impl PurgeSubcommandError { /// use parking_lot::ReentrantMutex; /// use torrust_tracker_deployer_lib::application::command_handlers::purge::handler::PurgeCommandHandler; /// use torrust_tracker_deployer_lib::presentation::cli::controllers::purge::handler::PurgeCommandController; + /// use torrust_tracker_deployer_lib::presentation::cli::input::cli::OutputFormat; /// use torrust_tracker_deployer_lib::presentation::cli::views::{UserOutput, VerbosityLevel}; /// use torrust_tracker_deployer_lib::infrastructure::persistence::file_repository_factory::FileRepositoryFactory; /// @@ -131,7 +132,7 @@ impl PurgeSubcommandError { /// let file_repository_factory = FileRepositoryFactory::new(Duration::from_secs(30)); /// let repository = file_repository_factory.create(data_dir.clone()); /// let handler = PurgeCommandHandler::new(repository, data_dir); - /// if let Err(e) = PurgeCommandController::new(handler, output).execute("test-env", false).await { + /// if let Err(e) = PurgeCommandController::new(handler, output).execute("test-env", false, OutputFormat::Text).await { /// eprintln!("Error: {e}"); /// eprintln!("\nTroubleshooting:\n{}", e.help()); /// } diff --git a/src/presentation/cli/controllers/purge/handler.rs b/src/presentation/cli/controllers/purge/handler.rs index dea9edca..79d46f27 100644 --- a/src/presentation/cli/controllers/purge/handler.rs +++ b/src/presentation/cli/controllers/purge/handler.rs @@ -10,6 +10,8 @@ use parking_lot::ReentrantMutex; use crate::application::command_handlers::purge::handler::PurgeCommandHandler; use crate::domain::environment::name::EnvironmentName; +use crate::presentation::cli::input::cli::OutputFormat; +use crate::presentation::cli::views::commands::purge::{JsonView, PurgeDetailsData, TextView}; use crate::presentation::cli::views::progress::ProgressReporter; use crate::presentation::cli::views::UserOutput; @@ -96,6 +98,7 @@ impl PurgeCommandController { /// /// * `environment_name` - The name of the environment to purge /// * `force` - Skip confirmation prompt if true + /// * `output_format` - Output format (text or JSON) /// /// # Errors /// @@ -115,6 +118,7 @@ impl PurgeCommandController { &mut self, environment_name: &str, force: bool, + output_format: OutputFormat, ) -> Result<(), PurgeSubcommandError> { let env_name = self.validate_environment_name(environment_name)?; @@ -146,7 +150,7 @@ impl PurgeCommandController { })?; self.progress.complete_step(None)?; - self.complete_workflow(environment_name)?; + self.complete_workflow(environment_name, output_format)?; Ok(()) } @@ -178,11 +182,22 @@ impl PurgeCommandController { /// Complete the workflow with success message /// /// Shows final success message to the user with workflow summary. + /// Dispatches to `TextView` or `JsonView` based on `output_format`. #[allow(clippy::result_large_err)] - fn complete_workflow(&mut self, environment_name: &str) -> Result<(), PurgeSubcommandError> { - self.progress.complete(&format!( - "Environment '{environment_name}' purged successfully" - ))?; + fn complete_workflow( + &mut self, + environment_name: &str, + output_format: OutputFormat, + ) -> Result<(), PurgeSubcommandError> { + let data = PurgeDetailsData::from_environment_name(environment_name); + 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/controllers/purge/mod.rs b/src/presentation/cli/controllers/purge/mod.rs index 5ad340c0..f005065f 100644 --- a/src/presentation/cli/controllers/purge/mod.rs +++ b/src/presentation/cli/controllers/purge/mod.rs @@ -21,6 +21,7 @@ //! ```rust //! use std::path::Path; //! use torrust_tracker_deployer_lib::bootstrap::Container; +//! use torrust_tracker_deployer_lib::presentation::cli::input::cli::OutputFormat; //! use torrust_tracker_deployer_lib::presentation::cli::views::VerbosityLevel; //! //! # #[tokio::main] @@ -28,7 +29,7 @@ //! let container = Container::new(VerbosityLevel::Normal, Path::new(".")); //! if let Err(e) = container //! .create_purge_controller() -//! .execute("test-env", false) +//! .execute("test-env", false, OutputFormat::Text) //! .await //! { //! eprintln!("Purge failed: {e}"); @@ -47,6 +48,7 @@ //! use std::cell::RefCell; //! use torrust_tracker_deployer_lib::application::command_handlers::purge::handler::PurgeCommandHandler; //! use torrust_tracker_deployer_lib::presentation::cli::controllers::purge::handler::PurgeCommandController; +//! use torrust_tracker_deployer_lib::presentation::cli::input::cli::OutputFormat; //! use torrust_tracker_deployer_lib::presentation::cli::views::{UserOutput, VerbosityLevel}; //! use torrust_tracker_deployer_lib::infrastructure::persistence::file_repository_factory::FileRepositoryFactory; //! @@ -57,7 +59,7 @@ //! let file_repository_factory = FileRepositoryFactory::new(Duration::from_secs(30)); //! let repository = file_repository_factory.create(data_dir.clone()); //! let handler = PurgeCommandHandler::new(repository, data_dir); -//! if let Err(e) = PurgeCommandController::new(handler, output).execute("test-env", false).await { +//! if let Err(e) = PurgeCommandController::new(handler, output).execute("test-env", false, OutputFormat::Text).await { //! eprintln!("Purge failed: {e}"); //! eprintln!("\n{}", e.help()); //! } diff --git a/src/presentation/cli/dispatch/router.rs b/src/presentation/cli/dispatch/router.rs index bbf4799c..ce657dbb 100644 --- a/src/presentation/cli/dispatch/router.rs +++ b/src/presentation/cli/dispatch/router.rs @@ -124,10 +124,11 @@ pub async fn route_command( Ok(()) } Commands::Purge { environment, force } => { + let output_format = context.output_format(); context .container() .create_purge_controller() - .execute(&environment, force) + .execute(&environment, force, output_format) .await?; Ok(()) } diff --git a/src/presentation/cli/views/commands/mod.rs b/src/presentation/cli/views/commands/mod.rs index e57ed1be..d7c2089d 100644 --- a/src/presentation/cli/views/commands/mod.rs +++ b/src/presentation/cli/views/commands/mod.rs @@ -9,6 +9,7 @@ pub mod create; pub mod destroy; pub mod list; pub mod provision; +pub mod purge; pub mod release; pub mod render; pub mod run; diff --git a/src/presentation/cli/views/commands/purge/mod.rs b/src/presentation/cli/views/commands/purge/mod.rs new file mode 100644 index 00000000..4502fa2b --- /dev/null +++ b/src/presentation/cli/views/commands/purge/mod.rs @@ -0,0 +1,52 @@ +//! Views for Purge Command +//! +//! This module contains view components for rendering purge command output. +//! +//! # Architecture +//! +//! This module follows the Strategy Pattern for rendering: +//! - `PurgeDetailsData`: 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 +//! - `purge_details.rs`: Main DTO with purge 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: &PurgeDetailsData) -> String` +//! 3. Export it from this module +//! 4. No need to modify existing views or the DTO + +pub mod view_data { + pub mod purge_details; + + // Re-export main types for convenience + pub use purge_details::PurgeDetailsData; +} + +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::PurgeDetailsData; +pub use views::{JsonView, TextView}; diff --git a/src/presentation/cli/views/commands/purge/view_data/purge_details.rs b/src/presentation/cli/views/commands/purge/view_data/purge_details.rs new file mode 100644 index 00000000..826a5d6f --- /dev/null +++ b/src/presentation/cli/views/commands/purge/view_data/purge_details.rs @@ -0,0 +1,101 @@ +//! Purge Details Data Transfer Object +//! +//! This module contains the presentation DTO for purge 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; + +/// Purge details data for rendering +/// +/// This struct holds all the data needed to render purge 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 +/// +/// `PurgeDetailsData` is built from just the environment name string since +/// the application-layer `PurgeCommandHandler::execute()` returns `()` on success — +/// there is no result struct. `purged: true` is always set because this DTO is only +/// constructed on the success path. +#[derive(Debug, Clone, PartialEq, Serialize)] +pub struct PurgeDetailsData { + /// Name of the environment that was purged + pub environment_name: String, + /// Always `true` when the command exits successfully + pub purged: bool, +} + +impl PurgeDetailsData { + /// Construct a `PurgeDetailsData` from an environment name + /// + /// This named constructor always sets `purged: true` because it is only + /// called on the success path — purge failures result in an error return, + /// never a `PurgeDetailsData`. + /// + /// # Arguments + /// + /// * `environment_name` - Name of the environment that was successfully purged + /// + /// # Examples + /// + /// ```rust + /// use torrust_tracker_deployer_lib::presentation::cli::views::commands::purge::PurgeDetailsData; + /// + /// let data = PurgeDetailsData::from_environment_name("my-env"); + /// + /// assert_eq!(data.environment_name, "my-env"); + /// assert!(data.purged); + /// ``` + #[must_use] + pub fn from_environment_name(environment_name: &str) -> Self { + Self { + environment_name: environment_name.to_string(), + purged: true, + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn it_should_build_dto_from_environment_name() { + // Act + let data = PurgeDetailsData::from_environment_name("test-env"); + + // Assert + assert_eq!(data.environment_name, "test-env"); + assert!(data.purged); + } + + #[test] + fn it_should_always_set_purged_to_true() { + // Arrange — no purge failure can produce a PurgeDetailsData + // (only success paths call from_environment_name) + let data = PurgeDetailsData::from_environment_name("test-env"); + + // Assert + assert!(data.purged, "purged should always be true on success path"); + } +} diff --git a/src/presentation/cli/views/commands/purge/views/json_view.rs b/src/presentation/cli/views/commands/purge/views/json_view.rs new file mode 100644 index 00000000..c28886e1 --- /dev/null +++ b/src/presentation/cli/views/commands/purge/views/json_view.rs @@ -0,0 +1,147 @@ +//! JSON View for Purge Command +//! +//! This module provides JSON-based rendering for the purge command. +//! It follows the Strategy Pattern, providing a machine-readable output format +//! for the same underlying data (`PurgeDetailsData` DTO). +//! +//! # Design +//! +//! The `JsonView` serializes purge result information to JSON using `serde_json`. +//! The output includes the environment name and a boolean confirming the purge. + +use crate::presentation::cli::views::commands::purge::PurgeDetailsData; + +/// View for rendering purge details as JSON +/// +/// This view provides machine-readable JSON output for automation workflows +/// and AI agents. It serializes the purge details without any transformations, +/// preserving all field names and structure from the DTO. +/// +/// # Examples +/// +/// ```rust +/// use torrust_tracker_deployer_lib::presentation::cli::views::commands::purge::{ +/// PurgeDetailsData, JsonView, +/// }; +/// +/// let data = PurgeDetailsData::from_environment_name("my-env"); +/// +/// 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["purged"], true); +/// ``` +pub struct JsonView; + +impl JsonView { + /// Render purge details as JSON + /// + /// Serializes the purge details to pretty-printed JSON format. + /// The JSON structure matches the DTO structure exactly: + /// - `environment_name`: Name of the purged environment + /// - `purged`: Always `true` on success + /// + /// # Arguments + /// + /// * `data` - Purge details to render + /// + /// # Returns + /// + /// A JSON string containing the serialized purge 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::purge::{ + /// PurgeDetailsData, JsonView, + /// }; + /// + /// let data = PurgeDetailsData::from_environment_name("prod-tracker"); + /// + /// let json = JsonView::render(&data); + /// + /// assert!(json.contains("\"environment_name\": \"prod-tracker\"")); + /// assert!(json.contains("\"purged\": true")); + /// ``` + #[must_use] + pub fn render(data: &PurgeDetailsData) -> String { + serde_json::to_string_pretty(data).unwrap_or_else(|e| { + format!( + r#"{{ + "error": "Failed to serialize purge details", + "message": "{e}" +}}"# + ) + }) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn create_test_data() -> PurgeDetailsData { + PurgeDetailsData::from_environment_name("test-env") + } + + #[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["purged"], true); + } + + #[test] + fn it_should_include_environment_name_field() { + // Arrange + let data = PurgeDetailsData::from_environment_name("my-env"); + + // 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_purged_true_field() { + // Arrange + let data = create_test_data(); + + // Act + let json = JsonView::render(&data); + + // Assert + assert!( + json.contains("\"purged\": true"), + "JSON should contain purged: 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/purge/views/text_view.rs b/src/presentation/cli/views/commands/purge/views/text_view.rs new file mode 100644 index 00000000..5197b21d --- /dev/null +++ b/src/presentation/cli/views/commands/purge/views/text_view.rs @@ -0,0 +1,119 @@ +//! Text View for Purge Command +//! +//! This module provides text-based rendering for the purge command. +//! It follows the Strategy Pattern, providing a human-readable output format +//! for the same underlying data (`PurgeDetailsData` DTO). +//! +//! # Design +//! +//! The `TextView` formats purge 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::purge::PurgeDetailsData; + +/// View for rendering purge 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::purge::{ +/// PurgeDetailsData, TextView, +/// }; +/// +/// let data = PurgeDetailsData::from_environment_name("my-env"); +/// +/// let output = TextView::render(&data); +/// assert!(output.contains("Environment 'my-env' purged successfully")); +/// ``` +pub struct TextView; + +impl TextView { + /// Render purge details as human-readable text + /// + /// Takes purge details and produces a human-readable output + /// intended to be wrapped by `ProgressReporter::complete()`. + /// + /// # Arguments + /// + /// * `data` - Purge details to render + /// + /// # Returns + /// + /// A formatted string: `"Environment '' purged successfully"`. + /// The `✅` prefix is added by `ProgressReporter::complete()`, not here. + /// + /// # Examples + /// + /// ```rust + /// use torrust_tracker_deployer_lib::presentation::cli::views::commands::purge::{ + /// PurgeDetailsData, TextView, + /// }; + /// + /// let data = PurgeDetailsData::from_environment_name("prod-tracker"); + /// + /// let text = TextView::render(&data); + /// + /// assert!(text.contains("Environment 'prod-tracker' purged successfully")); + /// ``` + #[must_use] + pub fn render(data: &PurgeDetailsData) -> String { + format!( + "Environment '{}' purged successfully", + data.environment_name + ) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn it_should_render_success_message_with_environment_name() { + // Arrange + let data = PurgeDetailsData::from_environment_name("test-env"); + + // Act + let text = TextView::render(&data); + + // Assert + assert_eq!(text, "Environment 'test-env' purged successfully"); + } + + #[test] + fn it_should_include_environment_name_in_output() { + // Arrange + let data = PurgeDetailsData::from_environment_name("my-production-env"); + + // 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 = PurgeDetailsData::from_environment_name("test-env"); + + // Act + let text = TextView::render(&data); + + // Assert + assert!( + !text.starts_with('✅'), + "TextView should not add the ✅ prefix — that is ProgressReporter's job" + ); + } +}