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}