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 {
174 let mut out = String::with_capacity(chunk.len());
175 let mut chars = chunk.chars().peekable();
176 while let Some(c) = chars.next() {
177 let u = c as u32;
178 if c == '\x1b' {
179 skip_escape_sequence(&mut chars);
180 } else if u == 0x09 || u == 0x0a || u == 0x0d {
181 out.push(c);
182 } else if u <= 0x1f {
183 } else if (0xfff9..=0xfffb).contains(&u) {
185 } else {
187 out.push(c);
188 }
189 }
190 out.replace('\r', "")
191}
192
193fn skip_escape_sequence(chars: &mut std::iter::Peekable<std::str::Chars<'_>>) {
198 match chars.peek() {
199 Some('[') => {
200 chars.next();
201 while let Some(c) = chars.next() {
204 if (0x40..=0x7e).contains(&(c as u32)) {
205 break;
206 }
207 }
208 }
209 Some(']') => {
210 chars.next();
211 while let Some(c) = chars.next() {
213 if c == '\x07' {
214 break;
215 }
216 if c == '\x1b' {
217 if let Some('\\') = chars.peek() {
218 chars.next();
219 }
220 break;
221 }
222 }
223 }
224 Some('(') | Some(')') => {
225 chars.next();
227 chars.next();
228 }
229 Some(_) => {
230 chars.next();
231 }
232 None => {}
233 }
234}
235
236pub async fn execute_shell_with_capture(
240 env: &Arc<dyn ExecutionEnv>,
241 command: &str,
242 options: ShellCaptureOptions<'_>,
243) -> Result<ShellCaptureResult, ExecutionError> {
244 let state = Arc::new(Mutex::new(CaptureState::default()));
245
246 let user_cb: Arc<
250 Mutex<Option<Box<dyn FnMut(&str, &dyn Fn() -> ShellCaptureProgress) + Send>>>,
251 > = Arc::new(Mutex::new(options.on_chunk));
252
253 let state_stdout = state.clone();
254 let user_cb_stdout = user_cb.clone();
255 let on_stdout: Box<dyn FnMut(&str) + Send> = Box::new(move |chunk: &str| {
256 let text = sanitize_and_strip_cr(chunk);
257 let snap = {
261 let mut s = match state_stdout.try_lock() {
262 Ok(g) => g,
263 Err(_) => return, };
265 s.ingest_chunk(&text);
266 s.create_progress()
267 };
268 let mut cbg = match user_cb_stdout.try_lock() {
269 Ok(g) => g,
270 Err(_) => return,
271 };
272 if let Some(cb) = cbg.as_mut() {
273 let snap = snap.clone();
276 let getter = move || snap.clone();
277 cb(chunk, &getter);
278 }
279 });
280
281 let state_stderr = state.clone();
282 let user_cb_stderr = user_cb.clone();
283 let on_stderr: Box<dyn FnMut(&str) + Send> = Box::new(move |chunk: &str| {
284 let text = sanitize_and_strip_cr(chunk);
285 let snap = {
286 let mut s = match state_stderr.try_lock() {
287 Ok(g) => g,
288 Err(_) => return,
289 };
290 s.ingest_chunk(&text);
291 s.create_progress()
292 };
293 let mut cbg = match user_cb_stderr.try_lock() {
294 Ok(g) => g,
295 Err(_) => return,
296 };
297 if let Some(cb) = cbg.as_mut() {
298 let snap = snap.clone();
299 let getter = move || snap.clone();
300 cb(chunk, &getter);
301 }
302 });
303
304 let exec_opts = ShellExecOptions {
305 cwd: options.cwd.clone(),
306 env: options.env.clone(),
307 inherit_env: options.inherit_env,
308 timeout: options.timeout,
309 cancel: options.cancel,
310 on_stdout: Some(on_stdout),
311 on_stderr: Some(on_stderr),
312 };
313
314 let exec_result = env.exec(command, exec_opts).await;
315
316 {
319 let progress = state.lock().await.create_progress();
320 if progress.truncation.truncated {
321 let s = state.lock().await;
322 if !s.full_output_requested {
323 let initial = s.tail_output.clone();
324 drop(s);
325 match init_full_output_file(env.clone(), &initial, options.cancel).await {
326 Ok(path) => {
327 let mut s2 = state.lock().await;
328 s2.full_output_path = Some(path);
329 s2.full_output_requested = true;
330 }
331 Err(e) => {
332 tracing::warn!("bash full-output temp file init failed: {e}");
336 }
337 }
338 }
339 }
340 }
341
342 let progress = state.lock().await.create_progress();
343 let ShellCaptureProgress {
346 output,
347 truncation,
348 full_output_path,
349 last_line_bytes,
350 } = progress;
351 let truncated_flag = truncation.truncated;
352 match exec_result {
353 Ok(out) => {
354 let cancelled = options.cancel.map(|t| t.is_cancelled()).unwrap_or(false);
355 Ok(ShellCaptureResult {
356 output: output.clone(),
357 truncation: truncation.clone(),
358 full_output_path: full_output_path.clone(),
359 last_line_bytes,
360 exit_code: if cancelled { None } else { Some(out.exit_code) },
361 cancelled,
362 truncated: truncated_flag,
363 execution_error: None,
364 })
365 }
366 Err(e) => {
367 let is_aborted = e.code == ExecutionErrorCode::Aborted
368 || options.cancel.map(|t| t.is_cancelled()).unwrap_or(false);
369 if is_aborted {
370 Ok(ShellCaptureResult {
371 output,
372 truncation,
373 full_output_path,
374 last_line_bytes,
375 exit_code: None,
376 cancelled: true,
377 truncated: truncated_flag,
378 execution_error: None,
379 })
380 } else if options.return_execution_errors {
381 Ok(ShellCaptureResult {
382 output,
383 truncation,
384 full_output_path,
385 last_line_bytes,
386 exit_code: None,
387 cancelled: false,
388 truncated: truncated_flag,
389 execution_error: Some(e),
390 })
391 } else {
392 Err(e)
393 }
394 }
395 }
396}
397
398async fn init_full_output_file(
402 env: Arc<dyn ExecutionEnv>,
403 initial: &str,
404 cancel: Option<&CancellationToken>,
405) -> Result<PathBuf, ExecutionError> {
406 let path = env
407 .create_temp_file("bash-", ".log", cancel)
408 .await
409 .map_err(|e| ExecutionError::new(ExecutionErrorCode::Unknown, e.to_string()))?;
410 env.append_file(
411 &path.to_string_lossy(),
412 FileContent::Text(initial.to_string()),
413 cancel,
414 )
415 .await
416 .map_err(|e| ExecutionError::new(ExecutionErrorCode::Unknown, e.to_string()))?;
417 Ok(path)
418}
419
420pub use crate::truncate::format_size;
422
423#[cfg(test)]
424mod tests {
425 use super::*;
426
427 #[test]
428 fn sanitize_strips_controls_keeps_tab_lf() {
429 let s = "a\x01b\tc\nd\x0be";
430 let out = sanitize_and_strip_cr(&s);
431 assert_eq!(out, "ab\tc\nde");
432 }
433
434 #[test]
435 fn sanitize_strips_cr() {
436 let s = "a\r\nb\rc";
437 let out = sanitize_and_strip_cr(&s);
438 assert_eq!(out, "a\nbc");
439 }
440
441 #[test]
442 fn sanitize_strips_interlinear_annotation() {
443 let s = "a\u{fff9}b\u{fffb}c";
444 let out = sanitize_and_strip_cr(&s);
445 assert_eq!(out, "abc");
446 }
447
448 #[test]
449 fn sanitize_strips_full_ansi_sequences() {
450 let s = "\x1b[36mvite v6.4.3\x1b[39m \x1b[32m\u{2713}\x1b[39m built\n";
453 assert_eq!(sanitize_and_strip_cr(s), "vite v6.4.3 \u{2713} built\n");
454 let s = "a\x1b[1m\x1b[2mX\x1b[22m\x1b[22mb";
455 assert_eq!(sanitize_and_strip_cr(s), "aXb");
456 }
457
458 #[test]
459 fn sanitize_strips_osc_sequences() {
460 assert_eq!(sanitize_and_strip_cr("a\x1b]0;title\x07b"), "ab");
461 assert_eq!(sanitize_and_strip_cr("a\x1b]8;;http://x\x1b\\b"), "ab");
462 }
463
464 #[test]
465 fn capture_state_ingest_and_progress() {
466 let mut s = CaptureState::default();
467 s.ingest_chunk("line1\nline2\n");
468 s.ingest_chunk("line3");
469 let p = s.create_progress();
470 assert_eq!(p.truncation.total_lines, 3);
472 assert!(!p.truncation.truncated);
473 assert_eq!(p.output, "line1\nline2\nline3");
474 }
475}