1use std::collections::VecDeque;
20use std::io::BufRead;
21use std::sync::mpsc;
22use std::time::{Duration, Instant};
23
24use super::renderer::{Renderer, StatusFields, Writer};
25use super::spinner::stderr_is_terminal;
26use super::{Role, Verbosity, strip_ansi};
27
28pub struct CommandOutput {
29 pub status: std::process::ExitStatus,
30 pub stdout: String,
31 pub stderr: String,
32 pub duration: Duration,
33}
34
35enum Captured {
36 Stdout(String),
37 Stderr(String),
38}
39
40pub(crate) fn run_command(
44 renderer: &Renderer,
45 sink: &dyn Writer,
46 multi: &indicatif::MultiProgress,
47 depth: usize,
48 cmd: &mut std::process::Command,
49 label: &str,
50) -> std::io::Result<CommandOutput> {
51 let start = Instant::now();
52 cmd.stdin(std::process::Stdio::null());
53 if stderr_is_terminal() && renderer.verbosity != Verbosity::Quiet {
54 run_with_progress(renderer, sink, multi, depth, cmd, label, start)
55 } else {
56 run_streaming(renderer, sink, depth, cmd, label, start)
57 }
58}
59
60fn make_output(
61 status: std::process::ExitStatus,
62 all_stdout: Vec<String>,
63 all_stderr: Vec<String>,
64 duration: Duration,
65) -> CommandOutput {
66 CommandOutput {
67 status,
68 stdout: all_stdout.join("\n"),
69 stderr: all_stderr.join("\n"),
70 duration,
71 }
72}
73
74fn sanitize_and_mute(renderer: &Renderer, line: &str) -> String {
80 let clean = strip_ansi(line);
81 renderer.theme.muted.apply_to(clean).to_string()
82}
83
84fn spawn_readers(child: &mut std::process::Child) -> mpsc::Receiver<Captured> {
85 let (tx, rx) = mpsc::channel();
86 if let Some(stdout) = child.stdout.take() {
87 let tx = tx.clone();
88 std::thread::spawn(move || {
89 for line in std::io::BufReader::new(stdout)
90 .lines()
91 .map_while(Result::ok)
92 {
93 let _ = tx.send(Captured::Stdout(line));
94 }
95 });
96 }
97 if let Some(stderr) = child.stderr.take() {
98 let tx = tx.clone();
99 std::thread::spawn(move || {
100 for line in std::io::BufReader::new(stderr)
101 .lines()
102 .map_while(Result::ok)
103 {
104 let _ = tx.send(Captured::Stderr(line));
105 }
106 });
107 }
108 drop(tx);
109 rx
110}
111
112fn run_with_progress(
113 renderer: &Renderer,
114 sink: &dyn Writer,
115 multi: &indicatif::MultiProgress,
116 depth: usize,
117 cmd: &mut std::process::Command,
118 label: &str,
119 start: Instant,
120) -> std::io::Result<CommandOutput> {
121 const VISIBLE_LINES: usize = 5;
122 let mut child = cmd
123 .stdout(std::process::Stdio::piped())
124 .stderr(std::process::Stdio::piped())
125 .spawn()?;
126 let pb = super::spinner::build_spinner(multi, renderer, label);
127 let rx = spawn_readers(&mut child);
128 let mut ring: VecDeque<String> = VecDeque::with_capacity(VISIBLE_LINES);
129 let mut all_stdout = Vec::new();
130 let mut all_stderr = Vec::new();
131 for line in rx {
135 let text = match &line {
136 Captured::Stdout(s) => {
137 all_stdout.push(s.clone());
138 s
139 }
140 Captured::Stderr(s) => {
141 all_stderr.push(s.clone());
142 s
143 }
144 };
145 if ring.len() >= VISIBLE_LINES {
146 ring.pop_front();
147 }
148 ring.push_back(text.clone());
149 let mut msg = label.to_string();
150 for l in &ring {
151 let display = if l.len() > 120 {
152 l.get(..120).unwrap_or(l)
153 } else {
154 l
155 };
156 msg.push_str(&format!(
157 "\n{}{}",
158 " ".repeat(depth + 1),
159 sanitize_and_mute(renderer, display)
160 ));
161 }
162 pb.set_message(msg);
163 }
164 let status = child.wait()?;
165 let duration = start.elapsed();
166 pb.finish_and_clear();
167 if status.success() {
168 renderer.render_status(
169 sink,
170 depth,
171 &StatusFields {
172 role: Role::Ok,
173 subject: label,
174 detail: None,
175 duration: Some(duration),
176 target: None,
177 },
178 );
179 } else {
180 renderer.render_status(
181 sink,
182 depth,
183 &StatusFields {
184 role: Role::Fail,
185 subject: label,
186 detail: Some("failed"),
187 duration: Some(duration),
188 target: None,
189 },
190 );
191 for line in &all_stderr {
192 let dim = sanitize_and_mute(renderer, line);
193 renderer.write_line(sink, depth + 1, &dim);
194 }
195 }
196 Ok(make_output(status, all_stdout, all_stderr, duration))
197}
198
199fn run_streaming(
200 renderer: &Renderer,
201 sink: &dyn Writer,
202 depth: usize,
203 cmd: &mut std::process::Command,
204 label: &str,
205 start: Instant,
206) -> std::io::Result<CommandOutput> {
207 let mut child = cmd
208 .stdout(std::process::Stdio::piped())
209 .stderr(std::process::Stdio::piped())
210 .spawn()?;
211 if renderer.verbosity != Verbosity::Quiet {
212 renderer.render_status(
213 sink,
214 depth,
215 &StatusFields {
216 role: Role::Running,
217 subject: label,
218 detail: None,
219 duration: None,
220 target: None,
221 },
222 );
223 }
224 let rx = spawn_readers(&mut child);
225 let mut all_stdout = Vec::new();
226 let mut all_stderr = Vec::new();
227 for line in rx {
228 match &line {
229 Captured::Stdout(s) => {
230 if renderer.verbosity != Verbosity::Quiet {
231 renderer.write_line(sink, depth + 1, s);
232 }
233 all_stdout.push(s.clone());
234 }
235 Captured::Stderr(s) => {
236 if renderer.verbosity != Verbosity::Quiet {
237 renderer.write_line(sink, depth + 1, s);
238 }
239 all_stderr.push(s.clone());
240 }
241 }
242 }
243 let status = child.wait()?;
244 let duration = start.elapsed();
245 let role = if status.success() {
246 Role::Ok
247 } else {
248 Role::Fail
249 };
250 renderer.render_status(
251 sink,
252 depth,
253 &StatusFields {
254 role,
255 subject: label,
256 detail: None,
257 duration: Some(duration),
258 target: None,
259 },
260 );
261 Ok(make_output(status, all_stdout, all_stderr, duration))
262}
263
264#[cfg(test)]
265mod tests {
266 use std::sync::{Arc, Mutex};
267 use std::time::Duration;
268
269 use super::super::Theme;
270 use super::super::renderer::StringSink;
271 use super::*;
272
273 fn with_deadline<F: FnOnce() -> R + Send + 'static, R: Send + 'static>(d: Duration, f: F) -> R {
276 let (tx, rx) = std::sync::mpsc::channel();
277 std::thread::spawn(move || {
278 let _ = tx.send(f());
279 });
280 rx.recv_timeout(d).expect("test exceeded deadline")
281 }
282
283 #[test]
290 #[serial_test::serial]
291 fn run_spinner_strips_ansi_from_external_tool_output() {
292 let _restore_no_color = std::env::var("NO_COLOR").ok();
293 unsafe {
295 std::env::remove_var("NO_COLOR");
296 }
297 let _guard = crate::output::test_support::ColorsEnabledGuard::set(true);
298
299 let renderer = Renderer::new(Theme::default(), Verbosity::Normal);
300 let foreign = "tool: \x1b[31mred\x1b[0m text \x1b[1mbold\x1b[0m";
301 let out = sanitize_and_mute(&renderer, foreign);
302 let visible = crate::output::strip_ansi(&out);
304 assert!(
305 visible.contains("tool: red text bold"),
306 "visible payload mismatch; got: {visible:?}"
307 );
308 assert!(
312 !out.contains("\x1b[31m"),
313 "foreign red SGR must be stripped before muted wrap; got: {out:?}"
314 );
315 assert!(
316 !out.contains("\x1b[1m"),
317 "foreign bold SGR must be stripped before muted wrap; got: {out:?}"
318 );
319
320 unsafe {
321 match _restore_no_color {
322 Some(v) => std::env::set_var("NO_COLOR", v),
323 None => std::env::remove_var("NO_COLOR"),
324 }
325 }
326 }
327
328 #[test]
332 #[serial_test::serial]
333 fn run_streaming_captures_stdout_and_emits_status() {
334 with_deadline(Duration::from_secs(10), || {
335 let buf = Arc::new(Mutex::new(String::new()));
336 let sink = StringSink(buf.clone());
337 let renderer = Renderer::new(Theme::default(), Verbosity::Normal);
338 let multi = indicatif::MultiProgress::new();
339 let mut cmd = std::process::Command::new("sh");
340 cmd.arg("-c").arg("printf 'hello\nworld\n'");
341 let out = run_command(&renderer, &sink, &multi, 0, &mut cmd, "say hi").unwrap();
346 assert!(out.status.success());
347 assert!(out.stdout.contains("hello"));
348 assert!(out.stdout.contains("world"));
349 let s = buf.lock().unwrap();
350 assert!(s.contains("say hi"));
351 });
352 }
353
354 #[test]
355 fn make_output_joins_captured_lines_with_newlines() {
356 let stdout = vec!["a".into(), "b".into(), "c".into()];
357 let stderr = vec!["x".into(), "y".into()];
358 let status = exit_status_from_code(0);
359 let out = make_output(status, stdout, stderr, Duration::from_millis(42));
360 assert_eq!(out.stdout, "a\nb\nc");
361 assert_eq!(out.stderr, "x\ny");
362 assert_eq!(out.duration, Duration::from_millis(42));
363 assert!(out.status.success());
364 }
365
366 #[test]
367 fn make_output_empty_captures_produce_empty_strings() {
368 let status = exit_status_from_code(0);
369 let out = make_output(status, vec![], vec![], Duration::from_secs(0));
370 assert!(out.stdout.is_empty());
371 assert!(out.stderr.is_empty());
372 }
373
374 #[test]
375 #[serial_test::serial]
376 fn run_streaming_emits_running_status_then_ok_on_success() {
377 with_deadline(Duration::from_secs(10), || {
378 let buf = Arc::new(Mutex::new(String::new()));
379 let sink = StringSink(buf.clone());
380 let renderer = Renderer::new(Theme::default(), Verbosity::Normal);
381 let mut cmd = std::process::Command::new("sh");
382 cmd.arg("-c").arg("printf 'line-one\nline-two\n'");
383 let out =
384 run_streaming(&renderer, &sink, 0, &mut cmd, "stream-job", Instant::now()).unwrap();
385
386 assert!(out.status.success());
387 assert_eq!(out.stdout, "line-one\nline-two");
388 assert!(out.stderr.is_empty());
389
390 let captured = crate::output::strip_ansi(&buf.lock().unwrap());
391 assert!(
392 captured.contains("stream-job"),
393 "label must appear in sink output; got: {captured:?}"
394 );
395 assert!(
396 captured.contains("line-one"),
397 "stdout line must be streamed to sink; got: {captured:?}"
398 );
399 assert!(
400 captured.contains("line-two"),
401 "stdout line must be streamed to sink; got: {captured:?}"
402 );
403 });
404 }
405
406 #[test]
407 #[serial_test::serial]
408 fn run_streaming_captures_stderr_separately() {
409 with_deadline(Duration::from_secs(10), || {
410 let buf = Arc::new(Mutex::new(String::new()));
411 let sink = StringSink(buf.clone());
412 let renderer = Renderer::new(Theme::default(), Verbosity::Normal);
413 let mut cmd = std::process::Command::new("sh");
414 cmd.arg("-c").arg("printf 'out\n'; printf 'err\n' 1>&2");
415 let out =
416 run_streaming(&renderer, &sink, 0, &mut cmd, "split", Instant::now()).unwrap();
417 assert!(out.status.success());
418 assert_eq!(out.stdout, "out");
419 assert_eq!(out.stderr, "err");
420 });
421 }
422
423 #[test]
424 #[serial_test::serial]
425 fn run_streaming_failure_emits_fail_role_and_propagates_exit_code() {
426 with_deadline(Duration::from_secs(10), || {
427 let buf = Arc::new(Mutex::new(String::new()));
428 let sink = StringSink(buf.clone());
429 let renderer = Renderer::new(Theme::default(), Verbosity::Normal);
430 let mut cmd = std::process::Command::new("sh");
431 cmd.arg("-c").arg("printf 'partial\n'; exit 7");
432 let out =
433 run_streaming(&renderer, &sink, 0, &mut cmd, "fail-job", Instant::now()).unwrap();
434
435 assert!(!out.status.success());
436 assert_eq!(out.status.code(), Some(7));
437 assert_eq!(out.stdout, "partial");
438
439 let captured = crate::output::strip_ansi(&buf.lock().unwrap());
440 assert!(
442 captured.contains("✗") || captured.contains("fail-job"),
443 "fail status must surface in sink; got: {captured:?}"
444 );
445 });
446 }
447
448 #[test]
449 #[serial_test::serial]
450 fn run_streaming_quiet_verbosity_suppresses_running_and_per_line_output() {
451 with_deadline(Duration::from_secs(10), || {
452 let buf = Arc::new(Mutex::new(String::new()));
453 let sink = StringSink(buf.clone());
454 let renderer = Renderer::new(Theme::default(), Verbosity::Quiet);
455 let mut cmd = std::process::Command::new("sh");
456 cmd.arg("-c").arg("printf 'q1\nq2\n'");
457 let out =
458 run_streaming(&renderer, &sink, 0, &mut cmd, "quiet-job", Instant::now()).unwrap();
459
460 assert!(out.status.success());
461 assert_eq!(out.stdout, "q1\nq2");
463
464 let captured = crate::output::strip_ansi(&buf.lock().unwrap());
465 assert!(
470 !captured.contains("q1"),
471 "quiet should not stream stdout lines; got: {captured:?}"
472 );
473 assert!(
474 !captured.contains("q2"),
475 "quiet should not stream stdout lines; got: {captured:?}"
476 );
477 });
478 }
479
480 #[test]
481 #[serial_test::serial]
482 fn run_with_progress_captures_both_streams_and_renders_label() {
483 with_deadline(Duration::from_secs(10), || {
487 let buf = Arc::new(Mutex::new(String::new()));
488 let sink = StringSink(buf.clone());
489 let renderer = Renderer::new(Theme::default(), Verbosity::Normal);
490 let multi = indicatif::MultiProgress::new();
491 let mut cmd = std::process::Command::new("sh");
492 cmd.arg("-c").arg("printf 'p-out\n'; printf 'p-err\n' 1>&2");
493
494 let out = run_with_progress(
495 &renderer,
496 &sink,
497 &multi,
498 0,
499 &mut cmd,
500 "spin-ok",
501 Instant::now(),
502 )
503 .unwrap();
504
505 assert!(out.status.success());
506 assert_eq!(out.stdout, "p-out");
507 assert_eq!(out.stderr, "p-err");
508
509 let captured = crate::output::strip_ansi(&buf.lock().unwrap());
510 assert!(
511 captured.contains("spin-ok"),
512 "success status must surface label; got: {captured:?}"
513 );
514 });
515 }
516
517 #[test]
518 #[serial_test::serial]
519 fn run_with_progress_dumps_stderr_under_fail_status() {
520 with_deadline(Duration::from_secs(10), || {
524 let buf = Arc::new(Mutex::new(String::new()));
525 let sink = StringSink(buf.clone());
526 let renderer = Renderer::new(Theme::default(), Verbosity::Normal);
527 let multi = indicatif::MultiProgress::new();
528 let mut cmd = std::process::Command::new("sh");
529 cmd.arg("-c")
530 .arg("printf 'boom-1\n' 1>&2; printf 'boom-2\n' 1>&2; exit 9");
531
532 let out = run_with_progress(
533 &renderer,
534 &sink,
535 &multi,
536 0,
537 &mut cmd,
538 "spin-fail",
539 Instant::now(),
540 )
541 .unwrap();
542
543 assert!(!out.status.success());
544 assert_eq!(out.status.code(), Some(9));
545 assert_eq!(out.stderr, "boom-1\nboom-2");
546
547 let captured = crate::output::strip_ansi(&buf.lock().unwrap());
548 assert!(
549 captured.contains("spin-fail"),
550 "fail status must surface label; got: {captured:?}"
551 );
552 assert!(
553 captured.contains("boom-1"),
554 "failed run must dump captured stderr; got: {captured:?}"
555 );
556 assert!(
557 captured.contains("boom-2"),
558 "failed run must dump every stderr line; got: {captured:?}"
559 );
560 });
561 }
562
563 #[test]
564 #[serial_test::serial]
565 fn run_with_progress_caps_ring_to_visible_lines_but_captures_everything() {
566 with_deadline(Duration::from_secs(15), || {
570 let buf = Arc::new(Mutex::new(String::new()));
571 let sink = StringSink(buf.clone());
572 let renderer = Renderer::new(Theme::default(), Verbosity::Normal);
573 let multi = indicatif::MultiProgress::new();
574 let mut cmd = std::process::Command::new("sh");
575 cmd.arg("-c")
576 .arg("for i in $(seq 1 12); do printf 'line-%02d\n' $i; done");
577
578 let out = run_with_progress(
579 &renderer,
580 &sink,
581 &multi,
582 0,
583 &mut cmd,
584 "many-lines",
585 Instant::now(),
586 )
587 .unwrap();
588
589 assert!(out.status.success());
590 let captured_lines: Vec<&str> = out.stdout.split('\n').collect();
592 assert_eq!(captured_lines.len(), 12);
593 assert_eq!(captured_lines.first().copied(), Some("line-01"));
594 assert_eq!(captured_lines.last().copied(), Some("line-12"));
595 });
596 }
597
598 #[test]
599 #[serial_test::serial]
600 fn run_with_progress_truncates_long_lines_in_ring_display() {
601 with_deadline(Duration::from_secs(10), || {
606 let buf = Arc::new(Mutex::new(String::new()));
607 let sink = StringSink(buf.clone());
608 let renderer = Renderer::new(Theme::default(), Verbosity::Normal);
609 let multi = indicatif::MultiProgress::new();
610 let payload = "x".repeat(250);
611 let mut cmd = std::process::Command::new("sh");
612 cmd.arg("-c").arg(format!("printf '%s\n' {}", payload));
613
614 let out = run_with_progress(
615 &renderer,
616 &sink,
617 &multi,
618 0,
619 &mut cmd,
620 "long-line",
621 Instant::now(),
622 )
623 .unwrap();
624
625 assert!(out.status.success());
626 assert_eq!(out.stdout.len(), 250);
627 assert_eq!(out.stdout, payload);
628 });
629 }
630
631 #[test]
632 #[serial_test::serial]
633 fn run_command_dispatches_to_streaming_when_stderr_not_tty() {
634 with_deadline(Duration::from_secs(10), || {
638 let buf = Arc::new(Mutex::new(String::new()));
639 let sink = StringSink(buf.clone());
640 let renderer = Renderer::new(Theme::default(), Verbosity::Normal);
641 let multi = indicatif::MultiProgress::new();
642 let mut cmd = std::process::Command::new("sh");
643 cmd.arg("-c").arg("printf 'dispatch-ok\n'; exit 0");
644 let out = run_command(&renderer, &sink, &multi, 0, &mut cmd, "dispatch").unwrap();
645 assert!(out.status.success());
646 assert_eq!(out.stdout, "dispatch-ok");
647 });
648 }
649
650 #[test]
651 #[serial_test::serial]
652 fn run_command_quiet_verbosity_takes_streaming_path() {
653 with_deadline(Duration::from_secs(10), || {
657 let buf = Arc::new(Mutex::new(String::new()));
658 let sink = StringSink(buf.clone());
659 let renderer = Renderer::new(Theme::default(), Verbosity::Quiet);
660 let multi = indicatif::MultiProgress::new();
661 let mut cmd = std::process::Command::new("sh");
662 cmd.arg("-c").arg("printf 'quiet-cap\n'");
663 let out = run_command(&renderer, &sink, &multi, 0, &mut cmd, "qcmd").unwrap();
664 assert!(out.status.success());
665 assert_eq!(out.stdout, "quiet-cap");
666 });
667 }
668
669 #[test]
670 fn sanitize_and_mute_preserves_text_when_no_foreign_ansi() {
671 let renderer = Renderer::new(Theme::default(), Verbosity::Normal);
672 let out = sanitize_and_mute(&renderer, "plain text");
673 let visible = crate::output::strip_ansi(&out);
677 assert_eq!(visible, "plain text");
678 }
679
680 fn exit_status_from_code(code: i32) -> std::process::ExitStatus {
683 std::process::Command::new("sh")
686 .arg("-c")
687 .arg(format!("exit {code}"))
688 .status()
689 .expect("sh exit must succeed")
690 }
691}