diff --git a/src/core/stream.rs b/src/core/stream.rs index 02a6cfc79..11d401077 100644 --- a/src/core/stream.rs +++ b/src/core/stream.rs @@ -241,6 +241,47 @@ pub fn status_to_exit_code(status: std::process::ExitStatus) -> i32 { 1 } +/// Collapse common single-line terminal redraw controls before filters parse output. +fn collapse_terminal_control(text: &str) -> String { + let mut visible = String::new(); + let mut line: Vec = Vec::new(); + let mut cursor = 0usize; + let mut chars = text.chars().peekable(); + + while let Some(ch) = chars.next() { + match ch { + '\r' if chars.peek() == Some(&'\n') => { + chars.next(); + visible.extend(line.drain(..)); + visible.push('\n'); + cursor = 0; + } + '\r' => { + cursor = 0; + } + '\n' => { + visible.extend(line.drain(..)); + visible.push('\n'); + cursor = 0; + } + '\u{8}' => { + cursor = cursor.saturating_sub(1); + } + ch => { + if cursor < line.len() { + line[cursor] = ch; + } else { + line.push(ch); + } + cursor += 1; + } + } + } + + visible.extend(line); + visible +} + // ISSUE #897: ChildGuard RAII prevents zombie processes that caused kernel panic pub const RAW_CAP: usize = 10_485_760; // 10 MiB @@ -366,6 +407,7 @@ pub fn run_streaming( StreamLine::Stderr(l) => (l, true), StreamLine::Stdout(l) => (l, false), }; + let line = collapse_terminal_control(&line); if is_stderr { if !capped_err { if raw_stderr.len() + line.len() < RAW_CAP { @@ -447,6 +489,7 @@ pub fn run_streaming( ); } } + raw_stdout = collapse_terminal_control(&raw_stdout); filtered = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| { filter_fn(&raw_stdout) })) @@ -472,15 +515,16 @@ pub fn run_streaming( ); } } + raw_stdout = collapse_terminal_control(&raw_stdout); filtered = raw_stdout.clone(); } } } - raw_stderr = stderr_thread.join().unwrap_or_else(|e| { + raw_stderr = collapse_terminal_control(&stderr_thread.join().unwrap_or_else(|e| { eprintln!("[rtk] warning: stderr reader thread panicked: {:?}", e); String::new() - }); + })); } if let Some(t) = stdin_thread { t.join().ok(); @@ -535,8 +579,8 @@ pub fn exec_capture(cmd: &mut Command) -> Result { cmd.stdin(Stdio::null()); let output = cmd.output().context("Failed to execute command")?; Ok(CaptureResult { - stdout: String::from_utf8_lossy(&output.stdout).into_owned(), - stderr: String::from_utf8_lossy(&output.stderr).into_owned(), + stdout: collapse_terminal_control(&String::from_utf8_lossy(&output.stdout)), + stderr: collapse_terminal_control(&String::from_utf8_lossy(&output.stderr)), exit_code: status_to_exit_code(output.status), }) } @@ -793,6 +837,16 @@ pub(crate) mod tests { assert_eq!(result.filtered.trim(), result.raw_stdout.trim()); } + #[test] + fn test_run_streaming_capture_only_collapses_carriage_return_progress() { + // nosemgrep: interpreter-execution + let mut cmd = Command::new("sh"); + cmd.args(["-c", "printf 'step 1\\rstep 2\\n'"]); + let result = run_streaming(&mut cmd, StdinMode::Null, FilterMode::CaptureOnly).unwrap(); + assert_eq!(result.raw_stdout, "step 2\n"); + assert_eq!(result.filtered, "step 2\n"); + } + #[test] fn test_exec_capture_success() { let mut cmd = Command::new("echo"); @@ -831,6 +885,24 @@ pub(crate) mod tests { assert!(combined.contains("err_msg")); } + #[test] + fn test_exec_capture_collapses_carriage_return_progress() { + // nosemgrep: interpreter-execution + let mut cmd = Command::new("sh"); + cmd.args(["-c", "printf 'Downloading... 0%%\\rDownloading... 3%%\\n'"]); + let result = exec_capture(&mut cmd).unwrap(); + assert_eq!(result.stdout, "Downloading... 3%\n"); + } + + #[test] + fn test_exec_capture_applies_backspace_overwrites() { + // nosemgrep: interpreter-execution + let mut cmd = Command::new("sh"); + cmd.args(["-c", "printf 'abc\\bD\\n'"]); + let result = exec_capture(&mut cmd).unwrap(); + assert_eq!(result.stdout, "abD\n"); + } + #[test] fn test_capture_result_combined_empty() { let r = CaptureResult {