1use 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
39const MAX_OUTPUT_BYTES: usize = DEFAULT_MAX_BYTES * 2;
41
42#[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#[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
64pub 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#[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 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
165fn 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 } else if (0xfff9..=0xfffb).contains(&u) {
177 } else {
179 out.push(c);
180 }
181 }
182 out.replace('\r', "")
183}
184
185pub 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 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 let snap = {
210 let mut s = match state_stdout.try_lock() {
211 Ok(g) => g,
212 Err(_) => return, };
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 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 {
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 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 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
347async 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
369pub 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 assert_eq!(p.truncation.total_lines, 3);
405 assert!(!p.truncation.truncated);
406 assert_eq!(p.output, "line1\nline2\nline3");
407 }
408}