1#[derive(Debug, Clone, Copy, Default)]
31pub struct OutputMode {
32 pub verbose: bool,
34 pub quiet: bool,
36 pub dry_run: bool
38}
39
40impl OutputMode {
41 pub fn is_verbose(self) -> bool {
43 self.verbose
44 }
45
46 pub fn is_quiet(self) -> bool {
48 self.quiet
49 }
50
51 pub fn is_dry_run(self) -> bool {
53 self.dry_run
54 }
55}
56
57pub trait Output {
70 fn writeln(&mut self, line: &str);
72
73 fn write(&mut self, text: &str);
75
76 fn verbose(&mut self, f: Box<dyn FnOnce() -> String>);
81
82 fn shell_command(&mut self, cmd: &str);
84
85 fn shell_line(&mut self, line: &str);
87
88 fn step_result(&mut self, label: &str, success: bool, elapsed_ms: u128, viewport: &[String]);
91
92 fn dry_run_shell(&mut self, _cmd: &str) {}
94
95 fn dry_run_write(&mut self, _path: &str) {}
97
98 fn dry_run_delete(&mut self, _path: &str) {}
100
101 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 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
129pub struct ConsoleOutput {
140 mode: OutputMode
141}
142
143impl ConsoleOutput {
144 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
217pub struct StringOutput {
235 buf: String
236}
237
238impl StringOutput {
239 pub fn new() -> Self {
241 Self { buf: String::new() }
242 }
243
244 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#[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 let mut out = StringOutput::new();
358 let mode = verbose_mode();
359
360 Output::log(&mut out, mode, "setting up cache");
362
363 assert_eq!(out.log(), "| setting up cache\n");
365 }
366
367 #[test]
368 fn log_helper_silent_when_not_verbose() {
369 let mut out = StringOutput::new();
371 let mode = OutputMode::default();
372
373 Output::log(&mut out, mode, "setting up cache");
375
376 assert_eq!(out.log(), "");
378 }
379
380 #[test]
381 fn log_exec_formats_command() {
382 let mut out = StringOutput::new();
384 let mode = verbose_mode();
385 let cmd = std::process::Command::new("node");
386
387 Output::log_exec(&mut out, mode, &cmd);
389
390 assert_eq!(out.log(), "| Exec: node\n");
392 }
393
394 #[test]
395 fn log_exec_includes_args() {
396 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 Output::log_exec(&mut out, mode, &cmd);
404
405 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 let mut out = StringOutput::new();
415
416 out.step_result("build", success, elapsed_ms, &[]);
418
419 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}