From eaa57be8bdad20bc9e303d7bb9a6a1299919b8fa Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 5 Nov 2025 22:16:05 +0000 Subject: [PATCH 1/4] Initial plan From d0464779373ab409d668868742c36d7ac35291e1 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 5 Nov 2025 22:53:30 +0000 Subject: [PATCH 2/4] refactor: convert user_output.rs to folder module structure - Created user_output/ folder with organized submodules - Separated core components: theme, channel, verbosity, traits - Organized messages into messages/ subdirectory (7 message types) - Organized sinks into sinks/ subdirectory (4 sink implementations + writers) - Organized formatters into formatters/ subdirectory (JsonFormatter) - Extracted test_support module for test utilities - Co-located all 155 tests with code in core.rs - Maintained 100% backward compatibility - all public APIs unchanged - All tests passing Co-authored-by: josecelano <58816+josecelano@users.noreply.github.com> --- src/presentation/user_output/channel.rs | 27 + .../{user_output.rs => user_output/core.rs} | 1735 +---------------- .../user_output/formatters/json.rs | 44 + .../user_output/formatters/mod.rs | 8 + .../user_output/messages/error.rs | 30 + .../user_output/messages/info_block.rs | 202 ++ src/presentation/user_output/messages/mod.rs | 19 + .../user_output/messages/progress.rs | 40 + .../user_output/messages/result.rs | 30 + .../user_output/messages/steps.rs | 203 ++ .../user_output/messages/success.rs | 30 + .../user_output/messages/warning.rs | 30 + src/presentation/user_output/mod.rs | 88 + .../user_output/sinks/composite.rs | 49 + src/presentation/user_output/sinks/file.rs | 62 + src/presentation/user_output/sinks/mod.rs | 18 + .../user_output/sinks/standard.rs | 70 + .../user_output/sinks/telemetry.rs | 42 + src/presentation/user_output/sinks/writers.rs | 75 + src/presentation/user_output/test_support.rs | 224 +++ src/presentation/user_output/theme.rs | 209 ++ src/presentation/user_output/traits.rs | 182 ++ src/presentation/user_output/verbosity.rs | 173 ++ 23 files changed, 1877 insertions(+), 1713 deletions(-) create mode 100644 src/presentation/user_output/channel.rs rename src/presentation/{user_output.rs => user_output/core.rs} (63%) create mode 100644 src/presentation/user_output/formatters/json.rs create mode 100644 src/presentation/user_output/formatters/mod.rs create mode 100644 src/presentation/user_output/messages/error.rs create mode 100644 src/presentation/user_output/messages/info_block.rs create mode 100644 src/presentation/user_output/messages/mod.rs create mode 100644 src/presentation/user_output/messages/progress.rs create mode 100644 src/presentation/user_output/messages/result.rs create mode 100644 src/presentation/user_output/messages/steps.rs create mode 100644 src/presentation/user_output/messages/success.rs create mode 100644 src/presentation/user_output/messages/warning.rs create mode 100644 src/presentation/user_output/mod.rs create mode 100644 src/presentation/user_output/sinks/composite.rs create mode 100644 src/presentation/user_output/sinks/file.rs create mode 100644 src/presentation/user_output/sinks/mod.rs create mode 100644 src/presentation/user_output/sinks/standard.rs create mode 100644 src/presentation/user_output/sinks/telemetry.rs create mode 100644 src/presentation/user_output/sinks/writers.rs create mode 100644 src/presentation/user_output/test_support.rs create mode 100644 src/presentation/user_output/theme.rs create mode 100644 src/presentation/user_output/traits.rs create mode 100644 src/presentation/user_output/verbosity.rs diff --git a/src/presentation/user_output/channel.rs b/src/presentation/user_output/channel.rs new file mode 100644 index 00000000..51683150 --- /dev/null +++ b/src/presentation/user_output/channel.rs @@ -0,0 +1,27 @@ +//! Output channel routing for user-facing messages +//! +//! This module defines the channel enum that determines whether messages +//! should be written to stdout or stderr. + +/// Output channel for routing messages +/// +/// Determines whether a message should be written to stdout or stderr. +/// Following Unix conventions: +/// - **stdout**: Final results and structured data for piping/redirection +/// - **stderr**: Progress updates, status messages, operational info, errors +/// +/// # Examples +/// +/// ```rust +/// use torrust_tracker_deployer_lib::presentation::user_output::Channel; +/// +/// let channel = Channel::Stdout; +/// assert_eq!(channel, Channel::Stdout); +/// ``` +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Channel { + /// Standard output stream for final results and data + Stdout, + /// Standard error stream for progress and operational messages + Stderr, +} diff --git a/src/presentation/user_output.rs b/src/presentation/user_output/core.rs similarity index 63% rename from src/presentation/user_output.rs rename to src/presentation/user_output/core.rs index 5b2a7821..9303af84 100644 --- a/src/presentation/user_output.rs +++ b/src/presentation/user_output/core.rs @@ -1,1491 +1,14 @@ -//! User-facing output handling -//! -//! This module provides user-facing output functionality separate from internal logging. -//! It implements a dual-channel strategy following Unix conventions and modern CLI best practices -//! (similar to cargo, docker, npm): -//! -//! - **stdout (Results Channel)**: Final results, structured data, output for piping/redirection -//! - **stderr (Progress/Operational Channel)**: Progress updates, status messages, warnings, errors -//! -//! This separation enables: -//! - Clean piping: `torrust-tracker-deployer destroy env | jq .status` works correctly -//! - Automation friendly: Scripts can redirect progress to /dev/null while capturing results -//! - Unix convention compliance: Follows established patterns from modern CLI tools -//! - Better UX: Progress feedback doesn't interfere with result data -//! -//! ## Type-Safe Channel Routing -//! -//! The module uses newtype wrappers (`StdoutWriter` and `StderrWriter`) to provide compile-time -//! guarantees that messages are routed to the correct output channel. This prevents accidental -//! channel confusion and makes the code more maintainable by catching routing errors at compile -//! time rather than runtime. -//! -//! The newtype pattern is a zero-cost abstraction - it has the same memory layout and performance -//! characteristics as the wrapped type, but provides type safety benefits. -//! -//! ## Buffering Behavior -//! -//! Output is line-buffered by default. Messages are typically flushed automatically -//! after each newline. For cases where immediate output is critical (e.g., before -//! long-running operations), call `flush()` explicitly: -//! -//! ```rust,ignore -//! output.progress("Starting long operation..."); -//! output.flush()?; // Ensure message appears before operation starts -//! perform_long_operation(); -//! ``` -//! -//! ## Example Usage -//! -//! ```rust -//! use torrust_tracker_deployer_lib::presentation::user_output::{UserOutput, VerbosityLevel}; -//! -//! let mut output = UserOutput::new(VerbosityLevel::Normal); -//! -//! // Progress messages go to stderr -//! output.progress("Destroying environment..."); -//! -//! // Success status goes to stderr -//! output.success("Environment destroyed successfully"); -//! -//! // Results go to stdout for piping -//! output.result(r#"{"status": "destroyed"}"#); -//! ``` -//! -//! ## Channel Strategy -//! -//! Based on research from [`docs/research/UX/console-app-output-patterns.md`](../../docs/research/UX/console-app-output-patterns.md): -//! -//! - **stdout**: Deployment results, configuration summaries, structured data (JSON) -//! - **stderr**: Step progress, status updates, warnings, error messages with actionable guidance -//! -//! See also: [`docs/research/UX/user-output-vs-logging-separation.md`](../../docs/research/UX/user-output-vs-logging-separation.md) +//! Core UserOutput struct and implementation use std::io::Write; -/// Output theme controlling symbols and formatting -/// -/// A theme defines the visual appearance of user-facing messages through -/// configurable symbols. Themes enable consistent styling across all output -/// and support different environments (terminals, CI/CD, accessibility needs). -/// -/// # Predefined Themes -/// -/// - **Emoji** (default): Unicode emoji symbols for interactive terminals -/// - **Plain**: Text labels like `[INFO]`, `[OK]` for CI/CD environments -/// - **ASCII**: Basic ASCII characters for limited terminal support -/// -/// # Examples -/// -/// ```rust -/// use torrust_tracker_deployer_lib::presentation::user_output::Theme; -/// -/// // Use emoji theme (default) -/// let theme = Theme::emoji(); -/// assert_eq!(theme.progress_symbol(), "⏳"); -/// -/// // Use plain text theme for CI/CD -/// let theme = Theme::plain(); -/// assert_eq!(theme.success_symbol(), "[OK]"); -/// -/// // Use ASCII theme for limited terminals -/// let theme = Theme::ascii(); -/// assert_eq!(theme.error_symbol(), "[x]"); -/// ``` -#[derive(Debug, Clone, PartialEq, Eq)] -#[allow(clippy::struct_field_names)] -pub struct Theme { - progress_symbol: String, - success_symbol: String, - warning_symbol: String, - error_symbol: String, -} - -impl Theme { - /// Create emoji theme with Unicode symbols (default) - /// - /// Best for interactive terminals with good Unicode support. - /// Uses emoji characters that are visually distinctive and widely supported. - /// - /// # Examples - /// - /// ```rust - /// use torrust_tracker_deployer_lib::presentation::user_output::Theme; - /// - /// let theme = Theme::emoji(); - /// assert_eq!(theme.progress_symbol(), "⏳"); - /// assert_eq!(theme.success_symbol(), "✅"); - /// assert_eq!(theme.warning_symbol(), "⚠️"); - /// assert_eq!(theme.error_symbol(), "❌"); - /// ``` - #[must_use] - pub fn emoji() -> Self { - Self { - progress_symbol: "⏳".to_string(), - success_symbol: "✅".to_string(), - warning_symbol: "⚠️".to_string(), - error_symbol: "❌".to_string(), - } - } - - /// Create plain text theme for CI/CD environments - /// - /// Uses text labels like `[INFO]`, `[OK]`, `[WARN]`, `[ERROR]` that work - /// in any environment without Unicode support. Ideal for CI/CD pipelines - /// and log aggregation systems. - /// - /// # Examples - /// - /// ```rust - /// use torrust_tracker_deployer_lib::presentation::user_output::Theme; - /// - /// let theme = Theme::plain(); - /// assert_eq!(theme.progress_symbol(), "[INFO]"); - /// assert_eq!(theme.success_symbol(), "[OK]"); - /// assert_eq!(theme.warning_symbol(), "[WARN]"); - /// assert_eq!(theme.error_symbol(), "[ERROR]"); - /// ``` - #[must_use] - pub fn plain() -> Self { - Self { - progress_symbol: "[INFO]".to_string(), - success_symbol: "[OK]".to_string(), - warning_symbol: "[WARN]".to_string(), - error_symbol: "[ERROR]".to_string(), - } - } - - /// Create ASCII-only theme using basic characters - /// - /// Uses simple ASCII characters that work on any terminal. - /// Good for environments with limited character set support or - /// when maximum compatibility is required. - /// - /// # Examples - /// - /// ```rust - /// use torrust_tracker_deployer_lib::presentation::user_output::Theme; - /// - /// let theme = Theme::ascii(); - /// assert_eq!(theme.progress_symbol(), "=>"); - /// assert_eq!(theme.success_symbol(), "[+]"); - /// assert_eq!(theme.warning_symbol(), "[!]"); - /// assert_eq!(theme.error_symbol(), "[x]"); - /// ``` - #[must_use] - pub fn ascii() -> Self { - Self { - progress_symbol: "=>".to_string(), - success_symbol: "[+]".to_string(), - warning_symbol: "[!]".to_string(), - error_symbol: "[x]".to_string(), - } - } - - /// Get the progress symbol for this theme - #[must_use] - pub fn progress_symbol(&self) -> &str { - &self.progress_symbol - } - - /// Get the success symbol for this theme - #[must_use] - pub fn success_symbol(&self) -> &str { - &self.success_symbol - } - - /// Get the warning symbol for this theme - #[must_use] - pub fn warning_symbol(&self) -> &str { - &self.warning_symbol - } - - /// Get the error symbol for this theme - #[must_use] - pub fn error_symbol(&self) -> &str { - &self.error_symbol - } -} - -impl Default for Theme { - /// Create the default theme (emoji) - /// - /// # Examples - /// - /// ```rust - /// use torrust_tracker_deployer_lib::presentation::user_output::Theme; - /// - /// let theme = Theme::default(); - /// assert_eq!(theme.progress_symbol(), "⏳"); - /// ``` - fn default() -> Self { - Self::emoji() - } -} - -/// Output channel for routing messages -/// -/// Determines whether a message should be written to stdout or stderr. -/// Following Unix conventions: -/// - **stdout**: Final results and structured data for piping/redirection -/// - **stderr**: Progress updates, status messages, operational info, errors -/// -/// # Examples -/// -/// ```rust -/// use torrust_tracker_deployer_lib::presentation::user_output::Channel; -/// -/// let channel = Channel::Stdout; -/// assert_eq!(channel, Channel::Stdout); -/// ``` -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub enum Channel { - /// Standard output stream for final results and data - Stdout, - /// Standard error stream for progress and operational messages - Stderr, -} - -/// Trait for output messages that can be written to user-facing channels -/// -/// This trait enables extensibility following the Open/Closed Principle. -/// Each message type encapsulates its own: -/// - Formatting logic (how it appears to users) -/// - Verbosity requirements (when it should be shown) -/// - Channel routing (stdout vs stderr) -/// -/// # Design Philosophy -/// -/// By implementing this trait, message types become self-contained and can be -/// added without modifying the `UserOutput` struct. This makes the system -/// extensible - new message types can be defined in external modules. -/// -/// # Examples -/// -/// ```rust,ignore -/// use torrust_tracker_deployer_lib::presentation::user_output::{OutputMessage, Theme, VerbosityLevel, Channel}; -/// -/// struct CustomMessage { -/// text: String, -/// } -/// -/// impl OutputMessage for CustomMessage { -/// fn format(&self, theme: &Theme) -> String { -/// format!("🎉 {}", self.text) -/// } -/// -/// fn required_verbosity(&self) -> VerbosityLevel { -/// VerbosityLevel::Normal -/// } -/// -/// fn channel(&self) -> Channel { -/// Channel::Stderr -/// } -/// -/// fn type_name(&self) -> &'static str { -/// "CustomMessage" -/// } -/// } -/// ``` -pub trait OutputMessage { - /// Format this message using the given theme - /// - /// This method defines how the message appears to users. It should - /// incorporate theme symbols and any necessary formatting. - /// - /// # Arguments - /// - /// * `theme` - The theme providing symbols for formatting - /// - /// # Returns - /// - /// A formatted string ready for display to users - fn format(&self, theme: &Theme) -> String; - - /// Get the minimum verbosity level required to show this message - /// - /// Messages are only displayed if the current verbosity level is - /// greater than or equal to the required level. - /// - /// # Returns - /// - /// The minimum verbosity level needed to display this message - fn required_verbosity(&self) -> VerbosityLevel; - - /// Get the output channel for this message - /// - /// Determines whether the message goes to stdout or stderr following - /// Unix conventions. - /// - /// # Returns - /// - /// The channel (Stdout or Stderr) where this message should be written - fn channel(&self) -> Channel; - - /// Get the type name of this message - /// - /// Returns a human-readable type identifier for this message type. - /// This is primarily used by formatter overrides (e.g., JSON formatter) - /// to include type information in the output. - /// - /// # Returns - /// - /// A static string representing the message type name - fn type_name(&self) -> &'static str; -} - -/// Optional trait for post-processing message output -/// -/// This allows transforming the standard message format without -/// modifying individual message types. Use sparingly - prefer -/// extending the message trait or using themes for most cases. -/// -/// # When to Use -/// -/// - **Machine-readable formats**: JSON, XML, structured logs -/// - **Additional decoration**: ANSI colors, markup codes -/// - **Output wrapping**: Adding metadata, timestamps, process info -/// -/// # When NOT to Use -/// -/// - **Symbol changes**: Use `Theme` instead -/// - **New message types**: Implement `OutputMessage` trait instead -/// - **Channel routing changes**: Define in message type's `channel()` method -/// -/// # Examples -/// -/// ```rust,ignore -/// use torrust_tracker_deployer_lib::presentation::user_output::{FormatterOverride, OutputMessage}; -/// -/// struct JsonFormatter; -/// -/// impl FormatterOverride for JsonFormatter { -/// fn transform(&self, formatted: &str, message: &dyn OutputMessage) -> String { -/// // Transform to JSON representation -/// format!(r#"{{"content": "{}"}}"#, formatted.trim()) -/// } -/// } -/// ``` -pub trait FormatterOverride: Send + Sync { - /// Transform formatted message output - /// - /// This method receives the already-formatted message (with theme applied) - /// and the original message object for context. It should return the - /// transformed output. - /// - /// # Arguments - /// - /// * `formatted` - The message already formatted with theme - /// * `message` - The original message object (for metadata/context) - /// - /// # Returns - /// - /// The transformed message string - fn transform(&self, formatted: &str, message: &dyn OutputMessage) -> String; -} - -/// Trait for output destinations -/// -/// An output sink receives formatted messages and writes them to a destination. -/// Sinks handle the mechanics of where output goes, not how it's formatted. -/// -/// # Design Philosophy -/// -/// Sinks receive already-formatted messages (with theme applied) and route them -/// to appropriate destinations. They don't handle formatting or verbosity filtering - -/// those concerns are handled by message types and filters respectively. -/// -/// # Examples -/// -/// ```rust,ignore -/// use torrust_tracker_deployer_lib::presentation::user_output::{OutputSink, OutputMessage}; -/// use std::fs::File; -/// -/// struct FileSink { -/// file: File, -/// } -/// -/// impl OutputSink for FileSink { -/// fn write_message(&mut self, message: &dyn OutputMessage, formatted: &str) { -/// use std::io::Write; -/// writeln!(self.file, "{}", formatted).ok(); -/// } -/// } -/// ``` -pub trait OutputSink: Send + Sync { - /// Write a formatted message to this sink - /// - /// # Arguments - /// - /// * `message` - The message object (for metadata like channel) - /// * `formatted` - The already-formatted message text - fn write_message(&mut self, message: &dyn OutputMessage, formatted: &str); -} - -/// Verbosity levels for user output -/// -/// Controls the amount of detail shown to users. Higher verbosity levels include -/// all output from lower levels. -/// -/// # Examples -/// -/// ```rust -/// use torrust_tracker_deployer_lib::presentation::user_output::VerbosityLevel; -/// -/// let level = VerbosityLevel::Normal; -/// assert!(level >= VerbosityLevel::Quiet); -/// assert!(level < VerbosityLevel::Verbose); -/// ``` -#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Default)] -pub enum VerbosityLevel { - /// Minimal output - only errors and final results - Quiet, - /// Default level - essential progress and results - #[default] - Normal, - /// Detailed progress including intermediate steps - Verbose, - /// Very detailed including decisions and retries - VeryVerbose, - /// Maximum detail for troubleshooting - Debug, -} - -// ============================================================================ -// Formatter Override Implementations -// ============================================================================ - -/// JSON formatter for machine-readable output -/// -/// Transforms messages into JSON objects with metadata including: -/// - Message type (for programmatic filtering) -/// - Channel (stdout/stderr) -/// - Content (the formatted message) -/// - Timestamp (ISO 8601 format) -/// -/// # Examples -/// -/// ```rust,ignore -/// use torrust_tracker_deployer_lib::presentation::user_output::{JsonFormatter, UserOutput, VerbosityLevel}; -/// -/// let formatter = JsonFormatter; -/// let mut output = UserOutput::with_formatter_override( -/// VerbosityLevel::Normal, -/// Box::new(formatter) -/// ); -/// -/// output.progress("Starting process"); -/// // Output: {"type":"ProgressMessage","channel":"Stderr","content":"⏳ Starting process","timestamp":"2025-11-04T12:34:56Z"} -/// ``` -pub struct JsonFormatter; - -impl FormatterOverride for JsonFormatter { - fn transform(&self, formatted: &str, message: &dyn OutputMessage) -> String { - let json = serde_json::json!({ - "type": message.type_name(), - "channel": format!("{:?}", message.channel()), - "content": formatted.trim(), // Remove trailing newlines for cleaner JSON - "timestamp": chrono::Utc::now().to_rfc3339(), - }) - .to_string(); - format!("{json}\n") - } -} - -// ============================================================================ -// Concrete Message Type Implementations -// ============================================================================ - -/// Progress message for ongoing operations -/// -/// Progress messages indicate that work is in progress. They are displayed -/// during operations to provide feedback to users. -/// -/// # Examples -/// -/// ```rust,ignore -/// use torrust_tracker_deployer_lib::presentation::user_output::ProgressMessage; -/// -/// let message = ProgressMessage { -/// text: "Destroying environment...".to_string(), -/// }; -/// ``` -pub struct ProgressMessage { - /// The progress message text - pub text: String, -} - -impl OutputMessage for ProgressMessage { - fn format(&self, theme: &Theme) -> String { - format!("{} {}\n", theme.progress_symbol(), self.text) - } - - fn required_verbosity(&self) -> VerbosityLevel { - VerbosityLevel::Normal - } - - fn channel(&self) -> Channel { - Channel::Stderr - } - - fn type_name(&self) -> &'static str { - "ProgressMessage" - } -} - -/// Success message for completed operations -/// -/// Success messages indicate that an operation completed successfully. -/// They provide positive feedback to users. -/// -/// # Examples -/// -/// ```rust,ignore -/// use torrust_tracker_deployer_lib::presentation::user_output::SuccessMessage; -/// -/// let message = SuccessMessage { -/// text: "Environment destroyed successfully".to_string(), -/// }; -/// ``` -pub struct SuccessMessage { - /// The success message text - pub text: String, -} - -impl OutputMessage for SuccessMessage { - fn format(&self, theme: &Theme) -> String { - format!("{} {}\n", theme.success_symbol(), self.text) - } - - fn required_verbosity(&self) -> VerbosityLevel { - VerbosityLevel::Normal - } - - fn channel(&self) -> Channel { - Channel::Stderr - } - - fn type_name(&self) -> &'static str { - "SuccessMessage" - } -} - -/// Warning message for non-critical issues -/// -/// Warning messages alert users to potential issues that don't prevent -/// operation completion but may need attention. -/// -/// # Examples -/// -/// ```rust,ignore -/// use torrust_tracker_deployer_lib::presentation::user_output::WarningMessage; -/// -/// let message = WarningMessage { -/// text: "Infrastructure may already be destroyed".to_string(), -/// }; -/// ``` -pub struct WarningMessage { - /// The warning message text - pub text: String, -} - -impl OutputMessage for WarningMessage { - fn format(&self, theme: &Theme) -> String { - format!("{} {}\n", theme.warning_symbol(), self.text) - } - - fn required_verbosity(&self) -> VerbosityLevel { - VerbosityLevel::Normal - } - - fn channel(&self) -> Channel { - Channel::Stderr - } - - fn type_name(&self) -> &'static str { - "WarningMessage" - } -} - -/// Error message for critical failures -/// -/// Error messages indicate critical failures that prevent operation completion. -/// They are always shown regardless of verbosity level. -/// -/// # Examples -/// -/// ```rust,ignore -/// use torrust_tracker_deployer_lib::presentation::user_output::ErrorMessage; -/// -/// let message = ErrorMessage { -/// text: "Failed to destroy environment".to_string(), -/// }; -/// ``` -pub struct ErrorMessage { - /// The error message text - pub text: String, -} - -impl OutputMessage for ErrorMessage { - fn format(&self, theme: &Theme) -> String { - format!("{} {}\n", theme.error_symbol(), self.text) - } - - fn required_verbosity(&self) -> VerbosityLevel { - VerbosityLevel::Quiet // Always shown - } - - fn channel(&self) -> Channel { - Channel::Stderr - } - - fn type_name(&self) -> &'static str { - "ErrorMessage" - } -} - -/// Result message for final output data -/// -/// Result messages contain final output data that can be piped or redirected. -/// They go to stdout without any symbols or formatting. -/// -/// # Examples -/// -/// ```rust,ignore -/// use torrust_tracker_deployer_lib::presentation::user_output::ResultMessage; -/// -/// let message = ResultMessage { -/// text: "Deployment complete".to_string(), -/// }; -/// ``` -pub struct ResultMessage { - /// The result message text - pub text: String, -} - -impl OutputMessage for ResultMessage { - fn format(&self, _theme: &Theme) -> String { - format!("{}\n", self.text) - } - - fn required_verbosity(&self) -> VerbosityLevel { - VerbosityLevel::Quiet - } - - fn channel(&self) -> Channel { - Channel::Stdout - } - - fn type_name(&self) -> &'static str { - "ResultMessage" - } -} - -/// Steps message for sequential instructions -/// -/// Steps messages display numbered lists of sequential items. -/// Useful for showing action items or instructions. -/// -/// # Examples -/// -/// Simple constructor for cases where you have all items upfront: -/// -/// ```rust -/// use torrust_tracker_deployer_lib::presentation::user_output::StepsMessage; -/// -/// let message = StepsMessage::new("Next steps:", vec![ -/// "Edit the configuration file".to_string(), -/// "Review the settings".to_string(), -/// ]); -/// ``` -/// -/// Builder pattern for dynamic construction or better readability: -/// -/// ```rust -/// use torrust_tracker_deployer_lib::presentation::user_output::StepsMessage; -/// -/// let message = StepsMessage::builder("Next steps:") -/// .add("Edit the configuration file") -/// .add("Review the settings") -/// .build(); -/// ``` -pub struct StepsMessage { - /// The title for the steps list - pub title: String, - /// The list of step items - pub items: Vec, -} - -impl StepsMessage { - /// Create a new steps message with the given title and items - /// - /// This is a convenience constructor for simple cases where you have - /// all items upfront. For dynamic construction or better readability, - /// consider using `StepsMessage::builder()` instead. - /// - /// # Examples - /// - /// ```rust - /// use torrust_tracker_deployer_lib::presentation::user_output::StepsMessage; - /// - /// let msg = StepsMessage::new("Next steps:", vec![ - /// "Edit config".to_string(), - /// "Run tests".to_string(), - /// ]); - /// ``` - #[must_use] - pub fn new(title: impl Into, items: Vec) -> Self { - Self { - title: title.into(), - items, - } - } - - /// Create a builder for constructing steps messages with a fluent API - /// - /// The builder pattern is useful when: - /// - Adding items dynamically - /// - You want self-documenting, readable code - /// - Building the message in multiple steps - /// - /// # Examples - /// - /// ```rust - /// use torrust_tracker_deployer_lib::presentation::user_output::StepsMessage; - /// - /// let msg = StepsMessage::builder("Next steps:") - /// .add("Edit configuration") - /// .add("Review settings") - /// .build(); - /// ``` - #[must_use] - pub fn builder(title: impl Into) -> StepsMessageBuilder { - StepsMessageBuilder::new(title) - } -} - -impl OutputMessage for StepsMessage { - fn format(&self, _theme: &Theme) -> String { - use std::fmt::Write; - - let mut output = format!("{}\n", self.title); - for (idx, step) in self.items.iter().enumerate() { - writeln!(&mut output, "{}. {}", idx + 1, step).ok(); - } - output - } - - fn required_verbosity(&self) -> VerbosityLevel { - VerbosityLevel::Normal - } - - fn channel(&self) -> Channel { - Channel::Stderr - } - - fn type_name(&self) -> &'static str { - "StepsMessage" - } -} - -/// Builder for constructing `StepsMessage` with a fluent API -/// -/// Provides a consuming builder pattern for constructing step messages -/// with optional customization. Use this for complex cases where items -/// are added dynamically or for improved readability. Simple cases can -/// use `StepsMessage::new()` directly. -/// -/// # Examples -/// -/// ```rust -/// use torrust_tracker_deployer_lib::presentation::user_output::StepsMessage; -/// -/// let message = StepsMessage::builder("Next steps:") -/// .add("Edit configuration") -/// .add("Review settings") -/// .add("Deploy changes") -/// .build(); -/// ``` -/// -/// Empty builders are valid: -/// -/// ```rust -/// use torrust_tracker_deployer_lib::presentation::user_output::StepsMessage; -/// -/// let message = StepsMessage::builder("Title").build(); -/// ``` -pub struct StepsMessageBuilder { - title: String, - items: Vec, -} - -impl StepsMessageBuilder { - /// Create a new builder with the given title - /// - /// # Examples - /// - /// ```rust - /// use torrust_tracker_deployer_lib::presentation::user_output::StepsMessageBuilder; - /// - /// let builder = StepsMessageBuilder::new("My steps:"); - /// ``` - #[must_use] - pub fn new(title: impl Into) -> Self { - Self { - title: title.into(), - items: Vec::new(), - } - } - - /// Add a step to the list (consuming self for method chaining) - /// - /// This method consumes the builder and returns it, enabling - /// method chaining in a fluent API style. - /// - /// # Examples - /// - /// ```rust - /// use torrust_tracker_deployer_lib::presentation::user_output::StepsMessage; - /// - /// let message = StepsMessage::builder("Steps:") - /// .add("First step") - /// .add("Second step") - /// .build(); - /// ``` - #[must_use] - #[allow(clippy::should_implement_trait)] - pub fn add(mut self, step: impl Into) -> Self { - self.items.push(step.into()); - self - } - - /// Build the final `StepsMessage` - /// - /// Consumes the builder and produces the final message. - /// - /// # Examples - /// - /// ```rust - /// use torrust_tracker_deployer_lib::presentation::user_output::StepsMessage; - /// - /// let message = StepsMessage::builder("Steps:") - /// .add("Step 1") - /// .build(); - /// ``` - #[must_use] - pub fn build(self) -> StepsMessage { - StepsMessage { - title: self.title, - items: self.items, - } - } -} - -/// Informational block message for grouped information -/// -/// Info block messages display a title followed by multiple lines of text. -/// Useful for displaying grouped information, configuration details, or -/// multi-line informational content. -/// -/// # Examples -/// -/// Simple constructor for cases where you have all lines upfront: -/// -/// ```rust -/// use torrust_tracker_deployer_lib::presentation::user_output::InfoBlockMessage; -/// -/// let message = InfoBlockMessage::new("Environment Details", vec![ -/// "Name: production".to_string(), -/// "Status: running".to_string(), -/// ]); -/// ``` -/// -/// Builder pattern for dynamic construction or better readability: -/// -/// ```rust -/// use torrust_tracker_deployer_lib::presentation::user_output::InfoBlockMessage; -/// -/// let message = InfoBlockMessage::builder("Environment Details") -/// .add_line("Name: production") -/// .add_line("Status: running") -/// .add_line("Uptime: 24 hours") -/// .build(); -/// ``` -pub struct InfoBlockMessage { - /// The title for the info block - pub title: String, - /// The lines of information - pub lines: Vec, -} - -impl InfoBlockMessage { - /// Create a new info block message with the given title and lines - /// - /// This is a convenience constructor for simple cases where you have - /// all lines upfront. For dynamic construction or better readability, - /// consider using `InfoBlockMessage::builder()` instead. - /// - /// # Examples - /// - /// ```rust - /// use torrust_tracker_deployer_lib::presentation::user_output::InfoBlockMessage; - /// - /// let msg = InfoBlockMessage::new("Configuration:", vec![ - /// " - username: 'torrust'".to_string(), - /// " - port: 22".to_string(), - /// ]); - /// ``` - #[must_use] - pub fn new(title: impl Into, lines: Vec) -> Self { - Self { - title: title.into(), - lines, - } - } - - /// Create a builder for constructing info block messages with a fluent API - /// - /// The builder pattern is useful when: - /// - Adding lines dynamically - /// - You want self-documenting, readable code - /// - Building the message in multiple steps - /// - /// # Examples - /// - /// ```rust - /// use torrust_tracker_deployer_lib::presentation::user_output::InfoBlockMessage; - /// - /// let msg = InfoBlockMessage::builder("Environment Details") - /// .add_line("Name: production") - /// .add_line("Status: active") - /// .build(); - /// ``` - #[must_use] - pub fn builder(title: impl Into) -> InfoBlockMessageBuilder { - InfoBlockMessageBuilder::new(title) - } -} - -impl OutputMessage for InfoBlockMessage { - fn format(&self, _theme: &Theme) -> String { - use std::fmt::Write; - - let mut output = format!("{}\n", self.title); - for line in &self.lines { - writeln!(&mut output, "{line}").ok(); - } - output - } - - fn required_verbosity(&self) -> VerbosityLevel { - VerbosityLevel::Normal - } - - fn channel(&self) -> Channel { - Channel::Stderr - } - - fn type_name(&self) -> &'static str { - "InfoBlockMessage" - } -} - -/// Builder for constructing `InfoBlockMessage` with a fluent API -/// -/// Provides a consuming builder pattern for constructing info block messages -/// with optional customization. Use this for complex cases where lines -/// are added dynamically or for improved readability. Simple cases can -/// use `InfoBlockMessage::new()` directly. -/// -/// # Examples -/// -/// ```rust -/// use torrust_tracker_deployer_lib::presentation::user_output::InfoBlockMessage; -/// -/// let message = InfoBlockMessage::builder("Environment Details") -/// .add_line("Name: production") -/// .add_line("Status: running") -/// .add_line("Uptime: 24 hours") -/// .build(); -/// ``` -/// -/// Empty builders are valid: -/// -/// ```rust -/// use torrust_tracker_deployer_lib::presentation::user_output::InfoBlockMessage; -/// -/// let message = InfoBlockMessage::builder("Title").build(); -/// ``` -pub struct InfoBlockMessageBuilder { - title: String, - lines: Vec, -} - -impl InfoBlockMessageBuilder { - /// Create a new builder with the given title - /// - /// # Examples - /// - /// ```rust - /// use torrust_tracker_deployer_lib::presentation::user_output::InfoBlockMessageBuilder; - /// - /// let builder = InfoBlockMessageBuilder::new("My info block:"); - /// ``` - #[must_use] - pub fn new(title: impl Into) -> Self { - Self { - title: title.into(), - lines: Vec::new(), - } - } - - /// Add a line to the info block (consuming self for method chaining) - /// - /// This method consumes the builder and returns it, enabling - /// method chaining in a fluent API style. - /// - /// # Examples - /// - /// ```rust - /// use torrust_tracker_deployer_lib::presentation::user_output::InfoBlockMessage; - /// - /// let message = InfoBlockMessage::builder("Info:") - /// .add_line("First line") - /// .add_line("Second line") - /// .build(); - /// ``` - #[must_use] - pub fn add_line(mut self, line: impl Into) -> Self { - self.lines.push(line.into()); - self - } - - /// Build the final `InfoBlockMessage` - /// - /// Consumes the builder and produces the final message. - /// - /// # Examples - /// - /// ```rust - /// use torrust_tracker_deployer_lib::presentation::user_output::InfoBlockMessage; - /// - /// let message = InfoBlockMessage::builder("Info:") - /// .add_line("Line 1") - /// .build(); - /// ``` - #[must_use] - pub fn build(self) -> InfoBlockMessage { - InfoBlockMessage { - title: self.title, - lines: self.lines, - } - } -} - -// ============================================================================ -// PRIVATE - Type-Safe Writer Wrappers -// ============================================================================ - -/// Stdout writer wrapper for type safety -/// -/// This newtype wrapper ensures that stdout-specific operations -/// can only be performed on stdout writers, preventing accidental -/// channel confusion at compile time. -/// -/// The wrapper provides a zero-cost abstraction - it has the same -/// memory layout and performance characteristics as the wrapped type, -/// but provides compile-time type safety. -struct StdoutWriter(Box); - -impl StdoutWriter { - /// Create a new stdout writer wrapper - fn new(writer: Box) -> Self { - Self(writer) - } - - /// Write a line to stdout - /// - /// Writes the given message to the stdout channel. - /// The message should include any necessary newline characters. - /// Errors are silently ignored as output operations are best-effort. - fn write_line(&mut self, message: &str) { - write!(self.0, "{message}").ok(); - } - - /// Write with a newline to stdout - /// - /// Writes the given message followed by a newline to the stdout channel. - /// Errors are silently ignored as output operations are best-effort. - #[allow(dead_code)] - fn writeln(&mut self, message: &str) { - writeln!(self.0, "{message}").ok(); - } -} - -/// Stderr writer wrapper for type safety -/// -/// This newtype wrapper ensures that stderr-specific operations -/// can only be performed on stderr writers, preventing accidental -/// channel confusion at compile time. -/// -/// The wrapper provides a zero-cost abstraction - it has the same -/// memory layout and performance characteristics as the wrapped type, -/// but provides compile-time type safety. -struct StderrWriter(Box); - -impl StderrWriter { - /// Create a new stderr writer wrapper - fn new(writer: Box) -> Self { - Self(writer) - } - - /// Write a line to stderr - /// - /// Writes the given message to the stderr channel. - /// The message should include any necessary newline characters. - /// Errors are silently ignored as output operations are best-effort. - fn write_line(&mut self, message: &str) { - write!(self.0, "{message}").ok(); - } - - /// Write with a newline to stderr - /// - /// Writes the given message followed by a newline to the stderr channel. - /// Errors are silently ignored as output operations are best-effort. - #[allow(dead_code)] - fn writeln(&mut self, message: &str) { - writeln!(self.0, "{message}").ok(); - } -} - -// ============================================================================ -// PRIVATE - Verbosity Filter -// ============================================================================ - -/// Determines what messages should be displayed based on verbosity level -/// -/// This struct encapsulates verbosity filtering logic, making it testable -/// independently from output formatting. -struct VerbosityFilter { - level: VerbosityLevel, -} - -impl VerbosityFilter { - /// Create a new verbosity filter with the specified level - fn new(level: VerbosityLevel) -> Self { - Self { level } - } - - /// Check if messages at the given level should be shown - fn should_show(&self, required_level: VerbosityLevel) -> bool { - self.level >= required_level - } - - /// Progress messages require Normal level - #[allow(dead_code)] - fn should_show_progress(&self) -> bool { - self.should_show(VerbosityLevel::Normal) - } - - /// Success messages require Normal level - #[allow(dead_code)] - fn should_show_success(&self) -> bool { - self.should_show(VerbosityLevel::Normal) - } - - /// Warning messages require Normal level - #[allow(dead_code)] - fn should_show_warnings(&self) -> bool { - self.should_show(VerbosityLevel::Normal) - } - - /// Errors are always shown regardless of verbosity level - #[allow(clippy::unused_self)] - #[allow(dead_code)] - fn should_show_errors(&self) -> bool { - true - } - - /// Blank lines require Normal level - fn should_show_blank_lines(&self) -> bool { - self.should_show(VerbosityLevel::Normal) - } - - /// Steps require Normal level - #[allow(dead_code)] - fn should_show_steps(&self) -> bool { - self.should_show(VerbosityLevel::Normal) - } - - /// Info blocks require Normal level - #[allow(dead_code)] - fn should_show_info_blocks(&self) -> bool { - self.should_show(VerbosityLevel::Normal) - } -} - -// ============================================================================ -// Output Sink Implementations -// ============================================================================ - -/// Standard sink writing to stdout/stderr -/// -/// This is the default sink that maintains backward compatibility with the -/// existing console output behavior. It routes messages to stdout or stderr -/// based on the message's channel. -/// -/// # Type Safety -/// -/// Uses `StdoutWriter` and `StderrWriter` wrappers for compile-time channel safety. -/// -/// # Examples -/// -/// ```rust,ignore -/// use torrust_tracker_deployer_lib::presentation::user_output::StandardSink; -/// -/// let sink = StandardSink::new( -/// Box::new(std::io::stdout()), -/// Box::new(std::io::stderr()) -/// ); -/// ``` -pub struct StandardSink { - stdout: StdoutWriter, - stderr: StderrWriter, -} - -impl StandardSink { - /// Create a new standard sink with the given writers - /// - /// This is useful for testing or when you need custom writers. - #[must_use] - pub fn new(stdout: Box, stderr: Box) -> Self { - Self { - stdout: StdoutWriter::new(stdout), - stderr: StderrWriter::new(stderr), - } - } - - /// Create a standard sink using default stdout/stderr - /// - /// This is the default console sink that writes to the standard - /// output and error streams. - #[must_use] - pub fn default_console() -> Self { - Self::new(Box::new(std::io::stdout()), Box::new(std::io::stderr())) - } -} - -impl OutputSink for StandardSink { - fn write_message(&mut self, message: &dyn OutputMessage, formatted: &str) { - match message.channel() { - Channel::Stdout => { - self.stdout.write_line(formatted); - } - Channel::Stderr => { - self.stderr.write_line(formatted); - } - } - } -} - -/// Composite sink that writes to multiple destinations -/// -/// Enables fan-out of messages to multiple sinks simultaneously. Useful for -/// scenarios like writing to both console and log file, or sending to both -/// stderr and telemetry service. -/// -/// # Examples -/// -/// ```rust,ignore -/// use torrust_tracker_deployer_lib::presentation::user_output::{CompositeSink, StandardSink, FileSink}; -/// -/// let composite = CompositeSink::new(vec![ -/// Box::new(StandardSink::default_console()), -/// Box::new(FileSink::new("output.log").unwrap()), -/// ]); -/// ``` -pub struct CompositeSink { - sinks: Vec>, -} - -impl CompositeSink { - /// Create a new composite sink with the given child sinks - /// - /// # Examples - /// - /// ```rust,ignore - /// use torrust_tracker_deployer_lib::presentation::user_output::CompositeSink; - /// - /// let composite = CompositeSink::new(vec![ - /// Box::new(StandardSink::default_console()), - /// Box::new(FileSink::new("output.log").unwrap()), - /// ]); - /// ``` - #[must_use] - pub fn new(sinks: Vec>) -> Self { - Self { sinks } - } - - /// Add a sink to the composite - /// - /// # Examples - /// - /// ```rust,ignore - /// use torrust_tracker_deployer_lib::presentation::user_output::CompositeSink; - /// - /// let mut composite = CompositeSink::new(vec![]); - /// composite.add_sink(Box::new(StandardSink::default_console())); - /// composite.add_sink(Box::new(FileSink::new("output.log").unwrap())); - /// ``` - pub fn add_sink(&mut self, sink: Box) { - self.sinks.push(sink); - } -} - -impl OutputSink for CompositeSink { - fn write_message(&mut self, message: &dyn OutputMessage, formatted: &str) { - for sink in &mut self.sinks { - sink.write_message(message, formatted); - } - } -} - -// ============================================================================ -// Example Sink Implementations -// ============================================================================ - -/// Example: File sink that writes all output to a file -/// -/// This is an example implementation showing how to create a custom sink -/// that writes to a file. In production, you might want to add buffering, -/// rotation, or other features. -/// -/// # Examples -/// -/// ```rust,ignore -/// use torrust_tracker_deployer_lib::presentation::user_output::{FileSink, UserOutput, VerbosityLevel, CompositeSink, StandardSink}; -/// -/// // Write to both console and file -/// let composite = CompositeSink::new(vec![ -/// Box::new(StandardSink::default_console()), -/// Box::new(FileSink::new("output.log").unwrap()), -/// ]); -/// let mut output = UserOutput::with_sink(VerbosityLevel::Normal, Box::new(composite)); -/// ``` -pub struct FileSink { - file: std::fs::File, -} - -impl FileSink { - /// Create a new file sink - /// - /// Opens or creates the file at the given path in append mode. - /// - /// # Errors - /// - /// Returns an error if the file cannot be opened or created. - /// - /// # Examples - /// - /// ```rust,ignore - /// use torrust_tracker_deployer_lib::presentation::user_output::FileSink; - /// - /// let sink = FileSink::new("output.log")?; - /// ``` - pub fn new(path: &str) -> std::io::Result { - let file = std::fs::OpenOptions::new() - .create(true) - .append(true) - .open(path)?; - Ok(Self { file }) - } -} - -impl OutputSink for FileSink { - fn write_message(&mut self, _message: &dyn OutputMessage, formatted: &str) { - writeln!(self.file, "{formatted}").ok(); - } -} - -/// Example: Telemetry sink for observability -/// -/// This is a stub implementation showing how a telemetry sink could be -/// implemented. In production, this would send events to a telemetry service. -/// -/// # Examples -/// -/// ```rust,ignore -/// use torrust_tracker_deployer_lib::presentation::user_output::{TelemetrySink, UserOutput, VerbosityLevel, CompositeSink, StandardSink}; -/// -/// // Write to both console and telemetry -/// let composite = CompositeSink::new(vec![ -/// Box::new(StandardSink::default_console()), -/// Box::new(TelemetrySink::new("https://telemetry.example.com".to_string())), -/// ]); -/// let mut output = UserOutput::with_sink(VerbosityLevel::Normal, Box::new(composite)); -/// ``` -pub struct TelemetrySink { - endpoint: String, -} - -impl TelemetrySink { - /// Create a new telemetry sink - /// - /// # Examples - /// - /// ```rust,ignore - /// use torrust_tracker_deployer_lib::presentation::user_output::TelemetrySink; - /// - /// let sink = TelemetrySink::new("https://telemetry.example.com".to_string()); - /// ``` - #[must_use] - pub fn new(endpoint: String) -> Self { - Self { endpoint } - } -} - -impl OutputSink for TelemetrySink { - fn write_message(&mut self, message: &dyn OutputMessage, formatted: &str) { - // In real implementation, send to telemetry service - tracing::debug!( - endpoint = %self.endpoint, - message_type = message.type_name(), - channel = ?message.channel(), - content = formatted, - "Telemetry event" - ); - } -} +use super::{ + Channel, FormatterOverride, OutputMessage, OutputSink, Theme, VerbosityLevel, +}; +use super::sinks::StandardSink; +use super::verbosity::VerbosityFilter; +use super::messages::*; -/// Handles user-facing output separate from internal logging -/// -/// Uses dual channels following Unix conventions and modern CLI best practices: -/// - **stdout**: Final results and data for piping/redirection -/// - **stderr**: Progress updates, status messages, operational info, errors -/// -/// This separation allows scripts to cleanly capture results while seeing progress: -/// -/// ```bash -/// # Suppress progress, capture results only -/// torrust-tracker-deployer destroy env 2>/dev/null > result.json -/// -/// # Suppress results, see progress only -/// torrust-tracker-deployer destroy env > /dev/null -/// ``` -/// -/// # Examples -/// -/// ```rust -/// use torrust_tracker_deployer_lib::presentation::user_output::{UserOutput, VerbosityLevel}; -/// -/// let mut output = UserOutput::new(VerbosityLevel::Normal); -/// -/// // Progress to stderr (visible during execution, doesn't interfere with piping) -/// output.progress("Processing data..."); -/// -/// // Results to stdout (can be piped to other commands) -/// output.result("Processing complete"); -/// ``` pub struct UserOutput { theme: Theme, verbosity_filter: VerbosityFilter, @@ -1966,235 +489,17 @@ impl UserOutput { } } -#[cfg(test)] -pub mod test_support { - //! Test support infrastructure for `UserOutput` testing - //! - //! Provides simplified test infrastructure for capturing and asserting on output - //! in tests across the codebase. - - use super::*; - use std::sync::{Arc, Mutex}; - - /// Writer implementation for tests that writes to a shared buffer - /// - /// Uses `Arc>>` to satisfy the `Send + Sync` requirements - /// of the `UserOutput::with_writers` method. - pub struct TestWriter { - buffer: Arc>>, - } - - impl TestWriter { - /// Create a new `TestWriter` with a shared buffer - pub fn new(buffer: Arc>>) -> Self { - Self { buffer } - } - } - - impl Write for TestWriter { - fn write(&mut self, buf: &[u8]) -> std::io::Result { - self.buffer.lock().unwrap().write(buf) - } - - fn flush(&mut self) -> std::io::Result<()> { - self.buffer.lock().unwrap().flush() - } - } - - /// Test wrapper for `UserOutput` that simplifies test code - /// - /// Provides easy access to captured stdout and stderr content, - /// eliminating the need for manual buffer management in tests. - /// - /// # Examples - /// - /// ```rust,ignore - /// use torrust_tracker_deployer_lib::presentation::user_output::test_support::TestUserOutput; - /// use torrust_tracker_deployer_lib::presentation::user_output::VerbosityLevel; - /// - /// let mut test_output = TestUserOutput::new(VerbosityLevel::Normal); - /// - /// test_output.output.progress("Processing..."); - /// - /// assert_eq!(test_output.stderr(), "⏳ Processing...\n"); - /// assert_eq!(test_output.stdout(), ""); - /// ``` - pub struct TestUserOutput { - /// The `UserOutput` instance being tested - pub output: UserOutput, - stdout_buffer: Arc>>, - stderr_buffer: Arc>>, - } - - impl TestUserOutput { - /// Create a new test output with the specified verbosity level and default theme - /// - /// # Examples - /// - /// ```rust,ignore - /// let test_output = TestUserOutput::new(VerbosityLevel::Normal); - /// ``` - #[must_use] - pub fn new(verbosity: VerbosityLevel) -> Self { - Self::with_theme(verbosity, Theme::default()) - } - - /// Create a new test output with the specified verbosity level and theme - /// - /// # Examples - /// - /// ```rust,ignore - /// let test_output = TestUserOutput::with_theme(VerbosityLevel::Normal, Theme::plain()); - /// ``` - #[must_use] - pub fn with_theme(verbosity: VerbosityLevel, theme: Theme) -> Self { - let stdout_buffer = Arc::new(Mutex::new(Vec::new())); - let stderr_buffer = Arc::new(Mutex::new(Vec::new())); - - let stdout_writer = Box::new(TestWriter::new(Arc::clone(&stdout_buffer))); - let stderr_writer = Box::new(TestWriter::new(Arc::clone(&stderr_buffer))); - - let output = - UserOutput::with_theme_and_writers(verbosity, theme, stdout_writer, stderr_writer); - - Self { - output, - stdout_buffer, - stderr_buffer, - } - } - - /// Create wrapped test output for use with APIs that require `Arc>` - /// - /// This is a convenience method for tests that just need a wrapped output - /// without access to the buffers. - /// - /// # Examples - /// - /// ```rust,ignore - /// let output = TestUserOutput::wrapped(VerbosityLevel::Normal); - /// // Use with APIs that expect Arc> - /// ``` - #[must_use] - pub fn wrapped(verbosity: VerbosityLevel) -> Arc> { - let test_output = Self::new(verbosity); - Arc::new(Mutex::new(test_output.output)) - } - - /// Wrap an existing `UserOutput` in an `Arc>` for use with APIs that require it - /// - /// Returns a tuple of (`Arc>`, stdout buffer, stderr buffer) for tests - /// that need access to both the wrapped output and the buffers. - /// - /// # Examples - /// - /// ```rust,ignore - /// let test_output = TestUserOutput::new(VerbosityLevel::Normal); - /// let (wrapped, stdout_buf, stderr_buf) = test_output.into_wrapped(); - /// // Use `wrapped` with APIs that expect Arc> - /// // Use buffers to assert on output content - /// ``` - #[must_use] - #[allow(clippy::type_complexity)] - pub fn into_wrapped( - self, - ) -> ( - Arc>, - Arc>>, - Arc>>, - ) { - let stdout_buf = Arc::clone(&self.stdout_buffer); - let stderr_buf = Arc::clone(&self.stderr_buffer); - (Arc::new(Mutex::new(self.output)), stdout_buf, stderr_buf) - } - - /// Get the content written to stdout as a String - /// - /// # Examples - /// - /// ```rust,ignore - /// let mut test_output = TestUserOutput::new(VerbosityLevel::Normal); - /// test_output.output.result("Done"); - /// assert_eq!(test_output.stdout(), "Done\n"); - /// ``` - /// - /// # Panics - /// - /// Panics if the mutex is poisoned or if the buffer contains invalid UTF-8. - /// These conditions indicate a test bug and should never occur in practice. - #[must_use] - pub fn stdout(&self) -> String { - String::from_utf8(self.stdout_buffer.lock().unwrap().clone()) - .expect("stdout should be valid UTF-8") - } - - /// Get the content written to stderr as a String - /// - /// # Examples - /// - /// ```rust,ignore - /// let mut test_output = TestUserOutput::new(VerbosityLevel::Normal); - /// test_output.output.progress("Working..."); - /// assert_eq!(test_output.stderr(), "⏳ Working...\n"); - /// ``` - /// - /// # Panics - /// - /// Panics if the mutex is poisoned or if the buffer contains invalid UTF-8. - /// These conditions indicate a test bug and should never occur in practice. - #[must_use] - pub fn stderr(&self) -> String { - String::from_utf8(self.stderr_buffer.lock().unwrap().clone()) - .expect("stderr should be valid UTF-8") - } - - /// Get both stdout and stderr content as a tuple - /// - /// # Examples - /// - /// ```rust,ignore - /// let mut test_output = TestUserOutput::new(VerbosityLevel::Normal); - /// test_output.output.progress("Working..."); - /// test_output.output.result("Done"); - /// let (stdout, stderr) = test_output.output_pair(); - /// assert_eq!(stdout, "Done\n"); - /// assert_eq!(stderr, "⏳ Working...\n"); - /// ``` - #[must_use] - #[allow(dead_code)] - pub fn output_pair(&self) -> (String, String) { - (self.stdout(), self.stderr()) - } - - /// Clear all captured output - /// - /// Useful when testing multiple operations in the same test. - /// - /// # Examples - /// - /// ```rust,ignore - /// let mut test_output = TestUserOutput::new(VerbosityLevel::Normal); - /// test_output.output.progress("Step 1"); - /// test_output.clear(); - /// test_output.output.progress("Step 2"); - /// assert_eq!(test_output.stderr(), "⏳ Step 2\n"); - /// ``` - /// - /// # Panics - /// - /// Panics if the mutex is poisoned. This indicates a test bug and should - /// never occur in practice. - #[allow(dead_code)] - pub fn clear(&mut self) { - self.stdout_buffer.lock().unwrap().clear(); - self.stderr_buffer.lock().unwrap().clear(); - } - } -} - #[cfg(test)] mod tests { use super::*; + + // These imports are used by nested test modules + #[allow(unused_imports)] + use crate::presentation::user_output::formatters::JsonFormatter; + #[allow(unused_imports)] + use crate::presentation::user_output::sinks::{CompositeSink, FileSink, StderrWriter, StdoutWriter, TelemetrySink}; + #[allow(unused_imports)] + use crate::presentation::user_output::test_support::{self, TestUserOutput, TestWriter}; // ============================================================================ // Type-Safe Writer Wrapper Tests @@ -3089,7 +1394,8 @@ mod tests { mod formatter_override { use super::super::*; - use crate::presentation::user_output::test_support::TestUserOutput; + use crate::presentation::user_output::formatters::JsonFormatter; + use crate::presentation::user_output::test_support::{self, TestUserOutput}; use std::sync::{Arc, Mutex}; // Custom test formatter to verify override is applied @@ -3525,7 +1831,8 @@ mod tests { mod builder_pattern { use super::super::*; - use crate::presentation::user_output::test_support::TestUserOutput; + use crate::presentation::user_output::formatters::JsonFormatter; + use crate::presentation::user_output::test_support::{self, TestUserOutput}; // ======================================================================== // StepsMessageBuilder Tests @@ -3877,6 +2184,8 @@ mod tests { mod output_sink { use super::super::*; + use crate::presentation::user_output::test_support; + use crate::presentation::user_output::{CompositeSink, FileSink, TelemetrySink}; use std::sync::{Arc, Mutex}; /// Mock sink for testing that captures messages @@ -4206,7 +2515,7 @@ mod tests { #[test] fn telemetry_sink_should_create_with_endpoint() { let sink = TelemetrySink::new("https://example.com".to_string()); - assert_eq!(sink.endpoint, "https://example.com"); + assert_eq!(sink.endpoint(), "https://example.com"); } #[test] diff --git a/src/presentation/user_output/formatters/json.rs b/src/presentation/user_output/formatters/json.rs new file mode 100644 index 00000000..8e2e48ef --- /dev/null +++ b/src/presentation/user_output/formatters/json.rs @@ -0,0 +1,44 @@ +//! JSON formatter implementation + +use super::super::{FormatterOverride, OutputMessage}; + +// ============================================================================ +// Formatter Override Implementations +// ============================================================================ + +/// JSON formatter for machine-readable output +/// +/// Transforms messages into JSON objects with metadata including: +/// - Message type (for programmatic filtering) +/// - Channel (stdout/stderr) +/// - Content (the formatted message) +/// - Timestamp (ISO 8601 format) +/// +/// # Examples +/// +/// ```rust,ignore +/// use torrust_tracker_deployer_lib::presentation::user_output::{JsonFormatter, UserOutput, VerbosityLevel}; +/// +/// let formatter = JsonFormatter; +/// let mut output = UserOutput::with_formatter_override( +/// VerbosityLevel::Normal, +/// Box::new(formatter) +/// ); +/// +/// output.progress("Starting process"); +/// // Output: {"type":"ProgressMessage","channel":"Stderr","content":"⏳ Starting process","timestamp":"2025-11-04T12:34:56Z"} +/// ``` +pub struct JsonFormatter; + +impl FormatterOverride for JsonFormatter { + fn transform(&self, formatted: &str, message: &dyn OutputMessage) -> String { + let json = serde_json::json!({ + "type": message.type_name(), + "channel": format!("{:?}", message.channel()), + "content": formatted.trim(), // Remove trailing newlines for cleaner JSON + "timestamp": chrono::Utc::now().to_rfc3339(), + }) + .to_string(); + format!("{json}\n") + } +} diff --git a/src/presentation/user_output/formatters/mod.rs b/src/presentation/user_output/formatters/mod.rs new file mode 100644 index 00000000..d6a34600 --- /dev/null +++ b/src/presentation/user_output/formatters/mod.rs @@ -0,0 +1,8 @@ +//! Formatter override implementations +//! +//! This module contains implementations of the `FormatterOverride` trait for +//! transforming output formats. + +pub use json::JsonFormatter; + +mod json; diff --git a/src/presentation/user_output/messages/error.rs b/src/presentation/user_output/messages/error.rs new file mode 100644 index 00000000..c24f86a7 --- /dev/null +++ b/src/presentation/user_output/messages/error.rs @@ -0,0 +1,30 @@ +//! Error message type for critical failures + +use super::super::{Channel, OutputMessage, Theme, VerbosityLevel}; + +/// Error message for critical failures +/// +/// Error messages indicate critical failures that prevent operation completion. +/// They are always shown regardless of verbosity level. +pub struct ErrorMessage { + /// The error message text + pub text: String, +} + +impl OutputMessage for ErrorMessage { + fn format(&self, theme: &Theme) -> String { + format!("{} {}\n", theme.error_symbol(), self.text) + } + + fn required_verbosity(&self) -> VerbosityLevel { + VerbosityLevel::Quiet // Always shown + } + + fn channel(&self) -> Channel { + Channel::Stderr + } + + fn type_name(&self) -> &'static str { + "ErrorMessage" + } +} diff --git a/src/presentation/user_output/messages/info_block.rs b/src/presentation/user_output/messages/info_block.rs new file mode 100644 index 00000000..d82dc925 --- /dev/null +++ b/src/presentation/user_output/messages/info_block.rs @@ -0,0 +1,202 @@ +//! Info block message type for structured information + +use super::super::{Channel, OutputMessage, Theme, VerbosityLevel}; + +/// Info block messages display a title followed by multiple lines of text. +/// Useful for displaying grouped information, configuration details, or +/// multi-line informational content. +/// +/// # Examples +/// +/// Simple constructor for cases where you have all lines upfront: +/// +/// ```rust +/// use torrust_tracker_deployer_lib::presentation::user_output::InfoBlockMessage; +/// +/// let message = InfoBlockMessage::new("Environment Details", vec![ +/// "Name: production".to_string(), +/// "Status: running".to_string(), +/// ]); +/// ``` +/// +/// Builder pattern for dynamic construction or better readability: +/// +/// ```rust +/// use torrust_tracker_deployer_lib::presentation::user_output::InfoBlockMessage; +/// +/// let message = InfoBlockMessage::builder("Environment Details") +/// .add_line("Name: production") +/// .add_line("Status: running") +/// .add_line("Uptime: 24 hours") +/// .build(); +/// ``` +pub struct InfoBlockMessage { + /// The title for the info block + pub title: String, + /// The lines of information + pub lines: Vec, +} + +impl InfoBlockMessage { + /// Create a new info block message with the given title and lines + /// + /// This is a convenience constructor for simple cases where you have + /// all lines upfront. For dynamic construction or better readability, + /// consider using `InfoBlockMessage::builder()` instead. + /// + /// # Examples + /// + /// ```rust + /// use torrust_tracker_deployer_lib::presentation::user_output::InfoBlockMessage; + /// + /// let msg = InfoBlockMessage::new("Configuration:", vec![ + /// " - username: 'torrust'".to_string(), + /// " - port: 22".to_string(), + /// ]); + /// ``` + #[must_use] + pub fn new(title: impl Into, lines: Vec) -> Self { + Self { + title: title.into(), + lines, + } + } + + /// Create a builder for constructing info block messages with a fluent API + /// + /// The builder pattern is useful when: + /// - Adding lines dynamically + /// - You want self-documenting, readable code + /// - Building the message in multiple steps + /// + /// # Examples + /// + /// ```rust + /// use torrust_tracker_deployer_lib::presentation::user_output::InfoBlockMessage; + /// + /// let msg = InfoBlockMessage::builder("Environment Details") + /// .add_line("Name: production") + /// .add_line("Status: active") + /// .build(); + /// ``` + #[must_use] + pub fn builder(title: impl Into) -> InfoBlockMessageBuilder { + InfoBlockMessageBuilder::new(title) + } +} + +impl OutputMessage for InfoBlockMessage { + fn format(&self, _theme: &Theme) -> String { + use std::fmt::Write; + + let mut output = format!("{}\n", self.title); + for line in &self.lines { + writeln!(&mut output, "{line}").ok(); + } + output + } + + fn required_verbosity(&self) -> VerbosityLevel { + VerbosityLevel::Normal + } + + fn channel(&self) -> Channel { + Channel::Stderr + } + + fn type_name(&self) -> &'static str { + "InfoBlockMessage" + } +} + +/// Builder for constructing `InfoBlockMessage` with a fluent API +/// +/// Provides a consuming builder pattern for constructing info block messages +/// with optional customization. Use this for complex cases where lines +/// are added dynamically or for improved readability. Simple cases can +/// use `InfoBlockMessage::new()` directly. +/// +/// # Examples +/// +/// ```rust +/// use torrust_tracker_deployer_lib::presentation::user_output::InfoBlockMessage; +/// +/// let message = InfoBlockMessage::builder("Environment Details") +/// .add_line("Name: production") +/// .add_line("Status: running") +/// .add_line("Uptime: 24 hours") +/// .build(); +/// ``` +/// +/// Empty builders are valid: +/// +/// ```rust +/// use torrust_tracker_deployer_lib::presentation::user_output::InfoBlockMessage; +/// +/// let message = InfoBlockMessage::builder("Title").build(); +/// ``` +pub struct InfoBlockMessageBuilder { + title: String, + lines: Vec, +} + +impl InfoBlockMessageBuilder { + /// Create a new builder with the given title + /// + /// # Examples + /// + /// ```rust + /// use torrust_tracker_deployer_lib::presentation::user_output::InfoBlockMessageBuilder; + /// + /// let builder = InfoBlockMessageBuilder::new("My info block:"); + /// ``` + #[must_use] + pub fn new(title: impl Into) -> Self { + Self { + title: title.into(), + lines: Vec::new(), + } + } + + /// Add a line to the info block (consuming self for method chaining) + /// + /// This method consumes the builder and returns it, enabling + /// method chaining in a fluent API style. + /// + /// # Examples + /// + /// ```rust + /// use torrust_tracker_deployer_lib::presentation::user_output::InfoBlockMessage; + /// + /// let message = InfoBlockMessage::builder("Info:") + /// .add_line("First line") + /// .add_line("Second line") + /// .build(); + /// ``` + #[must_use] + pub fn add_line(mut self, line: impl Into) -> Self { + self.lines.push(line.into()); + self + } + + /// Build the final `InfoBlockMessage` + /// + /// Consumes the builder and produces the final message. + /// + /// # Examples + /// + /// ```rust + /// use torrust_tracker_deployer_lib::presentation::user_output::InfoBlockMessage; + /// + /// let message = InfoBlockMessage::builder("Info:") + /// .add_line("Line 1") + /// .build(); + /// ``` + #[must_use] + pub fn build(self) -> InfoBlockMessage { + InfoBlockMessage { + title: self.title, + lines: self.lines, + } + } +} diff --git a/src/presentation/user_output/messages/mod.rs b/src/presentation/user_output/messages/mod.rs new file mode 100644 index 00000000..6c9b81be --- /dev/null +++ b/src/presentation/user_output/messages/mod.rs @@ -0,0 +1,19 @@ +//! Message type implementations for user output +//! +//! This module contains all concrete message types that implement the `OutputMessage` trait. + +pub use error::ErrorMessage; +pub use info_block::{InfoBlockMessage, InfoBlockMessageBuilder}; +pub use progress::ProgressMessage; +pub use result::ResultMessage; +pub use steps::{StepsMessage, StepsMessageBuilder}; +pub use success::SuccessMessage; +pub use warning::WarningMessage; + +mod error; +mod info_block; +mod progress; +mod result; +mod steps; +mod success; +mod warning; diff --git a/src/presentation/user_output/messages/progress.rs b/src/presentation/user_output/messages/progress.rs new file mode 100644 index 00000000..07bbba21 --- /dev/null +++ b/src/presentation/user_output/messages/progress.rs @@ -0,0 +1,40 @@ +//! Progress message type for ongoing operations + +use super::super::{Channel, OutputMessage, Theme, VerbosityLevel}; + +/// Progress message for ongoing operations +/// +/// Progress messages indicate that work is in progress. They are displayed +/// during operations to provide feedback to users. +/// +/// # Examples +/// +/// ```rust,ignore +/// use torrust_tracker_deployer_lib::presentation::user_output::ProgressMessage; +/// +/// let message = ProgressMessage { +/// text: "Destroying environment...".to_string(), +/// }; +/// ``` +pub struct ProgressMessage { + /// The progress message text + pub text: String, +} + +impl OutputMessage for ProgressMessage { + fn format(&self, theme: &Theme) -> String { + format!("{} {}\n", theme.progress_symbol(), self.text) + } + + fn required_verbosity(&self) -> VerbosityLevel { + VerbosityLevel::Normal + } + + fn channel(&self) -> Channel { + Channel::Stderr + } + + fn type_name(&self) -> &'static str { + "ProgressMessage" + } +} diff --git a/src/presentation/user_output/messages/result.rs b/src/presentation/user_output/messages/result.rs new file mode 100644 index 00000000..fdcced52 --- /dev/null +++ b/src/presentation/user_output/messages/result.rs @@ -0,0 +1,30 @@ +//! Result message type for final output data + +use super::super::{Channel, OutputMessage, Theme, VerbosityLevel}; + +/// Result message for final output data +/// +/// Result messages contain final output data that can be piped or redirected. +/// They go to stdout without any symbols or formatting. +pub struct ResultMessage { + /// The result message text + pub text: String, +} + +impl OutputMessage for ResultMessage { + fn format(&self, _theme: &Theme) -> String { + format!("{}\n", self.text) + } + + fn required_verbosity(&self) -> VerbosityLevel { + VerbosityLevel::Quiet + } + + fn channel(&self) -> Channel { + Channel::Stdout + } + + fn type_name(&self) -> &'static str { + "ResultMessage" + } +} diff --git a/src/presentation/user_output/messages/steps.rs b/src/presentation/user_output/messages/steps.rs new file mode 100644 index 00000000..ae7bcc2b --- /dev/null +++ b/src/presentation/user_output/messages/steps.rs @@ -0,0 +1,203 @@ +//! Steps message type for sequential instructions + +use super::super::{Channel, OutputMessage, Theme, VerbosityLevel}; + +/// Steps message for sequential instructions +/// +/// Steps messages display numbered lists of sequential items. +/// Useful for showing action items or instructions. +/// +/// # Examples +/// +/// Simple constructor for cases where you have all items upfront: +/// +/// ```rust +/// use torrust_tracker_deployer_lib::presentation::user_output::StepsMessage; +/// +/// let message = StepsMessage::new("Next steps:", vec![ +/// "Edit the configuration file".to_string(), +/// "Review the settings".to_string(), +/// ]); +/// ``` +/// +/// Builder pattern for dynamic construction or better readability: +/// +/// ```rust +/// use torrust_tracker_deployer_lib::presentation::user_output::StepsMessage; +/// +/// let message = StepsMessage::builder("Next steps:") +/// .add("Edit the configuration file") +/// .add("Review the settings") +/// .build(); +/// ``` +pub struct StepsMessage { + /// The title for the steps list + pub title: String, + /// The list of step items + pub items: Vec, +} + +impl StepsMessage { + /// Create a new steps message with the given title and items + /// + /// This is a convenience constructor for simple cases where you have + /// all items upfront. For dynamic construction or better readability, + /// consider using `StepsMessage::builder()` instead. + /// + /// # Examples + /// + /// ```rust + /// use torrust_tracker_deployer_lib::presentation::user_output::StepsMessage; + /// + /// let msg = StepsMessage::new("Next steps:", vec![ + /// "Edit config".to_string(), + /// "Run tests".to_string(), + /// ]); + /// ``` + #[must_use] + pub fn new(title: impl Into, items: Vec) -> Self { + Self { + title: title.into(), + items, + } + } + + /// Create a builder for constructing steps messages with a fluent API + /// + /// The builder pattern is useful when: + /// - Adding items dynamically + /// - You want self-documenting, readable code + /// - Building the message in multiple steps + /// + /// # Examples + /// + /// ```rust + /// use torrust_tracker_deployer_lib::presentation::user_output::StepsMessage; + /// + /// let msg = StepsMessage::builder("Next steps:") + /// .add("Edit configuration") + /// .add("Review settings") + /// .build(); + /// ``` + #[must_use] + pub fn builder(title: impl Into) -> StepsMessageBuilder { + StepsMessageBuilder::new(title) + } +} + +impl OutputMessage for StepsMessage { + fn format(&self, _theme: &Theme) -> String { + use std::fmt::Write; + + let mut output = format!("{}\n", self.title); + for (idx, step) in self.items.iter().enumerate() { + writeln!(&mut output, "{}. {}", idx + 1, step).ok(); + } + output + } + + fn required_verbosity(&self) -> VerbosityLevel { + VerbosityLevel::Normal + } + + fn channel(&self) -> Channel { + Channel::Stderr + } + + fn type_name(&self) -> &'static str { + "StepsMessage" + } +} + +/// Builder for constructing `StepsMessage` with a fluent API +/// +/// Provides a consuming builder pattern for constructing step messages +/// with optional customization. Use this for complex cases where items +/// are added dynamically or for improved readability. Simple cases can +/// use `StepsMessage::new()` directly. +/// +/// # Examples +/// +/// ```rust +/// use torrust_tracker_deployer_lib::presentation::user_output::StepsMessage; +/// +/// let message = StepsMessage::builder("Next steps:") +/// .add("Edit configuration") +/// .add("Review settings") +/// .add("Deploy changes") +/// .build(); +/// ``` +/// +/// Empty builders are valid: +/// +/// ```rust +/// use torrust_tracker_deployer_lib::presentation::user_output::StepsMessage; +/// +/// let message = StepsMessage::builder("Title").build(); +/// ``` +pub struct StepsMessageBuilder { + title: String, + items: Vec, +} + +impl StepsMessageBuilder { + /// Create a new builder with the given title + /// + /// # Examples + /// + /// ```rust + /// use torrust_tracker_deployer_lib::presentation::user_output::StepsMessageBuilder; + /// + /// let builder = StepsMessageBuilder::new("My steps:"); + /// ``` + #[must_use] + pub fn new(title: impl Into) -> Self { + Self { + title: title.into(), + items: Vec::new(), + } + } + + /// Add a step to the list (consuming self for method chaining) + /// + /// This method consumes the builder and returns it, enabling + /// method chaining in a fluent API style. + /// + /// # Examples + /// + /// ```rust + /// use torrust_tracker_deployer_lib::presentation::user_output::StepsMessage; + /// + /// let message = StepsMessage::builder("Steps:") + /// .add("First step") + /// .add("Second step") + /// .build(); + /// ``` + #[must_use] + #[allow(clippy::should_implement_trait)] + pub fn add(mut self, step: impl Into) -> Self { + self.items.push(step.into()); + self + } + + /// Build the final `StepsMessage` + /// + /// Consumes the builder and produces the final message. + /// + /// # Examples + /// + /// ```rust + /// use torrust_tracker_deployer_lib::presentation::user_output::StepsMessage; + /// + /// let message = StepsMessage::builder("Steps:") + /// .add("Step 1") + /// .build(); + /// ``` + #[must_use] + pub fn build(self) -> StepsMessage { + StepsMessage { + title: self.title, + items: self.items, + } + } +} diff --git a/src/presentation/user_output/messages/success.rs b/src/presentation/user_output/messages/success.rs new file mode 100644 index 00000000..2a69f6a9 --- /dev/null +++ b/src/presentation/user_output/messages/success.rs @@ -0,0 +1,30 @@ +//! Success message type for completed operations + +use super::super::{Channel, OutputMessage, Theme, VerbosityLevel}; + +/// Success message for completed operations +/// +/// Success messages indicate that an operation completed successfully. +/// They provide positive feedback to users. +pub struct SuccessMessage { + /// The success message text + pub text: String, +} + +impl OutputMessage for SuccessMessage { + fn format(&self, theme: &Theme) -> String { + format!("{} {}\n", theme.success_symbol(), self.text) + } + + fn required_verbosity(&self) -> VerbosityLevel { + VerbosityLevel::Normal + } + + fn channel(&self) -> Channel { + Channel::Stderr + } + + fn type_name(&self) -> &'static str { + "SuccessMessage" + } +} diff --git a/src/presentation/user_output/messages/warning.rs b/src/presentation/user_output/messages/warning.rs new file mode 100644 index 00000000..05570de4 --- /dev/null +++ b/src/presentation/user_output/messages/warning.rs @@ -0,0 +1,30 @@ +//! Warning message type for non-critical issues + +use super::super::{Channel, OutputMessage, Theme, VerbosityLevel}; + +/// Warning message for non-critical issues +/// +/// Warning messages alert users to potential issues that don't prevent +/// operation completion but may need attention. +pub struct WarningMessage { + /// The warning message text + pub text: String, +} + +impl OutputMessage for WarningMessage { + fn format(&self, theme: &Theme) -> String { + format!("{} {}\n", theme.warning_symbol(), self.text) + } + + fn required_verbosity(&self) -> VerbosityLevel { + VerbosityLevel::Normal + } + + fn channel(&self) -> Channel { + Channel::Stderr + } + + fn type_name(&self) -> &'static str { + "WarningMessage" + } +} diff --git a/src/presentation/user_output/mod.rs b/src/presentation/user_output/mod.rs new file mode 100644 index 00000000..3e1ec4f1 --- /dev/null +++ b/src/presentation/user_output/mod.rs @@ -0,0 +1,88 @@ +//! User-facing output handling +//! +//! This module provides user-facing output functionality separate from internal logging. +//! It implements a dual-channel strategy following Unix conventions and modern CLI best practices +//! (similar to cargo, docker, npm): +//! +//! - **stdout (Results Channel)**: Final results, structured data, output for piping/redirection +//! - **stderr (Progress/Operational Channel)**: Progress updates, status messages, warnings, errors +//! +//! This separation enables: +//! - Clean piping: `torrust-tracker-deployer destroy env | jq .status` works correctly +//! - Automation friendly: Scripts can redirect progress to /dev/null while capturing results +//! - Unix convention compliance: Follows established patterns from modern CLI tools +//! - Better UX: Progress feedback doesn't interfere with result data +//! +//! ## Type-Safe Channel Routing +//! +//! The module uses newtype wrappers (`StdoutWriter` and `StderrWriter`) to provide compile-time +//! guarantees that messages are routed to the correct output channel. This prevents accidental +//! channel confusion and makes the code more maintainable by catching routing errors at compile +//! time rather than runtime. +//! +//! The newtype pattern is a zero-cost abstraction - it has the same memory layout and performance +//! characteristics as the wrapped type, but provides type safety benefits. +//! +//! ## Buffering Behavior +//! +//! Output is line-buffered by default. Messages are typically flushed automatically +//! after each newline. For cases where immediate output is critical (e.g., before +//! long-running operations), call `flush()` explicitly: +//! +//! ```rust,ignore +//! output.progress("Starting long operation..."); +//! output.flush()?; // Ensure message appears before operation starts +//! perform_long_operation(); +//! ``` +//! +//! ## Example Usage +//! +//! ```rust +//! use torrust_tracker_deployer_lib::presentation::user_output::{UserOutput, VerbosityLevel}; +//! +//! let mut output = UserOutput::new(VerbosityLevel::Normal); +//! +//! // Progress messages go to stderr +//! output.progress("Destroying environment..."); +//! +//! // Success status goes to stderr +//! output.success("Environment destroyed successfully"); +//! +//! // Results go to stdout for piping +//! output.result(r#"{"status": "destroyed"}"#); +//! ``` +//! +//! ## Channel Strategy +//! +//! Based on research from [`docs/research/UX/console-app-output-patterns.md`](../../docs/research/UX/console-app-output-patterns.md): +//! +//! - **stdout**: Deployment results, configuration summaries, structured data (JSON) +//! - **stderr**: Step progress, status updates, warnings, error messages with actionable guidance +//! +//! See also: [`docs/research/UX/user-output-vs-logging-separation.md`](../../docs/research/UX/user-output-vs-logging-separation.md) + +// Re-export core types and traits for backward compatibility +pub use channel::Channel; +pub use core::UserOutput; +pub use formatters::JsonFormatter; +pub use messages::{ + ErrorMessage, InfoBlockMessage, InfoBlockMessageBuilder, ProgressMessage, ResultMessage, + StepsMessage, StepsMessageBuilder, SuccessMessage, WarningMessage, +}; +pub use sinks::{CompositeSink, FileSink, StandardSink, TelemetrySink}; +pub use theme::Theme; +pub use traits::{FormatterOverride, OutputMessage, OutputSink}; +pub use verbosity::VerbosityLevel; + +// Internal modules +mod channel; +mod core; +mod formatters; +mod messages; +mod sinks; +mod theme; +mod traits; +mod verbosity; + +// Test support module (public for use in tests across the codebase) +pub mod test_support; diff --git a/src/presentation/user_output/sinks/composite.rs b/src/presentation/user_output/sinks/composite.rs new file mode 100644 index 00000000..5b3c6f41 --- /dev/null +++ b/src/presentation/user_output/sinks/composite.rs @@ -0,0 +1,49 @@ +//! Composite output sink for multiple destinations + +use super::super::{OutputMessage, OutputSink}; + +pub struct CompositeSink { + sinks: Vec>, +} + +impl CompositeSink { + /// Create a new composite sink with the given child sinks + /// + /// # Examples + /// + /// ```rust,ignore + /// use torrust_tracker_deployer_lib::presentation::user_output::CompositeSink; + /// + /// let composite = CompositeSink::new(vec![ + /// Box::new(StandardSink::default_console()), + /// Box::new(FileSink::new("output.log").unwrap()), + /// ]); + /// ``` + #[must_use] + pub fn new(sinks: Vec>) -> Self { + Self { sinks } + } + + /// Add a sink to the composite + /// + /// # Examples + /// + /// ```rust,ignore + /// use torrust_tracker_deployer_lib::presentation::user_output::CompositeSink; + /// + /// let mut composite = CompositeSink::new(vec![]); + /// composite.add_sink(Box::new(StandardSink::default_console())); + /// composite.add_sink(Box::new(FileSink::new("output.log").unwrap())); + /// ``` + pub fn add_sink(&mut self, sink: Box) { + self.sinks.push(sink); + } +} + +impl OutputSink for CompositeSink { + fn write_message(&mut self, message: &dyn OutputMessage, formatted: &str) { + for sink in &mut self.sinks { + sink.write_message(message, formatted); + } + } +} diff --git a/src/presentation/user_output/sinks/file.rs b/src/presentation/user_output/sinks/file.rs new file mode 100644 index 00000000..e422ba38 --- /dev/null +++ b/src/presentation/user_output/sinks/file.rs @@ -0,0 +1,62 @@ +//! File output sink implementation + +use std::io::Write; + +use super::super::{OutputMessage, OutputSink}; + +// ============================================================================ +// Example Sink Implementations +// ============================================================================ + +/// Example: File sink that writes all output to a file +/// +/// This is an example implementation showing how to create a custom sink +/// that writes to a file. In production, you might want to add buffering, +/// rotation, or other features. +/// +/// # Examples +/// +/// ```rust,ignore +/// use torrust_tracker_deployer_lib::presentation::user_output::{FileSink, UserOutput, VerbosityLevel, CompositeSink, StandardSink}; +/// +/// // Write to both console and file +/// let composite = CompositeSink::new(vec![ +/// Box::new(StandardSink::default_console()), +/// Box::new(FileSink::new("output.log").unwrap()), +/// ]); +/// let mut output = UserOutput::with_sink(VerbosityLevel::Normal, Box::new(composite)); +/// ``` +pub struct FileSink { + file: std::fs::File, +} + +impl FileSink { + /// Create a new file sink + /// + /// Opens or creates the file at the given path in append mode. + /// + /// # Errors + /// + /// Returns an error if the file cannot be opened or created. + /// + /// # Examples + /// + /// ```rust,ignore + /// use torrust_tracker_deployer_lib::presentation::user_output::FileSink; + /// + /// let sink = FileSink::new("output.log")?; + /// ``` + pub fn new(path: &str) -> std::io::Result { + let file = std::fs::OpenOptions::new() + .create(true) + .append(true) + .open(path)?; + Ok(Self { file }) + } +} + +impl OutputSink for FileSink { + fn write_message(&mut self, _message: &dyn OutputMessage, formatted: &str) { + writeln!(self.file, "{formatted}").ok(); + } +} diff --git a/src/presentation/user_output/sinks/mod.rs b/src/presentation/user_output/sinks/mod.rs new file mode 100644 index 00000000..c21c41eb --- /dev/null +++ b/src/presentation/user_output/sinks/mod.rs @@ -0,0 +1,18 @@ +//! Output sink implementations for different destinations +//! +//! This module contains implementations of the `OutputSink` trait for various +//! output destinations. + +pub use composite::CompositeSink; +pub use file::FileSink; +pub use standard::StandardSink; +pub use telemetry::TelemetrySink; + +// Re-export writers for use within user_output module (including tests) +pub(in crate::presentation::user_output) use writers::{StderrWriter, StdoutWriter}; + +mod composite; +mod file; +mod standard; +mod telemetry; +pub(in crate::presentation::user_output) mod writers; diff --git a/src/presentation/user_output/sinks/standard.rs b/src/presentation/user_output/sinks/standard.rs new file mode 100644 index 00000000..e60d023d --- /dev/null +++ b/src/presentation/user_output/sinks/standard.rs @@ -0,0 +1,70 @@ +//! Standard output sink implementation + +use std::io::Write; + +use super::super::{Channel, OutputMessage, OutputSink}; +use super::writers::{StderrWriter, StdoutWriter}; + +// ============================================================================ +// Output Sink Implementations +// ============================================================================ + +/// Standard sink writing to stdout/stderr +/// +/// This is the default sink that maintains backward compatibility with the +/// existing console output behavior. It routes messages to stdout or stderr +/// based on the message's channel. +/// +/// # Type Safety +/// +/// Uses `StdoutWriter` and `StderrWriter` wrappers for compile-time channel safety. +/// +/// # Examples +/// +/// ```rust,ignore +/// use torrust_tracker_deployer_lib::presentation::user_output::StandardSink; +/// +/// let sink = StandardSink::new( +/// Box::new(std::io::stdout()), +/// Box::new(std::io::stderr()) +/// ); +/// ``` +pub struct StandardSink { + stdout: StdoutWriter, + stderr: StderrWriter, +} + +impl StandardSink { + /// Create a new standard sink with the given writers + /// + /// This is useful for testing or when you need custom writers. + #[must_use] + pub fn new(stdout: Box, stderr: Box) -> Self { + Self { + stdout: StdoutWriter::new(stdout), + stderr: StderrWriter::new(stderr), + } + } + + /// Create a standard sink using default stdout/stderr + /// + /// This is the default console sink that writes to the standard + /// output and error streams. + #[must_use] + pub fn default_console() -> Self { + Self::new(Box::new(std::io::stdout()), Box::new(std::io::stderr())) + } +} + +impl OutputSink for StandardSink { + fn write_message(&mut self, message: &dyn OutputMessage, formatted: &str) { + match message.channel() { + Channel::Stdout => { + self.stdout.write_line(formatted); + } + Channel::Stderr => { + self.stderr.write_line(formatted); + } + } + } +} diff --git a/src/presentation/user_output/sinks/telemetry.rs b/src/presentation/user_output/sinks/telemetry.rs new file mode 100644 index 00000000..1bb7237d --- /dev/null +++ b/src/presentation/user_output/sinks/telemetry.rs @@ -0,0 +1,42 @@ +//! Telemetry output sink implementation + +use super::super::{OutputMessage, OutputSink}; + +pub struct TelemetrySink { + endpoint: String, +} + +impl TelemetrySink { + /// Create a new telemetry sink + /// + /// # Examples + /// + /// ```rust,ignore + /// use torrust_tracker_deployer_lib::presentation::user_output::TelemetrySink; + /// + /// let sink = TelemetrySink::new("https://telemetry.example.com".to_string()); + /// ``` + #[must_use] + pub fn new(endpoint: String) -> Self { + Self { endpoint } + } + + /// Get the endpoint URL + #[cfg(test)] + pub fn endpoint(&self) -> &str { + &self.endpoint + } +} + +impl OutputSink for TelemetrySink { + fn write_message(&mut self, message: &dyn OutputMessage, formatted: &str) { + // In real implementation, send to telemetry service + tracing::debug!( + endpoint = %self.endpoint, + message_type = message.type_name(), + channel = ?message.channel(), + content = formatted, + "Telemetry event" + ); + } +} diff --git a/src/presentation/user_output/sinks/writers.rs b/src/presentation/user_output/sinks/writers.rs new file mode 100644 index 00000000..c141fa14 --- /dev/null +++ b/src/presentation/user_output/sinks/writers.rs @@ -0,0 +1,75 @@ +//! Type-safe writer wrappers for output channels + +use std::io::Write; + +/// Stdout writer wrapper for type safety +/// +/// This newtype wrapper ensures that stdout-specific operations +/// can only be performed on stdout writers, preventing accidental +/// channel confusion at compile time. +/// +/// The wrapper provides a zero-cost abstraction - it has the same +/// memory layout and performance characteristics as the wrapped type, +/// but provides compile-time type safety. +pub(in crate::presentation::user_output) struct StdoutWriter(Box); + +impl StdoutWriter { + /// Create a new stdout writer wrapper + pub(in crate::presentation::user_output) fn new(writer: Box) -> Self { + Self(writer) + } + + /// Write a line to stdout + /// + /// Writes the given message to the stdout channel. + /// The message should include any necessary newline characters. + /// Errors are silently ignored as output operations are best-effort. + pub(in crate::presentation::user_output) fn write_line(&mut self, message: &str) { + write!(self.0, "{message}").ok(); + } + + /// Write with a newline to stdout + /// + /// Writes the given message followed by a newline to the stdout channel. + /// Errors are silently ignored as output operations are best-effort. + #[allow(dead_code)] + pub(in crate::presentation::user_output) fn writeln(&mut self, message: &str) { + writeln!(self.0, "{message}").ok(); + } +} + +/// Stderr writer wrapper for type safety +/// +/// This newtype wrapper ensures that stderr-specific operations +/// can only be performed on stderr writers, preventing accidental +/// channel confusion at compile time. +/// +/// The wrapper provides a zero-cost abstraction - it has the same +/// memory layout and performance characteristics as the wrapped type, +/// but provides compile-time type safety. +pub(in crate::presentation::user_output) struct StderrWriter(Box); + +impl StderrWriter { + /// Create a new stderr writer wrapper + pub(in crate::presentation::user_output) fn new(writer: Box) -> Self { + Self(writer) + } + + /// Write a line to stderr + /// + /// Writes the given message to the stderr channel. + /// The message should include any necessary newline characters. + /// Errors are silently ignored as output operations are best-effort. + pub(in crate::presentation::user_output) fn write_line(&mut self, message: &str) { + write!(self.0, "{message}").ok(); + } + + /// Write with a newline to stderr + /// + /// Writes the given message followed by a newline to the stderr channel. + /// Errors are silently ignored as output operations are best-effort. + #[allow(dead_code)] + pub(in crate::presentation::user_output) fn writeln(&mut self, message: &str) { + writeln!(self.0, "{message}").ok(); + } +} diff --git a/src/presentation/user_output/test_support.rs b/src/presentation/user_output/test_support.rs new file mode 100644 index 00000000..c68fa560 --- /dev/null +++ b/src/presentation/user_output/test_support.rs @@ -0,0 +1,224 @@ +//! Test support infrastructure for `UserOutput` testing +//! +//! Provides simplified test infrastructure for capturing and asserting on output +//! in tests across the codebase. + +use std::io::Write; +use std::sync::{Arc, Mutex}; + +use super::*; + +/// Writer implementation for tests that writes to a shared buffer +/// +/// Uses `Arc>>` to satisfy the `Send + Sync` requirements +/// of the `UserOutput::with_writers` method. +pub struct TestWriter { + buffer: Arc>>, +} + +impl TestWriter { + /// Create a new `TestWriter` with a shared buffer + pub fn new(buffer: Arc>>) -> Self { + Self { buffer } + } +} + +impl Write for TestWriter { + fn write(&mut self, buf: &[u8]) -> std::io::Result { + self.buffer.lock().unwrap().write(buf) + } + + fn flush(&mut self) -> std::io::Result<()> { + self.buffer.lock().unwrap().flush() + } +} + +/// Test wrapper for `UserOutput` that simplifies test code +/// +/// Provides easy access to captured stdout and stderr content, +/// eliminating the need for manual buffer management in tests. +/// +/// # Examples +/// +/// ```rust,ignore +/// use torrust_tracker_deployer_lib::presentation::user_output::test_support::TestUserOutput; +/// use torrust_tracker_deployer_lib::presentation::user_output::VerbosityLevel; +/// +/// let mut test_output = TestUserOutput::new(VerbosityLevel::Normal); +/// +/// test_output.output.progress("Processing..."); +/// +/// assert_eq!(test_output.stderr(), "⏳ Processing...\n"); +/// assert_eq!(test_output.stdout(), ""); +/// ``` +pub struct TestUserOutput { + /// The `UserOutput` instance being tested + pub output: UserOutput, + stdout_buffer: Arc>>, + stderr_buffer: Arc>>, +} + +impl TestUserOutput { + /// Create a new test output with the specified verbosity level and default theme + /// + /// # Examples + /// + /// ```rust,ignore + /// let test_output = TestUserOutput::new(VerbosityLevel::Normal); + /// ``` + #[must_use] + pub fn new(verbosity: VerbosityLevel) -> Self { + Self::with_theme(verbosity, Theme::default()) + } + + /// Create a new test output with the specified verbosity level and theme + /// + /// # Examples + /// + /// ```rust,ignore + /// let test_output = TestUserOutput::with_theme(VerbosityLevel::Normal, Theme::plain()); + /// ``` + #[must_use] + pub fn with_theme(verbosity: VerbosityLevel, theme: Theme) -> Self { + let stdout_buffer = Arc::new(Mutex::new(Vec::new())); + let stderr_buffer = Arc::new(Mutex::new(Vec::new())); + + let stdout_writer = Box::new(TestWriter::new(Arc::clone(&stdout_buffer))); + let stderr_writer = Box::new(TestWriter::new(Arc::clone(&stderr_buffer))); + + let output = + UserOutput::with_theme_and_writers(verbosity, theme, stdout_writer, stderr_writer); + + Self { + output, + stdout_buffer, + stderr_buffer, + } + } + + /// Create wrapped test output for use with APIs that require `Arc>` + /// + /// This is a convenience method for tests that just need a wrapped output + /// without access to the buffers. + /// + /// # Examples + /// + /// ```rust,ignore + /// let output = TestUserOutput::wrapped(VerbosityLevel::Normal); + /// // Use with APIs that expect Arc> + /// ``` + #[must_use] + pub fn wrapped(verbosity: VerbosityLevel) -> Arc> { + let test_output = Self::new(verbosity); + Arc::new(Mutex::new(test_output.output)) + } + + /// Wrap an existing `UserOutput` in an `Arc>` for use with APIs that require it + /// + /// Returns a tuple of (`Arc>`, stdout buffer, stderr buffer) for tests + /// that need access to both the wrapped output and the buffers. + /// + /// # Examples + /// + /// ```rust,ignore + /// let test_output = TestUserOutput::new(VerbosityLevel::Normal); + /// let (wrapped, stdout_buf, stderr_buf) = test_output.into_wrapped(); + /// // Use `wrapped` with APIs that expect Arc> + /// // Use buffers to assert on output content + /// ``` + #[must_use] + #[allow(clippy::type_complexity)] + pub fn into_wrapped( + self, + ) -> ( + Arc>, + Arc>>, + Arc>>, + ) { + let stdout_buf = Arc::clone(&self.stdout_buffer); + let stderr_buf = Arc::clone(&self.stderr_buffer); + (Arc::new(Mutex::new(self.output)), stdout_buf, stderr_buf) + } + + /// Get the content written to stdout as a String + /// + /// # Examples + /// + /// ```rust,ignore + /// let mut test_output = TestUserOutput::new(VerbosityLevel::Normal); + /// test_output.output.result("Done"); + /// assert_eq!(test_output.stdout(), "Done\n"); + /// ``` + /// + /// # Panics + /// + /// Panics if the mutex is poisoned or if the buffer contains invalid UTF-8. + /// These conditions indicate a test bug and should never occur in practice. + #[must_use] + pub fn stdout(&self) -> String { + String::from_utf8(self.stdout_buffer.lock().unwrap().clone()) + .expect("stdout should be valid UTF-8") + } + + /// Get the content written to stderr as a String + /// + /// # Examples + /// + /// ```rust,ignore + /// let mut test_output = TestUserOutput::new(VerbosityLevel::Normal); + /// test_output.output.progress("Working..."); + /// assert_eq!(test_output.stderr(), "⏳ Working...\n"); + /// ``` + /// + /// # Panics + /// + /// Panics if the mutex is poisoned or if the buffer contains invalid UTF-8. + /// These conditions indicate a test bug and should never occur in practice. + #[must_use] + pub fn stderr(&self) -> String { + String::from_utf8(self.stderr_buffer.lock().unwrap().clone()) + .expect("stderr should be valid UTF-8") + } + + /// Get both stdout and stderr content as a tuple + /// + /// # Examples + /// + /// ```rust,ignore + /// let mut test_output = TestUserOutput::new(VerbosityLevel::Normal); + /// test_output.output.progress("Working..."); + /// test_output.output.result("Done"); + /// let (stdout, stderr) = test_output.output_pair(); + /// assert_eq!(stdout, "Done\n"); + /// assert_eq!(stderr, "⏳ Working...\n"); + /// ``` + #[must_use] + #[allow(dead_code)] + pub fn output_pair(&self) -> (String, String) { + (self.stdout(), self.stderr()) + } + + /// Clear all captured output + /// + /// Useful when testing multiple operations in the same test. + /// + /// # Examples + /// + /// ```rust,ignore + /// let mut test_output = TestUserOutput::new(VerbosityLevel::Normal); + /// test_output.output.progress("Step 1"); + /// test_output.clear(); + /// test_output.output.progress("Step 2"); + /// assert_eq!(test_output.stderr(), "⏳ Step 2\n"); + /// ``` + /// + /// # Panics + /// + /// Panics if the mutex is poisoned. This indicates a test bug and should + /// never occur in practice. + #[allow(dead_code)] + pub fn clear(&mut self) { + self.stdout_buffer.lock().unwrap().clear(); + self.stderr_buffer.lock().unwrap().clear(); + } +} diff --git a/src/presentation/user_output/theme.rs b/src/presentation/user_output/theme.rs new file mode 100644 index 00000000..ffd1b057 --- /dev/null +++ b/src/presentation/user_output/theme.rs @@ -0,0 +1,209 @@ +//! Theme configuration for user output +//! +//! This module provides theme support for user-facing messages, allowing customization +//! of visual symbols used throughout the output. + +/// Output theme controlling symbols and formatting +/// +/// A theme defines the visual appearance of user-facing messages through +/// configurable symbols. Themes enable consistent styling across all output +/// and support different environments (terminals, CI/CD, accessibility needs). +/// +/// # Predefined Themes +/// +/// - **Emoji** (default): Unicode emoji symbols for interactive terminals +/// - **Plain**: Text labels like `[INFO]`, `[OK]` for CI/CD environments +/// - **ASCII**: Basic ASCII characters for limited terminal support +/// +/// # Examples +/// +/// ```rust +/// use torrust_tracker_deployer_lib::presentation::user_output::Theme; +/// +/// // Use emoji theme (default) +/// let theme = Theme::emoji(); +/// assert_eq!(theme.progress_symbol(), "⏳"); +/// +/// // Use plain text theme for CI/CD +/// let theme = Theme::plain(); +/// assert_eq!(theme.success_symbol(), "[OK]"); +/// +/// // Use ASCII theme for limited terminals +/// let theme = Theme::ascii(); +/// assert_eq!(theme.error_symbol(), "[x]"); +/// ``` +#[derive(Debug, Clone, PartialEq, Eq)] +#[allow(clippy::struct_field_names)] +pub struct Theme { + progress_symbol: String, + success_symbol: String, + warning_symbol: String, + error_symbol: String, +} + +impl Theme { + /// Create emoji theme with Unicode symbols (default) + /// + /// Best for interactive terminals with good Unicode support. + /// Uses emoji characters that are visually distinctive and widely supported. + /// + /// # Examples + /// + /// ```rust + /// use torrust_tracker_deployer_lib::presentation::user_output::Theme; + /// + /// let theme = Theme::emoji(); + /// assert_eq!(theme.progress_symbol(), "⏳"); + /// assert_eq!(theme.success_symbol(), "✅"); + /// assert_eq!(theme.warning_symbol(), "⚠️"); + /// assert_eq!(theme.error_symbol(), "❌"); + /// ``` + #[must_use] + pub fn emoji() -> Self { + Self { + progress_symbol: "⏳".to_string(), + success_symbol: "✅".to_string(), + warning_symbol: "⚠️".to_string(), + error_symbol: "❌".to_string(), + } + } + + /// Create plain text theme for CI/CD environments + /// + /// Uses text labels like `[INFO]`, `[OK]`, `[WARN]`, `[ERROR]` that work + /// in any environment without Unicode support. Ideal for CI/CD pipelines + /// and log aggregation systems. + /// + /// # Examples + /// + /// ```rust + /// use torrust_tracker_deployer_lib::presentation::user_output::Theme; + /// + /// let theme = Theme::plain(); + /// assert_eq!(theme.progress_symbol(), "[INFO]"); + /// assert_eq!(theme.success_symbol(), "[OK]"); + /// assert_eq!(theme.warning_symbol(), "[WARN]"); + /// assert_eq!(theme.error_symbol(), "[ERROR]"); + /// ``` + #[must_use] + pub fn plain() -> Self { + Self { + progress_symbol: "[INFO]".to_string(), + success_symbol: "[OK]".to_string(), + warning_symbol: "[WARN]".to_string(), + error_symbol: "[ERROR]".to_string(), + } + } + + /// Create ASCII-only theme using basic characters + /// + /// Uses simple ASCII characters that work on any terminal. + /// Good for environments with limited character set support or + /// when maximum compatibility is required. + /// + /// # Examples + /// + /// ```rust + /// use torrust_tracker_deployer_lib::presentation::user_output::Theme; + /// + /// let theme = Theme::ascii(); + /// assert_eq!(theme.progress_symbol(), "=>"); + /// assert_eq!(theme.success_symbol(), "[+]"); + /// assert_eq!(theme.warning_symbol(), "[!]"); + /// assert_eq!(theme.error_symbol(), "[x]"); + /// ``` + #[must_use] + pub fn ascii() -> Self { + Self { + progress_symbol: "=>".to_string(), + success_symbol: "[+]".to_string(), + warning_symbol: "[!]".to_string(), + error_symbol: "[x]".to_string(), + } + } + + /// Get the progress symbol for this theme + #[must_use] + pub fn progress_symbol(&self) -> &str { + &self.progress_symbol + } + + /// Get the success symbol for this theme + #[must_use] + pub fn success_symbol(&self) -> &str { + &self.success_symbol + } + + /// Get the warning symbol for this theme + #[must_use] + pub fn warning_symbol(&self) -> &str { + &self.warning_symbol + } + + /// Get the error symbol for this theme + #[must_use] + pub fn error_symbol(&self) -> &str { + &self.error_symbol + } +} + +impl Default for Theme { + /// Create the default theme (emoji) + /// + /// # Examples + /// + /// ```rust + /// use torrust_tracker_deployer_lib::presentation::user_output::Theme; + /// + /// let theme = Theme::default(); + /// assert_eq!(theme.progress_symbol(), "⏳"); + /// ``` + fn default() -> Self { + Self::emoji() + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn emoji_theme_should_use_emoji_symbols() { + let theme = Theme::emoji(); + assert_eq!(theme.progress_symbol(), "⏳"); + assert_eq!(theme.success_symbol(), "✅"); + assert_eq!(theme.warning_symbol(), "⚠️"); + assert_eq!(theme.error_symbol(), "❌"); + } + + #[test] + fn plain_theme_should_use_text_labels() { + let theme = Theme::plain(); + assert_eq!(theme.progress_symbol(), "[INFO]"); + assert_eq!(theme.success_symbol(), "[OK]"); + assert_eq!(theme.warning_symbol(), "[WARN]"); + assert_eq!(theme.error_symbol(), "[ERROR]"); + } + + #[test] + fn ascii_theme_should_use_ascii_symbols() { + let theme = Theme::ascii(); + assert_eq!(theme.progress_symbol(), "=>"); + assert_eq!(theme.success_symbol(), "[+]"); + assert_eq!(theme.warning_symbol(), "[!]"); + assert_eq!(theme.error_symbol(), "[x]"); + } + + #[test] + fn default_theme_should_be_emoji() { + let theme = Theme::default(); + assert_eq!(theme, Theme::emoji()); + } + + #[test] + fn themes_should_be_cloneable() { + let theme1 = Theme::emoji(); + let theme2 = theme1.clone(); + assert_eq!(theme1, theme2); + } +} diff --git a/src/presentation/user_output/traits.rs b/src/presentation/user_output/traits.rs new file mode 100644 index 00000000..f0111ab9 --- /dev/null +++ b/src/presentation/user_output/traits.rs @@ -0,0 +1,182 @@ +//! Core traits for output message handling +//! +//! This module defines the traits that enable extensibility and abstraction +//! in the user output system. + +use super::{Channel, Theme, VerbosityLevel}; + +/// Trait for output messages that can be written to user-facing channels +/// +/// This trait enables extensibility following the Open/Closed Principle. +/// Each message type encapsulates its own: +/// - Formatting logic (how it appears to users) +/// - Verbosity requirements (when it should be shown) +/// - Channel routing (stdout vs stderr) +/// +/// # Design Philosophy +/// +/// By implementing this trait, message types become self-contained and can be +/// added without modifying the `UserOutput` struct. This makes the system +/// extensible - new message types can be defined in external modules. +/// +/// # Examples +/// +/// ```rust,ignore +/// use torrust_tracker_deployer_lib::presentation::user_output::{OutputMessage, Theme, VerbosityLevel, Channel}; +/// +/// struct CustomMessage { +/// text: String, +/// } +/// +/// impl OutputMessage for CustomMessage { +/// fn format(&self, theme: &Theme) -> String { +/// format!("🎉 {}", self.text) +/// } +/// +/// fn required_verbosity(&self) -> VerbosityLevel { +/// VerbosityLevel::Normal +/// } +/// +/// fn channel(&self) -> Channel { +/// Channel::Stderr +/// } +/// +/// fn type_name(&self) -> &'static str { +/// "CustomMessage" +/// } +/// } +/// ``` +pub trait OutputMessage { + /// Format this message using the given theme + /// + /// This method defines how the message appears to users. It should + /// incorporate theme symbols and any necessary formatting. + /// + /// # Arguments + /// + /// * `theme` - The theme providing symbols for formatting + /// + /// # Returns + /// + /// A formatted string ready for display to users + fn format(&self, theme: &Theme) -> String; + + /// Get the minimum verbosity level required to show this message + /// + /// Messages are only displayed if the current verbosity level is + /// greater than or equal to the required level. + /// + /// # Returns + /// + /// The minimum verbosity level needed to display this message + fn required_verbosity(&self) -> VerbosityLevel; + + /// Get the output channel for this message + /// + /// Determines whether the message goes to stdout or stderr following + /// Unix conventions. + /// + /// # Returns + /// + /// The channel (Stdout or Stderr) where this message should be written + fn channel(&self) -> Channel; + + /// Get the type name of this message + /// + /// Returns a human-readable type identifier for this message type. + /// This is primarily used by formatter overrides (e.g., JSON formatter) + /// to include type information in the output. + /// + /// # Returns + /// + /// A static string representing the message type name + fn type_name(&self) -> &'static str; +} + +/// Optional trait for post-processing message output +/// +/// This allows transforming the standard message format without +/// modifying individual message types. Use sparingly - prefer +/// extending the message trait or using themes for most cases. +/// +/// # When to Use +/// +/// - **Machine-readable formats**: JSON, XML, structured logs +/// - **Additional decoration**: ANSI colors, markup codes +/// - **Output wrapping**: Adding metadata, timestamps, process info +/// +/// # When NOT to Use +/// +/// - **Symbol changes**: Use `Theme` instead +/// - **New message types**: Implement `OutputMessage` trait instead +/// - **Channel routing changes**: Define in message type's `channel()` method +/// +/// # Examples +/// +/// ```rust,ignore +/// use torrust_tracker_deployer_lib::presentation::user_output::{FormatterOverride, OutputMessage}; +/// +/// struct JsonFormatter; +/// +/// impl FormatterOverride for JsonFormatter { +/// fn transform(&self, formatted: &str, message: &dyn OutputMessage) -> String { +/// // Transform to JSON representation +/// format!(r#"{{"content": "{}"}}"#, formatted.trim()) +/// } +/// } +/// ``` +pub trait FormatterOverride: Send + Sync { + /// Transform formatted message output + /// + /// This method receives the already-formatted message (with theme applied) + /// and the original message object for context. It should return the + /// transformed output. + /// + /// # Arguments + /// + /// * `formatted` - The message already formatted with theme + /// * `message` - The original message object (for metadata/context) + /// + /// # Returns + /// + /// The transformed message string + fn transform(&self, formatted: &str, message: &dyn OutputMessage) -> String; +} + +/// Trait for output destinations +/// +/// An output sink receives formatted messages and writes them to a destination. +/// Sinks handle the mechanics of where output goes, not how it's formatted. +/// +/// # Design Philosophy +/// +/// Sinks receive already-formatted messages (with theme applied) and route them +/// to appropriate destinations. They don't handle formatting or verbosity filtering - +/// those concerns are handled by message types and filters respectively. +/// +/// # Examples +/// +/// ```rust,ignore +/// use torrust_tracker_deployer_lib::presentation::user_output::{OutputSink, OutputMessage}; +/// use std::fs::File; +/// +/// struct FileSink { +/// file: File, +/// } +/// +/// impl OutputSink for FileSink { +/// fn write_message(&mut self, message: &dyn OutputMessage, formatted: &str) { +/// use std::io::Write; +/// writeln!(self.file, "{}", formatted).ok(); +/// } +/// } +/// ``` +pub trait OutputSink: Send + Sync { + /// Write a formatted message to this sink + /// + /// # Arguments + /// + /// * `message` - The message object (for metadata like channel) + /// * `formatted` - The already-formatted message text + fn write_message(&mut self, message: &dyn OutputMessage, formatted: &str); +} diff --git a/src/presentation/user_output/verbosity.rs b/src/presentation/user_output/verbosity.rs new file mode 100644 index 00000000..cdc420b1 --- /dev/null +++ b/src/presentation/user_output/verbosity.rs @@ -0,0 +1,173 @@ +//! Verbosity level control for user output +//! +//! This module provides verbosity level configuration and filtering logic +//! to control the amount of detail shown to users. + +/// Verbosity levels for user output +/// +/// Controls the amount of detail shown to users. Higher verbosity levels include +/// all output from lower levels. +/// +/// # Examples +/// +/// ```rust +/// use torrust_tracker_deployer_lib::presentation::user_output::VerbosityLevel; +/// +/// let level = VerbosityLevel::Normal; +/// assert!(level >= VerbosityLevel::Quiet); +/// assert!(level < VerbosityLevel::Verbose); +/// ``` +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Default)] +pub enum VerbosityLevel { + /// Minimal output - only errors and final results + Quiet, + /// Default level - essential progress and results + #[default] + Normal, + /// Detailed progress including intermediate steps + Verbose, + /// Very detailed including decisions and retries + VeryVerbose, + /// Maximum detail for troubleshooting + Debug, +} + +/// Determines what messages should be displayed based on verbosity level +/// +/// This struct encapsulates verbosity filtering logic, making it testable +/// independently from output formatting. +pub(super) struct VerbosityFilter { + level: VerbosityLevel, +} + +impl VerbosityFilter { + /// Create a new verbosity filter with the specified level + pub(super) fn new(level: VerbosityLevel) -> Self { + Self { level } + } + + /// Check if messages at the given level should be shown + pub(super) fn should_show(&self, required_level: VerbosityLevel) -> bool { + self.level >= required_level + } + + /// Progress messages require Normal level + #[allow(dead_code)] + pub(super) fn should_show_progress(&self) -> bool { + self.should_show(VerbosityLevel::Normal) + } + + /// Success messages require Normal level + #[allow(dead_code)] + pub(super) fn should_show_success(&self) -> bool { + self.should_show(VerbosityLevel::Normal) + } + + /// Warning messages require Normal level + #[allow(dead_code)] + pub(super) fn should_show_warnings(&self) -> bool { + self.should_show(VerbosityLevel::Normal) + } + + /// Errors are always shown regardless of verbosity level + #[allow(clippy::unused_self)] + #[allow(dead_code)] + pub(super) fn should_show_errors(&self) -> bool { + true + } + + /// Blank lines require Normal level + pub(super) fn should_show_blank_lines(&self) -> bool { + self.should_show(VerbosityLevel::Normal) + } + + /// Steps require Normal level + #[allow(dead_code)] + pub(super) fn should_show_steps(&self) -> bool { + self.should_show(VerbosityLevel::Normal) + } + + /// Info blocks require Normal level + #[allow(dead_code)] + pub(super) fn should_show_info_blocks(&self) -> bool { + self.should_show(VerbosityLevel::Normal) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn verbosity_filter_at_quiet_level_should_only_show_required_quiet() { + let filter = VerbosityFilter::new(VerbosityLevel::Quiet); + assert!(filter.should_show(VerbosityLevel::Quiet)); + assert!(!filter.should_show(VerbosityLevel::Normal)); + assert!(!filter.should_show(VerbosityLevel::Verbose)); + } + + #[test] + fn verbosity_filter_at_normal_level_should_show_quiet_and_normal() { + let filter = VerbosityFilter::new(VerbosityLevel::Normal); + assert!(filter.should_show(VerbosityLevel::Quiet)); + assert!(filter.should_show(VerbosityLevel::Normal)); + assert!(!filter.should_show(VerbosityLevel::Verbose)); + } + + #[test] + fn verbosity_filter_at_verbose_level_should_show_all_up_to_verbose() { + let filter = VerbosityFilter::new(VerbosityLevel::Verbose); + assert!(filter.should_show(VerbosityLevel::Quiet)); + assert!(filter.should_show(VerbosityLevel::Normal)); + assert!(filter.should_show(VerbosityLevel::Verbose)); + assert!(!filter.should_show(VerbosityLevel::VeryVerbose)); + } + + #[test] + fn verbosity_filter_at_debug_level_should_show_all_messages() { + let filter = VerbosityFilter::new(VerbosityLevel::Debug); + assert!(filter.should_show(VerbosityLevel::Quiet)); + assert!(filter.should_show(VerbosityLevel::Normal)); + assert!(filter.should_show(VerbosityLevel::Verbose)); + assert!(filter.should_show(VerbosityLevel::VeryVerbose)); + assert!(filter.should_show(VerbosityLevel::Debug)); + } + + #[test] + fn verbosity_levels_should_be_ordered() { + assert!(VerbosityLevel::Quiet < VerbosityLevel::Normal); + assert!(VerbosityLevel::Normal < VerbosityLevel::Verbose); + assert!(VerbosityLevel::Verbose < VerbosityLevel::VeryVerbose); + assert!(VerbosityLevel::VeryVerbose < VerbosityLevel::Debug); + } + + #[test] + fn default_verbosity_level_should_be_normal() { + let level = VerbosityLevel::default(); + assert_eq!(level, VerbosityLevel::Normal); + } + + #[test] + fn verbosity_filter_should_show_errors_at_any_level() { + let quiet_filter = VerbosityFilter::new(VerbosityLevel::Quiet); + let normal_filter = VerbosityFilter::new(VerbosityLevel::Normal); + assert!(quiet_filter.should_show_errors()); + assert!(normal_filter.should_show_errors()); + } + + #[test] + fn verbosity_filter_should_show_progress_only_at_normal_or_higher() { + let quiet_filter = VerbosityFilter::new(VerbosityLevel::Quiet); + let normal_filter = VerbosityFilter::new(VerbosityLevel::Normal); + assert!(!quiet_filter.should_show_progress()); + assert!(normal_filter.should_show_progress()); + } + + #[test] + fn verbosity_filter_should_show_blank_lines_only_at_normal_or_higher() { + let quiet_filter = VerbosityFilter::new(VerbosityLevel::Quiet); + let normal_filter = VerbosityFilter::new(VerbosityLevel::Normal); + assert!(!quiet_filter.should_show_blank_lines()); + assert!(normal_filter.should_show_blank_lines()); + } +} From 93e8f271cb37ee38c62577506f253d6d1d1f9d87 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 5 Nov 2025 22:58:59 +0000 Subject: [PATCH 3/4] style: apply rustfmt to user_output module Fixed import ordering and formatting to comply with rustfmt Co-authored-by: josecelano <58816+josecelano@users.noreply.github.com> --- src/presentation/user_output/core.rs | 12 ++++++------ src/presentation/user_output/sinks/mod.rs | 3 --- 2 files changed, 6 insertions(+), 9 deletions(-) diff --git a/src/presentation/user_output/core.rs b/src/presentation/user_output/core.rs index 9303af84..2d752d84 100644 --- a/src/presentation/user_output/core.rs +++ b/src/presentation/user_output/core.rs @@ -2,12 +2,10 @@ use std::io::Write; -use super::{ - Channel, FormatterOverride, OutputMessage, OutputSink, Theme, VerbosityLevel, -}; +use super::messages::*; use super::sinks::StandardSink; use super::verbosity::VerbosityFilter; -use super::messages::*; +use super::{Channel, FormatterOverride, OutputMessage, OutputSink, Theme, VerbosityLevel}; pub struct UserOutput { theme: Theme, @@ -492,12 +490,14 @@ impl UserOutput { #[cfg(test)] mod tests { use super::*; - + // These imports are used by nested test modules #[allow(unused_imports)] use crate::presentation::user_output::formatters::JsonFormatter; #[allow(unused_imports)] - use crate::presentation::user_output::sinks::{CompositeSink, FileSink, StderrWriter, StdoutWriter, TelemetrySink}; + use crate::presentation::user_output::sinks::writers::{StderrWriter, StdoutWriter}; + #[allow(unused_imports)] + use crate::presentation::user_output::sinks::{CompositeSink, FileSink, TelemetrySink}; #[allow(unused_imports)] use crate::presentation::user_output::test_support::{self, TestUserOutput, TestWriter}; diff --git a/src/presentation/user_output/sinks/mod.rs b/src/presentation/user_output/sinks/mod.rs index c21c41eb..676bfa7b 100644 --- a/src/presentation/user_output/sinks/mod.rs +++ b/src/presentation/user_output/sinks/mod.rs @@ -8,9 +8,6 @@ pub use file::FileSink; pub use standard::StandardSink; pub use telemetry::TelemetrySink; -// Re-export writers for use within user_output module (including tests) -pub(in crate::presentation::user_output) use writers::{StderrWriter, StdoutWriter}; - mod composite; mod file; mod standard; From 3df34053eddd01da7802bc001f0c655482a6b827 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 6 Nov 2025 07:28:25 +0000 Subject: [PATCH 4/4] fix: resolve clippy linting warnings in user_output module - Added backticks around UserOutput in doc comment - Replaced wildcard imports with explicit imports in core.rs - Replaced wildcard imports with explicit imports in test_support.rs - Added #[must_use] attribute to endpoint() method in telemetry.rs All clippy pedantic checks now pass. Co-authored-by: josecelano <58816+josecelano@users.noreply.github.com> --- src/presentation/user_output/core.rs | 7 +++++-- src/presentation/user_output/sinks/telemetry.rs | 1 + src/presentation/user_output/test_support.rs | 2 +- 3 files changed, 7 insertions(+), 3 deletions(-) diff --git a/src/presentation/user_output/core.rs b/src/presentation/user_output/core.rs index 2d752d84..ddc5bbf4 100644 --- a/src/presentation/user_output/core.rs +++ b/src/presentation/user_output/core.rs @@ -1,8 +1,11 @@ -//! Core UserOutput struct and implementation +//! Core `UserOutput` struct and implementation use std::io::Write; -use super::messages::*; +use super::messages::{ + ErrorMessage, InfoBlockMessage, ProgressMessage, ResultMessage, StepsMessage, SuccessMessage, + WarningMessage, +}; use super::sinks::StandardSink; use super::verbosity::VerbosityFilter; use super::{Channel, FormatterOverride, OutputMessage, OutputSink, Theme, VerbosityLevel}; diff --git a/src/presentation/user_output/sinks/telemetry.rs b/src/presentation/user_output/sinks/telemetry.rs index 1bb7237d..1d7fb7ae 100644 --- a/src/presentation/user_output/sinks/telemetry.rs +++ b/src/presentation/user_output/sinks/telemetry.rs @@ -23,6 +23,7 @@ impl TelemetrySink { /// Get the endpoint URL #[cfg(test)] + #[must_use] pub fn endpoint(&self) -> &str { &self.endpoint } diff --git a/src/presentation/user_output/test_support.rs b/src/presentation/user_output/test_support.rs index c68fa560..5d28204c 100644 --- a/src/presentation/user_output/test_support.rs +++ b/src/presentation/user_output/test_support.rs @@ -6,7 +6,7 @@ use std::io::Write; use std::sync::{Arc, Mutex}; -use super::*; +use super::{Theme, UserOutput, VerbosityLevel}; /// Writer implementation for tests that writes to a shared buffer ///