diff --git a/.github/skills/dev/rust-code-quality/handle-errors-in-code/skill.md b/.github/skills/dev/rust-code-quality/handle-errors-in-code/skill.md index 0ec94fb4..707631cd 100644 --- a/.github/skills/dev/rust-code-quality/handle-errors-in-code/skill.md +++ b/.github/skills/dev/rust-code-quality/handle-errors-in-code/skill.md @@ -85,6 +85,38 @@ impl ProvisionError { } ``` +## Unwrap and Expect Policy + +| Context | `.unwrap()` | `.expect("msg")` | `?` / `Result` | +| ---------------------- | ------------- | -------------------------------------------- | -------------- | +| Production code | ❌ Never | ✅ Only when failure is logically impossible | ✅ Default | +| Tests and doc examples | ✅ Acceptable | ✅ Preferred when message adds clarity | — | + +```rust +// ✅ Production: propagate errors with ? +fn load_config(path: &Path) -> Result { + let content = std::fs::read_to_string(path) + .map_err(|e| ConfigError::FileAccess { path: path.to_path_buf(), source: e })?; + serde_json::from_str(&content) + .map_err(|e| ConfigError::InvalidJson { path: path.to_path_buf(), source: e }) +} + +// ✅ Production: expect() only when failure is a code invariant violation +let pair = "key=value"; +let (k, v) = pair.split_once('=') + .expect("split on '=' always succeeds: the string literal contains '='"); + +// ❌ Production: never unwrap() +let value = some_result.unwrap(); + +// ✅ Tests and doc examples: unwrap() is fine +#[test] +fn it_should_parse_valid_config() { + let config: Config = serde_json::from_str(VALID_JSON).unwrap(); + assert_eq!(config.name, "test"); +} +``` + ## Quick Checklist - [ ] Error type uses `thiserror::Error` derive @@ -92,6 +124,7 @@ impl ProvisionError { - [ ] Error message includes fix instructions where possible - [ ] Prefer `enum` over `Box` or `anyhow` - [ ] No vague messages like "invalid input" or "error occurred" +- [ ] No `.unwrap()` in production code (tests and doc examples are fine) ## Reference diff --git a/docs/contributing/error-handling.md b/docs/contributing/error-handling.md index bfb9f347..e384eb8a 100644 --- a/docs/contributing/error-handling.md +++ b/docs/contributing/error-handling.md @@ -434,64 +434,64 @@ Errors in this project are organized following Domain-Driven Design layers. Each ### Unwrap and Expect Usage -The use of `unwrap()` is generally **discouraged** in this project. While it may make sense in certain contexts, we prefer alternatives that align with our principles of observability, traceability, and user-friendliness. - #### General Rule -**Prefer `expect()` over `unwrap()`** - Even in cases where panicking is acceptable, use `expect()` to provide meaningful context that aids debugging and aligns with our observability principles. +| Context | `.unwrap()` | `.expect("msg")` | `?` / `Result` | +| ---------------------- | ------------- | -------------------------------------------- | -------------- | +| Production code | ❌ Never | ✅ Only when failure is logically impossible | ✅ Default | +| Tests and doc examples | ✅ Acceptable | ✅ Preferred when message adds clarity | — | + +In **production code**, always propagate errors with `?` or explicit `map_err`. Use `.expect()` only for operations where failure is truly logically impossible given the surrounding code's invariants. Never use `.unwrap()` — it provides no context when it panics. + +In **tests and doc examples**, `.unwrap()` is acceptable. Prefer `.expect("message")` when the message would meaningfully help diagnose a test failure, but it is not required. #### When Unwrap/Expect is Acceptable -##### Tests +##### Tests and Doc Examples -In test code, panicking on unexpected failures is acceptable and even desired. However, **prefer `expect()` with descriptive messages** to make test failures easier to understand. +In test code and doc examples, panicking on unexpected failures is acceptable and even desired. `.unwrap()` is fine. Prefer `.expect("message")` when the message would help diagnose a failure, but it is not required. ```rust -// ✅ Good: Tests with expect() providing context +// ✅ Acceptable: plain unwrap() in tests #[test] fn it_should_parse_valid_config() { let config_str = r#"{"name": "test", "port": 8080}"#; - let config: Config = serde_json::from_str(config_str) - .expect("Failed to parse valid test configuration - this indicates a parsing bug"); + let config: Config = serde_json::from_str(config_str).unwrap(); assert_eq!(config.name, "test"); } -// ✅ Also acceptable in tests with clear context +// ✅ Also good: expect() adds useful context when a test fails #[test] fn it_should_create_temp_directory() { let temp_dir = TempDir::new() .expect("Failed to create temporary directory for test - check filesystem permissions"); // ... rest of test } - -// ❌ Avoid: Unwrap without context -#[test] -fn it_should_parse_valid_config() { - let config_str = r#"{"name": "test", "port": 8080}"#; - let config: Config = serde_json::from_str(config_str).unwrap(); // What failed? Why? - - assert_eq!(config.name, "test"); -} ``` -##### Infallible Operations +##### Infallible Operations in Production -Use `expect()` (not `unwrap()`) for operations that are logically infallible but return `Result` or `Option` due to API design. +Use `.expect()` (never `.unwrap()`) for operations that are logically infallible — where the code's own invariants make failure impossible. The `.expect()` message must explain _why_ failure is impossible. ```rust -// ✅ Good: expect() with clear reasoning -let port: u16 = env::var("PORT") - .expect("PORT environment variable must be set during application initialization") - .parse() - .expect("PORT must be a valid u16 number - validated during configuration loading"); +// ✅ Correct: expect() because the literal always contains '=' +let pair = "key=value"; +let (k, v) = pair.split_once('=') + .expect("split on '=' always succeeds: the string literal contains '='"); -// ✅ Good: Mutex operations that should never fail +// ✅ Correct: Mutex poisoning means a prior panic occurred — acceptable to surface that let data = self.state.lock() .expect("State mutex poisoned - indicates a panic occurred while holding the lock"); -// ❌ Avoid: unwrap() without explanation +// ❌ Wrong: unwrap() in production code — never acceptable let port: u16 = env::var("PORT").unwrap().parse().unwrap(); + +// ✅ Correct production alternative: propagate as a domain error +let port: u16 = env::var("PORT") + .map_err(|_| ConfigError::MissingEnvVar { name: "PORT" })? + .parse() + .map_err(|_| ConfigError::InvalidPort { raw: env::var("PORT").unwrap_or_default() })?; ``` #### When to Use Proper Error Handling Instead @@ -550,10 +550,10 @@ let config = CONFIG.get().unwrap(); #### Summary -- **Default**: Use proper error handling with `Result` and specific error types -- **Tests**: Use `expect()` with descriptive messages explaining what failed -- **Infallible operations**: Use `expect()` with clear reasoning about why failure is impossible -- **Never**: Use `unwrap()` without a very good reason (prefer `expect()` even in those cases) +- **Default (production)**: Use proper error handling with `Result`, `?`, and specific error types +- **Infallible operations (production)**: Use `.expect("reason")` — only when failure is logically impossible; the message must explain why +- **Never (production)**: Use `.unwrap()` — it provides no context and masks errors +- **Tests and doc examples**: `.unwrap()` is acceptable; prefer `.expect("message")` when the message adds diagnostic value This approach ensures that even panic messages provide valuable debugging context, maintaining our commitment to observability and traceability throughout the codebase. @@ -886,7 +886,7 @@ When reviewing error handling code, verify: - [ ] **Thiserror Usage**: Are enum errors using `thiserror` with proper `#[error]` attributes? - [ ] **Source Preservation**: Are source errors preserved with `#[source]` for traceability? - [ ] **Pattern Matching**: Can callers handle different error cases appropriately? -- [ ] **Unwrap/Expect**: Is `unwrap()` avoided in favor of `expect()` with descriptive messages? +- [ ] **Unwrap/Expect**: Is `unwrap()` absent from production code? (Tests and doc examples may use `.unwrap()`; production code uses `?` or `.expect("reason")` for logically-impossible failures only) - [ ] **Consistency**: Does the error follow project conventions? - [ ] **Error Grouping**: Are related errors grouped with section comments? - [ ] **Box Wrapping**: Are large nested errors wrapped with `Box` to avoid enum bloat? @@ -915,7 +915,7 @@ Look for error handling examples in: 2. **Use enums by default**: Only use `anyhow` when justified 3. **Include context**: Always provide enough information for diagnosis 4. **Make errors actionable**: Tell users how to fix the problem -5. **Prefer `expect()` over `unwrap()`**: Provide meaningful context even when panicking +5. **Never `unwrap()` in production**: Use `?` to propagate or `.expect("reason")` only for logically-impossible failures; `.unwrap()` is acceptable in tests 6. **Test error paths**: Write tests for error scenarios 7. **Document error types**: Document when and why specific errors occur diff --git a/docs/refactors/plans/standardize-json-view-render-api.md b/docs/refactors/plans/standardize-json-view-render-api.md index 59ffe922..47b0decf 100644 --- a/docs/refactors/plans/standardize-json-view-render-api.md +++ b/docs/refactors/plans/standardize-json-view-render-api.md @@ -41,14 +41,14 @@ incompatible patterns in use, and no mechanism to prevent future drift. **Total Active Proposals**: 2 **Total Postponed**: 0 **Total Discarded**: 0 -**Completed**: 0 +**Completed**: 2 **In Progress**: 0 -**Not Started**: 2 +**Not Started**: 0 ### Phase Summary -- **Phase 0 - Introduce ViewRenderError + Render Trait (High Impact, Low Effort)**: ⏳ 0/1 completed (0%) -- **Phase 1 - Standardize Return Type (High Impact, Medium Effort)**: ⏳ 0/1 completed (0%) +- **Phase 0 - Introduce ViewRenderError + Render Trait (High Impact, Low Effort)**: ✅ 1/1 completed (100%) +- **Phase 1 - Standardize Return Type (High Impact, Medium Effort)**: ✅ 1/1 completed (100%) ### Discarded Proposals @@ -107,13 +107,13 @@ be reviewed as one unit. Low effort since no existing call sites change yet. ### Proposal #1: Add `ViewRenderError` and `Render` Trait -**Status**: ⏳ Not Started +**Status**: ✅ Completed **Impact**: 🟢🟢🟢 High **Effort**: 🔵 Low **Priority**: P0 **Depends On**: None -**Completed**: - -**Commit**: - +**Completed**: 2026-02-27 +**Commit**: `91798861` #### Problem @@ -208,13 +208,13 @@ the trait when the error type is later updated. Defining `ViewRenderError` upfro #### Implementation Checklist -- [ ] Create `src/presentation/cli/views/render.rs` with `ViewRenderError` and `Render` -- [ ] Add `thiserror` as a dependency if not already present (check `Cargo.toml`) -- [ ] Re-export both from `src/presentation/cli/views/mod.rs` -- [ ] Add `impl Render<...> for JsonView` to all 13 `json_view.rs` files -- [ ] Add `impl Render<...> for TextView` to all 13 `text_view.rs` files -- [ ] Verify all tests pass -- [ ] Run `cargo run --bin linter all` and fix issues +- [x] Create `src/presentation/cli/views/render.rs` with `ViewRenderError` and `Render` +- [x] Add `thiserror` as a dependency if not already present (check `Cargo.toml`) +- [x] Re-export both from `src/presentation/cli/views/mod.rs` +- [x] Add `impl Render<...> for JsonView` to all 13 `json_view.rs` files +- [x] Add `impl Render<...> for TextView` to all 13 `text_view.rs` files +- [x] Verify all tests pass +- [x] Run `cargo run --bin linter all` and fix issues #### Testing Strategy @@ -231,13 +231,13 @@ pattern. ### Proposal #2: Remove `unwrap_or_else` Fallbacks and Return `Result` -**Status**: ⏳ Not Started +**Status**: ✅ Completed **Impact**: 🟢🟢🟢 High **Effort**: 🔵🔵 Medium **Priority**: P1 **Depends On**: Proposal #1 + [`standardize-command-view-folder-structure`](standardize-command-view-folder-structure.md) (must be applied first so file paths are stable) -**Completed**: - -**Commit**: - +**Completed**: 2026-02-28 +**Commit**: `80616e44` #### Problem @@ -300,12 +300,12 @@ an error. #### Implementation Checklist -- [ ] Update all 11 `json_view.rs` standalone `render()` methods to the `Render` trait impl -- [ ] Update call sites in all 11 command handlers to use `?` -- [ ] Add `From` conversions where missing in handler error types -- [ ] Also update `create` and `provision` (already return `Result`) to use the trait -- [ ] Verify all tests pass -- [ ] Run `cargo run --bin linter all` and fix issues +- [x] Update all 11 `json_view.rs` standalone `render()` methods to the `Render` trait impl +- [x] Update call sites in all 11 command handlers to use `?` +- [x] Add `From` conversions where missing in handler error types +- [x] Also update `create` and `provision` (already return `Result`) to use the trait +- [x] Verify all tests pass +- [x] Run `cargo run --bin linter all` and fix issues #### Testing Strategy @@ -318,23 +318,23 @@ behavior (the rendered JSON string) does not change. ## 📈 Timeline - **Start Date**: 2026-02-27 -- **Actual Completion**: TBD +- **Actual Completion**: 2026-02-28 ## 🔍 Review Process ### Approval Criteria -- [ ] Technical feasibility validated -- [ ] Aligns with [Development Principles](../development-principles.md) -- [ ] Implementation plan is clear and actionable -- [ ] Priorities are correct (high-impact/low-effort first) +- [x] Technical feasibility validated +- [x] Aligns with [Development Principles](../development-principles.md) +- [x] Implementation plan is clear and actionable +- [x] Priorities are correct (high-impact/low-effort first) ### Completion Criteria -- [ ] All active proposals implemented -- [ ] All tests passing -- [ ] All linters passing -- [ ] Documentation updated +- [x] All active proposals implemented +- [x] All tests passing +- [x] All linters passing +- [x] Documentation updated - [ ] Changes merged to main branch ## 📚 Related Documentation @@ -353,8 +353,17 @@ to be implemented (earlier PRs #351, #353) and happened to use `Result` from the Subsequent commands followed a slightly different pattern that evolved independently. The Copilot review on PR #397 was the first mention of the inconsistency. +A follow-up cleanup (`1c71cd7e`) also removed the inherent `pub fn render() -> String` +methods from all 13 `text_view.rs` files. These had been left intact after Proposals #1 +and #2 because they weren't part of the original scope. Post-completion review revealed +that keeping them created an asymmetry: `JsonView` exposed rendering exclusively through +the `Render` trait while `TextView` still had an inherent "convenience" method that +delegated to the trait. Applying the "least surprise" principle, the inherent methods were +removed and their bodies inlined directly into the `Render` impls, making all 26 view +structs consistent. + --- **Created**: 2026-02-27 -**Last Updated**: 2026-02-27 (revised: merged ViewRenderError into Proposal #1, removed deferred Phase 2) -**Status**: 📋 Planning +**Last Updated**: 2026-02-28 (follow-up: removed inherent render() from text_views, commit 1c71cd7e) +**Status**: ✅ Completed diff --git a/src/presentation/cli/controllers/configure/errors.rs b/src/presentation/cli/controllers/configure/errors.rs index e4c724ea..eaf10a7e 100644 --- a/src/presentation/cli/controllers/configure/errors.rs +++ b/src/presentation/cli/controllers/configure/errors.rs @@ -9,6 +9,7 @@ use thiserror::Error; use crate::application::command_handlers::configure::errors::ConfigureCommandHandlerError; use crate::domain::environment::name::EnvironmentNameError; use crate::presentation::cli::views::progress::ProgressReporterError; +use crate::presentation::cli::views::ViewRenderError; /// Configure command specific errors /// @@ -89,6 +90,12 @@ Tip: This is a critical bug - please report it with full logs using --log-output #[source] source: ProgressReporterError, }, + /// Output formatting failed (JSON serialization error). + /// This indicates an internal error in data serialization. + #[error( + "Failed to format output: {reason}\nTip: This is a critical bug - please report it with full logs using --log-output file-and-stderr" + )] + OutputFormatting { reason: String }, } // ============================================================================ @@ -100,6 +107,13 @@ impl From for ConfigureSubcommandError { Self::ProgressReportingFailed { source } } } +impl From for ConfigureSubcommandError { + fn from(e: ViewRenderError) -> Self { + Self::OutputFormatting { + reason: e.to_string(), + } + } +} impl ConfigureSubcommandError { /// Get detailed troubleshooting guidance for this error @@ -330,6 +344,9 @@ This is a critical internal error that should not occur during normal operation. This error indicates a bug in the progress reporting system. Please report it so we can fix it." } + Self::OutputFormatting { .. } => { + "Output Formatting Failed - Critical Internal Error:\n\nThis error should not occur during normal operation. It indicates a bug in the output formatting system.\n\n1. Immediate actions:\n - Save full error output\n - Copy log files from data/logs/\n - Note the exact command and output format being used\n\n2. Report the issue:\n - Create GitHub issue with full details\n - Include: command, output format (--output-format), error output, logs\n - Describe steps to reproduce\n\n3. Temporary workarounds:\n - Try using different output format (text vs json)\n - Try running command again\n\nPlease report it so we can fix it." + } } } } diff --git a/src/presentation/cli/controllers/configure/handler.rs b/src/presentation/cli/controllers/configure/handler.rs index eb6674cd..ae1a4177 100644 --- a/src/presentation/cli/controllers/configure/handler.rs +++ b/src/presentation/cli/controllers/configure/handler.rs @@ -19,6 +19,7 @@ use crate::presentation::cli::views::commands::configure::{ }; use crate::presentation::cli::views::progress::ProgressReporter; use crate::presentation::cli::views::progress::VerboseProgressListener; +use crate::presentation::cli::views::Render; use crate::presentation::cli::views::UserOutput; use crate::shared::clock::Clock; @@ -251,9 +252,7 @@ impl ConfigureCommandController { /// /// # Note /// - /// JSON serialization errors are handled inline by `JsonView::render()`, - /// which returns a fallback error JSON string. Therefore, this method - /// does not propagate serialization errors. + /// JSON serialization errors are propagated as `ConfigureSubcommandError::OutputFormatting`. #[allow(clippy::result_large_err)] fn display_configure_results( &mut self, @@ -263,8 +262,8 @@ impl ConfigureCommandController { self.progress.blank_line()?; let details = ConfigureDetailsData::from(configured); let output = match output_format { - OutputFormat::Text => TextView::render(&details), - OutputFormat::Json => JsonView::render(&details), + OutputFormat::Text => TextView::render(&details)?, + OutputFormat::Json => JsonView::render(&details)?, }; self.progress.result(&output)?; Ok(()) diff --git a/src/presentation/cli/controllers/create/subcommands/environment/errors.rs b/src/presentation/cli/controllers/create/subcommands/environment/errors.rs index 907a4d06..2e7fa1ee 100644 --- a/src/presentation/cli/controllers/create/subcommands/environment/errors.rs +++ b/src/presentation/cli/controllers/create/subcommands/environment/errors.rs @@ -9,6 +9,7 @@ use thiserror::Error; use crate::application::command_handlers::create::CreateCommandHandlerError; use crate::presentation::cli::views::progress::ProgressReporterError; +use crate::presentation::cli::views::ViewRenderError; /// Format of configuration file #[derive(Debug, Clone, Copy)] @@ -154,6 +155,14 @@ impl From for CreateEnvironmentCommandError { } } +impl From for CreateEnvironmentCommandError { + fn from(e: ViewRenderError) -> Self { + Self::OutputFormatting { + reason: e.to_string(), + } + } +} + impl CreateEnvironmentCommandError { /// Provides detailed troubleshooting guidance for this error /// diff --git a/src/presentation/cli/controllers/create/subcommands/environment/handler.rs b/src/presentation/cli/controllers/create/subcommands/environment/handler.rs index c0c873d6..240c006c 100644 --- a/src/presentation/cli/controllers/create/subcommands/environment/handler.rs +++ b/src/presentation/cli/controllers/create/subcommands/environment/handler.rs @@ -19,6 +19,7 @@ use crate::presentation::cli::views::commands::create::{ EnvironmentDetailsData, JsonView, TextView, }; use crate::presentation::cli::views::progress::ProgressReporter; +use crate::presentation::cli::views::Render; use crate::presentation::cli::views::UserOutput; use crate::shared::clock::Clock; @@ -307,7 +308,7 @@ impl CreateEnvironmentCommandController { // Render using appropriate view based on output format (Strategy Pattern) let output = match output_format { - OutputFormat::Text => TextView::render(&details), + OutputFormat::Text => TextView::render(&details)?, OutputFormat::Json => JsonView::render(&details).map_err(|e| { CreateEnvironmentCommandError::OutputFormatting { reason: format!("Failed to serialize environment details as JSON: {e}"), diff --git a/src/presentation/cli/controllers/destroy/errors.rs b/src/presentation/cli/controllers/destroy/errors.rs index 2e461a07..19a4fb5d 100644 --- a/src/presentation/cli/controllers/destroy/errors.rs +++ b/src/presentation/cli/controllers/destroy/errors.rs @@ -9,6 +9,7 @@ use thiserror::Error; use crate::application::command_handlers::destroy::DestroyCommandHandlerError; use crate::domain::environment::name::EnvironmentNameError; use crate::presentation::cli::views::progress::ProgressReporterError; +use crate::presentation::cli::views::ViewRenderError; /// Destroy command specific errors /// @@ -79,6 +80,12 @@ Tip: This is a critical bug - please report it with full logs using --log-output #[source] source: ProgressReporterError, }, + /// Output formatting failed (JSON serialization error). + /// This indicates an internal error in data serialization. + #[error( + "Failed to format output: {reason}\nTip: This is a critical bug - please report it with full logs using --log-output file-and-stderr" + )] + OutputFormatting { reason: String }, } // ============================================================================ @@ -90,6 +97,13 @@ impl From for DestroySubcommandError { Self::ProgressReportingFailed { source } } } +impl From for DestroySubcommandError { + fn from(e: ViewRenderError) -> Self { + Self::OutputFormatting { + reason: e.to_string(), + } + } +} impl DestroySubcommandError { /// Get detailed troubleshooting guidance for this error @@ -286,6 +300,9 @@ This should never happen in normal operation. This error indicates a serious bug in the application's progress reporting system. Please report it to the development team with full details." } + Self::OutputFormatting { .. } => { + "Output Formatting Failed - Critical Internal Error:\n\nThis error should not occur during normal operation. It indicates a bug in the output formatting system.\n\n1. Immediate actions:\n - Save full error output\n - Copy log files from data/logs/\n - Note the exact command and output format being used\n\n2. Report the issue:\n - Create GitHub issue with full details\n - Include: command, output format (--output-format), error output, logs\n - Describe steps to reproduce\n\n3. Temporary workarounds:\n - Try using different output format (text vs json)\n - Try running command again\n\nPlease report it so we can fix it." + } } } } diff --git a/src/presentation/cli/controllers/destroy/handler.rs b/src/presentation/cli/controllers/destroy/handler.rs index 5b7110d4..fcc6b628 100644 --- a/src/presentation/cli/controllers/destroy/handler.rs +++ b/src/presentation/cli/controllers/destroy/handler.rs @@ -16,6 +16,7 @@ use crate::domain::environment::Environment; use crate::presentation::cli::input::cli::OutputFormat; use crate::presentation::cli::views::commands::destroy::{DestroyDetailsData, JsonView, TextView}; use crate::presentation::cli::views::progress::ProgressReporter; +use crate::presentation::cli::views::Render; use crate::presentation::cli::views::UserOutput; use crate::shared::clock::Clock; @@ -215,8 +216,8 @@ impl DestroyCommandController { let details = DestroyDetailsData::from(destroyed); let output = match output_format { - OutputFormat::Text => TextView::render(&details), - OutputFormat::Json => JsonView::render(&details), + OutputFormat::Text => TextView::render(&details)?, + OutputFormat::Json => JsonView::render(&details)?, }; self.progress.result(&output)?; diff --git a/src/presentation/cli/controllers/list/errors.rs b/src/presentation/cli/controllers/list/errors.rs index 3de99966..dad55852 100644 --- a/src/presentation/cli/controllers/list/errors.rs +++ b/src/presentation/cli/controllers/list/errors.rs @@ -9,6 +9,7 @@ use std::path::PathBuf; use thiserror::Error; use crate::presentation::cli::views::progress::ProgressReporterError; +use crate::presentation::cli::views::ViewRenderError; /// List command specific errors /// @@ -60,6 +61,12 @@ Tip: This is a critical bug - please report it with full logs using --log-output #[source] source: ProgressReporterError, }, + /// Output formatting failed (JSON serialization error). + /// This indicates an internal error in data serialization. + #[error( + "Failed to format output: {reason}\nTip: This is a critical bug - please report it with full logs using --log-output file-and-stderr" + )] + OutputFormatting { reason: String }, } // ============================================================================ @@ -71,6 +78,13 @@ impl From for ListSubcommandError { Self::ProgressReportingFailed { source } } } +impl From for ListSubcommandError { + fn from(e: ViewRenderError) -> Self { + Self::OutputFormatting { + reason: e.to_string(), + } + } +} impl ListSubcommandError { /// Get detailed troubleshooting guidance for this error @@ -153,6 +167,9 @@ For more information, see docs/user-guide/commands.md" Report issues at: https://github.com/torrust/torrust-tracker-deployer/issues" } + Self::OutputFormatting { .. } => { + "Output Formatting Failed - Critical Internal Error:\n\nThis error should not occur during normal operation. It indicates a bug in the output formatting system.\n\n1. Immediate actions:\n - Save full error output\n - Copy log files from data/logs/\n - Note the exact command and output format being used\n\n2. Report the issue:\n - Create GitHub issue with full details\n - Include: command, output format (--output-format), error output, logs\n - Describe steps to reproduce\n\n3. Temporary workarounds:\n - Try using different output format (text vs json)\n - Try running command again\n\nPlease report it so we can fix it." + } } } } diff --git a/src/presentation/cli/controllers/list/handler.rs b/src/presentation/cli/controllers/list/handler.rs index a79be6a1..ae2f594c 100644 --- a/src/presentation/cli/controllers/list/handler.rs +++ b/src/presentation/cli/controllers/list/handler.rs @@ -15,6 +15,7 @@ use crate::application::traits::RepositoryProvider; use crate::presentation::cli::input::cli::output_format::OutputFormat; use crate::presentation::cli::views::commands::list::{JsonView, TextView}; use crate::presentation::cli::views::progress::ProgressReporter; +use crate::presentation::cli::views::Render; use crate::presentation::cli::views::UserOutput; use super::errors::ListSubcommandError; @@ -160,8 +161,8 @@ impl ListCommandController { // Pipeline: EnvironmentList → render → output to stdout // Use Strategy Pattern to select view based on output format let output = match output_format { - OutputFormat::Text => TextView::render(env_list), - OutputFormat::Json => JsonView::render(env_list), + OutputFormat::Text => TextView::render(env_list)?, + OutputFormat::Json => JsonView::render(env_list)?, }; self.progress.result(&output)?; diff --git a/src/presentation/cli/controllers/provision/errors.rs b/src/presentation/cli/controllers/provision/errors.rs index 28df2622..21fe23a0 100644 --- a/src/presentation/cli/controllers/provision/errors.rs +++ b/src/presentation/cli/controllers/provision/errors.rs @@ -9,6 +9,7 @@ use thiserror::Error; use crate::application::command_handlers::provision::errors::ProvisionCommandHandlerError; use crate::domain::environment::name::EnvironmentNameError; use crate::presentation::cli::views::progress::ProgressReporterError; +use crate::presentation::cli::views::ViewRenderError; /// Provision command specific errors /// @@ -111,6 +112,14 @@ impl From for ProvisionSubcommandError { } } +impl From for ProvisionSubcommandError { + fn from(e: ViewRenderError) -> Self { + Self::OutputFormatting { + reason: e.to_string(), + } + } +} + impl ProvisionSubcommandError { /// Get detailed troubleshooting guidance for this error /// diff --git a/src/presentation/cli/controllers/provision/handler.rs b/src/presentation/cli/controllers/provision/handler.rs index 9581ecfd..1800d74c 100644 --- a/src/presentation/cli/controllers/provision/handler.rs +++ b/src/presentation/cli/controllers/provision/handler.rs @@ -19,6 +19,7 @@ use crate::presentation::cli::views::commands::provision::{ }; use crate::presentation::cli::views::progress::ProgressReporter; use crate::presentation::cli::views::progress::VerboseProgressListener; +use crate::presentation::cli::views::Render; use crate::presentation::cli::views::UserOutput; use crate::shared::clock::Clock; @@ -270,7 +271,7 @@ impl ProvisionCommandController { // Render using appropriate view based on output format (Strategy Pattern) let output = match output_format { - OutputFormat::Text => TextView::render(&details), + OutputFormat::Text => TextView::render(&details)?, OutputFormat::Json => JsonView::render(&details).map_err(|e| { ProvisionSubcommandError::OutputFormatting { reason: format!("Failed to serialize provision details as JSON: {e}"), diff --git a/src/presentation/cli/controllers/purge/errors.rs b/src/presentation/cli/controllers/purge/errors.rs index 8b15ecb9..e25fecb8 100644 --- a/src/presentation/cli/controllers/purge/errors.rs +++ b/src/presentation/cli/controllers/purge/errors.rs @@ -9,6 +9,7 @@ use thiserror::Error; use crate::application::command_handlers::purge::errors::PurgeCommandHandlerError; use crate::domain::environment::name::EnvironmentNameError; use crate::presentation::cli::views::progress::ProgressReporterError; +use crate::presentation::cli::views::ViewRenderError; /// Purge command specific errors /// @@ -93,6 +94,12 @@ Tip: This is a critical bug - please report it with full logs using --log-output #[source] source: ProgressReporterError, }, + /// Output formatting failed (JSON serialization error). + /// This indicates an internal error in data serialization. + #[error( + "Failed to format output: {reason}\nTip: This is a critical bug - please report it with full logs using --log-output file-and-stderr" + )] + OutputFormatting { reason: String }, } // ============================================================================ @@ -104,6 +111,13 @@ impl From for PurgeSubcommandError { Self::ProgressReportingFailed { source } } } +impl From for PurgeSubcommandError { + fn from(e: ViewRenderError) -> Self { + Self::OutputFormatting { + reason: e.to_string(), + } + } +} impl PurgeSubcommandError { /// Get detailed troubleshooting guidance for this error @@ -297,6 +311,9 @@ Workaround: Try using --force flag and check if operation completes despite the error: torrust-tracker-deployer purge --force" } + Self::OutputFormatting { .. } => { + "Output Formatting Failed - Critical Internal Error:\n\nThis error should not occur during normal operation. It indicates a bug in the output formatting system.\n\n1. Immediate actions:\n - Save full error output\n - Copy log files from data/logs/\n - Note the exact command and output format being used\n\n2. Report the issue:\n - Create GitHub issue with full details\n - Include: command, output format (--output-format), error output, logs\n - Describe steps to reproduce\n\n3. Temporary workarounds:\n - Try using different output format (text vs json)\n - Try running command again\n\nPlease report it so we can fix it." + } } } } diff --git a/src/presentation/cli/controllers/purge/handler.rs b/src/presentation/cli/controllers/purge/handler.rs index 79d46f27..2c4444c9 100644 --- a/src/presentation/cli/controllers/purge/handler.rs +++ b/src/presentation/cli/controllers/purge/handler.rs @@ -13,6 +13,7 @@ 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::Render; use crate::presentation::cli::views::UserOutput; use super::errors::PurgeSubcommandError; @@ -192,10 +193,10 @@ impl PurgeCommandController { let data = PurgeDetailsData::from_environment_name(environment_name); match output_format { OutputFormat::Text => { - self.progress.complete(&TextView::render(&data))?; + self.progress.complete(&TextView::render(&data)?)?; } OutputFormat::Json => { - self.progress.result(&JsonView::render(&data))?; + self.progress.result(&JsonView::render(&data)?)?; } } Ok(()) diff --git a/src/presentation/cli/controllers/register/errors.rs b/src/presentation/cli/controllers/register/errors.rs index 7d04d52d..4568b371 100644 --- a/src/presentation/cli/controllers/register/errors.rs +++ b/src/presentation/cli/controllers/register/errors.rs @@ -11,6 +11,7 @@ use thiserror::Error; use crate::application::command_handlers::register::errors::RegisterCommandHandlerError; use crate::domain::environment::name::EnvironmentNameError; use crate::presentation::cli::views::progress::ProgressReporterError; +use crate::presentation::cli::views::ViewRenderError; /// Register command specific errors /// @@ -67,6 +68,12 @@ Tip: This is a critical bug - please report it with full logs using --log-output #[source] source: ProgressReporterError, }, + /// Output formatting failed (JSON serialization error). + /// This indicates an internal error in data serialization. + #[error( + "Failed to format output: {reason}\nTip: This is a critical bug - please report it with full logs using --log-output file-and-stderr" + )] + OutputFormatting { reason: String }, } // ============================================================================ @@ -78,6 +85,13 @@ impl From for RegisterSubcommandError { Self::ProgressReportingFailed { source } } } +impl From for RegisterSubcommandError { + fn from(e: ViewRenderError) -> Self { + Self::OutputFormatting { + reason: e.to_string(), + } + } +} impl RegisterSubcommandError { /// Get detailed troubleshooting guidance for this error @@ -207,6 +221,10 @@ When reporting: - Run with verbose logging: --log-output file-and-stderr - Include the log file from data/logs/" } + + Self::OutputFormatting { .. } => { + "Output Formatting Failed - Critical Internal Error:\n\nThis error should not occur during normal operation. It indicates a bug in the output formatting system.\n\n1. Immediate actions:\n - Save full error output\n - Copy log files from data/logs/\n - Note the exact command and output format being used\n\n2. Report the issue:\n - Create GitHub issue with full details\n - Include: command, output format (--output-format), error output, logs\n - Describe steps to reproduce\n\n3. Temporary workarounds:\n - Try using different output format (text vs json)\n - Try running command again\n\nPlease report it so we can fix it." + } } } } diff --git a/src/presentation/cli/controllers/register/handler.rs b/src/presentation/cli/controllers/register/handler.rs index 1617094d..f1b120dc 100644 --- a/src/presentation/cli/controllers/register/handler.rs +++ b/src/presentation/cli/controllers/register/handler.rs @@ -19,6 +19,7 @@ use crate::presentation::cli::views::commands::register::{ JsonView, RegisterDetailsData, TextView, }; use crate::presentation::cli::views::progress::ProgressReporter; +use crate::presentation::cli::views::Render; use crate::presentation::cli::views::UserOutput; use crate::shared::clock::Clock; @@ -223,10 +224,10 @@ impl RegisterCommandController { let data = RegisterDetailsData::from_environment(provisioned); match output_format { OutputFormat::Text => { - self.progress.complete(&TextView::render(&data))?; + self.progress.complete(&TextView::render(&data)?)?; } OutputFormat::Json => { - self.progress.result(&JsonView::render(&data))?; + self.progress.result(&JsonView::render(&data)?)?; } } Ok(()) diff --git a/src/presentation/cli/controllers/release/errors.rs b/src/presentation/cli/controllers/release/errors.rs index d013aaee..0925e09c 100644 --- a/src/presentation/cli/controllers/release/errors.rs +++ b/src/presentation/cli/controllers/release/errors.rs @@ -9,6 +9,7 @@ use thiserror::Error; use crate::application::command_handlers::release::ReleaseCommandHandlerError; use crate::domain::environment::name::EnvironmentNameError; use crate::presentation::cli::views::progress::ProgressReporterError; +use crate::presentation::cli::views::ViewRenderError; /// Release command specific errors /// @@ -86,6 +87,12 @@ Tip: This is a critical bug - please report it with full logs using --log-output #[source] source: ReleaseCommandHandlerError, }, + /// Output formatting failed (JSON serialization error). + /// This indicates an internal error in data serialization. + #[error( + "Failed to format output: {reason}\nTip: This is a critical bug - please report it with full logs using --log-output file-and-stderr" + )] + OutputFormatting { reason: String }, } // ============================================================================ @@ -97,6 +104,13 @@ impl From for ReleaseSubcommandError { Self::ProgressReportingFailed { source } } } +impl From for ReleaseSubcommandError { + fn from(e: ViewRenderError) -> Self { + Self::OutputFormatting { + reason: e.to_string(), + } + } +} impl ReleaseSubcommandError { /// Get detailed troubleshooting guidance for this error @@ -238,6 +252,9 @@ Please report it to the development team with full details." } Self::ApplicationLayerError { source } => source.help(), + Self::OutputFormatting { .. } => { + "Output Formatting Failed - Critical Internal Error:\n\nThis error should not occur during normal operation. It indicates a bug in the output formatting system.\n\n1. Immediate actions:\n - Save full error output\n - Copy log files from data/logs/\n - Note the exact command and output format being used\n\n2. Report the issue:\n - Create GitHub issue with full details\n - Include: command, output format (--output-format), error output, logs\n - Describe steps to reproduce\n\n3. Temporary workarounds:\n - Try using different output format (text vs json)\n - Try running command again\n\nPlease report it so we can fix it." + } } } } diff --git a/src/presentation/cli/controllers/release/handler.rs b/src/presentation/cli/controllers/release/handler.rs index 5f446438..223dc6aa 100644 --- a/src/presentation/cli/controllers/release/handler.rs +++ b/src/presentation/cli/controllers/release/handler.rs @@ -17,6 +17,7 @@ use crate::domain::environment::Environment; use crate::presentation::cli::input::cli::OutputFormat; use crate::presentation::cli::views::commands::release::{JsonView, ReleaseDetailsData, TextView}; use crate::presentation::cli::views::progress::{ProgressReporter, VerboseProgressListener}; +use crate::presentation::cli::views::Render; use crate::presentation::cli::views::UserOutput; use crate::shared::clock::Clock; @@ -200,8 +201,8 @@ impl ReleaseCommandController { let details = ReleaseDetailsData::from(released_env); let output = match output_format { - OutputFormat::Text => TextView::render(&details), - OutputFormat::Json => JsonView::render(&details), + OutputFormat::Text => TextView::render(&details)?, + OutputFormat::Json => JsonView::render(&details)?, }; self.progress.result(&output)?; diff --git a/src/presentation/cli/controllers/render/errors.rs b/src/presentation/cli/controllers/render/errors.rs index 380cfaa8..9cfab680 100644 --- a/src/presentation/cli/controllers/render/errors.rs +++ b/src/presentation/cli/controllers/render/errors.rs @@ -8,6 +8,7 @@ use thiserror::Error; use crate::application::command_handlers::render::RenderCommandHandlerError; use crate::presentation::cli::views::progress::ProgressReporterError; +use crate::presentation::cli::views::ViewRenderError; /// Errors that can occur in the render command controller /// @@ -73,8 +74,20 @@ pub enum RenderCommandError { /// Error from displaying progress to the user #[error(transparent)] ProgressReporter(#[from] ProgressReporterError), + /// Output formatting failed (JSON serialization error). + /// This indicates an internal error in data serialization. + #[error( + "Failed to format output: {reason}\nTip: This is a critical bug - please report it with full logs using --log-output file-and-stderr" + )] + OutputFormatting { reason: String }, +} +impl From for RenderCommandError { + fn from(e: ViewRenderError) -> Self { + Self::OutputFormatting { + reason: e.to_string(), + } + } } - impl RenderCommandError { /// Provides context-specific help for troubleshooting /// @@ -124,6 +137,9 @@ impl RenderCommandError { )), Self::Handler(e) => Some(e.help()), Self::ProgressReporter(_) => None, + Self::OutputFormatting { reason } => Some(format!( + "Output Formatting Failed - Critical Internal Error:\n\nThis is a critical internal error: {reason}\n\nPlease report this bug with full logs.", + )), } } } diff --git a/src/presentation/cli/controllers/render/handler.rs b/src/presentation/cli/controllers/render/handler.rs index 46955bd3..8455a8c7 100644 --- a/src/presentation/cli/controllers/render/handler.rs +++ b/src/presentation/cli/controllers/render/handler.rs @@ -18,6 +18,7 @@ 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::Render; use crate::presentation::cli::views::UserOutput; use super::errors::RenderCommandError; @@ -208,10 +209,10 @@ impl RenderCommandController { match output_format { OutputFormat::Text => { self.progress.blank_line()?; - self.progress.complete(&TextView::render(&data))?; + self.progress.complete(&TextView::render(&data)?)?; } OutputFormat::Json => { - self.progress.result(&JsonView::render(&data))?; + self.progress.result(&JsonView::render(&data)?)?; } } diff --git a/src/presentation/cli/controllers/run/errors.rs b/src/presentation/cli/controllers/run/errors.rs index 7841389d..39ee2d16 100644 --- a/src/presentation/cli/controllers/run/errors.rs +++ b/src/presentation/cli/controllers/run/errors.rs @@ -10,6 +10,7 @@ use crate::application::command_handlers::run::RunCommandHandlerError; use crate::domain::environment::name::EnvironmentNameError; use crate::domain::environment::repository::RepositoryError; use crate::presentation::cli::views::progress::ProgressReporterError; +use crate::presentation::cli::views::ViewRenderError; /// Run command specific errors /// @@ -86,6 +87,12 @@ Tip: This is a critical bug - please report it with full logs using --log-output #[source] source: ProgressReporterError, }, + /// Output formatting failed (JSON serialization error). + /// This indicates an internal error in data serialization. + #[error( + "Failed to format output: {reason}\nTip: This is a critical bug - please report it with full logs using --log-output file-and-stderr" + )] + OutputFormatting { reason: String }, } // ============================================================================ @@ -97,6 +104,13 @@ impl From for RunSubcommandError { Self::ProgressReportingFailed { source } } } +impl From for RunSubcommandError { + fn from(e: ViewRenderError) -> Self { + Self::OutputFormatting { + reason: e.to_string(), + } + } +} impl From for RunSubcommandError { fn from(error: RepositoryError) -> Self { @@ -327,6 +341,9 @@ This should never happen in normal operation. This error indicates a serious bug in the application's progress reporting system. Please report it to the development team with full details." } + Self::OutputFormatting { .. } => { + "Output Formatting Failed - Critical Internal Error:\n\nThis error should not occur during normal operation. It indicates a bug in the output formatting system.\n\n1. Immediate actions:\n - Save full error output\n - Copy log files from data/logs/\n - Note the exact command and output format being used\n\n2. Report the issue:\n - Create GitHub issue with full details\n - Include: command, output format (--output-format), error output, logs\n - Describe steps to reproduce\n\n3. Temporary workarounds:\n - Try using different output format (text vs json)\n - Try running command again\n\nPlease report it so we can fix it." + } } } } diff --git a/src/presentation/cli/controllers/run/handler.rs b/src/presentation/cli/controllers/run/handler.rs index 68ad11ba..b78e374a 100644 --- a/src/presentation/cli/controllers/run/handler.rs +++ b/src/presentation/cli/controllers/run/handler.rs @@ -16,6 +16,7 @@ use crate::domain::environment::state::AnyEnvironmentState; use crate::presentation::cli::input::cli::OutputFormat; use crate::presentation::cli::views::commands::run::{JsonView, RunDetailsData, TextView}; use crate::presentation::cli::views::progress::ProgressReporter; +use crate::presentation::cli::views::Render; use crate::presentation::cli::views::UserOutput; use crate::shared::clock::Clock; @@ -270,8 +271,8 @@ impl RunCommandController { // Render using appropriate view based on output format (Strategy Pattern) let output = match output_format { - OutputFormat::Text => TextView::render(&data), - OutputFormat::Json => JsonView::render(&data), + OutputFormat::Text => TextView::render(&data)?, + OutputFormat::Json => JsonView::render(&data)?, }; // Pipeline: RunDetailsData → render → output to stdout diff --git a/src/presentation/cli/controllers/show/errors.rs b/src/presentation/cli/controllers/show/errors.rs index 71db166d..a5300a03 100644 --- a/src/presentation/cli/controllers/show/errors.rs +++ b/src/presentation/cli/controllers/show/errors.rs @@ -8,6 +8,7 @@ use thiserror::Error; use crate::domain::environment::name::EnvironmentNameError; use crate::presentation::cli::views::progress::ProgressReporterError; +use crate::presentation::cli::views::ViewRenderError; /// Show command specific errors /// @@ -61,6 +62,12 @@ Tip: This is a critical bug - please report it with full logs using --log-output Tip: Check if the environment data is corrupted or permissions are correct" )] LoadError { name: String, message: String }, + /// Output formatting failed (JSON serialization error). + /// This indicates an internal error in data serialization. + #[error( + "Failed to format output: {reason}\nTip: This is a critical bug - please report it with full logs using --log-output file-and-stderr" + )] + OutputFormatting { reason: String }, } // ============================================================================ @@ -72,6 +79,13 @@ impl From for ShowSubcommandError { Self::ProgressReportingFailed { source } } } +impl From for ShowSubcommandError { + fn from(e: ViewRenderError) -> Self { + Self::OutputFormatting { + reason: e.to_string(), + } + } +} impl ShowSubcommandError { /// Get detailed troubleshooting guidance for this error @@ -153,6 +167,9 @@ This is a critical internal error. Please: - Remove corrupted data: rm -rf data/ - Create new environment: torrust-tracker-deployer create environment --env-file " } + Self::OutputFormatting { .. } => { + "Output Formatting Failed - Critical Internal Error:\n\nThis error should not occur during normal operation. It indicates a bug in the output formatting system.\n\n1. Immediate actions:\n - Save full error output\n - Copy log files from data/logs/\n - Note the exact command and output format being used\n\n2. Report the issue:\n - Create GitHub issue with full details\n - Include: command, output format (--output-format), error output, logs\n - Describe steps to reproduce\n\n3. Temporary workarounds:\n - Try using different output format (text vs json)\n - Try running command again\n\nPlease report it so we can fix it." + } } } } diff --git a/src/presentation/cli/controllers/show/handler.rs b/src/presentation/cli/controllers/show/handler.rs index b3c99704..bf7c2337 100644 --- a/src/presentation/cli/controllers/show/handler.rs +++ b/src/presentation/cli/controllers/show/handler.rs @@ -15,6 +15,7 @@ use crate::domain::environment::repository::EnvironmentRepository; use crate::presentation::cli::input::cli::OutputFormat; use crate::presentation::cli::views::commands::show::{JsonView, TextView}; use crate::presentation::cli::views::progress::ProgressReporter; +use crate::presentation::cli::views::Render; use crate::presentation::cli::views::UserOutput; use super::errors::ShowSubcommandError; @@ -204,8 +205,8 @@ impl ShowCommandController { // Render using appropriate view based on output format (Strategy Pattern) let output = match output_format { - OutputFormat::Text => TextView::render(env_info), - OutputFormat::Json => JsonView::render(env_info), + OutputFormat::Text => TextView::render(env_info)?, + OutputFormat::Json => JsonView::render(env_info)?, }; // Pipeline: EnvironmentInfo → render → output to stdout diff --git a/src/presentation/cli/controllers/test/errors.rs b/src/presentation/cli/controllers/test/errors.rs index 261d4d27..42567dbf 100644 --- a/src/presentation/cli/controllers/test/errors.rs +++ b/src/presentation/cli/controllers/test/errors.rs @@ -9,6 +9,7 @@ use thiserror::Error; use crate::application::command_handlers::test::errors::TestCommandHandlerError; use crate::domain::environment::name::EnvironmentNameError; use crate::presentation::cli::views::progress::ProgressReporterError; +use crate::presentation::cli::views::ViewRenderError; /// Test command specific errors /// @@ -78,6 +79,12 @@ Tip: This is a critical bug - please report it with full logs using --log-output #[source] source: ProgressReporterError, }, + /// Output formatting failed (JSON serialization error). + /// This indicates an internal error in data serialization. + #[error( + "Failed to format output: {reason}\nTip: This is a critical bug - please report it with full logs using --log-output file-and-stderr" + )] + OutputFormatting { reason: String }, } // ============================================================================ @@ -89,6 +96,13 @@ impl From for TestSubcommandError { Self::ProgressReportingFailed { source } } } +impl From for TestSubcommandError { + fn from(e: ViewRenderError) -> Self { + Self::OutputFormatting { + reason: e.to_string(), + } + } +} impl TestSubcommandError { /// Get detailed troubleshooting guidance for this error @@ -261,6 +275,9 @@ This is a critical bug that should be reported to the development team. For bug reports, visit: https://github.com/torrust/torrust-tracker-deployer/issues" } + Self::OutputFormatting { .. } => { + "Output Formatting Failed - Critical Internal Error:\n\nThis error should not occur during normal operation. It indicates a bug in the output formatting system.\n\n1. Immediate actions:\n - Save full error output\n - Copy log files from data/logs/\n - Note the exact command and output format being used\n\n2. Report the issue:\n - Create GitHub issue with full details\n - Include: command, output format (--output-format), error output, logs\n - Describe steps to reproduce\n\n3. Temporary workarounds:\n - Try using different output format (text vs json)\n - Try running command again\n\nPlease report it so we can fix it." + } } } } diff --git a/src/presentation/cli/controllers/test/handler.rs b/src/presentation/cli/controllers/test/handler.rs index b983020f..4fee05c6 100644 --- a/src/presentation/cli/controllers/test/handler.rs +++ b/src/presentation/cli/controllers/test/handler.rs @@ -15,6 +15,7 @@ use crate::domain::environment::repository::EnvironmentRepository; use crate::presentation::cli::input::cli::OutputFormat; use crate::presentation::cli::views::commands::test::{JsonView, TestResultData, TextView}; use crate::presentation::cli::views::progress::ProgressReporter; +use crate::presentation::cli::views::Render; use crate::presentation::cli::views::UserOutput; use super::errors::TestSubcommandError; @@ -230,8 +231,8 @@ impl TestCommandController { let data = TestResultData::new(environment_name, result); let output = match output_format { - OutputFormat::Text => TextView::render(&data), - OutputFormat::Json => JsonView::render(&data), + OutputFormat::Text => TextView::render(&data)?, + OutputFormat::Json => JsonView::render(&data)?, }; self.progress.result(&output)?; diff --git a/src/presentation/cli/controllers/validate/errors.rs b/src/presentation/cli/controllers/validate/errors.rs index 3c88e20e..69143592 100644 --- a/src/presentation/cli/controllers/validate/errors.rs +++ b/src/presentation/cli/controllers/validate/errors.rs @@ -7,6 +7,7 @@ use thiserror::Error; use crate::application::command_handlers::validate::ValidateCommandHandlerError; use crate::presentation::cli::views::progress::ProgressReporterError; +use crate::presentation::cli::views::ViewRenderError; /// Errors that can occur during validate command execution #[derive(Error, Debug)] @@ -45,6 +46,12 @@ pub enum ValidateSubcommandError { /// Progress reporter error #[error("Progress display error: {0}")] ProgressError(String), + /// Output formatting failed (JSON serialization error). + /// This indicates an internal error in data serialization. + #[error( + "Failed to format output: {reason}\nTip: This is a critical bug - please report it with full logs using --log-output file-and-stderr" + )] + OutputFormatting { reason: String }, } impl ValidateSubcommandError { @@ -71,6 +78,9 @@ impl ValidateSubcommandError { )), Self::ValidationFailed { source, .. } => Some(source.help()), Self::ProgressError(_) => None, + Self::OutputFormatting { reason } => Some(format!( + "Output Formatting Failed - Critical Internal Error:\n\nThis is a critical internal error: {reason}\n\nPlease report this bug with full logs.", + )), } } } @@ -80,3 +90,10 @@ impl From for ValidateSubcommandError { Self::ProgressError(err.to_string()) } } +impl From for ValidateSubcommandError { + fn from(e: ViewRenderError) -> Self { + Self::OutputFormatting { + reason: e.to_string(), + } + } +} diff --git a/src/presentation/cli/controllers/validate/handler.rs b/src/presentation/cli/controllers/validate/handler.rs index d85de42b..19507914 100644 --- a/src/presentation/cli/controllers/validate/handler.rs +++ b/src/presentation/cli/controllers/validate/handler.rs @@ -15,6 +15,7 @@ use crate::presentation::cli::views::commands::validate::{ JsonView, TextView, ValidateDetailsData, }; use crate::presentation::cli::views::progress::ProgressReporter; +use crate::presentation::cli::views::Render; use crate::presentation::cli::views::UserOutput; use super::errors::ValidateSubcommandError; @@ -176,10 +177,10 @@ impl ValidateCommandController { match output_format { OutputFormat::Text => { self.progress.blank_line()?; - self.progress.complete(&TextView::render(&data))?; + self.progress.complete(&TextView::render(&data)?)?; } OutputFormat::Json => { - self.progress.result(&JsonView::render(&data))?; + self.progress.result(&JsonView::render(&data)?)?; } } diff --git a/src/presentation/cli/views/commands/configure/views/json_view.rs b/src/presentation/cli/views/commands/configure/views/json_view.rs index d0d8a074..37d09e14 100644 --- a/src/presentation/cli/views/commands/configure/views/json_view.rs +++ b/src/presentation/cli/views/commands/configure/views/json_view.rs @@ -10,6 +10,7 @@ //! The output includes environment details and configuration state. use crate::presentation::cli::views::commands::configure::ConfigureDetailsData; +use crate::presentation::cli::views::{Render, ViewRenderError}; /// View for rendering configure details as JSON /// @@ -20,6 +21,7 @@ use crate::presentation::cli::views::commands::configure::ConfigureDetailsData; /// # Examples /// /// ```rust +/// # use torrust_tracker_deployer_lib::presentation::cli::views::Render; /// use torrust_tracker_deployer_lib::presentation::cli::views::commands::configure::{ /// ConfigureDetailsData, JsonView, /// }; @@ -35,7 +37,7 @@ use crate::presentation::cli::views::commands::configure::ConfigureDetailsData; /// created_at: Utc.with_ymd_and_hms(2026, 2, 20, 10, 0, 0).unwrap(), /// }; /// -/// let output = JsonView::render(&details); +/// let output = JsonView::render(&details).unwrap(); /// /// // Verify it's valid JSON /// let parsed: serde_json::Value = serde_json::from_str(&output).unwrap(); @@ -44,71 +46,16 @@ use crate::presentation::cli::views::commands::configure::ConfigureDetailsData; /// ``` pub struct JsonView; -impl JsonView { - /// Render configure details as JSON - /// - /// Serializes the configure 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 "Configured" on success - /// - `instance_ip`: IP address (nullable) - /// - `created_at`: ISO 8601 UTC timestamp - /// - /// # Arguments - /// - /// * `data` - Configure details to render - /// - /// # Returns - /// - /// A JSON string containing the serialized configure 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::configure::{ - /// ConfigureDetailsData, JsonView, - /// }; - /// use chrono::{TimeZone, Utc}; - /// use std::net::{IpAddr, Ipv4Addr}; - /// - /// let details = ConfigureDetailsData { - /// environment_name: "prod-tracker".to_string(), - /// instance_name: "torrust-tracker-vm-prod-tracker".to_string(), - /// provider: "lxd".to_string(), - /// state: "Configured".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\": \"Configured\"")); - /// ``` - #[must_use] - pub fn render(data: &ConfigureDetailsData) -> String { - serde_json::to_string_pretty(data).unwrap_or_else(|e| { - serde_json::to_string_pretty(&serde_json::json!({ - "error": "Failed to serialize configure details", - "message": e.to_string(), - })) - .unwrap_or_else(|_| { - r#"{ - "error": "Failed to serialize error message" -}"# - .to_string() - }) - }) +impl Render for JsonView { + fn render(data: &ConfigureDetailsData) -> Result { + Ok(serde_json::to_string_pretty(data)?) } } #[cfg(test)] mod tests { use super::*; + use crate::presentation::cli::views::Render; use chrono::{DateTime, TimeZone, Utc}; use std::net::{IpAddr, Ipv4Addr}; @@ -164,7 +111,7 @@ mod tests { let details = create_test_details_with_ip(Some(create_test_ip())); // Act - let json = JsonView::render(&details); + let json = JsonView::render(&details).unwrap(); // Assert - verify it's valid JSON with expected field values assert_json_fields_eq( @@ -186,7 +133,7 @@ mod tests { let details = create_test_details_with_ip(None); // Act - let json = JsonView::render(&details); + let json = JsonView::render(&details).unwrap(); // Assert let parsed: serde_json::Value = serde_json::from_str(&json).expect("Should be valid JSON"); @@ -199,7 +146,7 @@ mod tests { let details = create_test_details_with_ip(Some(IpAddr::V4(Ipv4Addr::new(192, 168, 1, 42)))); // Act - let json = JsonView::render(&details); + let json = JsonView::render(&details).unwrap(); // Assert - check all required fields are present assert_json_has_fields( diff --git a/src/presentation/cli/views/commands/configure/views/text_view.rs b/src/presentation/cli/views/commands/configure/views/text_view.rs index ead16bae..b03e7d3e 100644 --- a/src/presentation/cli/views/commands/configure/views/text_view.rs +++ b/src/presentation/cli/views/commands/configure/views/text_view.rs @@ -10,6 +10,7 @@ //! for terminal display and direct user consumption. use crate::presentation::cli::views::commands::configure::ConfigureDetailsData; +use crate::presentation::cli::views::{Render, ViewRenderError}; /// View for rendering configure details as human-readable text /// @@ -20,6 +21,7 @@ use crate::presentation::cli::views::commands::configure::ConfigureDetailsData; /// # Examples /// /// ```rust +/// # use torrust_tracker_deployer_lib::presentation::cli::views::Render; /// use torrust_tracker_deployer_lib::presentation::cli::views::commands::configure::{ /// ConfigureDetailsData, TextView, /// }; @@ -35,73 +37,19 @@ use crate::presentation::cli::views::commands::configure::ConfigureDetailsData; /// created_at: Utc.with_ymd_and_hms(2026, 2, 20, 10, 0, 0).unwrap(), /// }; /// -/// let output = TextView::render(&details); +/// let output = TextView::render(&details).unwrap(); /// assert!(output.contains("Environment Details:")); /// assert!(output.contains("my-env")); /// ``` pub struct TextView; -impl TextView { - /// Render configure details as human-readable formatted text - /// - /// Takes configure details and produces a human-readable output - /// suitable for displaying to users via stdout. - /// - /// # Arguments - /// - /// * `data` - Configure 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::cli::views::commands::configure::{ - /// ConfigureDetailsData, TextView, - /// }; - /// use chrono::{TimeZone, Utc}; - /// use std::net::{IpAddr, Ipv4Addr}; - /// - /// let details = ConfigureDetailsData { - /// environment_name: "prod-tracker".to_string(), - /// instance_name: "torrust-tracker-vm-prod-tracker".to_string(), - /// provider: "lxd".to_string(), - /// state: "Configured".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("Configured")); - /// ``` - #[must_use] - pub fn render(data: &ConfigureDetailsData) -> String { +impl Render for TextView { + fn render(data: &ConfigureDetailsData) -> Result { let instance_ip = data .instance_ip .map_or_else(|| "Not available".to_string(), |ip| ip.to_string()); - format!( + Ok(format!( r"Environment Details: Name: {} Instance: {} @@ -115,7 +63,7 @@ impl TextView { data.state, instance_ip, data.created_at.format("%Y-%m-%d %H:%M:%S UTC") - ) + )) } } @@ -164,7 +112,7 @@ mod tests { let details = create_test_details_with_ip(Some(create_test_ip())); // Act - let text = TextView::render(&details); + let text = TextView::render(&details).unwrap(); // Assert assert_contains_all( @@ -193,7 +141,7 @@ mod tests { let details = create_test_details_with_ip(None); // Act - let text = TextView::render(&details); + let text = TextView::render(&details).unwrap(); // Assert assert!(text.contains("Instance IP: Not available")); @@ -205,7 +153,7 @@ mod tests { let details = create_test_details_with_ip(Some(IpAddr::V4(Ipv4Addr::new(192, 168, 1, 42)))); // Act - let text = TextView::render(&details); + let text = TextView::render(&details).unwrap(); // Assert - check all sections are present assert_contains_all( diff --git a/src/presentation/cli/views/commands/create/views/json_view.rs b/src/presentation/cli/views/commands/create/views/json_view.rs index d403aa36..573fc164 100644 --- a/src/presentation/cli/views/commands/create/views/json_view.rs +++ b/src/presentation/cli/views/commands/create/views/json_view.rs @@ -5,6 +5,7 @@ //! (machine-readable JSON) for environment details. use super::super::EnvironmentDetailsData; +use crate::presentation::cli::views::{Render, ViewRenderError}; /// JSON view for rendering environment creation details /// @@ -21,6 +22,7 @@ use super::super::EnvironmentDetailsData; /// # Examples /// /// ```rust +/// # use torrust_tracker_deployer_lib::presentation::cli::views::Render; /// use std::path::PathBuf; /// use chrono::{TimeZone, Utc}; /// use torrust_tracker_deployer_lib::presentation::cli::views::commands::create::{ @@ -40,54 +42,16 @@ use super::super::EnvironmentDetailsData; /// ``` pub struct JsonView; -impl JsonView { - /// Render environment details as JSON - /// - /// Takes environment creation data and produces a JSON-formatted string - /// suitable for programmatic parsing and automation workflows. - /// - /// # Arguments - /// - /// * `data` - Environment details to render - /// - /// # Returns - /// - /// A JSON string containing: - /// - `environment_name`: Name of the created environment - /// - `instance_name`: Name of the VM instance - /// - `data_dir`: Path to environment data directory - /// - `build_dir`: Path to build artifacts directory - /// - `created_at`: ISO 8601 timestamp of environment creation - /// - /// # Format - /// - /// The output is pretty-printed JSON for readability: - /// ```json - /// { - /// "environment_name": "my-env", - /// "instance_name": "torrust-tracker-vm-my-env", - /// "data_dir": "./data/my-env", - /// "build_dir": "./build/my-env", - /// "created_at": "2026-02-16T14:30:00Z" - /// } - /// ``` - /// - /// # Errors - /// - /// Returns `serde_json::Error` if JSON serialization fails (very rare, - /// would indicate a bug in the serialization implementation). - pub fn render(data: &EnvironmentDetailsData) -> Result { - serde_json::to_string_pretty(data) +impl Render for JsonView { + fn render(data: &EnvironmentDetailsData) -> Result { + Ok(serde_json::to_string_pretty(data)?) } } -// ============================================================================ -// Unit Tests -// ============================================================================ - #[cfg(test)] mod tests { use super::*; + use crate::presentation::cli::views::Render; use chrono::{TimeZone, Utc}; use std::path::PathBuf; diff --git a/src/presentation/cli/views/commands/create/views/text_view.rs b/src/presentation/cli/views/commands/create/views/text_view.rs index 6d2089cd..178e54d9 100644 --- a/src/presentation/cli/views/commands/create/views/text_view.rs +++ b/src/presentation/cli/views/commands/create/views/text_view.rs @@ -5,6 +5,7 @@ //! (human-readable text) for environment details. use super::super::EnvironmentDetailsData; +use crate::presentation::cli::views::{Render, ViewRenderError}; /// Text view for rendering environment creation details /// @@ -21,6 +22,7 @@ use super::super::EnvironmentDetailsData; /// # Examples /// /// ```rust +/// # use torrust_tracker_deployer_lib::presentation::cli::views::Render; /// use std::path::PathBuf; /// use chrono::{TimeZone, Utc}; /// use torrust_tracker_deployer_lib::presentation::cli::views::commands::create::{ @@ -35,44 +37,14 @@ use super::super::EnvironmentDetailsData; /// created_at: Utc.with_ymd_and_hms(2026, 2, 16, 14, 30, 0).unwrap(), /// }; /// -/// let output = TextView::render(&data); +/// let output = TextView::render(&data).unwrap(); /// assert!(output.contains("Environment Details:")); /// assert!(output.contains("my-env")); /// ``` pub struct TextView; -impl TextView { - /// Render environment details as human-readable formatted text - /// - /// Takes environment creation data and produces a human-readable output - /// suitable for displaying to users via stdout. - /// - /// # Arguments - /// - /// * `data` - Environment details to render - /// - /// # Returns - /// - /// A formatted string containing: - /// - Section header ("Environment Details:") - /// - Numbered list of environment information - /// - Environment name - /// - Instance name - /// - Data directory path - /// - Build directory path - /// - /// # Format - /// - /// The output follows this structure: - /// ```text - /// Environment Details: - /// 1. Environment name: - /// 2. Instance name: - /// 3. Data directory: - /// 4. Build directory: - /// ``` - #[must_use] - pub fn render(data: &EnvironmentDetailsData) -> String { +impl Render for TextView { + fn render(data: &EnvironmentDetailsData) -> Result { let mut lines = Vec::new(); lines.push("Environment Details:".to_string()); @@ -81,14 +53,10 @@ impl TextView { lines.push(format!("3. Data directory: {}", data.data_dir.display())); lines.push(format!("4. Build directory: {}", data.build_dir.display())); - lines.join("\n") + Ok(lines.join("\n")) } } -// ============================================================================ -// Unit Tests -// ============================================================================ - #[cfg(test)] mod tests { use super::*; @@ -111,7 +79,7 @@ mod tests { }; // When - let output = TextView::render(&data); + let output = TextView::render(&data).unwrap(); // Then assert!(output.contains("Environment Details:")); @@ -136,7 +104,7 @@ mod tests { }; // When - let output = TextView::render(&data); + let output = TextView::render(&data).unwrap(); let lines: Vec<&str> = output.lines().collect(); // Then @@ -160,7 +128,7 @@ mod tests { }; // When - let output = TextView::render(&data); + let output = TextView::render(&data).unwrap(); // Then assert!(output.contains("/absolute/path/data/my-env")); diff --git a/src/presentation/cli/views/commands/destroy/views/json_view.rs b/src/presentation/cli/views/commands/destroy/views/json_view.rs index a8469162..450405b7 100644 --- a/src/presentation/cli/views/commands/destroy/views/json_view.rs +++ b/src/presentation/cli/views/commands/destroy/views/json_view.rs @@ -10,6 +10,7 @@ //! The output includes environment details and destroyed state. use crate::presentation::cli::views::commands::destroy::DestroyDetailsData; +use crate::presentation::cli::views::{Render, ViewRenderError}; /// View for rendering destroy details as JSON /// @@ -20,6 +21,7 @@ use crate::presentation::cli::views::commands::destroy::DestroyDetailsData; /// # Examples /// /// ```rust +/// # use torrust_tracker_deployer_lib::presentation::cli::views::Render; /// use torrust_tracker_deployer_lib::presentation::cli::views::commands::destroy::{ /// DestroyDetailsData, JsonView, /// }; @@ -35,7 +37,7 @@ use crate::presentation::cli::views::commands::destroy::DestroyDetailsData; /// created_at: Utc.with_ymd_and_hms(2026, 2, 20, 10, 0, 0).unwrap(), /// }; /// -/// let output = JsonView::render(&details); +/// let output = JsonView::render(&details).unwrap(); /// /// // Verify it's valid JSON /// let parsed: serde_json::Value = serde_json::from_str(&output).unwrap(); @@ -44,71 +46,16 @@ use crate::presentation::cli::views::commands::destroy::DestroyDetailsData; /// ``` pub struct JsonView; -impl JsonView { - /// Render destroy details as JSON - /// - /// Serializes the destroy 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 "Destroyed" on success - /// - `instance_ip`: IP address (nullable — `null` if never provisioned) - /// - `created_at`: ISO 8601 UTC timestamp - /// - /// # Arguments - /// - /// * `data` - Destroy details to render - /// - /// # Returns - /// - /// A JSON string containing the serialized destroy 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::destroy::{ - /// DestroyDetailsData, JsonView, - /// }; - /// use chrono::{TimeZone, Utc}; - /// use std::net::{IpAddr, Ipv4Addr}; - /// - /// let details = DestroyDetailsData { - /// environment_name: "prod-tracker".to_string(), - /// instance_name: "torrust-tracker-vm-prod-tracker".to_string(), - /// provider: "lxd".to_string(), - /// state: "Destroyed".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\": \"Destroyed\"")); - /// ``` - #[must_use] - pub fn render(data: &DestroyDetailsData) -> String { - serde_json::to_string_pretty(data).unwrap_or_else(|e| { - serde_json::to_string_pretty(&serde_json::json!({ - "error": "Failed to serialize destroy details", - "message": e.to_string(), - })) - .unwrap_or_else(|_| { - r#"{ - "error": "Failed to serialize error message" -}"# - .to_string() - }) - }) +impl Render for JsonView { + fn render(data: &DestroyDetailsData) -> Result { + Ok(serde_json::to_string_pretty(data)?) } } #[cfg(test)] mod tests { use super::*; + use crate::presentation::cli::views::Render; use chrono::{DateTime, TimeZone, Utc}; use std::net::{IpAddr, Ipv4Addr}; @@ -164,7 +111,7 @@ mod tests { let details = create_test_details_with_ip(Some(create_test_ip())); // Act - let json = JsonView::render(&details); + let json = JsonView::render(&details).unwrap(); // Assert - verify it's valid JSON with expected field values assert_json_fields_eq( @@ -186,7 +133,7 @@ mod tests { let details = create_test_details_with_ip(None); // Act - let json = JsonView::render(&details); + let json = JsonView::render(&details).unwrap(); // Assert let parsed: serde_json::Value = serde_json::from_str(&json).expect("Should be valid JSON"); @@ -199,7 +146,7 @@ mod tests { let details = create_test_details_with_ip(Some(IpAddr::V4(Ipv4Addr::new(192, 168, 1, 42)))); // Act - let json = JsonView::render(&details); + let json = JsonView::render(&details).unwrap(); // Assert - check all required fields are present assert_json_has_fields( diff --git a/src/presentation/cli/views/commands/destroy/views/text_view.rs b/src/presentation/cli/views/commands/destroy/views/text_view.rs index 2e27d103..c81f8c1d 100644 --- a/src/presentation/cli/views/commands/destroy/views/text_view.rs +++ b/src/presentation/cli/views/commands/destroy/views/text_view.rs @@ -10,6 +10,7 @@ //! for terminal display and direct user consumption. use crate::presentation::cli::views::commands::destroy::DestroyDetailsData; +use crate::presentation::cli::views::{Render, ViewRenderError}; /// View for rendering destroy details as human-readable text /// @@ -20,6 +21,7 @@ use crate::presentation::cli::views::commands::destroy::DestroyDetailsData; /// # Examples /// /// ```rust +/// # use torrust_tracker_deployer_lib::presentation::cli::views::Render; /// use torrust_tracker_deployer_lib::presentation::cli::views::commands::destroy::{ /// DestroyDetailsData, TextView, /// }; @@ -35,73 +37,19 @@ use crate::presentation::cli::views::commands::destroy::DestroyDetailsData; /// created_at: Utc.with_ymd_and_hms(2026, 2, 20, 10, 0, 0).unwrap(), /// }; /// -/// let output = TextView::render(&details); +/// let output = TextView::render(&details).unwrap(); /// assert!(output.contains("Environment Details:")); /// assert!(output.contains("my-env")); /// ``` pub struct TextView; -impl TextView { - /// Render destroy details as human-readable formatted text - /// - /// Takes destroy details and produces a human-readable output - /// suitable for displaying to users via stdout. - /// - /// # Arguments - /// - /// * `data` - Destroy details to render - /// - /// # Returns - /// - /// A formatted string containing: - /// - Environment Details section with name, instance, provider, state - /// - Instance IP (if available, otherwise "Not 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::cli::views::commands::destroy::{ - /// DestroyDetailsData, TextView, - /// }; - /// use chrono::{TimeZone, Utc}; - /// use std::net::{IpAddr, Ipv4Addr}; - /// - /// let details = DestroyDetailsData { - /// environment_name: "prod-tracker".to_string(), - /// instance_name: "torrust-tracker-vm-prod-tracker".to_string(), - /// provider: "lxd".to_string(), - /// state: "Destroyed".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("Destroyed")); - /// ``` - #[must_use] - pub fn render(data: &DestroyDetailsData) -> String { +impl Render for TextView { + fn render(data: &DestroyDetailsData) -> Result { let instance_ip = data .instance_ip .map_or_else(|| "Not available".to_string(), |ip| ip.to_string()); - format!( + Ok(format!( r"Environment Details: Name: {} Instance: {} @@ -115,7 +63,7 @@ impl TextView { data.state, instance_ip, data.created_at.format("%Y-%m-%d %H:%M:%S UTC") - ) + )) } } @@ -164,7 +112,7 @@ mod tests { let details = create_test_details_with_ip(Some(create_test_ip())); // Act - let text = TextView::render(&details); + let text = TextView::render(&details).unwrap(); // Assert assert_contains_all( @@ -193,7 +141,7 @@ mod tests { let details = create_test_details_with_ip(None); // Act - let text = TextView::render(&details); + let text = TextView::render(&details).unwrap(); // Assert assert!(text.contains("Instance IP: Not available")); @@ -205,7 +153,7 @@ mod tests { let details = create_test_details_with_ip(Some(IpAddr::V4(Ipv4Addr::new(192, 168, 1, 42)))); // Act - let text = TextView::render(&details); + let text = TextView::render(&details).unwrap(); // Assert - check all sections are present assert_contains_all( diff --git a/src/presentation/cli/views/commands/list/views/json_view.rs b/src/presentation/cli/views/commands/list/views/json_view.rs index 17429d08..982a7798 100644 --- a/src/presentation/cli/views/commands/list/views/json_view.rs +++ b/src/presentation/cli/views/commands/list/views/json_view.rs @@ -10,6 +10,7 @@ //! The output includes environment summaries, failed environments, and metadata. use crate::presentation::cli::views::commands::list::view_data::EnvironmentList; +use crate::presentation::cli::views::{Render, ViewRenderError}; /// View for rendering environment list as JSON /// @@ -20,6 +21,7 @@ use crate::presentation::cli::views::commands::list::view_data::EnvironmentList; /// # Examples /// /// ```rust +/// # use torrust_tracker_deployer_lib::presentation::cli::views::Render; /// use torrust_tracker_deployer_lib::application::command_handlers::list::info::{ /// EnvironmentList, EnvironmentSummary, /// }; @@ -35,7 +37,7 @@ use crate::presentation::cli::views::commands::list::view_data::EnvironmentList; /// ]; /// /// let list = EnvironmentList::new(summaries, vec![], "/path/to/data".to_string()); -/// let output = JsonView::render(&list); +/// let output = JsonView::render(&list).unwrap(); /// /// // Verify it's valid JSON /// let parsed: serde_json::Value = serde_json::from_str(&output).unwrap(); @@ -43,70 +45,9 @@ use crate::presentation::cli::views::commands::list::view_data::EnvironmentList; /// ``` pub struct JsonView; -impl JsonView { - /// Render environment list as JSON - /// - /// Serializes the environment list to pretty-printed JSON format. - /// The JSON structure matches the DTO structure exactly: - /// - `environments`: Array of environment summaries - /// - `total_count`: Number of successfully loaded environments - /// - `failed_environments`: Array of failures (name, error pairs) - /// - `data_directory`: Path to scanned directory - /// - /// # Arguments - /// - /// * `list` - Environment list to render - /// - /// # Returns - /// - /// A JSON string containing the serialized environment list. - /// 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::application::command_handlers::list::info::{ - /// EnvironmentList, EnvironmentSummary, - /// }; - /// use torrust_tracker_deployer_lib::presentation::cli::views::commands::list::JsonView; - /// - /// let summaries = vec![ - /// EnvironmentSummary::new( - /// "env1".to_string(), - /// "Running".to_string(), - /// "LXD".to_string(), - /// "2026-01-05T10:30:00Z".to_string(), - /// ), - /// EnvironmentSummary::new( - /// "env2".to_string(), - /// "Created".to_string(), - /// "Hetzner".to_string(), - /// "2026-01-06T14:15:30Z".to_string(), - /// ), - /// ]; - /// - /// let list = EnvironmentList::new(summaries, vec![], "/data".to_string()); - /// let json = JsonView::render(&list); - /// - /// assert!(json.contains("\"total_count\": 2")); - /// assert!(json.contains("\"env1\"")); - /// assert!(json.contains("\"env2\"")); - /// ``` - #[must_use] - pub fn render(list: &EnvironmentList) -> String { - serde_json::to_string_pretty(list).unwrap_or_else(|e| { - serde_json::to_string_pretty(&serde_json::json!({ - "error": "Failed to serialize environment list", - "message": e.to_string(), - })) - .unwrap_or_else(|_| { - r#"{ - "error": "Failed to serialize error message" -}"# - .to_string() - }) - }) +impl Render for JsonView { + fn render(data: &EnvironmentList) -> Result { + Ok(serde_json::to_string_pretty(data)?) } } @@ -116,12 +57,13 @@ mod tests { use super::*; use crate::presentation::cli::views::commands::list::view_data::EnvironmentSummary; + use crate::presentation::cli::views::Render; #[test] fn it_should_render_empty_environment_list_as_json() { let list = EnvironmentList::new(vec![], vec![], "/path/to/data".to_string()); - let output = JsonView::render(&list); + let output = JsonView::render(&list).unwrap(); // Verify it's valid JSON let parsed: Value = serde_json::from_str(&output).expect("Should be valid JSON"); @@ -143,7 +85,7 @@ mod tests { let list = EnvironmentList::new(summaries, vec![], "/data".to_string()); - let output = JsonView::render(&list); + let output = JsonView::render(&list).unwrap(); let parsed: Value = serde_json::from_str(&output).expect("Should be valid JSON"); @@ -182,7 +124,7 @@ mod tests { let list = EnvironmentList::new(summaries, vec![], "/workspace/data".to_string()); - let output = JsonView::render(&list); + let output = JsonView::render(&list).unwrap(); let parsed: Value = serde_json::from_str(&output).expect("Should be valid JSON"); @@ -217,7 +159,7 @@ mod tests { let list = EnvironmentList::new(summaries, failures, "/data".to_string()); - let output = JsonView::render(&list); + let output = JsonView::render(&list).unwrap(); let parsed: Value = serde_json::from_str(&output).expect("Should be valid JSON"); @@ -244,7 +186,7 @@ mod tests { let list = EnvironmentList::new(summaries, vec![], "/data".to_string()); - let output = JsonView::render(&list); + let output = JsonView::render(&list).unwrap(); // Pretty-printed JSON should have newlines and indentation assert!(output.contains('\n')); diff --git a/src/presentation/cli/views/commands/list/views/text_view.rs b/src/presentation/cli/views/commands/list/views/text_view.rs index 761d6ccd..382d4183 100644 --- a/src/presentation/cli/views/commands/list/views/text_view.rs +++ b/src/presentation/cli/views/commands/list/views/text_view.rs @@ -5,6 +5,7 @@ //! (human-readable text table) for environment lists. use crate::presentation::cli::views::commands::list::view_data::EnvironmentList; +use crate::presentation::cli::views::{Render, ViewRenderError}; /// Text view for rendering environment list /// @@ -21,6 +22,7 @@ use crate::presentation::cli::views::commands::list::view_data::EnvironmentList; /// # Examples /// /// ```rust +/// # use torrust_tracker_deployer_lib::presentation::cli::views::Render; /// use torrust_tracker_deployer_lib::application::command_handlers::list::info::{ /// EnvironmentList, EnvironmentSummary, /// }; @@ -36,71 +38,13 @@ use crate::presentation::cli::views::commands::list::view_data::EnvironmentList; /// ]; /// /// let list = EnvironmentList::new(summaries, vec![], "/path/to/data".to_string()); -/// let output = TextView::render(&list); +/// let output = TextView::render(&list).unwrap(); /// assert!(output.contains("my-production")); /// assert!(output.contains("Running")); /// ``` pub struct TextView; impl TextView { - /// Render environment list as a formatted string - /// - /// Takes the environment list and produces a human-readable table output - /// suitable for displaying to users via stdout. - /// - /// # Arguments - /// - /// * `list` - Environment list to render - /// - /// # Returns - /// - /// A formatted string containing: - /// - Header with count - /// - Table with environment summaries - /// - Warnings for failed environments (if any) - /// - Help text for empty workspaces - #[must_use] - pub fn render(list: &EnvironmentList) -> String { - let mut lines = Vec::new(); - - if list.is_empty() { - return Self::render_empty(list); - } - - // Header with count - lines.push(String::new()); - lines.push(format!("Environments ({} found):", list.total_count)); - lines.push(String::new()); - - // Table header - lines.push(Self::render_table_header()); - lines.push(Self::render_table_separator()); - - // Table rows - for env in &list.environments { - lines.push(Self::render_table_row(env)); - } - - // Partial failure warnings - if list.has_failures() { - lines.push(String::new()); - lines.push("Warning: Failed to load the following environments:".to_string()); - for (name, error) in &list.failed_environments { - lines.push(format!(" - {name}: {error}")); - } - lines.push(String::new()); - lines.push("For troubleshooting, see docs/user-guide/commands.md".to_string()); - } - - // Hint about purge command - lines.push(String::new()); - lines.push( - "Hint: Use 'purge' command to completely remove destroyed environments.".to_string(), - ); - - lines.join("\n") - } - /// Render empty workspace message fn render_empty(list: &EnvironmentList) -> String { let mut lines = Vec::new(); @@ -156,6 +100,49 @@ impl TextView { } } +impl Render for TextView { + fn render(list: &EnvironmentList) -> Result { + let mut lines = Vec::new(); + + if list.is_empty() { + return Ok(Self::render_empty(list)); + } + + // Header with count + lines.push(String::new()); + lines.push(format!("Environments ({} found):", list.total_count)); + lines.push(String::new()); + + // Table header + lines.push(Self::render_table_header()); + lines.push(Self::render_table_separator()); + + // Table rows + for env in &list.environments { + lines.push(Self::render_table_row(env)); + } + + // Partial failure warnings + if list.has_failures() { + lines.push(String::new()); + lines.push("Warning: Failed to load the following environments:".to_string()); + for (name, error) in &list.failed_environments { + lines.push(format!(" - {name}: {error}")); + } + lines.push(String::new()); + lines.push("For troubleshooting, see docs/user-guide/commands.md".to_string()); + } + + // Hint about purge command + lines.push(String::new()); + lines.push( + "Hint: Use 'purge' command to completely remove destroyed environments.".to_string(), + ); + + Ok(lines.join("\n")) + } +} + #[cfg(test)] mod tests { use super::*; @@ -165,7 +152,7 @@ mod tests { fn it_should_render_empty_workspace() { let list = EnvironmentList::new(vec![], vec![], "/path/to/data".to_string()); - let output = TextView::render(&list); + let output = TextView::render(&list).unwrap(); assert!(output.contains("No environments found in: /path/to/data")); assert!(output.contains("create environment --env-file")); @@ -182,7 +169,7 @@ mod tests { let list = EnvironmentList::new(summaries, vec![], "/path/to/data".to_string()); - let output = TextView::render(&list); + let output = TextView::render(&list).unwrap(); assert!(output.contains("Environments (1 found):")); assert!(output.contains("Name")); @@ -212,7 +199,7 @@ mod tests { let list = EnvironmentList::new(summaries, vec![], "/path/to/data".to_string()); - let output = TextView::render(&list); + let output = TextView::render(&list).unwrap(); assert!(output.contains("production")); assert!(output.contains("Running")); @@ -240,7 +227,7 @@ mod tests { let list = EnvironmentList::new(summaries, failures, "/path/to/data".to_string()); - let output = TextView::render(&list); + let output = TextView::render(&list).unwrap(); assert!(output.contains("Warning: Failed to load the following environments:")); assert!(output.contains("broken-env: Invalid JSON")); @@ -260,7 +247,7 @@ mod tests { let list = EnvironmentList::new(summaries, vec![], "/path/to/data".to_string()); - let output = TextView::render(&list); + let output = TextView::render(&list).unwrap(); // Should truncate the long name at 50 characters assert!(output.contains("very-long-environment-name-that-exceeds-column-...")); @@ -293,7 +280,7 @@ mod tests { let list = EnvironmentList::new(summaries, vec![], "/path/to/data".to_string()); - let output = TextView::render(&list); + let output = TextView::render(&list).unwrap(); assert!(output.contains("Environments (3 found):")); assert!(output.contains("env1")); diff --git a/src/presentation/cli/views/commands/provision/views/json_view.rs b/src/presentation/cli/views/commands/provision/views/json_view.rs index ba4dbe46..4b119bfa 100644 --- a/src/presentation/cli/views/commands/provision/views/json_view.rs +++ b/src/presentation/cli/views/commands/provision/views/json_view.rs @@ -5,6 +5,7 @@ //! (machine-readable JSON) for provision details. use super::super::ProvisionDetailsData; +use crate::presentation::cli::views::{Render, ViewRenderError}; /// JSON view for rendering provision details /// @@ -21,6 +22,7 @@ use super::super::ProvisionDetailsData; /// # Examples /// /// ```rust +/// # use torrust_tracker_deployer_lib::presentation::cli::views::Render; /// use std::net::{IpAddr, Ipv4Addr}; /// use std::path::PathBuf; /// use chrono::{TimeZone, Utc}; @@ -46,62 +48,16 @@ use super::super::ProvisionDetailsData; /// ``` pub struct JsonView; -impl JsonView { - /// Render provision details as JSON - /// - /// Takes provision data and produces a JSON-formatted string - /// suitable for programmatic parsing and automation workflows. - /// - /// # Arguments - /// - /// * `data` - Provision details to render - /// - /// # Returns - /// - /// A JSON string containing: - /// - `environment_name`: Name of the provisioned environment - /// - `instance_name`: Name of the VM instance - /// - `instance_ip`: IP address of the provisioned instance (nullable) - /// - `ssh_username`: SSH username for connections - /// - `ssh_port`: SSH port number - /// - `ssh_private_key_path`: Path to SSH private key - /// - `provider`: Infrastructure provider (lxd, hetzner, etc.) - /// - `provisioned_at`: ISO 8601 timestamp of provisioning - /// - `domains`: Array of configured domain names (empty for non-HTTPS) - /// - /// # Format - /// - /// The output is pretty-printed JSON for readability: - /// ```json - /// { - /// "environment_name": "my-env", - /// "instance_name": "torrust-tracker-vm-my-env", - /// "instance_ip": "10.140.190.39", - /// "ssh_username": "torrust", - /// "ssh_port": 22, - /// "ssh_private_key_path": "/path/to/key", - /// "provider": "lxd", - /// "provisioned_at": "2026-02-16T14:30:00Z", - /// "domains": ["tracker.example.com"] - /// } - /// ``` - /// - /// # Errors - /// - /// Returns `serde_json::Error` if JSON serialization fails (very rare, - /// would indicate a bug in the serialization implementation). - pub fn render(data: &ProvisionDetailsData) -> Result { - serde_json::to_string_pretty(data) +impl Render for JsonView { + fn render(data: &ProvisionDetailsData) -> Result { + Ok(serde_json::to_string_pretty(data)?) } } -// ============================================================================ -// Unit Tests -// ============================================================================ - #[cfg(test)] mod tests { use super::*; + use crate::presentation::cli::views::Render; use chrono::{TimeZone, Utc}; use std::net::{IpAddr, Ipv4Addr}; use std::path::PathBuf; diff --git a/src/presentation/cli/views/commands/provision/views/text_view.rs b/src/presentation/cli/views/commands/provision/views/text_view.rs index b6224a3f..538ed947 100644 --- a/src/presentation/cli/views/commands/provision/views/text_view.rs +++ b/src/presentation/cli/views/commands/provision/views/text_view.rs @@ -5,6 +5,7 @@ //! (human-readable text) for provision details. use super::super::ProvisionDetailsData; +use crate::presentation::cli::views::{Render, ViewRenderError}; /// Text view for rendering provision details /// @@ -21,6 +22,7 @@ use super::super::ProvisionDetailsData; /// # Examples /// /// ```rust +/// # use torrust_tracker_deployer_lib::presentation::cli::views::Render; /// use std::net::{IpAddr, Ipv4Addr}; /// use std::path::PathBuf; /// use chrono::{TimeZone, Utc}; @@ -41,50 +43,14 @@ use super::super::ProvisionDetailsData; /// domains: vec!["tracker.example.com".to_string()], /// }; /// -/// let output = TextView::render(&data); +/// let output = TextView::render(&data).unwrap(); /// assert!(output.contains("Instance Connection Details:")); /// assert!(output.contains("10.140.190.39")); /// ``` pub struct TextView; -impl TextView { - /// Render provision details as human-readable formatted text - /// - /// Takes provision data and produces a human-readable output - /// suitable for displaying to users via stdout. - /// - /// # Arguments - /// - /// * `data` - Provision details to render - /// - /// # Returns - /// - /// A formatted string containing: - /// - Instance Connection Details section with IP, SSH port, private key path, username - /// - SSH connection command (if IP is available) - /// - DNS setup reminder (if domains are configured) - /// - /// # Format - /// - /// The output follows this structure: - /// ```text - /// Instance Connection Details: - /// IP Address: - /// SSH Port: - /// SSH Private Key: - /// SSH Username: - /// - /// Connect using: - /// ssh -i @ -p - /// - /// ⚠️ DNS Setup Required: - /// Your configuration uses custom domains... - /// Configured domains: - /// - - /// - - /// ``` - #[must_use] - pub fn render(data: &ProvisionDetailsData) -> String { +impl Render for TextView { + fn render(data: &ProvisionDetailsData) -> Result { let mut lines = Vec::new(); // Connection details section @@ -139,14 +105,10 @@ impl TextView { } } - lines.join("\n") + Ok(lines.join("\n")) } } -// ============================================================================ -// Unit Tests -// ============================================================================ - #[cfg(test)] mod tests { use super::*; @@ -180,7 +142,7 @@ mod tests { }; // When - let output = TextView::render(&data); + let output = TextView::render(&data).unwrap(); // Then assert!(output.contains("Instance Connection Details:")); @@ -211,7 +173,7 @@ mod tests { }; // When - let output = TextView::render(&data); + let output = TextView::render(&data).unwrap(); // Then assert!(output.contains("Instance Connection Details:")); @@ -237,7 +199,7 @@ mod tests { }; // When - let output = TextView::render(&data); + let output = TextView::render(&data).unwrap(); // Then assert!(output.contains("IP Address: ")); diff --git a/src/presentation/cli/views/commands/purge/views/json_view.rs b/src/presentation/cli/views/commands/purge/views/json_view.rs index 665f44a4..66300c93 100644 --- a/src/presentation/cli/views/commands/purge/views/json_view.rs +++ b/src/presentation/cli/views/commands/purge/views/json_view.rs @@ -10,6 +10,7 @@ //! The output includes the environment name and a boolean confirming the purge. use crate::presentation::cli::views::commands::purge::PurgeDetailsData; +use crate::presentation::cli::views::{Render, ViewRenderError}; /// View for rendering purge details as JSON /// @@ -20,13 +21,14 @@ use crate::presentation::cli::views::commands::purge::PurgeDetailsData; /// # Examples /// /// ```rust +/// # use torrust_tracker_deployer_lib::presentation::cli::views::Render; /// 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); +/// let output = JsonView::render(&data).unwrap(); /// /// // Verify it's valid JSON /// let parsed: serde_json::Value = serde_json::from_str(&output).unwrap(); @@ -35,58 +37,16 @@ use crate::presentation::cli::views::commands::purge::PurgeDetailsData; /// ``` 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| { - serde_json::to_string_pretty(&serde_json::json!({ - "error": "Failed to serialize purge details", - "message": e.to_string(), - })) - .unwrap_or_else(|_| { - r#"{ - "error": "Failed to serialize error message" -}"# - .to_string() - }) - }) +impl Render for JsonView { + fn render(data: &PurgeDetailsData) -> Result { + Ok(serde_json::to_string_pretty(data)?) } } #[cfg(test)] mod tests { use super::*; + use crate::presentation::cli::views::Render; fn create_test_data() -> PurgeDetailsData { PurgeDetailsData::from_environment_name("test-env") @@ -98,7 +58,7 @@ mod tests { let data = create_test_data(); // Act - let json = JsonView::render(&data); + let json = JsonView::render(&data).unwrap(); // Assert let parsed: serde_json::Value = @@ -113,7 +73,7 @@ mod tests { let data = PurgeDetailsData::from_environment_name("my-env"); // Act - let json = JsonView::render(&data); + let json = JsonView::render(&data).unwrap(); // Assert assert!( @@ -128,7 +88,7 @@ mod tests { let data = create_test_data(); // Act - let json = JsonView::render(&data); + let json = JsonView::render(&data).unwrap(); // Assert assert!( @@ -143,7 +103,7 @@ mod tests { let data = create_test_data(); // Act - let json = JsonView::render(&data); + let json = JsonView::render(&data).unwrap(); // 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 index 5197b21d..5dd186b5 100644 --- a/src/presentation/cli/views/commands/purge/views/text_view.rs +++ b/src/presentation/cli/views/commands/purge/views/text_view.rs @@ -11,6 +11,7 @@ //! output format produced before the Strategy Pattern was introduced. use crate::presentation::cli::views::commands::purge::PurgeDetailsData; +use crate::presentation::cli::views::{Render, ViewRenderError}; /// View for rendering purge details as human-readable text /// @@ -23,51 +24,24 @@ use crate::presentation::cli::views::commands::purge::PurgeDetailsData; /// # Examples /// /// ```rust +/// # use torrust_tracker_deployer_lib::presentation::cli::views::Render; /// 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); +/// let output = TextView::render(&data).unwrap(); /// 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!( +impl Render for TextView { + fn render(data: &PurgeDetailsData) -> Result { + Ok(format!( "Environment '{}' purged successfully", data.environment_name - ) + )) } } @@ -81,7 +55,7 @@ mod tests { let data = PurgeDetailsData::from_environment_name("test-env"); // Act - let text = TextView::render(&data); + let text = TextView::render(&data).unwrap(); // Assert assert_eq!(text, "Environment 'test-env' purged successfully"); @@ -93,7 +67,7 @@ mod tests { let data = PurgeDetailsData::from_environment_name("my-production-env"); // Act - let text = TextView::render(&data); + let text = TextView::render(&data).unwrap(); // Assert assert!( @@ -108,7 +82,7 @@ mod tests { let data = PurgeDetailsData::from_environment_name("test-env"); // Act - let text = TextView::render(&data); + let text = TextView::render(&data).unwrap(); // Assert assert!( diff --git a/src/presentation/cli/views/commands/register/views/json_view.rs b/src/presentation/cli/views/commands/register/views/json_view.rs index e0a17d3f..d383084c 100644 --- a/src/presentation/cli/views/commands/register/views/json_view.rs +++ b/src/presentation/cli/views/commands/register/views/json_view.rs @@ -11,6 +11,7 @@ //! confirming the registration. use crate::presentation::cli::views::commands::register::RegisterDetailsData; +use crate::presentation::cli::views::{Render, ViewRenderError}; /// View for rendering register details as JSON /// @@ -21,6 +22,7 @@ use crate::presentation::cli::views::commands::register::RegisterDetailsData; /// # Examples /// /// ```rust +/// # use torrust_tracker_deployer_lib::presentation::cli::views::Render; /// use torrust_tracker_deployer_lib::presentation::cli::views::commands::register::{ /// RegisterDetailsData, JsonView, /// }; @@ -32,7 +34,7 @@ use crate::presentation::cli::views::commands::register::RegisterDetailsData; /// registered: true, /// }; /// -/// let output = JsonView::render(&data); +/// let output = JsonView::render(&data).unwrap(); /// /// // Verify it's valid JSON /// let parsed: serde_json::Value = serde_json::from_str(&output).unwrap(); @@ -43,67 +45,16 @@ use crate::presentation::cli::views::commands::register::RegisterDetailsData; /// ``` pub struct JsonView; -impl JsonView { - /// Render register details as JSON - /// - /// Serializes the register details to pretty-printed JSON format. - /// The JSON structure matches the DTO structure exactly: - /// - `environment_name`: Name of the registered environment - /// - `instance_ip`: IP address of the registered instance - /// - `ssh_port`: SSH port of the registered instance - /// - `registered`: Always `true` on success - /// - /// # Arguments - /// - /// * `data` - Register details to render - /// - /// # Returns - /// - /// A JSON string containing the serialized register details. - /// If serialization fails (which should never happen with valid data), - /// returns an error JSON object with the serialization error message. - /// - /// # Examples - /// - /// ```rust - /// use torrust_tracker_deployer_lib::presentation::cli::views::commands::register::{ - /// RegisterDetailsData, JsonView, - /// }; - /// - /// let data = RegisterDetailsData { - /// environment_name: "prod-tracker".to_string(), - /// instance_ip: "10.0.0.1".to_string(), - /// ssh_port: 2222, - /// registered: true, - /// }; - /// - /// let json = JsonView::render(&data); - /// - /// assert!(json.contains("\"environment_name\": \"prod-tracker\"")); - /// assert!(json.contains("\"instance_ip\": \"10.0.0.1\"")); - /// assert!(json.contains("\"ssh_port\": 2222")); - /// assert!(json.contains("\"registered\": true")); - /// ``` - #[must_use] - pub fn render(data: &RegisterDetailsData) -> String { - serde_json::to_string_pretty(data).unwrap_or_else(|e| { - serde_json::to_string_pretty(&serde_json::json!({ - "error": "Failed to serialize register details", - "message": e.to_string(), - })) - .unwrap_or_else(|_| { - r#"{ - "error": "Failed to serialize error message" -}"# - .to_string() - }) - }) +impl Render for JsonView { + fn render(data: &RegisterDetailsData) -> Result { + Ok(serde_json::to_string_pretty(data)?) } } #[cfg(test)] mod tests { use super::*; + use crate::presentation::cli::views::Render; fn create_test_data() -> RegisterDetailsData { RegisterDetailsData { @@ -120,7 +71,7 @@ mod tests { let data = create_test_data(); // Act - let json = JsonView::render(&data); + let json = JsonView::render(&data).unwrap(); // Assert let parsed: serde_json::Value = @@ -142,7 +93,7 @@ mod tests { }; // Act - let json = JsonView::render(&data); + let json = JsonView::render(&data).unwrap(); // Assert assert!( @@ -157,7 +108,7 @@ mod tests { let data = create_test_data(); // Act - let json = JsonView::render(&data); + let json = JsonView::render(&data).unwrap(); // Assert assert!( @@ -172,7 +123,7 @@ mod tests { let data = create_test_data(); // Act - let json = JsonView::render(&data); + let json = JsonView::render(&data).unwrap(); // Assert assert!( @@ -187,7 +138,7 @@ mod tests { let data = create_test_data(); // Act - let json = JsonView::render(&data); + let json = JsonView::render(&data).unwrap(); // Assert assert!( @@ -202,7 +153,7 @@ mod tests { let data = create_test_data(); // Act - let json = JsonView::render(&data); + let json = JsonView::render(&data).unwrap(); // Assert — pretty-printed JSON contains newlines assert!(json.contains('\n'), "JSON should be pretty-printed"); diff --git a/src/presentation/cli/views/commands/register/views/text_view.rs b/src/presentation/cli/views/commands/register/views/text_view.rs index 9ef3a0ba..a63e2c9f 100644 --- a/src/presentation/cli/views/commands/register/views/text_view.rs +++ b/src/presentation/cli/views/commands/register/views/text_view.rs @@ -11,6 +11,7 @@ //! output format produced before the Strategy Pattern was introduced. use crate::presentation::cli::views::commands::register::RegisterDetailsData; +use crate::presentation::cli::views::{Render, ViewRenderError}; /// View for rendering register details as human-readable text /// @@ -23,6 +24,7 @@ use crate::presentation::cli::views::commands::register::RegisterDetailsData; /// # Examples /// /// ```rust +/// # use torrust_tracker_deployer_lib::presentation::cli::views::Render; /// use torrust_tracker_deployer_lib::presentation::cli::views::commands::register::{ /// RegisterDetailsData, TextView, /// }; @@ -34,50 +36,17 @@ use crate::presentation::cli::views::commands::register::RegisterDetailsData; /// registered: true, /// }; /// -/// let output = TextView::render(&data); +/// let output = TextView::render(&data).unwrap(); /// assert!(output.contains("Instance registered successfully with environment 'my-env'")); /// ``` pub struct TextView; -impl TextView { - /// Render register details as human-readable text - /// - /// Takes register details and produces a human-readable output - /// intended to be wrapped by `ProgressReporter::complete()`. - /// - /// # Arguments - /// - /// * `data` - Register details to render - /// - /// # Returns - /// - /// A formatted string: `"Instance registered successfully with environment ''"`. - /// The `✅` prefix is added by `ProgressReporter::complete()`, not here. - /// - /// # Examples - /// - /// ```rust - /// use torrust_tracker_deployer_lib::presentation::cli::views::commands::register::{ - /// RegisterDetailsData, TextView, - /// }; - /// - /// let data = RegisterDetailsData { - /// environment_name: "prod-tracker".to_string(), - /// instance_ip: "10.0.0.1".to_string(), - /// ssh_port: 22, - /// registered: true, - /// }; - /// - /// let text = TextView::render(&data); - /// - /// assert!(text.contains("Instance registered successfully with environment 'prod-tracker'")); - /// ``` - #[must_use] - pub fn render(data: &RegisterDetailsData) -> String { - format!( +impl Render for TextView { + fn render(data: &RegisterDetailsData) -> Result { + Ok(format!( "Instance registered successfully with environment '{}'", data.environment_name - ) + )) } } @@ -100,7 +69,7 @@ mod tests { let data = create_test_data(); // Act - let text = TextView::render(&data); + let text = TextView::render(&data).unwrap(); // Assert assert_eq!( @@ -120,7 +89,7 @@ mod tests { }; // Act - let text = TextView::render(&data); + let text = TextView::render(&data).unwrap(); // Assert assert!( @@ -135,7 +104,7 @@ mod tests { let data = create_test_data(); // Act - let text = TextView::render(&data); + let text = TextView::render(&data).unwrap(); // Assert assert!( diff --git a/src/presentation/cli/views/commands/release/views/json_view.rs b/src/presentation/cli/views/commands/release/views/json_view.rs index c6dcf68a..e879458c 100644 --- a/src/presentation/cli/views/commands/release/views/json_view.rs +++ b/src/presentation/cli/views/commands/release/views/json_view.rs @@ -10,6 +10,7 @@ //! The output includes environment details and release state. use crate::presentation::cli::views::commands::release::ReleaseDetailsData; +use crate::presentation::cli::views::{Render, ViewRenderError}; /// View for rendering release details as JSON /// @@ -20,6 +21,7 @@ use crate::presentation::cli::views::commands::release::ReleaseDetailsData; /// # Examples /// /// ```rust +/// # use torrust_tracker_deployer_lib::presentation::cli::views::Render; /// use torrust_tracker_deployer_lib::presentation::cli::views::commands::release::{ /// ReleaseDetailsData, JsonView, /// }; @@ -35,7 +37,7 @@ use crate::presentation::cli::views::commands::release::ReleaseDetailsData; /// created_at: Utc.with_ymd_and_hms(2026, 2, 20, 10, 0, 0).unwrap(), /// }; /// -/// let output = JsonView::render(&details); +/// let output = JsonView::render(&details).unwrap(); /// /// // Verify it's valid JSON /// let parsed: serde_json::Value = serde_json::from_str(&output).unwrap(); @@ -44,71 +46,16 @@ use crate::presentation::cli::views::commands::release::ReleaseDetailsData; /// ``` 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::cli::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| { - serde_json::to_string_pretty(&serde_json::json!({ - "error": "Failed to serialize release details", - "message": e.to_string(), - })) - .unwrap_or_else(|_| { - r#"{ - "error": "Failed to serialize error message" -}"# - .to_string() - }) - }) +impl Render for JsonView { + fn render(data: &ReleaseDetailsData) -> Result { + Ok(serde_json::to_string_pretty(data)?) } } #[cfg(test)] mod tests { use super::*; + use crate::presentation::cli::views::Render; use chrono::{DateTime, TimeZone, Utc}; use std::net::{IpAddr, Ipv4Addr}; @@ -164,7 +111,7 @@ mod tests { let details = create_test_details_with_ip(Some(create_test_ip())); // Act - let json = JsonView::render(&details); + let json = JsonView::render(&details).unwrap(); // Assert - verify it's valid JSON with expected field values assert_json_fields_eq( @@ -186,7 +133,7 @@ mod tests { let details = create_test_details_with_ip(None); // Act - let json = JsonView::render(&details); + let json = JsonView::render(&details).unwrap(); // Assert let parsed: serde_json::Value = serde_json::from_str(&json).expect("Should be valid JSON"); @@ -199,7 +146,7 @@ mod tests { let details = create_test_details_with_ip(Some(IpAddr::V4(Ipv4Addr::new(192, 168, 1, 42)))); // Act - let json = JsonView::render(&details); + let json = JsonView::render(&details).unwrap(); // Assert - check all required fields are present assert_json_has_fields( diff --git a/src/presentation/cli/views/commands/release/views/text_view.rs b/src/presentation/cli/views/commands/release/views/text_view.rs index b81c5498..fc9e8a98 100644 --- a/src/presentation/cli/views/commands/release/views/text_view.rs +++ b/src/presentation/cli/views/commands/release/views/text_view.rs @@ -10,6 +10,7 @@ //! for terminal display and direct user consumption. use crate::presentation::cli::views::commands::release::ReleaseDetailsData; +use crate::presentation::cli::views::{Render, ViewRenderError}; /// View for rendering release details as human-readable text /// @@ -20,6 +21,7 @@ use crate::presentation::cli::views::commands::release::ReleaseDetailsData; /// # Examples /// /// ```rust +/// # use torrust_tracker_deployer_lib::presentation::cli::views::Render; /// use torrust_tracker_deployer_lib::presentation::cli::views::commands::release::{ /// ReleaseDetailsData, TextView, /// }; @@ -35,73 +37,19 @@ use crate::presentation::cli::views::commands::release::ReleaseDetailsData; /// created_at: Utc.with_ymd_and_hms(2026, 2, 20, 10, 0, 0).unwrap(), /// }; /// -/// let output = TextView::render(&details); +/// let output = TextView::render(&details).unwrap(); /// 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::cli::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 { +impl Render for TextView { + fn render(data: &ReleaseDetailsData) -> Result { let instance_ip = data .instance_ip .map_or_else(|| "Not available".to_string(), |ip| ip.to_string()); - format!( + Ok(format!( r"Environment Details: Name: {} Instance: {} @@ -115,7 +63,7 @@ impl TextView { data.state, instance_ip, data.created_at.format("%Y-%m-%d %H:%M:%S UTC") - ) + )) } } @@ -164,7 +112,7 @@ mod tests { let details = create_test_details_with_ip(Some(create_test_ip())); // Act - let text = TextView::render(&details); + let text = TextView::render(&details).unwrap(); // Assert assert_contains_all( @@ -193,7 +141,7 @@ mod tests { let details = create_test_details_with_ip(None); // Act - let text = TextView::render(&details); + let text = TextView::render(&details).unwrap(); // Assert assert!(text.contains("Instance IP: Not available")); @@ -205,7 +153,7 @@ mod tests { let details = create_test_details_with_ip(Some(IpAddr::V4(Ipv4Addr::new(192, 168, 1, 42)))); // Act - let text = TextView::render(&details); + let text = TextView::render(&details).unwrap(); // Assert - check all sections are present assert_contains_all( diff --git a/src/presentation/cli/views/commands/render/views/json_view.rs b/src/presentation/cli/views/commands/render/views/json_view.rs index db7776e3..d3933866 100644 --- a/src/presentation/cli/views/commands/render/views/json_view.rs +++ b/src/presentation/cli/views/commands/render/views/json_view.rs @@ -11,6 +11,7 @@ //! and output directory for the generated artifacts. use crate::presentation::cli::views::commands::render::RenderDetailsData; +use crate::presentation::cli::views::{Render, ViewRenderError}; /// View for rendering render details as JSON /// @@ -21,6 +22,7 @@ use crate::presentation::cli::views::commands::render::RenderDetailsData; /// # Examples /// /// ```rust +/// # use torrust_tracker_deployer_lib::presentation::cli::views::Render; /// use torrust_tracker_deployer_lib::presentation::cli::views::commands::render::{ /// RenderDetailsData, JsonView, /// }; @@ -32,7 +34,7 @@ use crate::presentation::cli::views::commands::render::RenderDetailsData; /// output_dir: "/tmp/build/my-env".to_string(), /// }; /// -/// let output = JsonView::render(&data); +/// let output = JsonView::render(&data).unwrap(); /// /// // Verify it's valid JSON /// let parsed: serde_json::Value = serde_json::from_str(&output).unwrap(); @@ -41,65 +43,16 @@ use crate::presentation::cli::views::commands::render::RenderDetailsData; /// ``` 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| { - serde_json::to_string_pretty(&serde_json::json!({ - "error": "Failed to serialize render details", - "message": e.to_string(), - })) - .unwrap_or_else(|_| { - r#"{ - "error": "Failed to serialize error message" -}"# - .to_string() - }) - }) +impl Render for JsonView { + fn render(data: &RenderDetailsData) -> Result { + Ok(serde_json::to_string_pretty(data)?) } } #[cfg(test)] mod tests { use super::*; + use crate::presentation::cli::views::Render; // Test fixtures @@ -143,7 +96,7 @@ mod tests { let data = create_test_data(); // Act - let json = JsonView::render(&data); + let json = JsonView::render(&data).unwrap(); // Assert - verify it's valid JSON with expected string field values assert_json_str_fields_eq( @@ -163,7 +116,7 @@ mod tests { let data = create_test_data(); // Act - let json = JsonView::render(&data); + let json = JsonView::render(&data).unwrap(); // Assert - every documented field must be present assert_json_has_fields( @@ -183,7 +136,7 @@ mod tests { let data = create_test_data(); // Act - let json = JsonView::render(&data); + let json = JsonView::render(&data).unwrap(); // Assert let result = serde_json::from_str::(&json); @@ -201,7 +154,7 @@ mod tests { }; // Act - let json = JsonView::render(&data); + let json = JsonView::render(&data).unwrap(); // Assert assert_json_str_fields_eq(&json, &[("config_source", "Environment: my-env")]); @@ -213,7 +166,7 @@ mod tests { let data = create_test_data(); // Act - let json = JsonView::render(&data); + let json = JsonView::render(&data).unwrap(); // Assert - pretty-printed JSON has newlines and indentation assert!( diff --git a/src/presentation/cli/views/commands/render/views/text_view.rs b/src/presentation/cli/views/commands/render/views/text_view.rs index 5eb71560..83376601 100644 --- a/src/presentation/cli/views/commands/render/views/text_view.rs +++ b/src/presentation/cli/views/commands/render/views/text_view.rs @@ -11,6 +11,7 @@ //! output format produced before the Strategy Pattern was introduced. use crate::presentation::cli::views::commands::render::RenderDetailsData; +use crate::presentation::cli::views::{Render, ViewRenderError}; /// View for rendering render details as human-readable text /// @@ -24,6 +25,7 @@ use crate::presentation::cli::views::commands::render::RenderDetailsData; /// # Examples /// /// ```rust +/// # use torrust_tracker_deployer_lib::presentation::cli::views::Render; /// use torrust_tracker_deployer_lib::presentation::cli::views::commands::render::{ /// RenderDetailsData, TextView, /// }; @@ -35,70 +37,16 @@ use crate::presentation::cli::views::commands::render::RenderDetailsData; /// output_dir: "/tmp/build/my-env".to_string(), /// }; /// -/// let output = TextView::render(&data); +/// let output = TextView::render(&data).unwrap(); /// 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!( +impl Render for TextView { + fn render(data: &RenderDetailsData) -> Result { + Ok(format!( "Deployment artifacts generated successfully!\n\n\ \x20\x20Source: {}\n\ \x20\x20Target IP: {}\n\ @@ -108,7 +56,7 @@ impl TextView { \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, - ) + )) } } @@ -145,7 +93,7 @@ mod tests { let data = create_test_data(); // Act - let text = TextView::render(&data); + let text = TextView::render(&data).unwrap(); // Assert assert_contains_all( @@ -166,7 +114,7 @@ mod tests { let data = create_test_data(); // Act - let text = TextView::render(&data); + let text = TextView::render(&data).unwrap(); // Assert - each significant data point appears in the output assert_contains_all( @@ -185,7 +133,7 @@ mod tests { let data = create_test_data(); // Act - let text = TextView::render(&data); + let text = TextView::render(&data).unwrap(); // Assert - next steps section is present assert_contains_all(&text, &["Next steps:", "Review artifacts", "provision"]); @@ -202,7 +150,7 @@ mod tests { }; // Act - let text = TextView::render(&data); + let text = TextView::render(&data).unwrap(); // Assert assert_contains_all( diff --git a/src/presentation/cli/views/commands/run/views/json_view.rs b/src/presentation/cli/views/commands/run/views/json_view.rs index 0d8cafa1..19b86090 100644 --- a/src/presentation/cli/views/commands/run/views/json_view.rs +++ b/src/presentation/cli/views/commands/run/views/json_view.rs @@ -11,6 +11,7 @@ //! service information from the existing DTOs. use crate::presentation::cli::views::commands::run::view_data::RunDetailsData; +use crate::presentation::cli::views::{Render, ViewRenderError}; /// View for rendering run command output as JSON /// @@ -21,6 +22,7 @@ use crate::presentation::cli::views::commands::run::view_data::RunDetailsData; /// # Examples /// /// ```rust +/// # use torrust_tracker_deployer_lib::presentation::cli::views::Render; /// use torrust_tracker_deployer_lib::application::command_handlers::show::info::ServiceInfo; /// use torrust_tracker_deployer_lib::presentation::cli::views::commands::run::{JsonView, RunDetailsData}; /// @@ -39,7 +41,7 @@ use crate::presentation::cli::views::commands::run::view_data::RunDetailsData; /// ); /// /// let data = RunDetailsData::new("my-env".to_string(), services, None); -/// let output = JsonView::render(&data); +/// let output = JsonView::render(&data).unwrap(); /// // Verify it's valid JSON /// let parsed: serde_json::Value = serde_json::from_str(&output).unwrap(); /// assert_eq!(parsed["environment_name"], "my-env"); @@ -47,72 +49,9 @@ use crate::presentation::cli::views::commands::run::view_data::RunDetailsData; /// ``` pub struct JsonView; -impl JsonView { - /// Render run command output as JSON - /// - /// Serializes the `RunDetailsData` DTO to pretty-printed JSON format. - /// - /// # Arguments - /// - /// * `data` - Run details DTO containing environment name, state, - /// service endpoints, and optional Grafana information - /// - /// # Returns - /// - /// A JSON string containing the serialized run command output. - /// If serialization fails (which should never happen with valid data), - /// returns an error JSON object. - /// - /// # Examples - /// - /// ```rust - /// use torrust_tracker_deployer_lib::application::command_handlers::show::info::{ - /// ServiceInfo, GrafanaInfo, - /// }; - /// use torrust_tracker_deployer_lib::presentation::cli::views::commands::run::{JsonView, RunDetailsData}; - /// use url::Url; - /// - /// let services = ServiceInfo::new( - /// vec!["udp://10.0.0.1:6969/announce".to_string()], - /// vec![], - /// vec!["http://10.0.0.1:7070/announce".to_string()], - /// vec![], - /// "http://10.0.0.1:1212/api".to_string(), - /// false, - /// false, - /// "http://10.0.0.1:1313/health_check".to_string(), - /// false, - /// false, - /// vec![], - /// ); - /// - /// let grafana = GrafanaInfo::new( - /// Url::parse("http://10.0.0.1:3000").unwrap(), - /// false, - /// ); - /// - /// let data = RunDetailsData::new("my-env".to_string(), services, Some(grafana)); - /// let json = JsonView::render(&data); - /// // Verify it's valid JSON and has expected fields - /// let parsed: serde_json::Value = serde_json::from_str(&json).unwrap(); - /// assert_eq!(parsed["environment_name"], "my-env"); - /// assert!(parsed["services"].is_object()); - /// assert!(parsed["grafana"].is_object()); - /// ``` - #[must_use] - pub fn render(data: &RunDetailsData) -> String { - serde_json::to_string_pretty(data).unwrap_or_else(|e| { - serde_json::to_string_pretty(&serde_json::json!({ - "error": "Failed to serialize run details", - "message": e.to_string(), - })) - .unwrap_or_else(|_| { - r#"{ - "error": "Failed to serialize error message" -}"# - .to_string() - }) - }) +impl Render for JsonView { + fn render(data: &RunDetailsData) -> Result { + Ok(serde_json::to_string_pretty(data)?) } } @@ -123,17 +62,18 @@ mod tests { use crate::application::command_handlers::show::info::{GrafanaInfo, ServiceInfo}; use super::*; + use crate::presentation::cli::views::Render; fn sample_data() -> RunDetailsData { let services = ServiceInfo::new( vec!["udp://udp.tracker.local:6969/announce".to_string()], vec![], - vec!["http://10.140.190.133:7070/announce".to_string()], + vec!["http://10.140.190.133:7070/announce".to_string()], // DevSkim: ignore DS137138 vec![], - "http://10.140.190.133:1212/api".to_string(), + "http://10.140.190.133:1212/api".to_string(), // DevSkim: ignore DS137138 false, false, - "http://10.140.190.133:1313/health_check".to_string(), + "http://10.140.190.133:1313/health_check".to_string(), // DevSkim: ignore DS137138 false, false, vec![], @@ -144,7 +84,7 @@ mod tests { #[test] fn it_should_render_basic_json_output() { let data = sample_data(); - let output = JsonView::render(&data); + let output = JsonView::render(&data).unwrap(); assert!( output.contains(r#""environment_name":"test-env""#) @@ -162,25 +102,25 @@ mod tests { let services = ServiceInfo::new( vec!["udp://udp.tracker.local:6969/announce".to_string()], vec![], - vec!["http://10.140.190.133:7070/announce".to_string()], + vec!["http://10.140.190.133:7070/announce".to_string()], // DevSkim: ignore DS137138 vec![], - "http://10.140.190.133:1212/api".to_string(), + "http://10.140.190.133:1212/api".to_string(), // DevSkim: ignore DS137138 false, false, - "http://10.140.190.133:1313/health_check".to_string(), + "http://10.140.190.133:1313/health_check".to_string(), // DevSkim: ignore DS137138 false, false, vec![], ); - let grafana = GrafanaInfo::new(Url::parse("http://10.140.190.133:3000").unwrap(), false); + let grafana = GrafanaInfo::new(Url::parse("http://10.140.190.133:3000").unwrap(), false); // DevSkim: ignore DS137138 let data = RunDetailsData::new("test-env".to_string(), services, Some(grafana)); - let output = JsonView::render(&data); + let output = JsonView::render(&data).unwrap(); assert!(output.contains(r#""grafana":"#)); assert!( - output.contains(r#""url":"http://10.140.190.133:3000/""#) - || output.contains(r#""url": "http://10.140.190.133:3000/""#) + output.contains(r#""url":"http://10.140.190.133:3000/""#) // DevSkim: ignore DS137138 + || output.contains(r#""url": "http://10.140.190.133:3000/""#) // DevSkim: ignore DS137138 ); assert!( output.contains(r#""uses_https":false"#) || output.contains(r#""uses_https": false"#) @@ -190,7 +130,7 @@ mod tests { #[test] fn it_should_produce_valid_json() { let data = sample_data(); - let output = JsonView::render(&data); + let output = JsonView::render(&data).unwrap(); let parsed: serde_json::Value = serde_json::from_str(&output).expect("Output should be valid JSON"); @@ -204,7 +144,7 @@ mod tests { #[test] fn it_should_include_all_service_fields() { let data = sample_data(); - let output = JsonView::render(&data); + let output = JsonView::render(&data).unwrap(); let parsed: serde_json::Value = serde_json::from_str(&output).unwrap(); let services_obj = &parsed["services"]; diff --git a/src/presentation/cli/views/commands/run/views/text_view.rs b/src/presentation/cli/views/commands/run/views/text_view.rs index 29429fb8..e062e4f2 100644 --- a/src/presentation/cli/views/commands/run/views/text_view.rs +++ b/src/presentation/cli/views/commands/run/views/text_view.rs @@ -7,6 +7,7 @@ use crate::presentation::cli::views::commands::run::view_data::RunDetailsData; use crate::presentation::cli::views::commands::shared::service_urls::{ CompactServiceUrlsView, DnsHintView, }; +use crate::presentation::cli::views::{Render, ViewRenderError}; /// View for rendering run command output as human-readable text /// @@ -17,6 +18,7 @@ use crate::presentation::cli::views::commands::shared::service_urls::{ /// # Examples /// /// ```rust +/// # use torrust_tracker_deployer_lib::presentation::cli::views::Render; /// use torrust_tracker_deployer_lib::application::command_handlers::show::info::ServiceInfo; /// use torrust_tracker_deployer_lib::presentation::cli::views::commands::run::{TextView, RunDetailsData}; /// @@ -35,26 +37,14 @@ use crate::presentation::cli::views::commands::shared::service_urls::{ /// ); /// /// let data = RunDetailsData::new("my-env".to_string(), services, None); -/// let output = TextView::render(&data); +/// let output = TextView::render(&data).unwrap(); /// assert!(output.contains("Services are now accessible:")); /// assert!(output.contains("Tip:")); /// ``` pub struct TextView; -impl TextView { - /// Render run command output as human-readable text - /// - /// # Arguments - /// - /// * `data` - Run details DTO containing environment name, service endpoints, - /// and optional Grafana information - /// - /// # Returns - /// - /// A formatted string with service URLs, DNS hints (if applicable), - /// and a tip about using the show command for more details. - #[must_use] - pub fn render(data: &RunDetailsData) -> String { +impl Render for TextView { + fn render(data: &RunDetailsData) -> Result { let mut output = Vec::new(); // Render service URLs (only public services) @@ -75,7 +65,7 @@ impl TextView { data.environment_name )); - output.join("") + Ok(output.join("")) } } @@ -104,7 +94,7 @@ mod tests { #[test] fn it_should_render_basic_output() { let data = sample_data(); - let output = TextView::render(&data); + let output = TextView::render(&data).unwrap(); assert!(output.contains("Services are now accessible:")); assert!(output.contains("udp://udp.tracker.local:6969/announce")); @@ -135,7 +125,7 @@ mod tests { let grafana = GrafanaInfo::new(Url::parse("http://10.140.190.133:3000").unwrap(), false); let data = RunDetailsData::new("test-env".to_string(), services, Some(grafana)); - let output = TextView::render(&data); + let output = TextView::render(&data).unwrap(); assert!(output.contains("Grafana:")); assert!(output.contains("http://10.140.190.133:3000")); @@ -163,7 +153,7 @@ mod tests { ); let data = RunDetailsData::new("test-env".to_string(), services, None); - let output = TextView::render(&data); + let output = TextView::render(&data).unwrap(); assert!(output.contains("Note: HTTPS services require DNS configuration")); } @@ -185,7 +175,7 @@ mod tests { ); let data = RunDetailsData::new("my-environment".to_string(), services, None); - let output = TextView::render(&data); + let output = TextView::render(&data).unwrap(); assert!(output.contains("Tip: Run 'torrust-tracker-deployer show my-environment'")); } diff --git a/src/presentation/cli/views/commands/show/views/json_view.rs b/src/presentation/cli/views/commands/show/views/json_view.rs index 14b4e36d..1fb58281 100644 --- a/src/presentation/cli/views/commands/show/views/json_view.rs +++ b/src/presentation/cli/views/commands/show/views/json_view.rs @@ -11,6 +11,7 @@ //! purposes and contains all necessary information in a well-structured format. use crate::presentation::cli::views::commands::show::view_data::EnvironmentInfo; +use crate::presentation::cli::views::{Render, ViewRenderError}; /// View for rendering environment information as JSON /// @@ -21,6 +22,7 @@ use crate::presentation::cli::views::commands::show::view_data::EnvironmentInfo; /// # Examples /// /// ```rust +/// # use torrust_tracker_deployer_lib::presentation::cli::views::Render; /// use torrust_tracker_deployer_lib::application::command_handlers::show::info::EnvironmentInfo; /// use torrust_tracker_deployer_lib::presentation::cli::views::commands::show::JsonView; /// use chrono::{TimeZone, Utc}; @@ -34,7 +36,7 @@ use crate::presentation::cli::views::commands::show::view_data::EnvironmentInfo; /// "created".to_string(), /// ); /// -/// let output = JsonView::render(&info); +/// let output = JsonView::render(&info).unwrap(); /// // Verify it's valid JSON /// let parsed: serde_json::Value = serde_json::from_str(&output).unwrap(); /// assert_eq!(parsed["name"], "my-env"); @@ -42,66 +44,9 @@ use crate::presentation::cli::views::commands::show::view_data::EnvironmentInfo; /// ``` pub struct JsonView; -impl JsonView { - /// Render environment information as JSON - /// - /// Serializes the `EnvironmentInfo` DTO to pretty-printed JSON format. - /// The output structure mirrors the DTO structure exactly, making it - /// easy to parse programmatically. - /// - /// # Arguments - /// - /// * `info` - Environment information to render - /// - /// # Returns - /// - /// A JSON string containing the serialized environment information. - /// If serialization fails (which should never happen with valid data), - /// returns an error JSON object. - /// - /// # Examples - /// - /// ```rust - /// use torrust_tracker_deployer_lib::application::command_handlers::show::info::{ - /// EnvironmentInfo, InfrastructureInfo, - /// }; - /// use torrust_tracker_deployer_lib::presentation::cli::views::commands::show::JsonView; - /// use std::net::{IpAddr, Ipv4Addr}; - /// use chrono::Utc; - /// - /// let info = EnvironmentInfo::new( - /// "my-env".to_string(), - /// "Provisioned".to_string(), - /// "LXD".to_string(), - /// Utc::now(), - /// "provisioned".to_string(), - /// ).with_infrastructure(InfrastructureInfo::new( - /// IpAddr::V4(Ipv4Addr::new(10, 140, 190, 14)), - /// 22, - /// "ubuntu".to_string(), - /// "/home/user/.ssh/key".to_string(), - /// )); - /// - /// let json = JsonView::render(&info); - /// // Verify it's valid JSON and has expected fields - /// let parsed: serde_json::Value = serde_json::from_str(&json).unwrap(); - /// assert_eq!(parsed["name"], "my-env"); - /// assert!(parsed["infrastructure"].is_object()); - /// ``` - #[must_use] - pub fn render(info: &EnvironmentInfo) -> String { - serde_json::to_string_pretty(info).unwrap_or_else(|e| { - serde_json::to_string_pretty(&serde_json::json!({ - "error": "Failed to serialize show details", - "message": e.to_string(), - })) - .unwrap_or_else(|_| { - r#"{ - "error": "Failed to serialize error message" -}"# - .to_string() - }) - }) +impl Render for JsonView { + fn render(data: &EnvironmentInfo) -> Result { + Ok(serde_json::to_string_pretty(data)?) } } @@ -113,6 +58,7 @@ mod tests { use super::*; use crate::presentation::cli::views::commands::show::view_data::InfrastructureInfo; + use crate::presentation::cli::views::Render; #[test] fn it_should_render_created_state_as_json() { @@ -125,7 +71,7 @@ mod tests { "created".to_string(), ); - let output = JsonView::render(&info); + let output = JsonView::render(&info).unwrap(); // Verify JSON structure assert!( @@ -165,7 +111,7 @@ mod tests { "/home/user/.ssh/key".to_string(), )); - let output = JsonView::render(&info); + let output = JsonView::render(&info).unwrap(); // Verify infrastructure section exists assert!(output.contains(r#""infrastructure":"#)); @@ -191,7 +137,7 @@ mod tests { "created".to_string(), ); - let output = JsonView::render(&info); + let output = JsonView::render(&info).unwrap(); // Verify it's valid JSON by parsing it let parsed: serde_json::Value = diff --git a/src/presentation/cli/views/commands/show/views/text_view.rs b/src/presentation/cli/views/commands/show/views/text_view.rs index be46c7b0..a617a94d 100644 --- a/src/presentation/cli/views/commands/show/views/text_view.rs +++ b/src/presentation/cli/views/commands/show/views/text_view.rs @@ -24,6 +24,7 @@ use super::prometheus::PrometheusView; use super::tracker_services::TrackerServicesView; use crate::presentation::cli::views::commands::show::view_data::EnvironmentInfo; +use crate::presentation::cli::views::{Render, ViewRenderError}; /// View for rendering environment information /// @@ -41,6 +42,7 @@ use crate::presentation::cli::views::commands::show::view_data::EnvironmentInfo; /// # Examples /// /// ```rust +/// # use torrust_tracker_deployer_lib::presentation::cli::views::Render; /// use torrust_tracker_deployer_lib::application::command_handlers::show::info::EnvironmentInfo; /// use torrust_tracker_deployer_lib::presentation::cli::views::commands::show::TextView; /// use chrono::{TimeZone, Utc}; @@ -54,60 +56,14 @@ use crate::presentation::cli::views::commands::show::view_data::EnvironmentInfo; /// "created".to_string(), /// ); /// -/// let output = TextView::render(&info); +/// let output = TextView::render(&info).unwrap(); /// assert!(output.contains("Environment: my-env")); /// assert!(output.contains("State: Created")); /// ``` pub struct TextView; -impl TextView { - /// Render environment information as a formatted string - /// - /// Takes environment info and produces a human-readable output suitable - /// for displaying to users via stdout. Uses composition to delegate - /// rendering to specialized child views. - /// - /// # Arguments - /// - /// * `info` - Environment information to render - /// - /// # Returns - /// - /// A formatted string containing: - /// - Basic information (name, state, provider) - /// - Infrastructure details (if available) - /// - Service information (if available, for Released/Running states) - /// - Next step guidance - /// - /// # Examples - /// - /// ```rust - /// use torrust_tracker_deployer_lib::application::command_handlers::show::info::{ - /// EnvironmentInfo, InfrastructureInfo, - /// }; - /// use torrust_tracker_deployer_lib::presentation::cli::views::commands::show::TextView; - /// use std::net::{IpAddr, Ipv4Addr}; - /// use chrono::Utc; - /// - /// let info = EnvironmentInfo::new( - /// "prod-env".to_string(), - /// "Provisioned".to_string(), - /// "LXD".to_string(), - /// Utc::now(), - /// "provisioned".to_string(), - /// ).with_infrastructure(InfrastructureInfo::new( - /// IpAddr::V4(Ipv4Addr::new(10, 140, 190, 171)), - /// 22, - /// "torrust".to_string(), - /// "~/.ssh/id_rsa".to_string(), - /// )); - /// - /// let output = TextView::render(&info); - /// assert!(output.contains("10.140.190.171")); - /// assert!(output.contains("ssh -i")); - /// ``` - #[must_use] - pub fn render(info: &EnvironmentInfo) -> String { +impl Render for TextView { + fn render(info: &EnvironmentInfo) -> Result { let mut lines = Vec::new(); // Basic information (always present) @@ -147,7 +103,7 @@ impl TextView { // Next step guidance (always present) lines.extend(NextStepGuidanceView::render(&info.state_name)); - lines.join("\n") + Ok(lines.join("\n")) } } @@ -177,7 +133,7 @@ mod tests { "created".to_string(), ); - let output = TextView::render(&info); + let output = TextView::render(&info).unwrap(); assert!(output.contains("Environment: test-env")); assert!(output.contains("State: Created")); @@ -202,7 +158,7 @@ mod tests { "~/.ssh/id_rsa".to_string(), )); - let output = TextView::render(&info); + let output = TextView::render(&info).unwrap(); assert!(output.contains("Infrastructure:")); assert!(output.contains("Instance IP: 10.140.190.171")); @@ -236,7 +192,7 @@ mod tests { vec![], // No TLS domains )); - let output = TextView::render(&info); + let output = TextView::render(&info).unwrap(); assert!(output.contains("Tracker Services:")); assert!(output.contains("UDP Trackers:")); @@ -278,7 +234,7 @@ mod tests { vec![], )); - let output = TextView::render(&info); + let output = TextView::render(&info).unwrap(); // Should have all sections assert!(output.contains("Environment: full-env")); @@ -327,7 +283,7 @@ mod tests { ], )); - let output = TextView::render(&info); + let output = TextView::render(&info).unwrap(); // Check HTTPS trackers section assert!(output.contains("HTTP Trackers (HTTPS via Caddy):")); @@ -371,7 +327,7 @@ mod tests { "/key".to_string(), )); - let output = TextView::render(&info); + let output = TextView::render(&info).unwrap(); assert!(output.contains("-p 2222")); } diff --git a/src/presentation/cli/views/commands/test/views/json_view.rs b/src/presentation/cli/views/commands/test/views/json_view.rs index 605066e8..65c03cf2 100644 --- a/src/presentation/cli/views/commands/test/views/json_view.rs +++ b/src/presentation/cli/views/commands/test/views/json_view.rs @@ -10,6 +10,7 @@ //! The output includes test result status and any advisory DNS warnings. use crate::presentation::cli::views::commands::test::TestResultData; +use crate::presentation::cli::views::{Render, ViewRenderError}; /// View for rendering test results as JSON /// @@ -20,6 +21,7 @@ use crate::presentation::cli::views::commands::test::TestResultData; /// # Examples /// /// ```rust +/// # use torrust_tracker_deployer_lib::presentation::cli::views::Render; /// use torrust_tracker_deployer_lib::presentation::cli::views::commands::test::{ /// TestResultData, JsonView, /// }; @@ -31,7 +33,7 @@ use crate::presentation::cli::views::commands::test::TestResultData; /// dns_warnings: vec![], /// }; /// -/// let output = JsonView::render(&data); +/// let output = JsonView::render(&data).unwrap(); /// /// // Verify it's valid JSON /// let parsed: serde_json::Value = serde_json::from_str(&output).unwrap(); @@ -40,39 +42,9 @@ use crate::presentation::cli::views::commands::test::TestResultData; /// ``` pub struct JsonView; -impl JsonView { - /// Render test results as JSON - /// - /// Serializes the test results to pretty-printed JSON format. - /// The JSON structure matches the DTO structure exactly: - /// - `environment_name`: Name of the tested environment - /// - `instance_ip`: IP address of the tested instance - /// - `result`: Always "pass" on success - /// - `dns_warnings`: Array of advisory DNS warnings (may be empty) - /// - /// # Arguments - /// - /// * `data` - Test result data to render - /// - /// # Returns - /// - /// A JSON string containing the serialized test results. - /// If serialization fails (which should never happen with valid data), - /// returns an error JSON object with the serialization error message. - #[must_use] - pub fn render(data: &TestResultData) -> String { - serde_json::to_string_pretty(data).unwrap_or_else(|e| { - serde_json::to_string_pretty(&serde_json::json!({ - "error": "Failed to serialize test results", - "message": e.to_string(), - })) - .unwrap_or_else(|_| { - r#"{ - "error": "Failed to serialize error message" -}"# - .to_string() - }) - }) +impl Render for JsonView { + fn render(data: &TestResultData) -> Result { + Ok(serde_json::to_string_pretty(data)?) } } @@ -80,6 +52,7 @@ impl JsonView { mod tests { use super::*; use crate::presentation::cli::views::commands::test::DnsWarningData; + use crate::presentation::cli::views::Render; // Test fixtures and helpers @@ -143,7 +116,7 @@ mod tests { let data = create_test_data_no_warnings(); // Act - let json = JsonView::render(&data); + let json = JsonView::render(&data).unwrap(); // Assert - verify it's valid JSON with expected field values assert_json_fields_eq( @@ -162,7 +135,7 @@ mod tests { let data = create_test_data_no_warnings(); // Act - let json = JsonView::render(&data); + let json = JsonView::render(&data).unwrap(); // Assert assert_json_has_fields( @@ -177,7 +150,7 @@ mod tests { let data = create_test_data_no_warnings(); // Act - let json = JsonView::render(&data); + let json = JsonView::render(&data).unwrap(); // Assert let parsed: serde_json::Value = serde_json::from_str(&json).expect("Should be valid JSON"); @@ -191,7 +164,7 @@ mod tests { let data = create_test_data_with_warnings(); // Act - let json = JsonView::render(&data); + let json = JsonView::render(&data).unwrap(); // Assert let parsed: serde_json::Value = serde_json::from_str(&json).expect("Should be valid JSON"); diff --git a/src/presentation/cli/views/commands/test/views/text_view.rs b/src/presentation/cli/views/commands/test/views/text_view.rs index 925c1a5a..8fa8c692 100644 --- a/src/presentation/cli/views/commands/test/views/text_view.rs +++ b/src/presentation/cli/views/commands/test/views/text_view.rs @@ -13,6 +13,7 @@ use std::fmt::Write; use crate::presentation::cli::views::commands::test::TestResultData; +use crate::presentation::cli::views::{Render, ViewRenderError}; /// View for rendering test results as human-readable text /// @@ -23,6 +24,7 @@ use crate::presentation::cli::views::commands::test::TestResultData; /// # Examples /// /// ```rust +/// # use torrust_tracker_deployer_lib::presentation::cli::views::Render; /// use torrust_tracker_deployer_lib::presentation::cli::views::commands::test::{ /// TestResultData, TextView, /// }; @@ -34,42 +36,14 @@ use crate::presentation::cli::views::commands::test::TestResultData; /// dns_warnings: vec![], /// }; /// -/// let output = TextView::render(&data); +/// let output = TextView::render(&data).unwrap(); /// assert!(output.contains("Test Results:")); /// assert!(output.contains("my-env")); /// ``` pub struct TextView; -impl TextView { - /// Render test results as human-readable formatted text - /// - /// Takes test results and produces human-readable output suitable for - /// displaying to users via stdout. - /// - /// # Arguments - /// - /// * `data` - Test result data to render - /// - /// # Returns - /// - /// A formatted string containing: - /// - Test Results section with environment name, instance IP, and result - /// - DNS Warnings section (only if warnings are present) - /// - /// # Format - /// - /// The output follows this structure: - /// ```text - /// Test Results: - /// Environment: - /// Instance IP: - /// Result: - /// - /// DNS Warnings: (only if warnings exist) - /// - : - /// ``` - #[must_use] - pub fn render(data: &TestResultData) -> String { +impl Render for TextView { + fn render(data: &TestResultData) -> Result { let mut output = format!( r"Test Results: Environment: {} @@ -85,7 +59,7 @@ impl TextView { } } - output + Ok(output) } } @@ -143,7 +117,7 @@ mod tests { let data = create_test_data_no_warnings(); // Act - let text = TextView::render(&data); + let text = TextView::render(&data).unwrap(); // Assert assert_contains_all( @@ -166,7 +140,7 @@ mod tests { let data = create_test_data_no_warnings(); // Act - let text = TextView::render(&data); + let text = TextView::render(&data).unwrap(); // Assert assert!(!text.contains("DNS Warnings:")); @@ -178,7 +152,7 @@ mod tests { let data = create_test_data_with_warnings(); // Act - let text = TextView::render(&data); + let text = TextView::render(&data).unwrap(); // Assert assert_contains_all( @@ -199,7 +173,7 @@ mod tests { let data = create_test_data_with_warnings(); // Act - let text = TextView::render(&data); + let text = TextView::render(&data).unwrap(); // Assert - each warning should be on its own bullet line let warning_lines: Vec<&str> = text diff --git a/src/presentation/cli/views/commands/validate/views/json_view.rs b/src/presentation/cli/views/commands/validate/views/json_view.rs index f76b4dc9..705396b6 100644 --- a/src/presentation/cli/views/commands/validate/views/json_view.rs +++ b/src/presentation/cli/views/commands/validate/views/json_view.rs @@ -11,6 +11,7 @@ //! and feature flags for the validated configuration. use crate::presentation::cli::views::commands::validate::ValidateDetailsData; +use crate::presentation::cli::views::{Render, ViewRenderError}; /// View for rendering validate details as JSON /// @@ -21,6 +22,7 @@ use crate::presentation::cli::views::commands::validate::ValidateDetailsData; /// # Examples /// /// ```rust +/// # use torrust_tracker_deployer_lib::presentation::cli::views::Render; /// use torrust_tracker_deployer_lib::presentation::cli::views::commands::validate::{ /// ValidateDetailsData, JsonView, /// }; @@ -36,7 +38,7 @@ use crate::presentation::cli::views::commands::validate::ValidateDetailsData; /// has_backup: false, /// }; /// -/// let output = JsonView::render(&data); +/// let output = JsonView::render(&data).unwrap(); /// /// // Verify it's valid JSON /// let parsed: serde_json::Value = serde_json::from_str(&output).unwrap(); @@ -45,73 +47,16 @@ use crate::presentation::cli::views::commands::validate::ValidateDetailsData; /// ``` pub struct JsonView; -impl JsonView { - /// Render validate details as JSON - /// - /// Serializes the validation details to pretty-printed JSON format. - /// The JSON structure matches the DTO structure exactly: - /// - `environment_name`: Name of the validated environment - /// - `config_file`: Path to the validated configuration file - /// - `provider`: Infrastructure provider (lowercase) - /// - `is_valid`: Always `true` on success - /// - `has_prometheus`: Whether Prometheus is configured - /// - `has_grafana`: Whether Grafana is configured - /// - `has_https`: Whether HTTPS is configured - /// - `has_backup`: Whether backups are configured - /// - /// # Arguments - /// - /// * `data` - Validate details to render - /// - /// # Returns - /// - /// A JSON string containing the serialized validate 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::validate::{ - /// ValidateDetailsData, JsonView, - /// }; - /// - /// let data = ValidateDetailsData { - /// environment_name: "prod-tracker".to_string(), - /// config_file: "envs/prod-tracker.json".to_string(), - /// provider: "lxd".to_string(), - /// is_valid: true, - /// has_prometheus: true, - /// has_grafana: true, - /// has_https: true, - /// has_backup: false, - /// }; - /// - /// let json = JsonView::render(&data); - /// - /// assert!(json.contains("\"environment_name\": \"prod-tracker\"")); - /// assert!(json.contains("\"is_valid\": true")); - /// ``` - #[must_use] - pub fn render(data: &ValidateDetailsData) -> String { - serde_json::to_string_pretty(data).unwrap_or_else(|e| { - serde_json::to_string_pretty(&serde_json::json!({ - "error": "Failed to serialize validate details", - "message": e.to_string(), - })) - .unwrap_or_else(|_| { - r#"{ - "error": "Failed to serialize error message" -}"# - .to_string() - }) - }) +impl Render for JsonView { + fn render(data: &ValidateDetailsData) -> Result { + Ok(serde_json::to_string_pretty(data)?) } } #[cfg(test)] mod tests { use super::*; + use crate::presentation::cli::views::Render; // Test fixtures @@ -171,7 +116,7 @@ mod tests { let data = create_test_data(); // Act - let json = JsonView::render(&data); + let json = JsonView::render(&data).unwrap(); // Assert - verify it's valid JSON with expected string field values assert_json_str_fields_eq( @@ -190,7 +135,7 @@ mod tests { let data = create_test_data(); // Act - let json = JsonView::render(&data); + let json = JsonView::render(&data).unwrap(); // Assert assert_json_bool_fields_eq( @@ -211,7 +156,7 @@ mod tests { let data = create_test_data(); // Act - let json = JsonView::render(&data); + let json = JsonView::render(&data).unwrap(); // Assert - check all required fields are present assert_json_has_fields( @@ -244,7 +189,7 @@ mod tests { }; // Act - let json = JsonView::render(&data); + let json = JsonView::render(&data).unwrap(); // Assert assert_json_bool_fields_eq( @@ -273,7 +218,7 @@ mod tests { }; // Act - let json = JsonView::render(&data); + let json = JsonView::render(&data).unwrap(); // Assert assert_json_bool_fields_eq( diff --git a/src/presentation/cli/views/commands/validate/views/text_view.rs b/src/presentation/cli/views/commands/validate/views/text_view.rs index c6f6283c..c2815561 100644 --- a/src/presentation/cli/views/commands/validate/views/text_view.rs +++ b/src/presentation/cli/views/commands/validate/views/text_view.rs @@ -11,6 +11,7 @@ //! output format produced before the Strategy Pattern was introduced. use crate::presentation::cli::views::commands::validate::ValidateDetailsData; +use crate::presentation::cli::views::{Render, ViewRenderError}; /// View for rendering validate details as human-readable text /// @@ -24,6 +25,7 @@ use crate::presentation::cli::views::commands::validate::ValidateDetailsData; /// # Examples /// /// ```rust +/// # use torrust_tracker_deployer_lib::presentation::cli::views::Render; /// use torrust_tracker_deployer_lib::presentation::cli::views::commands::validate::{ /// ValidateDetailsData, TextView, /// }; @@ -39,72 +41,16 @@ use crate::presentation::cli::views::commands::validate::ValidateDetailsData; /// has_backup: false, /// }; /// -/// let output = TextView::render(&data); +/// let output = TextView::render(&data).unwrap(); /// assert!(output.contains("Configuration file 'envs/my-env.json' is valid")); /// assert!(output.contains("Environment Details:")); /// assert!(output.contains("my-env")); /// ``` pub struct TextView; -impl TextView { - /// Render validate details as human-readable formatted text - /// - /// Takes validation details and produces a human-readable output - /// intended to be wrapped by `ProgressReporter::complete()`. - /// - /// # Arguments - /// - /// * `data` - Validate details to render - /// - /// # Returns - /// - /// A formatted string containing: - /// - First line: "Configuration file '``' is valid" - /// - Blank line separator - /// - "Environment Details:" section with name, provider, and feature flags - /// - /// # Format - /// - /// The output follows this structure: - /// ```text - /// Configuration file '``' is valid - /// - /// Environment Details: - /// • Name: - /// • Provider: - /// • Prometheus: Enabled|Disabled - /// • Grafana: Enabled|Disabled - /// • HTTPS: Enabled|Disabled - /// • Backups: Enabled|Disabled - /// ``` - /// - /// # Examples - /// - /// ```rust - /// use torrust_tracker_deployer_lib::presentation::cli::views::commands::validate::{ - /// ValidateDetailsData, TextView, - /// }; - /// - /// let data = ValidateDetailsData { - /// environment_name: "prod-tracker".to_string(), - /// config_file: "envs/prod-tracker.json".to_string(), - /// provider: "lxd".to_string(), - /// is_valid: true, - /// has_prometheus: true, - /// has_grafana: true, - /// has_https: false, - /// has_backup: false, - /// }; - /// - /// let text = TextView::render(&data); - /// - /// assert!(text.contains("Configuration file 'envs/prod-tracker.json' is valid")); - /// assert!(text.contains("Name:")); - /// assert!(text.contains("prod-tracker")); - /// ``` - #[must_use] - pub fn render(data: &ValidateDetailsData) -> String { - format!( +impl Render for TextView { + fn render(data: &ValidateDetailsData) -> Result { + Ok(format!( "Configuration file '{}' is valid\n\nEnvironment Details:\n\ • Name: {}\n\ • Provider: {}\n\ @@ -135,7 +81,7 @@ impl TextView { } else { "Disabled" } - ) + )) } } @@ -189,7 +135,7 @@ mod tests { let data = create_test_data_all_enabled(); // Act - let text = TextView::render(&data); + let text = TextView::render(&data).unwrap(); // Assert assert_contains_all( @@ -215,7 +161,7 @@ mod tests { let data = create_test_data_all_enabled(); // Act - let text = TextView::render(&data); + let text = TextView::render(&data).unwrap(); // Assert - all feature sections show "Enabled" let enabled_count = text.matches("Enabled").count(); @@ -231,7 +177,7 @@ mod tests { let data = create_test_data_all_disabled(); // Act - let text = TextView::render(&data); + let text = TextView::render(&data).unwrap(); // Assert - all feature sections show "Disabled" let disabled_count = text.matches("Disabled").count(); @@ -247,7 +193,7 @@ mod tests { let data = create_test_data_all_disabled(); // Act - let text = TextView::render(&data); + let text = TextView::render(&data).unwrap(); // Assert assert!( @@ -262,7 +208,7 @@ mod tests { let data = create_test_data_all_enabled(); // Act - let text = TextView::render(&data); + let text = TextView::render(&data).unwrap(); // Assert assert_contains_all( diff --git a/src/presentation/cli/views/formatters/json.rs b/src/presentation/cli/views/formatters/json.rs index c57e0d9c..7c15db4b 100644 --- a/src/presentation/cli/views/formatters/json.rs +++ b/src/presentation/cli/views/formatters/json.rs @@ -43,10 +43,6 @@ impl FormatterOverride for JsonFormatter { } } -// ============================================================================ -// Unit Tests -// ============================================================================ - #[cfg(test)] mod tests { use super::*; diff --git a/src/presentation/cli/views/mod.rs b/src/presentation/cli/views/mod.rs index eb7a5c5f..c7947863 100644 --- a/src/presentation/cli/views/mod.rs +++ b/src/presentation/cli/views/mod.rs @@ -80,6 +80,10 @@ pub use traits::{FormatterOverride, OutputMessage, OutputSink}; pub use user_output::UserOutput; pub use verbosity::VerbosityLevel; +// Render trait and error type +pub mod render; +pub use render::{Render, ViewRenderError}; + // Internal modules mod channel; mod formatters; diff --git a/src/presentation/cli/views/render.rs b/src/presentation/cli/views/render.rs new file mode 100644 index 00000000..f2f2f41f --- /dev/null +++ b/src/presentation/cli/views/render.rs @@ -0,0 +1,75 @@ +//! Render trait and error type for command view rendering. +//! +//! This module defines the shared interface that all command view structs implement. +//! Every `JsonView` and `TextView` across all command modules implements [`Render`], +//! providing compile-time enforcement of a consistent render signature. +//! +//! # Design +//! +//! The [`Render`] trait is generic over the DTO type `T` so that each view module +//! can bind it to its own view-data type: +//! +//! ```rust,ignore +//! impl Render for JsonView { ... } +//! impl Render for TextView { ... } +//! ``` +//! +//! Using a dedicated [`ViewRenderError`] type (rather than `serde_json::Error` directly) +//! decouples the presentation layer from the serialization backend. If the backend ever +//! changes, only this module and its `From` impls need updating — not every call site. +//! +//! # Error handling +//! +//! Text renderers always return `Ok` — they do pure string formatting and never fail. +//! JSON renderers call `serde_json::to_string_pretty`, which is expected to succeed for +//! the plain `#[derive(Serialize)]` DTOs used in this project, but serialization errors +//! are still possible (e.g. non-finite floats, non-string map keys, custom `Serialize` +//! impls) and are propagated as [`ViewRenderError`]. + +/// Error produced by a [`Render`] implementation. +/// +/// Using a dedicated error type decouples the presentation layer from the +/// serialization library. If the backend ever changes, only this type and +/// its `From` impls need updating — not every call site. +#[derive(Debug, thiserror::Error)] +pub enum ViewRenderError { + /// JSON serialization failed. + #[error("JSON serialization failed: {0}")] + Serialization(#[from] serde_json::Error), +} + +/// Trait for rendering command output data into a string. +/// +/// Implementors transform a DTO (`T`) into a displayable or parseable string. +/// The `Result` return type is required even for infallible renderers (e.g., [`TextView`](super::commands::configure::views::text_view::TextView)) +/// so that all renderers share a uniform interface and callers can use `?` unconditionally. +/// +/// # Examples +/// +/// ```rust,ignore +/// use crate::presentation::cli::views::{Render, ViewRenderError}; +/// +/// // JSON view — calls serde_json, theoretically fallible +/// impl Render for JsonView { +/// fn render(data: &ConfigureDetailsData) -> Result { +/// Ok(serde_json::to_string_pretty(data)?) +/// } +/// } +/// +/// // Text view — pure string formatting, always Ok +/// impl Render for TextView { +/// fn render(data: &ConfigureDetailsData) -> Result { +/// Ok(format!("Configuration completed for '{}'", data.environment_name)) +/// } +/// } +/// ``` +pub trait Render { + /// Render `data` into a string representation. + /// + /// # Errors + /// + /// Returns a [`ViewRenderError`] if rendering fails. Text renderers always + /// return `Ok`; JSON renderers return `Err` only if serialization fails + /// (which is unreachable for plain `#[derive(Serialize)]` types). + fn render(data: &T) -> Result; +}