Skip to main content

jt_consoleutils/
output.rs

1//! The [`Output`](crate::output::Output) trait and its standard implementations.
2//!
3//! # Overview
4//!
5//! - [`OutputMode`](crate::output::OutputMode) — a plain `Copy` struct that carries the three
6//!   common CLI flags (`verbose`, `quiet`, `dry_run`).
7//! - [`Output`](crate::output::Output) — the core trait; implement it to redirect output anywhere.
8//! - [`ConsoleOutput`](crate::output::ConsoleOutput) — the production implementation; respects
9//!   `quiet` / `verbose` and writes to stdout.
10//! - [`StringOutput`](crate::output::StringOutput) — an in-memory implementation for use in tests;
11//!   captures all output in a `String` that can be inspected with
12//!   [`StringOutput::log`](crate::output::StringOutput::log).
13
14// ---------------------------------------------------------------------------
15// OutputMode
16// ---------------------------------------------------------------------------
17
18/// Carries the three standard CLI output-mode flags.
19///
20/// Construct with struct literal syntax or [`Default::default`] (all flags
21/// `false`):
22///
23/// ```rust
24/// use jt_consoleutils::output::OutputMode;
25///
26/// let mode = OutputMode { verbose: true, ..OutputMode::default() };
27/// assert!(mode.is_verbose());
28/// assert!(!mode.is_quiet());
29/// ```
30#[derive(Debug, Clone, Copy, Default)]
31pub struct OutputMode {
32   /// Enable verbose output: commands and their output are echoed.
33   pub verbose: bool,
34   /// Suppress all output, including normal progress messages.
35   pub quiet: bool,
36   /// Dry-run mode: announce operations without executing them.
37   pub dry_run: bool
38}
39
40impl OutputMode {
41   /// Returns `true` when verbose output is enabled.
42   pub fn is_verbose(self) -> bool {
43      self.verbose
44   }
45
46   /// Returns `true` when quiet mode is active (all output suppressed).
47   pub fn is_quiet(self) -> bool {
48      self.quiet
49   }
50
51   /// Returns `true` when dry-run mode is active.
52   pub fn is_dry_run(self) -> bool {
53      self.dry_run
54   }
55}
56
57// ---------------------------------------------------------------------------
58// Output trait
59// ---------------------------------------------------------------------------
60
61/// Abstraction over console output, enabling tests to capture output in memory.
62///
63/// The three standard implementations are:
64/// - [`ConsoleOutput`] — writes to stdout, respecting `quiet` / `verbose`.
65/// - [`StringOutput`] — captures everything in a `String` for assertions.
66/// - `MockShell`'s internal output — not part of this trait, but follows the same pattern.
67///
68/// Implement this trait to redirect output to a logger, a file, or anywhere else.
69pub trait Output {
70   /// Write `line` followed by a newline. Suppressed in quiet mode.
71   fn writeln(&mut self, line: &str);
72
73   /// Write `text` without a trailing newline. Suppressed in quiet mode.
74   fn write(&mut self, text: &str);
75
76   /// Emit a lazily-evaluated message, only in verbose mode.
77   ///
78   /// The closure is only called when the implementation decides to display it,
79   /// avoiding string allocation cost in non-verbose builds.
80   fn verbose(&mut self, f: Box<dyn FnOnce() -> String>);
81
82   /// Echo a shell command about to be run (verbose mode only).
83   fn shell_command(&mut self, cmd: &str);
84
85   /// Echo a single line of output from a running shell command.
86   fn shell_line(&mut self, line: &str);
87
88   /// Render the result of a completed step: a tick/cross, label, elapsed time,
89   /// and (on failure) the last few lines of output from the `viewport`.
90   fn step_result(&mut self, label: &str, success: bool, elapsed_ms: u128, viewport: &[String]);
91
92   /// Dry-run: announce a shell command that would be executed.
93   fn dry_run_shell(&mut self, _cmd: &str) {}
94
95   /// Dry-run: announce a file that would be written.
96   fn dry_run_write(&mut self, _path: &str) {}
97
98   /// Dry-run: announce a file or directory that would be deleted.
99   fn dry_run_delete(&mut self, _path: &str) {}
100
101   /// Log a message in verbose mode without any extra ceremony.
102   fn log(&mut self, mode: OutputMode, msg: &str) {
103      if mode.is_verbose() {
104         let owned = msg.to_owned();
105         self.verbose(Box::new(move || owned));
106      }
107   }
108
109   /// Log a command about to be executed (verbose mode).
110   fn log_exec(&mut self, mode: OutputMode, cmd: &std::process::Command) {
111      if mode.is_verbose() {
112         let program = cmd.get_program().to_string_lossy().into_owned();
113         let args: Vec<_> = cmd.get_args().map(|a| a.to_string_lossy().into_owned()).collect();
114         self.verbose(Box::new(move || {
115            if args.is_empty() { format!("Exec: {program}") } else { format!("Exec: {program} {}", args.join(" ")) }
116         }));
117      }
118   }
119}
120
121fn format_elapsed(ms: u128) -> String {
122   if ms < 1000 { format!("{ms}ms") } else { format!("{}s", ms / 1000) }
123}
124
125fn with_prefix(prefix: &str, msg: &str) -> String {
126   msg.lines().map(|l| format!("{prefix}{l}\n")).collect()
127}
128
129// ---------------------------------------------------------------------------
130// ConsoleOutput
131// ---------------------------------------------------------------------------
132
133/// Production [`Output`] implementation that writes to stdout.
134///
135/// Behavior depends on the [`OutputMode`] supplied at construction:
136/// - `quiet`: all methods are silent.
137/// - `verbose`: commands, their arguments, and verbose messages are printed.
138/// - default: normal progress messages are printed; verbose output is hidden.
139pub struct ConsoleOutput {
140   mode: OutputMode
141}
142
143impl ConsoleOutput {
144   /// Create a new `ConsoleOutput` driven by `mode`.
145   pub fn new(mode: OutputMode) -> Self {
146      Self { mode }
147   }
148}
149
150impl Output for ConsoleOutput {
151   fn writeln(&mut self, line: &str) {
152      if !self.mode.is_quiet() {
153         println!("{line}");
154      }
155   }
156
157   fn write(&mut self, text: &str) {
158      if !self.mode.is_quiet() {
159         use std::io::Write;
160         print!("{text}");
161         let _ = std::io::stdout().flush();
162      }
163   }
164
165   fn verbose(&mut self, f: Box<dyn FnOnce() -> String>) {
166      if self.mode.is_verbose() && !self.mode.is_quiet() {
167         print!("{}", with_prefix("| ", &f()));
168      }
169   }
170
171   fn shell_command(&mut self, cmd: &str) {
172      if self.mode.is_verbose() && !self.mode.is_quiet() {
173         println!("> {cmd}");
174      }
175   }
176
177   fn shell_line(&mut self, line: &str) {
178      if !self.mode.is_quiet() {
179         println!("> {line}");
180      }
181   }
182
183   fn step_result(&mut self, label: &str, success: bool, elapsed_ms: u128, viewport: &[String]) {
184      if self.mode.is_quiet() {
185         return;
186      }
187      let t = format_elapsed(elapsed_ms);
188      if success {
189         println!("\x1b[32m✓\x1b[0m {label} \x1b[2m({t})\x1b[0m");
190      } else {
191         println!("\x1b[31m✗\x1b[0m {label} \x1b[2m({t})\x1b[0m");
192         for line in viewport {
193            println!("  \x1b[31m{line}\x1b[0m");
194         }
195      }
196   }
197
198   fn dry_run_shell(&mut self, cmd: &str) {
199      if self.mode.is_dry_run() {
200         println!("[dry-run] would run: {cmd}");
201      }
202   }
203
204   fn dry_run_write(&mut self, path: &str) {
205      if self.mode.is_dry_run() {
206         println!("[dry-run] would write: {path}");
207      }
208   }
209
210   fn dry_run_delete(&mut self, path: &str) {
211      if self.mode.is_dry_run() {
212         println!("[dry-run] would delete: {path}");
213      }
214   }
215}
216
217// ---------------------------------------------------------------------------
218// StringOutput — a test-helper implementation that captures output in memory.
219// Intentionally pub so downstream crates can use it in their own tests.
220// ---------------------------------------------------------------------------
221
222/// In-memory [`Output`] implementation for use in tests.
223///
224/// All output is appended to an internal `String`. Call [`StringOutput::log`]
225/// to retrieve the full captured output and assert on it.
226///
227/// ```rust
228/// use jt_consoleutils::output::{Output, StringOutput};
229///
230/// let mut out = StringOutput::new();
231/// out.writeln("hello");
232/// assert_eq!(out.log(), "hello\n");
233/// ```
234pub struct StringOutput {
235   buf: String
236}
237
238impl StringOutput {
239   /// Create a new, empty `StringOutput`.
240   pub fn new() -> Self {
241      Self { buf: String::new() }
242   }
243
244   /// Return the full captured output as a string slice.
245   pub fn log(&self) -> &str {
246      &self.buf
247   }
248}
249
250impl Default for StringOutput {
251   fn default() -> Self {
252      Self::new()
253   }
254}
255
256impl Output for StringOutput {
257   fn writeln(&mut self, line: &str) {
258      self.buf.push_str(line);
259      self.buf.push('\n');
260   }
261
262   fn write(&mut self, text: &str) {
263      self.buf.push_str(text);
264   }
265
266   fn verbose(&mut self, f: Box<dyn FnOnce() -> String>) {
267      self.buf.push_str(&with_prefix("| ", &f()));
268   }
269
270   fn shell_command(&mut self, cmd: &str) {
271      self.buf.push_str(&with_prefix("> ", cmd));
272   }
273
274   fn shell_line(&mut self, line: &str) {
275      self.buf.push_str(&with_prefix("> ", line));
276   }
277
278   fn step_result(&mut self, label: &str, success: bool, elapsed_ms: u128, _viewport: &[String]) {
279      let symbol = if success { '✓' } else { '✗' };
280      self.buf.push_str(&format!("{symbol} {label} ({})\n", format_elapsed(elapsed_ms)));
281   }
282
283   fn dry_run_shell(&mut self, cmd: &str) {
284      self.buf.push_str(&format!("[dry-run] would run: {cmd}\n"));
285   }
286
287   fn dry_run_write(&mut self, path: &str) {
288      self.buf.push_str(&format!("[dry-run] would write: {path}\n"));
289   }
290
291   fn dry_run_delete(&mut self, path: &str) {
292      self.buf.push_str(&format!("[dry-run] would delete: {path}\n"));
293   }
294}
295
296// ---------------------------------------------------------------------------
297// Tests
298// ---------------------------------------------------------------------------
299
300#[cfg(test)]
301mod tests {
302   use rstest::rstest;
303
304   use super::*;
305
306   fn verbose_mode() -> OutputMode {
307      OutputMode { verbose: true, ..Default::default() }
308   }
309
310   #[test]
311   fn string_output_captures_lines() {
312      let mut out = StringOutput::new();
313      out.writeln("hello");
314      out.writeln("world");
315      assert_eq!(out.log(), "hello\nworld\n");
316   }
317
318   #[test]
319   fn string_output_write_no_newline() {
320      let mut out = StringOutput::new();
321      out.write("a");
322      out.write("b");
323      assert_eq!(out.log(), "ab");
324   }
325
326   #[test]
327   fn string_output_captures_verbose() {
328      let mut out = StringOutput::new();
329      out.verbose(Box::new(|| "debug info".to_string()));
330      assert_eq!(out.log(), "| debug info\n");
331   }
332
333   #[test]
334   fn string_output_verbose_multiline() {
335      let mut out = StringOutput::new();
336      out.verbose(Box::new(|| "line one\nline two".to_string()));
337      assert_eq!(out.log(), "| line one\n| line two\n");
338   }
339
340   #[test]
341   fn string_output_shell_command() {
342      let mut out = StringOutput::new();
343      out.shell_command("pnpm install");
344      assert_eq!(out.log(), "> pnpm install\n");
345   }
346
347   #[test]
348   fn string_output_shell_line() {
349      let mut out = StringOutput::new();
350      out.shell_line("installed pnpm@9.1.0");
351      assert_eq!(out.log(), "> installed pnpm@9.1.0\n");
352   }
353
354   #[test]
355   fn log_helper_delegates_to_verbose() {
356      // Given
357      let mut out = StringOutput::new();
358      let mode = verbose_mode();
359
360      // When
361      Output::log(&mut out, mode, "setting up cache");
362
363      // Then
364      assert_eq!(out.log(), "| setting up cache\n");
365   }
366
367   #[test]
368   fn log_helper_silent_when_not_verbose() {
369      // Given
370      let mut out = StringOutput::new();
371      let mode = OutputMode::default();
372
373      // When
374      Output::log(&mut out, mode, "setting up cache");
375
376      // Then
377      assert_eq!(out.log(), "");
378   }
379
380   #[test]
381   fn log_exec_formats_command() {
382      // Given
383      let mut out = StringOutput::new();
384      let mode = verbose_mode();
385      let cmd = std::process::Command::new("node");
386
387      // When
388      Output::log_exec(&mut out, mode, &cmd);
389
390      // Then
391      assert_eq!(out.log(), "| Exec: node\n");
392   }
393
394   #[test]
395   fn log_exec_includes_args() {
396      // Given
397      let mut out = StringOutput::new();
398      let mode = verbose_mode();
399      let mut cmd = std::process::Command::new("pnpm");
400      cmd.arg("install");
401
402      // When
403      Output::log_exec(&mut out, mode, &cmd);
404
405      // Then
406      assert_eq!(out.log(), "| Exec: pnpm install\n");
407   }
408
409   #[rstest]
410   #[case(true, 1200, "✓ build (1s)\n")]
411   #[case(false, 300, "✗ build (300ms)\n")]
412   fn string_output_step_result(#[case] success: bool, #[case] elapsed_ms: u128, #[case] expected: &str) {
413      // Given
414      let mut out = StringOutput::new();
415
416      // When
417      out.step_result("build", success, elapsed_ms, &[]);
418
419      // Then
420      assert_eq!(out.log(), expected);
421   }
422
423   #[test]
424   fn string_output_dry_run_shell() {
425      let mut out = StringOutput::new();
426      out.dry_run_shell("rm -rf /");
427      assert_eq!(out.log(), "[dry-run] would run: rm -rf /\n");
428   }
429
430   #[test]
431   fn string_output_dry_run_write() {
432      let mut out = StringOutput::new();
433      out.dry_run_write("/some/path.json");
434      assert_eq!(out.log(), "[dry-run] would write: /some/path.json\n");
435   }
436
437   #[test]
438   fn string_output_dry_run_delete() {
439      let mut out = StringOutput::new();
440      out.dry_run_delete("/some/dir");
441      assert_eq!(out.log(), "[dry-run] would delete: /some/dir\n");
442   }
443}