Skip to main content

torrust_tracker_deployer_lib/presentation/cli/views/
user_output.rs

1//! `UserOutput` struct and implementation
2//!
3//! This module provides the main `UserOutput` struct which handles user-facing output
4//! formatting and routing. It implements a sink-based architecture with support for
5//! multiple output destinations, themes, verbosity levels, and custom formatters.
6//!
7//! The `UserOutput` struct is the primary interface for displaying messages to users,
8//! following Unix conventions with dual-channel output (stdout for results, stderr
9//! for progress and status messages).
10
11// Standard library imports
12use std::io::Write;
13
14// Internal crate imports
15use super::messages::{
16    BlankLineMessage, DebugDetailMessage, DetailMessage, ErrorMessage, InfoBlockMessage,
17    ProgressMessage, ResultMessage, StepProgressMessage, StepsMessage, SuccessMessage,
18    WarningMessage,
19};
20use super::sinks::StandardSink;
21use super::verbosity::VerbosityFilter;
22use super::{FormatterOverride, OutputMessage, OutputSink, Theme, VerbosityLevel};
23
24/// User-facing output handler with sink-based architecture
25///
26/// `UserOutput` provides a clean interface for displaying messages to users with support for:
27/// - Multiple output sinks (console, file, telemetry, etc.)
28/// - Verbosity levels (quiet, normal, verbose, debug)  
29/// - Customizable themes (emoji, plain text, ASCII)
30/// - Optional formatter overrides (JSON, colored output)
31/// - Dual-channel routing (stdout for results, stderr for progress)
32///
33/// # Examples
34///
35/// Basic usage:
36/// ```rust
37/// use torrust_tracker_deployer_lib::presentation::cli::views::{UserOutput, VerbosityLevel};
38///
39/// let mut output = UserOutput::new(VerbosityLevel::Normal);
40/// output.progress("Starting operation...");
41/// output.success("Operation completed successfully");
42/// output.result(r#"{"status": "completed"}"#);
43/// ```
44///
45/// With custom theme:
46/// ```rust
47/// use torrust_tracker_deployer_lib::presentation::cli::views::{UserOutput, VerbosityLevel, Theme};
48///
49/// let mut output = UserOutput::with_theme(VerbosityLevel::Normal, Theme::plain());
50/// output.progress("Processing...");
51/// ```
52pub struct UserOutput {
53    theme: Theme,
54    verbosity_filter: VerbosityFilter,
55    sink: Box<dyn OutputSink>,
56    formatter_override: Option<Box<dyn FormatterOverride>>,
57}
58
59impl UserOutput {
60    /// Create new `UserOutput` with default stdout/stderr channels and emoji theme
61    ///
62    /// Uses `StandardSink` for backward compatibility with existing console output.
63    ///
64    /// # Examples
65    ///
66    /// ```rust
67    /// use torrust_tracker_deployer_lib::presentation::cli::views::{UserOutput, VerbosityLevel};
68    ///
69    /// let output = UserOutput::new(VerbosityLevel::Normal);
70    /// ```
71    #[must_use]
72    pub fn new(verbosity: VerbosityLevel) -> Self {
73        Self::with_theme(verbosity, Theme::default())
74    }
75
76    /// Create `UserOutput` with a specific theme
77    ///
78    /// Allows customization of output symbols while using default stdout/stderr channels.
79    /// Uses `StandardSink` internally for backward compatibility.
80    ///
81    /// # Examples
82    ///
83    /// ```rust
84    /// use torrust_tracker_deployer_lib::presentation::cli::views::{UserOutput, VerbosityLevel, Theme};
85    ///
86    /// // Use plain text theme for CI/CD
87    /// let output = UserOutput::with_theme(VerbosityLevel::Normal, Theme::plain());
88    ///
89    /// // Use ASCII theme for limited terminals
90    /// let output = UserOutput::with_theme(VerbosityLevel::Normal, Theme::ascii());
91    /// ```
92    #[must_use]
93    pub fn with_theme(verbosity: VerbosityLevel, theme: Theme) -> Self {
94        Self::with_sink(verbosity, Box::new(StandardSink::default_console()))
95            .with_theme_applied(theme)
96    }
97
98    /// Create `UserOutput` with theme and custom writers (for testing)
99    ///
100    /// This constructor allows full customization including theme and writers,
101    /// primarily used for testing where output needs to be captured.
102    ///
103    /// # Examples
104    ///
105    /// ```rust
106    /// use torrust_tracker_deployer_lib::presentation::cli::views::{UserOutput, VerbosityLevel, Theme};
107    /// use std::io::Cursor;
108    ///
109    /// let stdout_buf = Vec::new();
110    /// let stderr_buf = Vec::new();
111    ///
112    /// let output = UserOutput::with_theme_and_writers(
113    ///     VerbosityLevel::Normal,
114    ///     Theme::plain(),
115    ///     Box::new(Cursor::new(stdout_buf)),
116    ///     Box::new(Cursor::new(stderr_buf)),
117    /// );
118    /// ```
119    #[must_use]
120    pub fn with_theme_and_writers(
121        verbosity: VerbosityLevel,
122        theme: Theme,
123        stdout_writer: Box<dyn Write + Send + Sync>,
124        stderr_writer: Box<dyn Write + Send + Sync>,
125    ) -> Self {
126        Self {
127            theme,
128            verbosity_filter: VerbosityFilter::new(verbosity),
129            sink: Box::new(StandardSink::new(stdout_writer, stderr_writer)),
130            formatter_override: None,
131        }
132    }
133
134    /// Display progress message to stderr (Normal level and above)
135    ///
136    /// Progress messages go to stderr following cargo/docker patterns.
137    /// This keeps stdout clean for result data that may be piped.
138    ///
139    /// # Examples
140    ///
141    /// ```rust
142    /// use torrust_tracker_deployer_lib::presentation::cli::views::{UserOutput, VerbosityLevel};
143    ///
144    /// let mut output = UserOutput::new(VerbosityLevel::Normal);
145    /// output.progress("Destroying environment...");
146    /// // Output to stderr: ⏳ Destroying environment...
147    /// ```
148    pub fn progress(&mut self, message: &str) {
149        self.write(&ProgressMessage {
150            text: message.to_string(),
151        });
152    }
153
154    /// Display success message to stderr (Normal level and above)
155    ///
156    /// Success status goes to stderr to allow clean result piping.
157    ///
158    /// # Examples
159    ///
160    /// ```rust
161    /// use torrust_tracker_deployer_lib::presentation::cli::views::{UserOutput, VerbosityLevel};
162    ///
163    /// let mut output = UserOutput::new(VerbosityLevel::Normal);
164    /// output.success("Environment destroyed successfully");
165    /// // Output to stderr: ✅ Environment destroyed successfully
166    /// ```
167    pub fn success(&mut self, message: &str) {
168        self.write(&SuccessMessage {
169            text: message.to_string(),
170        });
171    }
172
173    /// Display warning message to stderr (Normal level and above)
174    ///
175    /// # Examples
176    ///
177    /// ```rust
178    /// use torrust_tracker_deployer_lib::presentation::cli::views::{UserOutput, VerbosityLevel};
179    ///
180    /// let mut output = UserOutput::new(VerbosityLevel::Normal);
181    /// output.warn("Infrastructure may already be destroyed");
182    /// // Output to stderr: ⚠️  Infrastructure may already be destroyed
183    /// ```
184    pub fn warn(&mut self, message: &str) {
185        self.write(&WarningMessage {
186            text: message.to_string(),
187        });
188    }
189
190    /// Display error message to stderr (all levels)
191    ///
192    /// Errors are always shown regardless of verbosity level.
193    ///
194    /// # Examples
195    ///
196    /// ```rust
197    /// use torrust_tracker_deployer_lib::presentation::cli::views::{UserOutput, VerbosityLevel};
198    ///
199    /// let mut output = UserOutput::new(VerbosityLevel::Quiet);
200    /// output.error("Failed to destroy environment");
201    /// // Output to stderr: ❌ Failed to destroy environment
202    /// ```
203    pub fn error(&mut self, message: &str) {
204        self.write(&ErrorMessage {
205            text: message.to_string(),
206        });
207    }
208
209    /// Display a step progress message to stderr (Verbose level and above)
210    ///
211    /// Step progress messages mark workflow step boundaries during command
212    /// execution. They are shown when the user requests verbose output (`-v`).
213    ///
214    /// # Examples
215    ///
216    /// ```rust
217    /// use torrust_tracker_deployer_lib::presentation::cli::views::{UserOutput, VerbosityLevel};
218    ///
219    /// let mut output = UserOutput::new(VerbosityLevel::Verbose);
220    /// output.step_progress("  [Step 1/9] Rendering OpenTofu templates...");
221    /// // Output to stderr: 📋   [Step 1/9] Rendering OpenTofu templates...
222    /// ```
223    pub fn step_progress(&mut self, message: &str) {
224        self.write(&StepProgressMessage {
225            text: message.to_string(),
226        });
227    }
228
229    /// Display a detail message to stderr (`VeryVerbose` level and above)
230    ///
231    /// Detail messages provide contextual information within steps during command
232    /// execution. They are shown when the user requests very verbose output (`-vv`).
233    ///
234    /// # Examples
235    ///
236    /// ```rust
237    /// use torrust_tracker_deployer_lib::presentation::cli::views::{UserOutput, VerbosityLevel};
238    ///
239    /// let mut output = UserOutput::new(VerbosityLevel::VeryVerbose);
240    /// output.detail("     → Instance IP: 10.140.190.235");
241    /// // Output to stderr: 📋      → Instance IP: 10.140.190.235
242    /// ```
243    pub fn detail(&mut self, message: &str) {
244        self.write(&DetailMessage {
245            text: message.to_string(),
246        });
247    }
248
249    /// Display a debug detail message to stderr (Debug level and above)
250    ///
251    /// Debug detail messages provide technical implementation details during command
252    /// execution. They are shown when the user requests maximum verbosity (`-vvv`).
253    ///
254    /// # Examples
255    ///
256    /// ```rust
257    /// use torrust_tracker_deployer_lib::presentation::cli::views::{UserOutput, VerbosityLevel};
258    ///
259    /// let mut output = UserOutput::new(VerbosityLevel::Debug);
260    /// output.debug_detail("     → Command: tofu init");
261    /// // Output to stderr: 🔍      → Command: tofu init
262    /// ```
263    pub fn debug_detail(&mut self, message: &str) {
264        self.write(&DebugDetailMessage {
265            text: message.to_string(),
266        });
267    }
268
269    /// Output final results to stdout for piping/redirection
270    ///
271    /// This is where deployment results, configuration summaries, etc. go.
272    /// Since this goes to stdout, it can be cleanly piped to other commands.
273    ///
274    /// # Examples
275    ///
276    /// ```rust
277    /// use torrust_tracker_deployer_lib::presentation::cli::views::{UserOutput, VerbosityLevel};
278    ///
279    /// let mut output = UserOutput::new(VerbosityLevel::Normal);
280    /// output.result("Deployment complete");
281    /// // Output to stdout: Deployment complete
282    /// ```
283    pub fn result(&mut self, message: &str) {
284        self.write(&ResultMessage {
285            text: message.to_string(),
286        });
287    }
288
289    /// Output structured data to stdout (JSON, etc.)
290    ///
291    /// For machine-readable output that should be piped or processed.
292    /// This is equivalent to `result()` but exists for semantic clarity.
293    ///
294    /// # Examples
295    ///
296    /// ```rust
297    /// use torrust_tracker_deployer_lib::presentation::cli::views::{UserOutput, VerbosityLevel};
298    ///
299    /// let mut output = UserOutput::new(VerbosityLevel::Normal);
300    /// output.data(r#"{"status": "destroyed", "environment": "test"}"#);
301    /// // Output to stdout: {"status": "destroyed", "environment": "test"}
302    /// ```
303    pub fn data(&mut self, data: &str) {
304        self.result(data);
305    }
306
307    /// Display a blank line to stderr (Normal level and above)
308    ///
309    /// Used for spacing between sections of output to improve readability.
310    ///
311    /// # Examples
312    ///
313    /// ```rust
314    /// use torrust_tracker_deployer_lib::presentation::cli::views::{UserOutput, VerbosityLevel};
315    ///
316    /// let mut output = UserOutput::new(VerbosityLevel::Normal);
317    /// output.success("Configuration template generated");
318    /// output.blank_line();
319    /// output.progress("Starting next steps...");
320    /// ```
321    pub fn blank_line(&mut self) {
322        self.write(&BlankLineMessage);
323    }
324
325    /// Display a numbered list of steps to stderr (Normal level and above)
326    ///
327    /// Useful for displaying sequential instructions or action items.
328    ///
329    /// # Examples
330    ///
331    /// ```rust
332    /// use torrust_tracker_deployer_lib::presentation::cli::views::{UserOutput, VerbosityLevel};
333    ///
334    /// let mut output = UserOutput::new(VerbosityLevel::Normal);
335    /// output.steps("Next steps:", &[
336    ///     "Edit the configuration file",
337    ///     "Review the settings",
338    ///     "Run the deploy command",
339    /// ]);
340    /// // Output to stderr:
341    /// // Next steps:
342    /// // 1. Edit the configuration file
343    /// // 2. Review the settings
344    /// // 3. Run the deploy command
345    /// ```
346    pub fn steps(&mut self, title: &str, steps: &[&str]) {
347        self.write(&StepsMessage {
348            title: title.to_string(),
349            items: steps.iter().map(|s| (*s).to_string()).collect(),
350        });
351    }
352
353    /// Display a multi-line information block to stderr (Normal level and above)
354    ///
355    /// Useful for displaying grouped information or detailed messages.
356    ///
357    /// # Examples
358    ///
359    /// ```rust
360    /// use torrust_tracker_deployer_lib::presentation::cli::views::{UserOutput, VerbosityLevel};
361    ///
362    /// let mut output = UserOutput::new(VerbosityLevel::Normal);
363    /// output.info_block("Configuration options:", &[
364    ///     "  - username: 'torrust' (default)",
365    ///     "  - port: 22 (default SSH port)",
366    ///     "  - key_path: path/to/key",
367    /// ]);
368    /// // Output to stderr:
369    /// // Configuration options:
370    /// //   - username: 'torrust' (default)
371    /// //   - port: 22 (default SSH port)
372    /// //   - key_path: path/to/key
373    /// ```
374    pub fn info_block(&mut self, title: &str, lines: &[&str]) {
375        self.write(&InfoBlockMessage {
376            title: title.to_string(),
377            lines: lines.iter().map(|s| (*s).to_string()).collect(),
378        });
379    }
380
381    /// Create `UserOutput` with a custom sink
382    ///
383    /// This constructor enables the use of alternative output destinations,
384    /// including composite sinks for multi-destination output.
385    ///
386    /// # Examples
387    ///
388    /// ```rust,ignore
389    /// use torrust_tracker_deployer_lib::presentation::cli::views::{
390    ///     UserOutput, VerbosityLevel, CompositeSink, StandardSink, FileSink
391    /// };
392    ///
393    /// // Console + File output
394    /// let composite = CompositeSink::new(vec![
395    ///     Box::new(StandardSink::default_console()),
396    ///     Box::new(FileSink::new("output.log").unwrap()),
397    /// ]);
398    /// let output = UserOutput::with_sink(VerbosityLevel::Normal, Box::new(composite));
399    /// ```
400    #[must_use]
401    fn with_sink(verbosity: VerbosityLevel, sink: Box<dyn OutputSink>) -> Self {
402        Self {
403            theme: Theme::default(),
404            verbosity_filter: VerbosityFilter::new(verbosity),
405            sink,
406            formatter_override: None,
407        }
408    }
409
410    /// Internal helper to apply theme to an existing `UserOutput`
411    fn with_theme_applied(mut self, theme: Theme) -> Self {
412        self.theme = theme;
413        self
414    }
415
416    /// Write a message to the appropriate channel using trait dispatch
417    ///
418    /// This is the core method for extensible message handling. It uses the
419    /// `OutputMessage` trait to determine formatting, verbosity requirements,
420    /// and channel routing. Messages are routed through the configured sink,
421    /// enabling multi-destination output.
422    ///
423    /// # Examples
424    ///
425    /// ```rust,ignore
426    /// use torrust_tracker_deployer_lib::presentation::cli::views::{UserOutput, VerbosityLevel, ProgressMessage};
427    ///
428    /// let mut output = UserOutput::new(VerbosityLevel::Normal);
429    /// output.write(&ProgressMessage {
430    ///     text: "Processing...".to_string(),
431    /// });
432    /// ```
433    fn write(&mut self, message: &dyn OutputMessage) {
434        if !self
435            .verbosity_filter
436            .should_show(message.required_verbosity())
437        {
438            return;
439        }
440
441        let mut formatted = message.format(&self.theme);
442
443        // Apply optional format override
444        if let Some(override_formatter) = &self.formatter_override {
445            formatted = override_formatter.transform(&formatted, message);
446        }
447
448        // Write through sink
449        self.sink.write_message(message, &formatted);
450    }
451}
452
453#[cfg(test)]
454mod tests {
455    use super::*;
456
457    mod verbosity {
458        use super::*;
459        use crate::presentation::cli::views::testing::TestUserOutput;
460        use crate::presentation::cli::views::Channel;
461
462        /// Test message that requires Verbose level
463        struct TestVerboseMessage {
464            text: String,
465        }
466
467        impl OutputMessage for TestVerboseMessage {
468            fn format(&self, _theme: &Theme) -> String {
469                format!("TEST: {}\n", self.text)
470            }
471
472            fn required_verbosity(&self) -> VerbosityLevel {
473                VerbosityLevel::Verbose
474            }
475
476            fn channel(&self) -> Channel {
477                Channel::Stderr
478            }
479
480            fn type_name(&self) -> &'static str {
481                "TestVerboseMessage"
482            }
483        }
484
485        #[test]
486        fn it_should_ignore_message_when_verbosity_level_is_below_required() {
487            // Create UserOutput with Normal verbosity
488            let mut test_output = TestUserOutput::new(VerbosityLevel::Normal);
489
490            // Create a message that requires Verbose level (higher than Normal)
491            let message = TestVerboseMessage {
492                text: "This should not appear".to_string(),
493            };
494
495            // Try to write the message - should be ignored due to insufficient verbosity
496            test_output.output.write(&message);
497
498            // Both stdout and stderr should be empty since message was filtered out
499            assert_eq!(test_output.stdout(), "");
500            assert_eq!(test_output.stderr(), "");
501        }
502    }
503
504    mod formatter {
505        use super::*;
506        use crate::presentation::cli::views::formatters::JsonFormatter;
507        use crate::presentation::cli::views::testing::TestWriter;
508        use crate::presentation::cli::views::Channel;
509        use parking_lot::Mutex;
510        use std::sync::Arc;
511
512        /// Test message with Normal verbosity for formatter testing
513        struct TestNormalMessage {
514            text: String,
515        }
516
517        impl OutputMessage for TestNormalMessage {
518            fn format(&self, _theme: &Theme) -> String {
519                format!("MSG: {}\n", self.text)
520            }
521
522            fn required_verbosity(&self) -> VerbosityLevel {
523                VerbosityLevel::Normal
524            }
525
526            fn channel(&self) -> Channel {
527                Channel::Stderr
528            }
529
530            fn type_name(&self) -> &'static str {
531                "TestNormalMessage"
532            }
533        }
534
535        #[test]
536        fn it_should_apply_formatter_override_to_transform_message() {
537            // Create buffers for capturing output
538            let stderr_buffer = Arc::new(Mutex::new(Vec::new()));
539            let stdout_buffer = Arc::new(Mutex::new(Vec::new()));
540
541            // Create UserOutput with JsonFormatter
542            let mut output = UserOutput {
543                theme: Theme::default(),
544                verbosity_filter: VerbosityFilter::new(VerbosityLevel::Normal),
545                sink: Box::new(StandardSink::new(
546                    Box::new(TestWriter::new(Arc::clone(&stdout_buffer))),
547                    Box::new(TestWriter::new(Arc::clone(&stderr_buffer))),
548                )),
549                formatter_override: Some(Box::new(JsonFormatter)),
550            };
551
552            // Create and write a test message
553            let message = TestNormalMessage {
554                text: "test message".to_string(),
555            };
556            output.write(&message);
557
558            // Verify the formatter transformed the output to JSON format
559            let stderr_output = String::from_utf8(stderr_buffer.lock().clone()).unwrap();
560
561            // Parse JSON to verify structure (timestamp is dynamic, so we check fields exist)
562            let json: serde_json::Value = serde_json::from_str(&stderr_output).unwrap();
563            assert_eq!(json["type"], "TestNormalMessage");
564            assert_eq!(json["channel"], "Stderr");
565            assert_eq!(json["content"], "MSG: test message");
566            assert!(json["timestamp"].is_string());
567
568            // Stdout should be empty (message goes to stderr)
569            let stdout_output = String::from_utf8(stdout_buffer.lock().clone()).unwrap();
570            assert_eq!(stdout_output, "");
571        }
572    }
573
574    mod theme {
575        use super::*;
576        use crate::presentation::cli::views::testing::TestUserOutput;
577        use crate::presentation::cli::views::Channel;
578        use rstest::rstest;
579
580        /// Test message that uses theme symbols in formatting
581        struct TestThemedMessage {
582            text: String,
583        }
584
585        impl OutputMessage for TestThemedMessage {
586            fn format(&self, theme: &Theme) -> String {
587                format!("{} {}\n", theme.success_symbol(), self.text)
588            }
589
590            fn required_verbosity(&self) -> VerbosityLevel {
591                VerbosityLevel::Normal
592            }
593
594            fn channel(&self) -> Channel {
595                Channel::Stderr
596            }
597
598            fn type_name(&self) -> &'static str {
599                "TestThemedMessage"
600            }
601        }
602
603        #[rstest]
604        #[case(Theme::emoji(), "✅ Operation completed\n")]
605        #[case(Theme::plain(), "[OK] Operation completed\n")]
606        #[case(Theme::ascii(), "[+] Operation completed\n")]
607        fn it_should_format_message_differently_with_different_themes(
608            #[case] theme: Theme,
609            #[case] expected_output: &str,
610        ) {
611            let mut test_output = TestUserOutput::with_theme(VerbosityLevel::Normal, theme);
612
613            let message = TestThemedMessage {
614                text: "Operation completed".to_string(),
615            };
616
617            test_output.output.write(&message);
618
619            assert_eq!(test_output.stderr(), expected_output);
620        }
621    }
622
623    mod sink {
624        use super::*;
625        use crate::presentation::cli::views::testing::TestWriter;
626        use crate::presentation::cli::views::Channel;
627        use parking_lot::Mutex;
628        use std::sync::Arc;
629
630        /// Test message for sink redirection testing
631        struct TestSinkMessage {
632            text: String,
633        }
634
635        impl OutputMessage for TestSinkMessage {
636            fn format(&self, _theme: &Theme) -> String {
637                format!("SINK_TEST: {}\n", self.text)
638            }
639
640            fn required_verbosity(&self) -> VerbosityLevel {
641                VerbosityLevel::Normal
642            }
643
644            fn channel(&self) -> Channel {
645                Channel::Stderr
646            }
647
648            fn type_name(&self) -> &'static str {
649                "TestSinkMessage"
650            }
651        }
652
653        #[test]
654        fn it_should_write_output_to_custom_sink() {
655            // Create custom buffers to capture output
656            let stderr_buffer = Arc::new(Mutex::new(Vec::new()));
657            let stdout_buffer = Arc::new(Mutex::new(Vec::new()));
658
659            // Create UserOutput with custom sink using TestWriter
660            let mut output = UserOutput {
661                theme: Theme::default(),
662                verbosity_filter: VerbosityFilter::new(VerbosityLevel::Normal),
663                sink: Box::new(StandardSink::new(
664                    Box::new(TestWriter::new(Arc::clone(&stdout_buffer))),
665                    Box::new(TestWriter::new(Arc::clone(&stderr_buffer))),
666                )),
667                formatter_override: None,
668            };
669
670            // Write a message
671            let message = TestSinkMessage {
672                text: "custom sink output".to_string(),
673            };
674            output.write(&message);
675
676            // Verify output was captured in custom sink (stderr buffer)
677            let stderr_output = String::from_utf8(stderr_buffer.lock().clone()).unwrap();
678            assert_eq!(stderr_output, "SINK_TEST: custom sink output\n");
679
680            // Stdout should be empty (message goes to stderr)
681            let stdout_output = String::from_utf8(stdout_buffer.lock().clone()).unwrap();
682            assert_eq!(stdout_output, "");
683        }
684    }
685
686    mod channel_routing {
687        use super::*;
688        use crate::presentation::cli::views::testing::TestUserOutput;
689        use crate::presentation::cli::views::Channel;
690        use rstest::rstest;
691
692        /// Test message that can be configured to go to either channel
693        struct TestChannelMessage {
694            text: String,
695            target_channel: Channel,
696        }
697
698        impl OutputMessage for TestChannelMessage {
699            fn format(&self, _theme: &Theme) -> String {
700                format!("CHANNEL: {}\n", self.text)
701            }
702
703            fn required_verbosity(&self) -> VerbosityLevel {
704                VerbosityLevel::Normal
705            }
706
707            fn channel(&self) -> Channel {
708                self.target_channel
709            }
710
711            fn type_name(&self) -> &'static str {
712                "TestChannelMessage"
713            }
714        }
715
716        #[rstest]
717        #[case(Channel::Stdout, "CHANNEL: stdout message\n", "")]
718        #[case(Channel::Stderr, "", "CHANNEL: stderr message\n")]
719        fn it_should_route_message_to_correct_channel(
720            #[case] channel: Channel,
721            #[case] expected_stdout: &str,
722            #[case] expected_stderr: &str,
723        ) {
724            let mut test_output = TestUserOutput::new(VerbosityLevel::Normal);
725
726            let message_text = match channel {
727                Channel::Stdout => "stdout message",
728                Channel::Stderr => "stderr message",
729            };
730
731            let message = TestChannelMessage {
732                text: message_text.to_string(),
733                target_channel: channel,
734            };
735
736            test_output.output.write(&message);
737
738            assert_eq!(test_output.stdout(), expected_stdout);
739            assert_eq!(test_output.stderr(), expected_stderr);
740        }
741    }
742}