Skip to main content

xianyun_executor/exec/
streamed.rs

1//! Streaming command execution (TTY scrolling area / prefixed lines /
2//! logger fallback).
3//!
4//! Ported from `internal/executor/exec_streamed.go`. Uses the same
5//! `tokio::process::Command` + background-runtime bridge as `exec::run`
6//! (see `exec::runtime`). `chooseStreamer`'s three-way fan-out is ported as
7//! `choose_streamer`/`Streamer`: a scrolling area (TTY +
8//! `use_scrolling_area`), prefixed lines (TTY, no scrolling area), or the
9//! `tracing`-based logger fallback (no TTY) used by every existing test in
10//! this module (stderr isn't a terminal under `cargo test`).
11//!
12//! Go renders with `pterm` (ANSI styling) directly. This port uses
13//! `anstyle` for the SGR codes and writes through `anstream`'s
14//! auto-detecting stream wrapper, which strips styling when stdout/stderr
15//! isn't a terminal or `NO_COLOR` is set -- matching this project's
16//! terminal-accessibility requirements without each call site needing its
17//! own `NO_COLOR` check.
18
19use std::process::Stdio;
20use std::time::Duration;
21
22use anstyle::{AnsiColor, Style};
23use tokio::io::AsyncBufReadExt;
24use tokio::process::Command as TokioCommand;
25use tokio::sync::mpsc;
26
27use super::{ExecError, apply_env, runtime};
28
29/// Ported from `ExecStreamedOpts`.
30///
31/// `timeout` has no Go counterpart on the struct itself -- see
32/// `Options::timeout`'s doc comment in `exec::mod` for why this port
33/// threads a deadline explicitly instead of via an ambient context.
34#[derive(Debug, Clone, Default)]
35pub struct ExecStreamedOpts {
36    pub working_dir: Option<String>,
37    pub use_scrolling_area: bool,
38    pub process_descriptive_name: Option<String>,
39    pub env: Option<Vec<String>>,
40    pub timeout: Option<Duration>,
41}
42
43/// Which output stream a [`StreamLine`] came from.
44#[derive(Debug, Clone, Copy, PartialEq, Eq)]
45enum StreamSource {
46    Stdout,
47    Stderr,
48}
49
50/// One line read from the child's stdout or stderr, forwarded to whichever
51/// `Streamer` is rendering this command's output.
52struct StreamLine {
53    source: StreamSource,
54    text: String,
55}
56
57/// Reads `reader` line by line, forwarding each to `tx` tagged with
58/// `source`. Mirrors Go's `command.Status()` polling loop collecting new
59/// lines, but delivers each line as it arrives instead of rescanning a
60/// growing buffered slice every 100ms.
61async fn stream_lines<R>(reader: R, source: StreamSource, tx: mpsc::UnboundedSender<StreamLine>)
62where
63    R: tokio::io::AsyncRead + Unpin,
64{
65    let mut lines = tokio::io::BufReader::new(reader).lines();
66    while let Ok(Some(text)) = lines.next_line().await {
67        if tx.send(StreamLine { source, text }).is_err() {
68            return;
69        }
70    }
71}
72
73/// Ported from `chooseStreamer`'s three cases.
74#[derive(Debug, Clone, Copy, PartialEq, Eq)]
75enum Streamer {
76    /// Ported from `limitedAreaStreamer`.
77    LimitedArea,
78    /// Ported from `prefixedStreamer`.
79    Prefixed,
80    /// Ported from `logStreamer`.
81    Log,
82}
83
84/// Ported from `chooseStreamer`. Takes `is_tty` explicitly (rather than
85/// calling [`is_stderr_tty`] itself) so the TTY-dependent branching logic
86/// is unit-testable without a real terminal attached to the test process.
87fn choose_streamer(is_tty: bool, opts: &ExecStreamedOpts) -> Streamer {
88    if is_tty {
89        if opts.use_scrolling_area {
90            Streamer::LimitedArea
91        } else {
92            Streamer::Prefixed
93        }
94    } else {
95        Streamer::Log
96    }
97}
98
99/// Ported from the package-level `shellescape.QuoteCommand` call sites in
100/// `exec_streamed.go`: a minimal, display-only shell quoter (this never
101/// feeds back into an actual shell invocation, only a human-readable
102/// header/prefix line). Wraps any argument containing whitespace or shell
103/// metacharacters in single quotes, escaping embedded single quotes.
104fn quote_command(parts: &[&str]) -> String {
105    parts
106        .iter()
107        .map(|part| quote_arg(part))
108        .collect::<Vec<_>>()
109        .join(" ")
110}
111
112fn quote_arg(arg: &str) -> String {
113    let needs_quoting = arg.is_empty()
114        || arg
115            .chars()
116            .any(|c| c.is_whitespace() || "'\"\\$`!*?[]{}()<>|&;~#".contains(c));
117
118    if !needs_quoting {
119        return arg.to_string();
120    }
121
122    format!("'{}'", arg.replace('\'', r"'\''"))
123}
124
125/// Ported from `logStreamer`.
126fn run_log_streamer(process_name: &str, line: &StreamLine) {
127    match line.source {
128        StreamSource::Stdout => tracing::debug!(command = %process_name, "stdout" = %line.text),
129        StreamSource::Stderr => tracing::debug!(command = %process_name, "stderr" = %line.text),
130    }
131}
132
133/// Ported from `prefixedStreamer`. Prints the command's header line once,
134/// then each incoming line prefixed, wrapped, and colored (gray for
135/// stdout, light white for stderr, matching Go's palette). Generic over
136/// the writer so tests can assert on a `Vec<u8>` instead of real stderr;
137/// production calls it with `anstream::stderr()`, which auto-strips the
138/// styling below when not a terminal or `NO_COLOR` is set.
139async fn run_prefixed_streamer(
140    mut rx: mpsc::UnboundedReceiver<StreamLine>,
141    process_name: &str,
142    cmd_line: &str,
143    mut out: impl std::io::Write,
144) {
145    let header_style = Style::new().bold().fg_color(Some(AnsiColor::White.into()));
146    let _ = writeln!(out, "{header_style}{cmd_line}{header_style:#}");
147
148    let prefix = format_prefix(process_name);
149    let max_line_width = term_width_for_prefixed();
150
151    let stdout_style = Style::new().fg_color(Some(AnsiColor::BrightBlack.into()));
152    let stderr_style = Style::new().fg_color(Some(AnsiColor::BrightWhite.into()));
153
154    while let Some(line) = rx.recv().await {
155        let style = match line.source {
156            StreamSource::Stdout => stdout_style,
157            StreamSource::Stderr => stderr_style,
158        };
159        for wrapped in wrap_line(&line.text, max_line_width) {
160            let _ = writeln!(out, "{prefix} {style}{wrapped}{style:#}");
161        }
162    }
163}
164
165/// Ported from the prefix-building half of `prefixedStreamer` (right-pad
166/// to 8 chars, bold white).
167fn format_prefix(process_name: &str) -> String {
168    let truncated: String = process_name.chars().take(8).collect();
169    let padded = format!("{truncated:<8}");
170    let style = Style::new().bold().fg_color(Some(AnsiColor::White.into()));
171    format!("{style}{padded}{style:#}")
172}
173
174/// Ported from `getTermWidth`.
175fn term_width_for_prefixed() -> usize {
176    let width = terminal_width().unwrap_or(80);
177    width.saturating_sub(10).max(40)
178}
179
180/// Ported from `pterm.GetTerminalWidth`.
181fn terminal_width() -> Option<usize> {
182    terminal_size::terminal_size().map(|(terminal_size::Width(w), _)| w as usize)
183}
184
185/// Ported from `limitedAreaStreamer`. Generic over the writer for the same
186/// testability reason as [`run_prefixed_streamer`].
187async fn run_limited_area_streamer(
188    mut rx: mpsc::UnboundedReceiver<StreamLine>,
189    cmd_line: String,
190    mut out: impl std::io::Write,
191) {
192    const MAX_LINES: usize = 5;
193
194    let width = terminal_width().unwrap_or(80);
195    let max_line_width = width.saturating_sub(1).max(40);
196
197    let mut area = AreaPrinter::default();
198    let mut display_lines: Vec<String> = Vec::new();
199    let mut ticker = tokio::time::interval(Duration::from_millis(100));
200    ticker.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Delay);
201
202    loop {
203        tokio::select! {
204            received = rx.recv() => {
205                match received {
206                    Some(line) => {
207                        display_lines.push(line.text);
208                        if display_lines.len() > MAX_LINES {
209                            let excess = display_lines.len() - MAX_LINES;
210                            display_lines.drain(0..excess);
211                        }
212                    }
213                    None => break,
214                }
215            }
216            _ = ticker.tick() => {
217                flush_lines_to_area(&cmd_line, &display_lines, max_line_width, &mut area);
218                let _ = area.redraw(&mut out);
219            }
220        }
221    }
222
223    flush_lines_to_area(&cmd_line, &display_lines, max_line_width, &mut area);
224    let _ = area.redraw(&mut out);
225    let _ = area.finish(&mut out);
226}
227
228async fn exec_streamed_command_async(
229    opts: ExecStreamedOpts,
230    name: &str,
231    args: &[String],
232) -> Result<(), ExecError> {
233    let mut cmd = TokioCommand::new(name);
234    cmd.args(args);
235    apply_env(&mut cmd, &opts.env);
236    if let Some(dir) = &opts.working_dir
237        && !dir.is_empty()
238    {
239        cmd.current_dir(dir);
240    }
241    cmd.stdin(Stdio::null());
242    cmd.stdout(Stdio::piped());
243    cmd.stderr(Stdio::piped());
244
245    let process_name = opts
246        .process_descriptive_name
247        .clone()
248        .unwrap_or_else(|| name.to_string());
249
250    let mut child = cmd
251        .spawn()
252        .map_err(|error| ExecError::RunFailed(error.to_string()))?;
253
254    let stdout = child.stdout.take().expect("stdout was piped");
255    let stderr = child.stderr.take().expect("stderr was piped");
256
257    let (tx, rx) = mpsc::unbounded_channel::<StreamLine>();
258    let stdout_tx = tx.clone();
259    let stdout_task = tokio::spawn(stream_lines(stdout, StreamSource::Stdout, stdout_tx));
260    let stderr_task = tokio::spawn(stream_lines(stderr, StreamSource::Stderr, tx.clone()));
261    drop(tx);
262
263    let streamer_kind = choose_streamer(is_stderr_tty(), &opts);
264    let cmd_line_parts: Vec<&str> = std::iter::once(name)
265        .chain(args.iter().map(String::as_str))
266        .collect();
267    let cmd_line = quote_command(&cmd_line_parts);
268
269    let renderer_task = match streamer_kind {
270        Streamer::Log => {
271            let process_name = process_name.clone();
272            tokio::spawn(async move {
273                let mut rx = rx;
274                while let Some(line) = rx.recv().await {
275                    run_log_streamer(&process_name, &line);
276                }
277            })
278        }
279        Streamer::Prefixed => {
280            let process_name = process_name.clone();
281            tokio::spawn(async move {
282                run_prefixed_streamer(rx, &process_name, &cmd_line, anstream::stderr()).await
283            })
284        }
285        Streamer::LimitedArea => {
286            tokio::spawn(run_limited_area_streamer(rx, cmd_line, anstream::stderr()))
287        }
288    };
289
290    let wait_result = match opts.timeout {
291        Some(timeout) => {
292            tokio::select! {
293                res = child.wait() => Some(res),
294                () = tokio::time::sleep(timeout) => None,
295            }
296        }
297        None => Some(child.wait().await),
298    };
299
300    let cancelled = wait_result.is_none();
301    let final_status = match wait_result {
302        Some(res) => res,
303        None => {
304            let _ = child.kill().await;
305            child.wait().await
306        }
307    };
308
309    let _ = stdout_task.await;
310    let _ = stderr_task.await;
311    let _ = renderer_task.await;
312
313    if cancelled {
314        return Err(ExecError::Cancelled);
315    }
316
317    match final_status {
318        Ok(status) => {
319            if status.success() {
320                Ok(())
321            } else {
322                Err(ExecError::CommandFailed(status.code().unwrap_or(-1)))
323            }
324        }
325        Err(error) => Err(ExecError::RunFailed(error.to_string())),
326    }
327}
328
329/// Ported from the package-level `execStreamedCommand` function.
330pub fn exec_streamed_command(
331    opts: Option<ExecStreamedOpts>,
332    name: &str,
333    args: &[String],
334) -> Result<(), ExecError> {
335    runtime().block_on(exec_streamed_command_async(
336        opts.unwrap_or_default(),
337        name,
338        args,
339    ))
340}
341
342/// Ported from `truncateLine`.
343pub fn truncate_line(line: &str, max_width: usize) -> String {
344    line.chars().take(max_width).collect()
345}
346
347/// Ported from `wrapLine`.
348pub fn wrap_line(line: &str, max_width: usize) -> Vec<String> {
349    if max_width == 0 {
350        return vec![line.to_string()];
351    }
352
353    let chars: Vec<char> = line.chars().collect();
354    if chars.len() <= max_width {
355        return vec![line.to_string()];
356    }
357
358    chars
359        .chunks(max_width)
360        .map(|chunk| chunk.iter().collect())
361        .collect()
362}
363
364/// Ported from `isStderrTTY`.
365pub fn is_stderr_tty() -> bool {
366    use std::io::IsTerminal;
367    std::io::stderr().is_terminal()
368}
369
370/// Ported from `writePrefixedLinesFiltered`. `tui` mirrors
371/// `*logfilter.TUI`; see `crate::logfilter` for its port status.
372///
373/// The `Println`-based TUI output path (`tui.Println`) is not exercised
374/// here since `logfilter::Tui::start`/`println` are themselves
375/// unimplemented pending a terminal-rendering crate choice (see
376/// `PORT_NOTES.md`); when a `Tui` is present, matching lines are still
377/// logged via `tracing` rather than silently dropped.
378pub fn write_prefixed_lines_filtered(
379    tui: Option<&crate::logfilter::Tui>,
380    stdout: &[String],
381    stderr: &[String],
382    styled_prefix: &str,
383    max_line_width: usize,
384) {
385    let matches = |line: &str| tui.is_none_or(|tui| tui.matches(line));
386
387    for line in stdout {
388        if !matches(line) {
389            continue;
390        }
391        for wrapped in wrap_line(line, max_line_width) {
392            tracing::debug!("{styled_prefix} {wrapped}");
393        }
394    }
395
396    for line in stderr {
397        if !matches(line) {
398            continue;
399        }
400        for wrapped in wrap_line(line, max_line_width) {
401            tracing::debug!("{styled_prefix} {wrapped}");
402        }
403    }
404}
405
406/// A port of `pterm.AreaPrinter`: an in-place, self-redrawing block of
407/// terminal lines (not a full alternate-screen TUI -- `pterm` itself is a
408/// terminal-formatting library, not a TUI framework, so cursor-up-and-
409/// overwrite via raw ANSI escapes is the faithful equivalent, matching
410/// what `pterm.AreaPrinter` does internally).
411///
412/// `buffer` holds the plain-text content [`flush_lines_to_area`] builds
413/// (kept styling-free so truncation-width assertions stay exact); colors
414/// are applied separately in [`AreaPrinter::redraw`], after width
415/// truncation, so SGR escape bytes never skew a line's displayed width.
416#[derive(Default)]
417pub struct AreaPrinter {
418    pub buffer: String,
419    /// How many terminal lines the last [`redraw`](Self::redraw) call
420    /// wrote, so the next call knows how far to move the cursor up before
421    /// overwriting.
422    rendered_lines: usize,
423}
424
425impl AreaPrinter {
426    /// Clears the previously rendered block (if any) and rewrites
427    /// `self.buffer` in its place, coloring the header line bold white and
428    /// the rest dark gray (matching `pterm.AreaPrinter.Update`'s styling in
429    /// `flushLinesToArea`).
430    pub fn redraw(&mut self, writer: &mut impl std::io::Write) -> std::io::Result<()> {
431        let lines: Vec<&str> = self.buffer.lines().collect();
432
433        if self.rendered_lines > 0 {
434            write!(writer, "\x1b[{}A", self.rendered_lines)?;
435        }
436
437        let header_style = Style::new().bold().fg_color(Some(AnsiColor::White.into()));
438        let content_style = Style::new().fg_color(Some(AnsiColor::BrightBlack.into()));
439
440        for (i, line) in lines.iter().enumerate() {
441            let style = if i == 0 { header_style } else { content_style };
442            write!(writer, "\r\x1b[2K{style}{line}{style:#}\n")?;
443        }
444
445        self.rendered_lines = lines.len();
446        writer.flush()
447    }
448
449    /// Moves the cursor below the area for good, mirroring
450    /// `pterm.AreaPrinter.Stop` plus `limitedAreaStreamer`'s trailing
451    /// `fmt.Fprintln(os.Stderr)`.
452    pub fn finish(&mut self, writer: &mut impl std::io::Write) -> std::io::Result<()> {
453        self.rendered_lines = 0;
454        writeln!(writer)
455    }
456}
457
458/// Ported from `flushLinesToArea`.
459pub fn flush_lines_to_area(
460    cmd_line: &str,
461    display_lines: &[String],
462    max_line_width: usize,
463    area: &mut AreaPrinter,
464) {
465    if display_lines.is_empty() {
466        return;
467    }
468
469    let header = truncate_line(cmd_line, max_line_width);
470    let mut content = header;
471    for line in display_lines {
472        content.push('\n');
473        content.push_str(&truncate_line(line, max_line_width));
474    }
475    area.buffer = content;
476}
477
478#[cfg(test)]
479mod tests {
480    use super::*;
481
482    #[test]
483    fn exec_streamed_command_success() {
484        let err = exec_streamed_command(None, "echo", &["hello world".into()]);
485        assert!(err.is_ok());
486    }
487
488    #[test]
489    fn exec_streamed_command_failure() {
490        let err = exec_streamed_command(None, "sh", &["-c".into(), "exit 1".into()]).unwrap_err();
491        assert!(matches!(err, ExecError::CommandFailed(1)));
492    }
493
494    #[test]
495    fn exec_streamed_command_large_output() {
496        let err = exec_streamed_command(None, "seq", &["1".into(), "100".into()]);
497        assert!(err.is_ok());
498    }
499
500    #[test]
501    fn exec_streamed_command_very_large_output() {
502        let err = exec_streamed_command(None, "seq", &["1".into(), "1000".into()]);
503        assert!(err.is_ok());
504    }
505
506    /// Ported from `TestExecStreamedCommand_ContextCancellation`.
507    ///
508    /// NOTE: Go's version spawns the command in a goroutine, cancels the
509    /// context after 100ms, and blocks on the result channel. Rust's
510    /// equivalent needs a `tokio::spawn`ed task plus a `CancellationToken`
511    /// (or racing `tokio::select!` against a `tokio::process::Child`) --
512    /// `std::thread::spawn` alone has no cancellation primitive. See
513    /// PORT_NOTES.md.
514    #[test]
515    fn exec_streamed_command_context_cancellation() {
516        // Go: context.WithCancel, cancelled after 100ms from a separate
517        // goroutine. This port has no ambient context to cancel out of
518        // band (see PORT_NOTES.md), so the equivalent deadline is passed
519        // explicitly via `ExecStreamedOpts.timeout`.
520        let err = exec_streamed_command(
521            Some(ExecStreamedOpts {
522                timeout: Some(Duration::from_millis(100)),
523                ..Default::default()
524            }),
525            "sleep",
526            &["10".into()],
527        );
528        assert!(err.is_err(), "expected error after cancellation");
529    }
530
531    #[test]
532    fn exec_streamed_command_context_timeout() {
533        // Go: context.WithTimeout(200ms).
534        let err = exec_streamed_command(
535            Some(ExecStreamedOpts {
536                timeout: Some(Duration::from_millis(200)),
537                ..Default::default()
538            }),
539            "sleep",
540            &["10".into()],
541        )
542        .unwrap_err();
543        assert!(matches!(err, ExecError::Cancelled));
544    }
545
546    #[test]
547    fn exec_streamed_command_mixed_output() {
548        let err = exec_streamed_command(
549            None,
550            "sh",
551            &["-c".into(), "echo stdout; echo stderr >&2".into()],
552        );
553        assert!(err.is_ok());
554    }
555
556    #[test]
557    fn exec_streamed_command_command_not_found() {
558        let err = exec_streamed_command(None, "nonexistent-command-xyz", &[]);
559        assert!(err.is_err(), "expected error for nonexistent command");
560    }
561
562    #[test]
563    fn exec_streamed_command_empty_output() {
564        let err = exec_streamed_command(None, "true", &[]);
565        assert!(err.is_ok());
566    }
567
568    #[test]
569    fn exec_streamed_command_long_running_command() {
570        let err = exec_streamed_command(
571            None,
572            "sh",
573            &[
574                "-c".into(),
575                "for i in $(seq 1 20); do echo $i; sleep 0.01; done".into(),
576            ],
577        );
578        assert!(err.is_ok());
579    }
580
581    #[test]
582    fn exec_streamed_command_long_lines() {
583        let long_string = "a".repeat(1 << 10);
584        let err = exec_streamed_command(None, "echo", &[long_string]);
585        assert!(err.is_ok());
586    }
587
588    #[test]
589    fn truncate_line_cases() {
590        let cases: &[(&str, &str, usize, &str)] = &[
591            ("line shorter than max", "hello", 10, "hello"),
592            ("line equal to max", "hello", 5, "hello"),
593            ("line longer than max", "hello world", 5, "hello"),
594            ("empty line", "", 10, ""),
595            (
596                "very long line",
597                "this is a very long line that should be truncated",
598                20,
599                "this is a very long ",
600            ),
601            ("emoji truncation", "hello ๐Ÿ‘‹ world ๐ŸŒ", 10, "hello ๐Ÿ‘‹ wo"),
602            (
603                "multi-byte unicode characters",
604                "ๆ—ฅๆœฌ่ชžใฎใƒ†ใ‚ญใ‚นใƒˆ",
605                5,
606                "ๆ—ฅๆœฌ่ชžใฎใƒ†",
607            ),
608            ("mixed ascii and unicode", "test ๆต‹่ฏ• ั‚ะตัั‚", 8, "test ๆต‹่ฏ• "),
609            ("truncate at emoji boundary", "abc๐Ÿ‘‹def", 4, "abc๐Ÿ‘‹"),
610        ];
611
612        for (name, line, max_width, want) in cases {
613            assert_eq!(truncate_line(line, *max_width), *want, "{name}");
614        }
615    }
616
617    #[test]
618    fn flush_lines_to_area_long_cmd_line() {
619        let mut area = AreaPrinter::default();
620
621        let long_cmd = "a".repeat(200);
622        let max_width = 80;
623
624        flush_lines_to_area(
625            &long_cmd,
626            &["output line".to_string()],
627            max_width,
628            &mut area,
629        );
630
631        for line in area.buffer.lines() {
632            assert!(
633                line.chars().count() <= max_width,
634                "area header line exceeds max_width: {line:?}"
635            );
636        }
637    }
638
639    /// New coverage (no direct Go equivalent -- `pterm.AreaPrinter`'s
640    /// redraw is opaque library internals Go itself doesn't unit test):
641    /// the first `redraw` writes the buffer's lines without a leading
642    /// cursor-up escape (nothing to clear yet), and colors the header line
643    /// differently from the content lines.
644    #[test]
645    fn area_printer_redraw_first_call_has_no_cursor_up() {
646        let mut area = AreaPrinter {
647            buffer: "header\nline one\nline two".to_string(),
648            ..Default::default()
649        };
650
651        let mut out = Vec::new();
652        area.redraw(&mut out).unwrap();
653        let rendered = String::from_utf8(out).unwrap();
654
655        assert!(
656            !rendered.starts_with("\x1b["),
657            "first redraw should not move the cursor: {rendered:?}"
658        );
659        assert!(rendered.contains("header"));
660        assert!(rendered.contains("line one"));
661        assert!(rendered.contains("line two"));
662        assert_eq!(area.rendered_lines, 3);
663    }
664
665    /// A second `redraw` moves the cursor up by exactly the previous
666    /// line count before rewriting, so the block overwrites in place
667    /// instead of scrolling the terminal.
668    #[test]
669    fn area_printer_redraw_second_call_moves_cursor_up_by_previous_line_count() {
670        let mut area = AreaPrinter {
671            buffer: "header\nline one".to_string(),
672            ..Default::default()
673        };
674        area.redraw(&mut Vec::new()).unwrap();
675        assert_eq!(area.rendered_lines, 2);
676
677        area.buffer = "header\nline one\nline two".to_string();
678        let mut out = Vec::new();
679        area.redraw(&mut out).unwrap();
680        let rendered = String::from_utf8(out).unwrap();
681
682        assert!(
683            rendered.starts_with("\x1b[2A"),
684            "should move up by the previous render's 2 lines: {rendered:?}"
685        );
686        assert_eq!(area.rendered_lines, 3);
687    }
688
689    /// `finish` resets the line count (so a later `redraw` starts fresh,
690    /// matching a brand-new command's area) and leaves the cursor below
691    /// the block with a trailing newline, mirroring
692    /// `limitedAreaStreamer`'s `fmt.Fprintln(os.Stderr)` after
693    /// `area.Stop()`.
694    #[test]
695    fn area_printer_finish_resets_and_writes_trailing_newline() {
696        let mut area = AreaPrinter {
697            buffer: "header\nline one".to_string(),
698            ..Default::default()
699        };
700        area.redraw(&mut Vec::new()).unwrap();
701        assert_eq!(area.rendered_lines, 2);
702
703        let mut out = Vec::new();
704        area.finish(&mut out).unwrap();
705
706        assert_eq!(area.rendered_lines, 0);
707        assert_eq!(out, b"\n");
708    }
709
710    /// New coverage: `quote_command` (this port's `shellescape.QuoteCommand`
711    /// stand-in) leaves plain arguments bare and single-quotes anything
712    /// shell-meaningful, escaping embedded single quotes.
713    #[test]
714    fn quote_command_cases() {
715        let cases: &[(&str, &[&str], &str)] = &[
716            (
717                "plain args need no quoting",
718                &["helm", "install"],
719                "helm install",
720            ),
721            (
722                "an argument with a space is quoted",
723                &["echo", "hello world"],
724                "echo 'hello world'",
725            ),
726            ("an empty argument is quoted", &["echo", ""], "echo ''"),
727            (
728                "an embedded single quote is escaped",
729                &["echo", "it's"],
730                "echo 'it'\\''s'",
731            ),
732            (
733                "a shell metacharacter is quoted",
734                &["sh", "-c", "a && b"],
735                "sh -c 'a && b'",
736            ),
737        ];
738
739        for (name, parts, want) in cases {
740            assert_eq!(quote_command(parts), *want, "{name}");
741        }
742    }
743
744    /// New coverage: `format_prefix` right-pads to 8 characters and
745    /// truncates longer names, matching `prefixedStreamer`'s prefix
746    /// construction.
747    #[test]
748    fn format_prefix_pads_and_truncates() {
749        assert_eq!(strip_ansi(&format_prefix("gitaly")), "gitaly  ");
750        assert_eq!(
751            strip_ansi(&format_prefix("a-very-long-process-name")),
752            "a-very-l"
753        );
754    }
755
756    /// Strips SGR escape sequences, for asserting on a styled string's
757    /// plain-text content without hard-coding the exact ANSI bytes.
758    fn strip_ansi(s: &str) -> String {
759        let mut result = String::new();
760        let mut chars = s.chars();
761        while let Some(c) = chars.next() {
762            if c == '\x1b' {
763                for esc_c in chars.by_ref() {
764                    if esc_c == 'm' {
765                        break;
766                    }
767                }
768                continue;
769            }
770            result.push(c);
771        }
772        result
773    }
774
775    #[test]
776    fn is_stderr_tty_does_not_panic() {
777        let _ = is_stderr_tty();
778    }
779
780    /// Ported from `chooseStreamer`'s three branches. Takes `is_tty`
781    /// explicitly (see `choose_streamer`'s doc comment) so this is
782    /// testable without a real terminal attached to the test process.
783    #[test]
784    fn choose_streamer_cases() {
785        let scrolling = ExecStreamedOpts {
786            use_scrolling_area: true,
787            ..Default::default()
788        };
789        let not_scrolling = ExecStreamedOpts {
790            use_scrolling_area: false,
791            ..Default::default()
792        };
793
794        assert_eq!(
795            choose_streamer(true, &scrolling),
796            Streamer::LimitedArea,
797            "TTY + scrolling area"
798        );
799        assert_eq!(
800            choose_streamer(true, &not_scrolling),
801            Streamer::Prefixed,
802            "TTY without scrolling area"
803        );
804        assert_eq!(
805            choose_streamer(false, &scrolling),
806            Streamer::Log,
807            "no TTY always falls back to the logger, regardless of the option"
808        );
809        assert_eq!(
810            choose_streamer(false, &not_scrolling),
811            Streamer::Log,
812            "no TTY, no scrolling area"
813        );
814    }
815
816    /// New coverage: `run_prefixed_streamer` prints the header once, then
817    /// each line prefixed and wrapped, stdout and stderr both reaching the
818    /// output (colored differently, but [`strip_ansi`] hides that here).
819    #[test]
820    fn run_prefixed_streamer_prints_header_then_prefixed_lines() {
821        let (tx, rx) = mpsc::unbounded_channel();
822        tx.send(StreamLine {
823            source: StreamSource::Stdout,
824            text: "building...".to_string(),
825        })
826        .unwrap();
827        tx.send(StreamLine {
828            source: StreamSource::Stderr,
829            text: "a warning".to_string(),
830        })
831        .unwrap();
832        drop(tx);
833
834        let mut out = Vec::new();
835        runtime().block_on(run_prefixed_streamer(
836            rx,
837            "helm",
838            "helm install app",
839            &mut out,
840        ));
841        let rendered = strip_ansi(&String::from_utf8(out).unwrap());
842
843        let lines: Vec<&str> = rendered.lines().collect();
844        assert_eq!(lines[0], "helm install app", "header line");
845        assert_eq!(lines[1], "helm     building...");
846        assert_eq!(lines[2], "helm     a warning");
847    }
848
849    /// New coverage: `run_limited_area_streamer` keeps only the last 5
850    /// lines and redraws the area at least once before the channel closes.
851    #[test]
852    fn run_limited_area_streamer_keeps_only_the_last_five_lines() {
853        let (tx, rx) = mpsc::unbounded_channel();
854        for i in 1..=8 {
855            tx.send(StreamLine {
856                source: StreamSource::Stdout,
857                text: format!("line {i}"),
858            })
859            .unwrap();
860        }
861        drop(tx);
862
863        let mut out = Vec::new();
864        runtime().block_on(run_limited_area_streamer(
865            rx,
866            "a-long-running-command".to_string(),
867            &mut out,
868        ));
869        let rendered = strip_ansi(&String::from_utf8(out).unwrap());
870
871        // The final redraw (after the channel closes) reflects the last 5
872        // lines only; earlier lines are gone.
873        assert!(rendered.contains("line 8"));
874        assert!(rendered.contains("line 4"));
875        assert!(!rendered.contains("line 3"));
876        assert!(!rendered.contains("line 1"));
877    }
878
879    #[test]
880    fn exec_streamed_command_rapid_output() {
881        let err = exec_streamed_command(None, "seq", &["1".into(), "100000".into()]);
882        assert!(err.is_ok());
883    }
884
885    #[test]
886    fn exec_streamed_command_binary_output() {
887        let err = exec_streamed_command(
888            None,
889            "sh",
890            &["-c".into(), r"printf '\x00\x01\x02\x03\xff\xfe\xfd'".into()],
891        );
892        assert!(err.is_ok());
893    }
894
895    #[test]
896    fn exec_streamed_command_unicode_output() {
897        let err = exec_streamed_command(
898            None,
899            "sh",
900            &["-c".into(), "echo 'โœ“ Success ๐Ÿ‘ ๆ—ฅๆœฌ่ชž ๐ŸŒ'".into()],
901        );
902        assert!(err.is_ok());
903    }
904
905    #[test]
906    fn exec_streamed_command_with_opts_success() {
907        let err = exec_streamed_command(
908            Some(ExecStreamedOpts {
909                process_descriptive_name: Some("test-process".into()),
910                use_scrolling_area: false,
911                ..Default::default()
912            }),
913            "echo",
914            &["hello world".into()],
915        );
916        assert!(err.is_ok());
917    }
918
919    #[test]
920    fn exec_streamed_command_with_opts_failure() {
921        let err = exec_streamed_command(
922            Some(ExecStreamedOpts {
923                process_descriptive_name: Some("test-process".into()),
924                use_scrolling_area: false,
925                ..Default::default()
926            }),
927            "sh",
928            &["-c".into(), "exit 1".into()],
929        )
930        .unwrap_err();
931        assert!(matches!(err, ExecError::CommandFailed(1)));
932    }
933
934    #[test]
935    fn exec_streamed_command_with_opts_context_cancellation() {
936        let err = exec_streamed_command(
937            Some(ExecStreamedOpts {
938                process_descriptive_name: Some("long-process".into()),
939                use_scrolling_area: false,
940                timeout: Some(Duration::from_millis(100)),
941                ..Default::default()
942            }),
943            "sleep",
944            &["10".into()],
945        );
946        assert!(err.is_err(), "expected error after cancellation");
947    }
948
949    /// Ported from `TestExecStreamedCommand_MultipleProcesses`.
950    #[test]
951    fn exec_streamed_command_multiple_processes() {
952        for i in 1..=3 {
953            let err = exec_streamed_command(
954                Some(ExecStreamedOpts {
955                    process_descriptive_name: Some(format!("process-{i}")),
956                    use_scrolling_area: false,
957                    ..Default::default()
958                }),
959                "echo",
960                &[format!("process {i} output")],
961            );
962            assert!(err.is_ok());
963        }
964    }
965
966    #[test]
967    fn exec_streamed_command_process_identification() {
968        let err = exec_streamed_command(
969            Some(ExecStreamedOpts {
970                process_descriptive_name: Some("gitaly-process".into()),
971                use_scrolling_area: false,
972                ..Default::default()
973            }),
974            "echo",
975            &["gitaly output".into()],
976        );
977        assert!(err.is_ok());
978    }
979
980    #[test]
981    fn exec_streamed_command_working_directory() {
982        let tmp_dir = std::env::temp_dir();
983
984        let err = exec_streamed_command(
985            Some(ExecStreamedOpts {
986                working_dir: Some(tmp_dir.to_string_lossy().into_owned()),
987                process_descriptive_name: Some("test-process".into()),
988                use_scrolling_area: false,
989                ..Default::default()
990            }),
991            "pwd",
992            &[],
993        );
994        assert!(err.is_ok());
995    }
996
997    #[test]
998    fn exec_streamed_command_empty_dir() {
999        let err = exec_streamed_command(
1000            Some(ExecStreamedOpts {
1001                working_dir: Some(String::new()),
1002                process_descriptive_name: Some("test-process".into()),
1003                use_scrolling_area: false,
1004                ..Default::default()
1005            }),
1006            "echo",
1007            &["test".into()],
1008        );
1009        assert!(err.is_ok());
1010    }
1011
1012    #[test]
1013    fn exec_streamed_command_nonexistent_dir() {
1014        let err = exec_streamed_command(
1015            Some(ExecStreamedOpts {
1016                working_dir: Some("/nonexistent/directory/path".into()),
1017                process_descriptive_name: Some("test-process".into()),
1018                use_scrolling_area: false,
1019                ..Default::default()
1020            }),
1021            "echo",
1022            &["test".into()],
1023        );
1024        assert!(err.is_err());
1025    }
1026
1027    #[test]
1028    fn write_prefixed_lines_filtered_nil_tui_does_not_panic() {
1029        write_prefixed_lines_filtered(None, &["[vite] some log".to_string()], &[], "prefix", 80);
1030    }
1031}