Skip to main content

rpi_tools/
shell_output.rs

1//! Mirrors `packages/agent/src/harness/utils/shell-output.ts` — the bash tool's
2//! output-capture layer wrapping `Shell::exec`.
3//!
4//! Combines stdout+stderr into a single rolling tail buffer (capped at
5//! `max_output_bytes = DEFAULT_MAX_BYTES * 2 = 100KB`), spills to a temp file
6//! (`bash-*.log`) once cumulative output exceeds `DEFAULT_MAX_BYTES`/`DEFAULT_MAX_LINES`,
7//! and produces a [`ShellCaptureResult`] carrying truncation metadata + the
8//! temp-file path. `on_chunk` fires per sanitized/CR-stripped chunk with a
9//! progress snapshot.
10//!
11//! `return_execution_errors: true` causes exec failures to come back as a
12//! success-result with `execution_error` populated (so the bash tool — not the
13//! env — owns error rendering via `append_status`). There is NO time-based
14//! throttle here; throttling lives in the bash tool.
15//!
16//! **v1 divergence from the TS:** the TS initializes the temp file mid-stream
17//! (inside the stdout/stderr callback) and appends subsequent chunks. Doing
18//! async file I/O from a sync `FnMut` callback is unsound in Rust, so the v1
19//! port performs the spill in a single **post-stream** pass: the entire
20//! captured tail is written to the temp file after `exec` resolves. The
21//! `full_output_path` and truncation metadata are identical; only the timing
22//! of the file write differs. Tests verify the temp file exists when output
23//! exceeds limits — they don't depend on mid-stream creation.
24
25use std::collections::HashMap;
26use std::path::PathBuf;
27use std::sync::Arc;
28
29use tokio::sync::Mutex;
30use tokio_util::sync::CancellationToken;
31
32use crate::env::{ExecutionEnv, FileContent, ShellExecOptions};
33use crate::error::{ExecutionError, ExecutionErrorCode};
34use crate::truncate::{
35    trim_to_last_utf8_bytes, truncate_tail, TruncationLimit, TruncationOptions, TruncationResult,
36    DEFAULT_MAX_BYTES, DEFAULT_MAX_LINES,
37};
38
39/// The 100KB cap on the in-memory `tail_output` rolling buffer.
40const MAX_OUTPUT_BYTES: usize = DEFAULT_MAX_BYTES * 2;
41
42/// Mirrors `ShellCaptureProgress`.
43#[derive(Clone)]
44pub struct ShellCaptureProgress {
45    pub output: String,
46    pub truncation: TruncationResult,
47    pub full_output_path: Option<PathBuf>,
48    pub last_line_bytes: usize,
49}
50
51/// Mirrors `ShellCaptureResult`.
52#[derive(Clone)]
53pub struct ShellCaptureResult {
54    pub output: String,
55    pub truncation: TruncationResult,
56    pub full_output_path: Option<PathBuf>,
57    pub last_line_bytes: usize,
58    pub exit_code: Option<i32>,
59    pub cancelled: bool,
60    pub truncated: bool,
61    pub execution_error: Option<ExecutionError>,
62}
63
64/// Mirrors `ShellCaptureOptions`.
65pub struct ShellCaptureOptions<'a> {
66    pub cwd: Option<PathBuf>,
67    pub env: Option<HashMap<String, String>>,
68    pub inherit_env: bool,
69    pub timeout: Option<f64>,
70    pub cancel: Option<&'a CancellationToken>,
71    pub on_chunk: Option<Box<dyn FnMut(&str, &dyn Fn() -> ShellCaptureProgress) + Send + 'a>>,
72    pub return_execution_errors: bool,
73}
74
75impl<'a> Default for ShellCaptureOptions<'a> {
76    fn default() -> Self {
77        Self {
78            cwd: None,
79            env: None,
80            inherit_env: true,
81            timeout: None,
82            cancel: None,
83            on_chunk: None,
84            return_execution_errors: false,
85        }
86    }
87}
88
89/// Shared capture state, mutated by the stdout/stderr closures and read by the
90/// post-stream progress computation.
91#[derive(Default)]
92struct CaptureState {
93    tail_output: String,
94    total_bytes: usize,
95    completed_lines: usize,
96    has_open_line: bool,
97    current_line_bytes: usize,
98    full_output_path: Option<PathBuf>,
99    full_output_requested: bool,
100}
101
102impl CaptureState {
103    fn ingest_chunk(&mut self, text: &str) {
104        let text_bytes = text.len();
105        self.total_bytes += text_bytes;
106        let newline_count = text.matches('\n').count();
107        self.completed_lines += newline_count;
108        let last_newline = text.rfind('\n');
109        match last_newline {
110            Some(idx) => {
111                let trailing = &text[idx + 1..];
112                self.current_line_bytes = trailing.len();
113                self.has_open_line = !trailing.is_empty();
114            }
115            None if !text.is_empty() => {
116                self.current_line_bytes += text_bytes;
117                self.has_open_line = true;
118            }
119            None => {}
120        }
121        self.tail_output.push_str(text);
122        // Cap the in-memory tail at MAX_OUTPUT_BYTES.
123        let trimmed = trim_to_last_utf8_bytes(&self.tail_output, MAX_OUTPUT_BYTES);
124        if trimmed.len() != self.tail_output.len() {
125            self.tail_output = trimmed.to_string();
126        }
127    }
128
129    fn create_progress(&self) -> ShellCaptureProgress {
130        let tail_truncation = truncate_tail(&self.tail_output, TruncationOptions::default());
131        let total_lines = self.completed_lines + if self.has_open_line { 1 } else { 0 };
132        let truncated = total_lines > DEFAULT_MAX_LINES || self.total_bytes > DEFAULT_MAX_BYTES;
133        let truncated_by = if truncated {
134            tail_truncation
135                .truncated_by
136                .or(if self.total_bytes > DEFAULT_MAX_BYTES {
137                    Some(TruncationLimit::Bytes)
138                } else {
139                    Some(TruncationLimit::Lines)
140                })
141        } else {
142            None
143        };
144        let truncation = TruncationResult {
145            truncated,
146            truncated_by,
147            total_lines,
148            total_bytes: self.total_bytes,
149            ..tail_truncation
150        };
151        let output = if truncated {
152            truncation.content.clone()
153        } else {
154            self.tail_output.clone()
155        };
156        ShellCaptureProgress {
157            output,
158            truncation,
159            full_output_path: self.full_output_path.clone(),
160            last_line_bytes: self.current_line_bytes,
161        }
162    }
163}
164
165/// Sanitize a chunk: drop C0 controls (except TAB/LF/CR) and interlinear
166/// annotation chars (U+FFF9..U+FFFB), then strip all CR. Mirrors
167/// `sanitizeBinaryOutput` + the per-chunk `.replace(/\r/g, "")`.
168fn sanitize_and_strip_cr(chunk: &str) -> String {
169    let mut out = String::with_capacity(chunk.len());
170    for c in chunk.chars() {
171        let u = c as u32;
172        if u == 0x09 || u == 0x0a || u == 0x0d {
173            out.push(c);
174        } else if u <= 0x1f {
175            // other C0 controls → drop
176        } else if (0xfff9..=0xfffb).contains(&u) {
177            // interlinear annotation → drop
178        } else {
179            out.push(c);
180        }
181    }
182    out.replace('\r', "")
183}
184
185/// Mirrors `executeShellWithCapture`. Drives `env.exec` with on_stdout/on_stderr
186/// callbacks that fold into [`CaptureState`], spills to a temp file in a
187/// post-stream pass, and returns the terminal [`ShellCaptureResult`].
188pub async fn execute_shell_with_capture(
189    env: &Arc<dyn ExecutionEnv>,
190    command: &str,
191    options: ShellCaptureOptions<'_>,
192) -> Result<ShellCaptureResult, ExecutionError> {
193    let state = Arc::new(Mutex::new(CaptureState::default()));
194
195    // The user on_chunk callback. Both streams fire it (the TS wires both to the
196    // same onChunk); we share it via Arc<Mutex<Option<Box>>> so both closures can
197    // call it without moving the Box twice.
198    let user_cb: Arc<
199        Mutex<Option<Box<dyn FnMut(&str, &dyn Fn() -> ShellCaptureProgress) + Send>>>,
200    > = Arc::new(Mutex::new(options.on_chunk));
201
202    let state_stdout = state.clone();
203    let user_cb_stdout = user_cb.clone();
204    let on_stdout: Box<dyn FnMut(&str) + Send> = Box::new(move |chunk: &str| {
205        let text = sanitize_and_strip_cr(chunk);
206        // Snapshot the progress BEFORE ingest so on_chunk sees the pre-chunk
207        // state? The TS calls onChunk after updating, with the post-chunk
208        // progress. We match: ingest first, then snapshot.
209        let snap = {
210            let mut s = match state_stdout.try_lock() {
211                Ok(g) => g,
212                Err(_) => return, // contention — skip this chunk's callback
213            };
214            s.ingest_chunk(&text);
215            s.create_progress()
216        };
217        let mut cbg = match user_cb_stdout.try_lock() {
218            Ok(g) => g,
219            Err(_) => return,
220        };
221        if let Some(cb) = cbg.as_mut() {
222            // The TS onChunk receives (chunk_text, getProgress). We pass the
223            // snapshot getter as a closure returning the already-captured value.
224            let snap = snap.clone();
225            let getter = move || snap.clone();
226            cb(chunk, &getter);
227        }
228    });
229
230    let state_stderr = state.clone();
231    let user_cb_stderr = user_cb.clone();
232    let on_stderr: Box<dyn FnMut(&str) + Send> = Box::new(move |chunk: &str| {
233        let text = sanitize_and_strip_cr(chunk);
234        let snap = {
235            let mut s = match state_stderr.try_lock() {
236                Ok(g) => g,
237                Err(_) => return,
238            };
239            s.ingest_chunk(&text);
240            s.create_progress()
241        };
242        let mut cbg = match user_cb_stderr.try_lock() {
243            Ok(g) => g,
244            Err(_) => return,
245        };
246        if let Some(cb) = cbg.as_mut() {
247            let snap = snap.clone();
248            let getter = move || snap.clone();
249            cb(chunk, &getter);
250        }
251    });
252
253    let exec_opts = ShellExecOptions {
254        cwd: options.cwd.clone(),
255        env: options.env.clone(),
256        inherit_env: options.inherit_env,
257        timeout: options.timeout,
258        cancel: options.cancel,
259        on_stdout: Some(on_stdout),
260        on_stderr: Some(on_stderr),
261    };
262
263    let exec_result = env.exec(command, exec_opts).await;
264
265    // Post-stream safety net: if truncated and no temp file yet, init it now
266    // with the captured tail.
267    {
268        let progress = state.lock().await.create_progress();
269        if progress.truncation.truncated {
270            let s = state.lock().await;
271            if !s.full_output_requested {
272                let initial = s.tail_output.clone();
273                drop(s);
274                match init_full_output_file(env.clone(), &initial, options.cancel).await {
275                    Ok(path) => {
276                        let mut s2 = state.lock().await;
277                        s2.full_output_path = Some(path);
278                        s2.full_output_requested = true;
279                    }
280                    Err(e) => {
281                        // Spill failure is non-fatal — the capture still
282                        // returns; the truncation metadata is present, just no
283                        // fullOutputPath. Log via tracing.
284                        tracing::warn!("bash full-output temp file init failed: {e}");
285                    }
286                }
287            }
288        }
289    }
290
291    let progress = state.lock().await.create_progress();
292    // Pull the scalar/move fields out once so we never read a partially-moved
293    // `progress` (e.g. `progress.truncation.truncated` after `truncation:` moved it).
294    let ShellCaptureProgress {
295        output,
296        truncation,
297        full_output_path,
298        last_line_bytes,
299    } = progress;
300    let truncated_flag = truncation.truncated;
301    match exec_result {
302        Ok(out) => {
303            let cancelled = options.cancel.map(|t| t.is_cancelled()).unwrap_or(false);
304            Ok(ShellCaptureResult {
305                output: output.clone(),
306                truncation: truncation.clone(),
307                full_output_path: full_output_path.clone(),
308                last_line_bytes,
309                exit_code: if cancelled { None } else { Some(out.exit_code) },
310                cancelled,
311                truncated: truncated_flag,
312                execution_error: None,
313            })
314        }
315        Err(e) => {
316            let is_aborted = e.code == ExecutionErrorCode::Aborted
317                || options.cancel.map(|t| t.is_cancelled()).unwrap_or(false);
318            if is_aborted {
319                Ok(ShellCaptureResult {
320                    output,
321                    truncation,
322                    full_output_path,
323                    last_line_bytes,
324                    exit_code: None,
325                    cancelled: true,
326                    truncated: truncated_flag,
327                    execution_error: None,
328                })
329            } else if options.return_execution_errors {
330                Ok(ShellCaptureResult {
331                    output,
332                    truncation,
333                    full_output_path,
334                    last_line_bytes,
335                    exit_code: None,
336                    cancelled: false,
337                    truncated: truncated_flag,
338                    execution_error: Some(e),
339                })
340            } else {
341                Err(e)
342            }
343        }
344    }
345}
346
347/// Create the temp file with initial content. Mirrors `ensureFullOutputFile`'s
348/// first-call path: `env.createTempFile({prefix:"bash-", suffix:".log"})` +
349/// `appendFile(path, initialContent)`.
350async fn init_full_output_file(
351    env: Arc<dyn ExecutionEnv>,
352    initial: &str,
353    cancel: Option<&CancellationToken>,
354) -> Result<PathBuf, ExecutionError> {
355    let path = env
356        .create_temp_file("bash-", ".log", cancel)
357        .await
358        .map_err(|e| ExecutionError::new(ExecutionErrorCode::Unknown, e.to_string()))?;
359    env.append_file(
360        &path.to_string_lossy(),
361        FileContent::Text(initial.to_string()),
362        cancel,
363    )
364    .await
365    .map_err(|e| ExecutionError::new(ExecutionErrorCode::Unknown, e.to_string()))?;
366    Ok(path)
367}
368
369/// Re-export `format_size` for the bash tool's truncation footer messages.
370pub use crate::truncate::format_size;
371
372#[cfg(test)]
373mod tests {
374    use super::*;
375
376    #[test]
377    fn sanitize_strips_controls_keeps_tab_lf() {
378        let s = "a\x01b\tc\nd\x0be";
379        let out = sanitize_and_strip_cr(&s);
380        assert_eq!(out, "ab\tc\nde");
381    }
382
383    #[test]
384    fn sanitize_strips_cr() {
385        let s = "a\r\nb\rc";
386        let out = sanitize_and_strip_cr(&s);
387        assert_eq!(out, "a\nbc");
388    }
389
390    #[test]
391    fn sanitize_strips_interlinear_annotation() {
392        let s = "a\u{fff9}b\u{fffb}c";
393        let out = sanitize_and_strip_cr(&s);
394        assert_eq!(out, "abc");
395    }
396
397    #[test]
398    fn capture_state_ingest_and_progress() {
399        let mut s = CaptureState::default();
400        s.ingest_chunk("line1\nline2\n");
401        s.ingest_chunk("line3");
402        let p = s.create_progress();
403        // 3 lines total (line1, line2, line3-open).
404        assert_eq!(p.truncation.total_lines, 3);
405        assert!(!p.truncation.truncated);
406        assert_eq!(p.output, "line1\nline2\nline3");
407    }
408}