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 shell: Option<crate::env::ShellOverride>,
67 pub cwd: Option<PathBuf>,
68 pub env: Option<HashMap<String, String>>,
69 pub inherit_env: bool,
70 pub timeout: Option<f64>,
71 pub cancel: Option<&'a CancellationToken>,
72 pub on_chunk: Option<Box<dyn FnMut(&str, &dyn Fn() -> ShellCaptureProgress) + Send + 'a>>,
73 pub return_execution_errors: bool,
74}
75
76impl<'a> Default for ShellCaptureOptions<'a> {
77 fn default() -> Self {
78 Self {
79 shell: None,
80 cwd: None,
81 env: None,
82 inherit_env: true,
83 timeout: None,
84 cancel: None,
85 on_chunk: None,
86 return_execution_errors: false,
87 }
88 }
89}
90
91#[derive(Default)]
94struct CaptureState {
95 tail_output: String,
96 total_bytes: usize,
97 completed_lines: usize,
98 has_open_line: bool,
99 current_line_bytes: usize,
100 full_output_path: Option<PathBuf>,
101 full_output_requested: bool,
102}
103
104impl CaptureState {
105 fn ingest_chunk(&mut self, text: &str) {
106 let text_bytes = text.len();
107 self.total_bytes += text_bytes;
108 let newline_count = text.matches('\n').count();
109 self.completed_lines += newline_count;
110 let last_newline = text.rfind('\n');
111 match last_newline {
112 Some(idx) => {
113 let trailing = &text[idx + 1..];
114 self.current_line_bytes = trailing.len();
115 self.has_open_line = !trailing.is_empty();
116 }
117 None if !text.is_empty() => {
118 self.current_line_bytes += text_bytes;
119 self.has_open_line = true;
120 }
121 None => {}
122 }
123 self.tail_output.push_str(text);
124 let trimmed = trim_to_last_utf8_bytes(&self.tail_output, MAX_OUTPUT_BYTES);
126 if trimmed.len() != self.tail_output.len() {
127 self.tail_output = trimmed.to_string();
128 }
129 }
130
131 fn create_progress(&self) -> ShellCaptureProgress {
132 let tail_truncation = truncate_tail(&self.tail_output, TruncationOptions::default());
133 let total_lines = self.completed_lines + if self.has_open_line { 1 } else { 0 };
134 let truncated = total_lines > DEFAULT_MAX_LINES || self.total_bytes > DEFAULT_MAX_BYTES;
135 let truncated_by = if truncated {
136 tail_truncation
137 .truncated_by
138 .or(if self.total_bytes > DEFAULT_MAX_BYTES {
139 Some(TruncationLimit::Bytes)
140 } else {
141 Some(TruncationLimit::Lines)
142 })
143 } else {
144 None
145 };
146 let truncation = TruncationResult {
147 truncated,
148 truncated_by,
149 total_lines,
150 total_bytes: self.total_bytes,
151 ..tail_truncation
152 };
153 let output = if truncated {
154 truncation.content.clone()
155 } else {
156 self.tail_output.clone()
157 };
158 ShellCaptureProgress {
159 output,
160 truncation,
161 full_output_path: self.full_output_path.clone(),
162 last_line_bytes: self.current_line_bytes,
163 }
164 }
165}
166
167fn sanitize_and_strip_cr(chunk: &str) -> String {
176 let mut out = String::with_capacity(chunk.len());
177 let mut chars = chunk.chars().peekable();
178 while let Some(c) = chars.next() {
179 let u = c as u32;
180 if c == '\x1b' {
181 skip_escape_sequence(&mut chars);
182 } else if u == 0x09 || u == 0x0a || u == 0x0d {
183 out.push(c);
184 } else if u <= 0x1f {
185 } else if (0xfff9..=0xfffb).contains(&u) {
187 } else {
189 out.push(c);
190 }
191 }
192 out.replace('\r', "")
193}
194
195fn skip_escape_sequence(chars: &mut std::iter::Peekable<std::str::Chars<'_>>) {
200 match chars.peek() {
201 Some('[') => {
202 chars.next();
203 while let Some(c) = chars.next() {
206 if (0x40..=0x7e).contains(&(c as u32)) {
207 break;
208 }
209 }
210 }
211 Some(']') => {
212 chars.next();
213 while let Some(c) = chars.next() {
215 if c == '\x07' {
216 break;
217 }
218 if c == '\x1b' {
219 if let Some('\\') = chars.peek() {
220 chars.next();
221 }
222 break;
223 }
224 }
225 }
226 Some('(') | Some(')') => {
227 chars.next();
229 chars.next();
230 }
231 Some(_) => {
232 chars.next();
233 }
234 None => {}
235 }
236}
237
238pub async fn execute_shell_with_capture(
242 env: &Arc<dyn ExecutionEnv>,
243 command: &str,
244 options: ShellCaptureOptions<'_>,
245) -> Result<ShellCaptureResult, ExecutionError> {
246 let state = Arc::new(Mutex::new(CaptureState::default()));
247
248 let user_cb: Arc<
252 Mutex<Option<Box<dyn FnMut(&str, &dyn Fn() -> ShellCaptureProgress) + Send>>>,
253 > = Arc::new(Mutex::new(options.on_chunk));
254
255 let state_stdout = state.clone();
256 let user_cb_stdout = user_cb.clone();
257 let on_stdout: Box<dyn FnMut(&str) + Send> = Box::new(move |chunk: &str| {
258 let text = sanitize_and_strip_cr(chunk);
259 let snap = {
263 let mut s = match state_stdout.try_lock() {
264 Ok(g) => g,
265 Err(_) => return, };
267 s.ingest_chunk(&text);
268 s.create_progress()
269 };
270 let mut cbg = match user_cb_stdout.try_lock() {
271 Ok(g) => g,
272 Err(_) => return,
273 };
274 if let Some(cb) = cbg.as_mut() {
275 let snap = snap.clone();
278 let getter = move || snap.clone();
279 cb(chunk, &getter);
280 }
281 });
282
283 let state_stderr = state.clone();
284 let user_cb_stderr = user_cb.clone();
285 let on_stderr: Box<dyn FnMut(&str) + Send> = Box::new(move |chunk: &str| {
286 let text = sanitize_and_strip_cr(chunk);
287 let snap = {
288 let mut s = match state_stderr.try_lock() {
289 Ok(g) => g,
290 Err(_) => return,
291 };
292 s.ingest_chunk(&text);
293 s.create_progress()
294 };
295 let mut cbg = match user_cb_stderr.try_lock() {
296 Ok(g) => g,
297 Err(_) => return,
298 };
299 if let Some(cb) = cbg.as_mut() {
300 let snap = snap.clone();
301 let getter = move || snap.clone();
302 cb(chunk, &getter);
303 }
304 });
305
306 let exec_opts = ShellExecOptions {
307 shell: options.shell.clone(),
308 cwd: options.cwd.clone(),
309 env: options.env.clone(),
310 inherit_env: options.inherit_env,
311 timeout: options.timeout,
312 cancel: options.cancel,
313 on_stdout: Some(on_stdout),
314 on_stderr: Some(on_stderr),
315 };
316
317 let exec_result = env.exec(command, exec_opts).await;
318
319 {
322 let progress = state.lock().await.create_progress();
323 if progress.truncation.truncated {
324 let s = state.lock().await;
325 if !s.full_output_requested {
326 let initial = s.tail_output.clone();
327 drop(s);
328 match init_full_output_file(env.clone(), &initial, options.cancel).await {
329 Ok(path) => {
330 let mut s2 = state.lock().await;
331 s2.full_output_path = Some(path);
332 s2.full_output_requested = true;
333 }
334 Err(e) => {
335 tracing::warn!("bash full-output temp file init failed: {e}");
339 }
340 }
341 }
342 }
343 }
344
345 let progress = state.lock().await.create_progress();
346 let ShellCaptureProgress {
349 output,
350 truncation,
351 full_output_path,
352 last_line_bytes,
353 } = progress;
354 let truncated_flag = truncation.truncated;
355 match exec_result {
356 Ok(out) => {
357 let cancelled = options.cancel.map(|t| t.is_cancelled()).unwrap_or(false);
358 Ok(ShellCaptureResult {
359 output: output.clone(),
360 truncation: truncation.clone(),
361 full_output_path: full_output_path.clone(),
362 last_line_bytes,
363 exit_code: if cancelled { None } else { Some(out.exit_code) },
364 cancelled,
365 truncated: truncated_flag,
366 execution_error: None,
367 })
368 }
369 Err(e) => {
370 let is_aborted = e.code == ExecutionErrorCode::Aborted
371 || options.cancel.map(|t| t.is_cancelled()).unwrap_or(false);
372 if is_aborted {
373 Ok(ShellCaptureResult {
374 output,
375 truncation,
376 full_output_path,
377 last_line_bytes,
378 exit_code: None,
379 cancelled: true,
380 truncated: truncated_flag,
381 execution_error: None,
382 })
383 } else if options.return_execution_errors {
384 Ok(ShellCaptureResult {
385 output,
386 truncation,
387 full_output_path,
388 last_line_bytes,
389 exit_code: None,
390 cancelled: false,
391 truncated: truncated_flag,
392 execution_error: Some(e),
393 })
394 } else {
395 Err(e)
396 }
397 }
398 }
399}
400
401async fn init_full_output_file(
405 env: Arc<dyn ExecutionEnv>,
406 initial: &str,
407 cancel: Option<&CancellationToken>,
408) -> Result<PathBuf, ExecutionError> {
409 let path = env
410 .create_temp_file("bash-", ".log", cancel)
411 .await
412 .map_err(|e| ExecutionError::new(ExecutionErrorCode::Unknown, e.to_string()))?;
413 env.append_file(
414 &path.to_string_lossy(),
415 FileContent::Text(initial.to_string()),
416 cancel,
417 )
418 .await
419 .map_err(|e| ExecutionError::new(ExecutionErrorCode::Unknown, e.to_string()))?;
420 Ok(path)
421}
422
423pub use crate::truncate::format_size;
425
426#[cfg(test)]
427mod tests {
428 use super::*;
429
430 #[test]
431 fn sanitize_strips_controls_keeps_tab_lf() {
432 let s = "a\x01b\tc\nd\x0be";
433 let out = sanitize_and_strip_cr(&s);
434 assert_eq!(out, "ab\tc\nde");
435 }
436
437 #[test]
438 fn sanitize_strips_cr() {
439 let s = "a\r\nb\rc";
440 let out = sanitize_and_strip_cr(&s);
441 assert_eq!(out, "a\nbc");
442 }
443
444 #[test]
445 fn sanitize_strips_interlinear_annotation() {
446 let s = "a\u{fff9}b\u{fffb}c";
447 let out = sanitize_and_strip_cr(&s);
448 assert_eq!(out, "abc");
449 }
450
451 #[test]
452 fn sanitize_strips_full_ansi_sequences() {
453 let s = "\x1b[36mvite v6.4.3\x1b[39m \x1b[32m\u{2713}\x1b[39m built\n";
456 assert_eq!(sanitize_and_strip_cr(s), "vite v6.4.3 \u{2713} built\n");
457 let s = "a\x1b[1m\x1b[2mX\x1b[22m\x1b[22mb";
458 assert_eq!(sanitize_and_strip_cr(s), "aXb");
459 }
460
461 #[test]
462 fn sanitize_strips_osc_sequences() {
463 assert_eq!(sanitize_and_strip_cr("a\x1b]0;title\x07b"), "ab");
464 assert_eq!(sanitize_and_strip_cr("a\x1b]8;;http://x\x1b\\b"), "ab");
465 }
466
467 #[test]
468 fn capture_state_ingest_and_progress() {
469 let mut s = CaptureState::default();
470 s.ingest_chunk("line1\nline2\n");
471 s.ingest_chunk("line3");
472 let p = s.create_progress();
473 assert_eq!(p.truncation.total_lines, 3);
475 assert!(!p.truncation.truncated);
476 assert_eq!(p.output, "line1\nline2\nline3");
477 }
478}