Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
65 changes: 32 additions & 33 deletions docs/issues/394-add-json-output-to-purge-command.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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

Expand Down Expand Up @@ -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

Expand Down Expand Up @@ -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

Expand Down
46 changes: 46 additions & 0 deletions docs/user-guide/commands/purge.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
1 change: 1 addition & 0 deletions project-words.txt
Original file line number Diff line number Diff line change
Expand Up @@ -152,6 +152,7 @@ certbot
certonly
chatbots
chdir
checkmark
checkmarks
checkpointed
checkpointing
Expand Down
3 changes: 2 additions & 1 deletion src/presentation/cli/controllers/purge/errors.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
///
Expand All @@ -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());
/// }
Expand Down
25 changes: 20 additions & 5 deletions src/presentation/cli/controllers/purge/handler.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down Expand Up @@ -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
///
Expand All @@ -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)?;

Expand Down Expand Up @@ -146,7 +150,7 @@ impl PurgeCommandController {
})?;
self.progress.complete_step(None)?;

self.complete_workflow(environment_name)?;
self.complete_workflow(environment_name, output_format)?;

Ok(())
}
Expand Down Expand Up @@ -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(())
}

Expand Down
6 changes: 4 additions & 2 deletions src/presentation/cli/controllers/purge/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -21,14 +21,15 @@
//! ```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]
//! # async fn main() {
//! 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}");
Expand All @@ -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;
//!
Expand All @@ -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());
//! }
Expand Down
3 changes: 2 additions & 1 deletion src/presentation/cli/dispatch/router.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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(())
}
Expand Down
1 change: 1 addition & 0 deletions src/presentation/cli/views/commands/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
52 changes: 52 additions & 0 deletions src/presentation/cli/views/commands/purge/mod.rs
Original file line number Diff line number Diff line change
@@ -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};
Loading
Loading