Skip to main content

cfgd_core/output/
process.rs

1//! Process execution with live output display.
2//!
3//! `run_command` is the single entry point. It picks between two strategies
4//! based on TTY + verbosity:
5//!
6//! - **TTY + non-quiet** → `run_with_progress`: a spinner with a bounded
7//!   tailing ring (last N lines of stdout/stderr render under the spinner;
8//!   muted, indented to `depth + 1`). On exit, the spinner clears and a
9//!   single Status line replaces it.
10//! - **Non-TTY or quiet** → `run_streaming`: each child line streams to the
11//!   sink as it arrives. A leading Status(Running) opens the activity and a
12//!   final Status(Ok|Fail) closes it.
13//!
14//! Either path captures full stdout + stderr into the returned
15//! `CommandOutput` so callers can post-process even when output was muted.
16//!
17//! This is the controlled `std::process::Command` execution layer for
18//! `output`; see `module-boundaries.md`.
19use 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
40/// Run `cmd` with live output display. TTY mode: bounded scrolling region with
41/// spinner. Non-TTY / quiet: stream lines as they arrive. Either way, captures
42/// stdout+stderr for the return value.
43pub(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
74/// Sanitize a captured external-tool line and wrap it in the renderer's
75/// `muted` style. Strips foreign ANSI BEFORE the style is applied so a
76/// stray `\x1b[0m` in the tool output cannot prematurely close the muted
77/// styling, and foreign color escapes cannot paint past the spinner /
78/// post-failure dump.
79fn 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    // Blocking recv: the spinner's steady tick redraws independently of message
132    // updates, so a poll loop adds no value. Iteration ends when all tx clones
133    // drop (reader threads finish).
134    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    /// Run `f` in a thread with a deadline; panic if it doesn't return in time.
274    /// Used to bound this test's worst-case if a child process hangs (CI flake).
275    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    /// Foreign ANSI carried in a captured external-tool stdout/stderr line
284    /// must be stripped BEFORE the renderer's `muted` style wraps it. A stray
285    /// `\x1b[0m` in the tool output would otherwise prematurely close the
286    /// muted styling on the spinner display line (or the post-failure dump),
287    /// and foreign color escapes would paint past the spinner. `Printer::run`
288    /// hands captured lines through `sanitize_and_mute` for that reason.
289    #[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        // SAFETY: single-threaded under serial_test::serial; restored below.
294        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        // The visible payload survives sanitation.
303        let visible = crate::output::strip_ansi(&out);
304        assert!(
305            visible.contains("tool: red text bold"),
306            "visible payload mismatch; got: {visible:?}"
307        );
308        // None of the foreign SGRs survive. The renderer's `muted` style is a
309        // dim grey foreground; the foreign red foreground `31` would never be
310        // emitted by the renderer itself, so its absence proves sanitation.
311        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    // serial_test::serial because the test mutates the process's stdio inheritance
329    // tracking implicitly via `Command::spawn`; running concurrently with another
330    // process-spawning test can cause stderr_is_terminal() to flip mid-test.
331    #[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            // Streaming path: in CI, stderr is not a TTY → run_streaming fires.
342            // Locally in a terminal you'll hit run_with_progress instead — both
343            // paths satisfy this test's assertions, but if you see flakes
344            // locally, run with `TERM=dumb cargo test ...`.
345            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            // Failure renders the configured fail icon (✗ by default).
441            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            // Capture is independent of verbosity — the caller still sees both lines.
462            assert_eq!(out.stdout, "q1\nq2");
463
464            let captured = crate::output::strip_ansi(&buf.lock().unwrap());
465            // Quiet verbosity: no Running status, no per-line passthrough. The
466            // final Ok status is rendered unconditionally (render_status is
467            // routed regardless of verbosity in this path so callers know the
468            // process finished).
469            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        // Force the spinner path by calling `run_with_progress` directly; the
484        // public `run_command` would route to `run_streaming` in this test env
485        // because stderr is not a TTY.
486        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        // Failure path emits a Fail status followed by every captured stderr
521        // line dumped at depth+1 under the muted style — this is the diagnostic
522        // surface a user sees when a spawned build/lint command fails.
523        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        // The spinner ring only shows the last 5 lines, but the captured
567        // `stdout` collection must still hold every single line the child
568        // emitted — callers post-process this collection.
569        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            // Every emitted line is captured (ring trimming is purely visual).
591            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        // Lines longer than 120 chars are truncated on the spinner display,
602        // but full content is preserved in the captured `stdout`. We verify
603        // capture preserves the full line — the spinner display path is
604        // exercised but not directly observable from the StringSink.
605        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        // In CI / under `cargo test`, stderr is never a TTY, so `run_command`
635        // routes to `run_streaming`. Verify the public entry point produces
636        // the same CommandOutput shape as a direct `run_streaming` call.
637        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        // Even when stderr IS a TTY, Quiet verbosity forces the streaming
654        // path. We can't fake a TTY easily, but Quiet should always work
655        // and still capture output.
656        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        // Without ColorsEnabledGuard the wrapped style may or may not emit
674        // escape codes depending on the suite's prior state. Strip ANSI and
675        // confirm the payload survives.
676        let visible = crate::output::strip_ansi(&out);
677        assert_eq!(visible, "plain text");
678    }
679
680    /// Build an `ExitStatus` with the given exit code, portable across Unix
681    /// and Windows for the make_output tests above.
682    fn exit_status_from_code(code: i32) -> std::process::ExitStatus {
683        // Run `sh -c "exit N"` synchronously and capture the resulting status.
684        // Cheaper than depending on platform-specific `ExitStatusExt`.
685        std::process::Command::new("sh")
686            .arg("-c")
687            .arg(format!("exit {code}"))
688            .status()
689            .expect("sh exit must succeed")
690    }
691}