1use crate::llm::message::{ContentBlock, Message, UserMessage};
11use std::io::{BufRead, BufReader, Read};
12use std::path::Path;
13use std::process::{Command, Stdio};
14use uuid::Uuid;
15
16pub const MAX_CAPTURE_BYTES: usize = 50 * 1024; #[derive(Debug, Clone, PartialEq, Eq)]
21pub struct CapturedOutput {
22 pub text: String,
24 pub truncated: bool,
26 pub exit_code: Option<i32>,
28}
29
30pub fn capture_lines<R: Read>(
35 reader: R,
36 buffer: &mut String,
37 truncated: &mut bool,
38 mut on_line: impl FnMut(&str),
39) {
40 for line in BufReader::new(reader).lines() {
41 match line {
42 Ok(line) => {
43 on_line(&line);
44 if !*truncated {
45 if buffer.len() + line.len() + 1 > MAX_CAPTURE_BYTES {
46 *truncated = true;
47 } else {
48 buffer.push_str(&line);
49 buffer.push('\n');
50 }
51 }
52 }
53 Err(_) => break,
54 }
55 }
56}
57
58pub fn run_and_capture(
64 cmd: &str,
65 cwd: &Path,
66 mut on_stdout: impl FnMut(&str),
67 mut on_stderr: impl FnMut(&str),
68) -> Result<CapturedOutput, String> {
69 let mut child = Command::new("bash")
70 .arg("-c")
71 .arg(cmd)
72 .current_dir(cwd)
73 .stdout(Stdio::piped())
74 .stderr(Stdio::piped())
75 .spawn()
76 .map_err(|e| format!("bash error: {e}"))?;
77
78 let mut captured = String::new();
79 let mut truncated = false;
80
81 if let Some(stdout) = child.stdout.take() {
82 capture_lines(stdout, &mut captured, &mut truncated, &mut on_stdout);
83 }
84
85 if let Some(stderr) = child.stderr.take() {
86 capture_lines(stderr, &mut captured, &mut truncated, &mut on_stderr);
87 }
88
89 let exit_code = child.wait().ok().and_then(|s| s.code());
90
91 Ok(CapturedOutput {
92 text: captured,
93 truncated,
94 exit_code,
95 })
96}
97
98pub fn build_context_message(cmd: &str, output: &CapturedOutput) -> Option<Message> {
102 if output.text.is_empty() {
103 return None;
104 }
105
106 let suffix = if output.truncated {
107 "\n[output truncated at 50KB]"
108 } else {
109 ""
110 };
111 let context_text = format!("[Shell output from: {cmd}]\n{}{suffix}", output.text);
112
113 Some(Message::User(UserMessage {
114 uuid: Uuid::new_v4(),
115 timestamp: chrono::Utc::now().to_rfc3339(),
116 content: vec![ContentBlock::Text { text: context_text }],
117 is_meta: true,
118 is_compact_summary: false,
119 }))
120}
121
122#[cfg(test)]
127mod tests {
128 use super::*;
129 use std::io::Cursor;
130
131 #[test]
134 fn captures_all_lines_under_limit() {
135 let input = "line one\nline two\nline three\n";
136 let reader = Cursor::new(input);
137 let mut buffer = String::new();
138 let mut truncated = false;
139 let mut printed = Vec::new();
140
141 capture_lines(reader, &mut buffer, &mut truncated, |line| {
142 printed.push(line.to_string());
143 });
144
145 assert_eq!(buffer, "line one\nline two\nline three\n");
146 assert!(!truncated);
147 assert_eq!(printed, vec!["line one", "line two", "line three"]);
148 }
149
150 #[test]
151 fn truncates_at_limit() {
152 let long_line = "x".repeat(1024);
154 let lines: Vec<String> = (0..60).map(|_| long_line.clone()).collect();
155 let input = lines.join("\n") + "\n";
156 let reader = Cursor::new(input);
157 let mut buffer = String::new();
158 let mut truncated = false;
159
160 capture_lines(reader, &mut buffer, &mut truncated, |_| {});
161
162 assert!(truncated);
163 assert!(buffer.len() <= MAX_CAPTURE_BYTES);
164 let captured_lines: Vec<&str> = buffer.lines().collect();
166 assert!(captured_lines.len() < 60);
167 assert!(!captured_lines.is_empty());
168 }
169
170 #[test]
171 fn empty_input_produces_empty_buffer() {
172 let reader = Cursor::new("");
173 let mut buffer = String::new();
174 let mut truncated = false;
175
176 capture_lines(reader, &mut buffer, &mut truncated, |_| {});
177
178 assert!(buffer.is_empty());
179 assert!(!truncated);
180 }
181
182 #[test]
183 fn single_line_no_newline() {
184 let reader = Cursor::new("just one line");
185 let mut buffer = String::new();
186 let mut truncated = false;
187
188 capture_lines(reader, &mut buffer, &mut truncated, |_| {});
189
190 assert_eq!(buffer, "just one line\n");
191 assert!(!truncated);
192 }
193
194 #[test]
195 fn on_line_called_for_every_line_even_after_truncation() {
196 let long_line = "x".repeat(MAX_CAPTURE_BYTES + 100);
199 let input = format!("{long_line}\nsecond line\n");
200 let reader = Cursor::new(input);
201 let mut buffer = String::new();
202 let mut truncated = false;
203 let mut callback_count = 0;
204
205 capture_lines(reader, &mut buffer, &mut truncated, |_| {
206 callback_count += 1;
207 });
208
209 assert_eq!(callback_count, 2);
211 assert!(truncated);
212 }
213
214 #[test]
215 fn truncation_boundary_exact() {
216 let fill_len = MAX_CAPTURE_BYTES - 10;
219 let fill = "a".repeat(fill_len);
220 let overflow = "b".repeat(20);
221 let input = format!("{fill}\n{overflow}\n");
222 let reader = Cursor::new(input);
223 let mut buffer = String::new();
224 let mut truncated = false;
225
226 capture_lines(reader, &mut buffer, &mut truncated, |_| {});
227
228 assert!(truncated);
231 assert!(buffer.contains(&fill));
232 assert!(!buffer.contains(&overflow));
233 }
234
235 #[test]
238 fn builds_message_with_header() {
239 let output = CapturedOutput {
240 text: "hello world\n".to_string(),
241 truncated: false,
242 exit_code: Some(0),
243 };
244
245 let msg = build_context_message("echo hello", &output).unwrap();
246 if let Message::User(u) = &msg {
247 assert!(u.is_meta);
248 assert!(!u.is_compact_summary);
249 assert_eq!(u.content.len(), 1);
250 if let ContentBlock::Text { text } = &u.content[0] {
251 assert!(text.starts_with("[Shell output from: echo hello]\n"));
252 assert!(text.contains("hello world"));
253 assert!(!text.contains("[output truncated"));
254 } else {
255 panic!("Expected Text block");
256 }
257 } else {
258 panic!("Expected User message");
259 }
260 }
261
262 #[test]
263 fn builds_message_with_truncation_suffix() {
264 let output = CapturedOutput {
265 text: "partial output\n".to_string(),
266 truncated: true,
267 exit_code: Some(0),
268 };
269
270 let msg = build_context_message("big-cmd", &output).unwrap();
271 if let Message::User(u) = &msg {
272 if let ContentBlock::Text { text } = &u.content[0] {
273 assert!(text.contains("[output truncated at 50KB]"));
274 assert!(text.starts_with("[Shell output from: big-cmd]\n"));
275 } else {
276 panic!("Expected Text block");
277 }
278 } else {
279 panic!("Expected User message");
280 }
281 }
282
283 #[test]
284 fn empty_output_returns_none() {
285 let output = CapturedOutput {
286 text: String::new(),
287 truncated: false,
288 exit_code: Some(0),
289 };
290
291 assert!(build_context_message("true", &output).is_none());
292 }
293
294 #[test]
295 fn preserves_multiline_output() {
296 let output = CapturedOutput {
297 text: "line 1\nline 2\nline 3\n".to_string(),
298 truncated: false,
299 exit_code: Some(0),
300 };
301
302 let msg = build_context_message("seq 3", &output).unwrap();
303 if let Message::User(u) = &msg {
304 if let ContentBlock::Text { text } = &u.content[0] {
305 assert!(text.contains("line 1\nline 2\nline 3\n"));
306 } else {
307 panic!("Expected Text block");
308 }
309 } else {
310 panic!("Expected User message");
311 }
312 }
313
314 #[test]
315 fn special_characters_in_command_preserved() {
316 let output = CapturedOutput {
317 text: "ok\n".to_string(),
318 truncated: false,
319 exit_code: Some(0),
320 };
321
322 let msg = build_context_message("grep -r 'foo bar' .", &output).unwrap();
323 if let Message::User(u) = &msg {
324 if let ContentBlock::Text { text } = &u.content[0] {
325 assert!(text.contains("[Shell output from: grep -r 'foo bar' .]"));
326 } else {
327 panic!("Expected Text block");
328 }
329 } else {
330 panic!("Expected User message");
331 }
332 }
333
334 #[test]
337 fn captures_stdout_from_echo() {
338 let dir = std::env::temp_dir();
339 let result = run_and_capture("echo hello_e2e", &dir, |_| {}, |_| {}).unwrap();
340
341 assert_eq!(result.text, "hello_e2e\n");
342 assert!(!result.truncated);
343 assert_eq!(result.exit_code, Some(0));
344 }
345
346 #[test]
347 fn captures_stderr() {
348 let dir = std::env::temp_dir();
349 let result = run_and_capture("echo err_msg >&2", &dir, |_| {}, |_| {}).unwrap();
350
351 assert!(result.text.contains("err_msg"));
352 assert!(!result.truncated);
353 }
354
355 #[test]
356 fn captures_mixed_stdout_stderr() {
357 let dir = std::env::temp_dir();
358 let result = run_and_capture(
359 "echo stdout_line && echo stderr_line >&2",
360 &dir,
361 |_| {},
362 |_| {},
363 )
364 .unwrap();
365
366 assert!(result.text.contains("stdout_line"));
367 assert!(result.text.contains("stderr_line"));
368 }
369
370 #[test]
371 fn captures_multiline_output() {
372 let dir = std::env::temp_dir();
373 let result = run_and_capture("printf 'a\\nb\\nc\\n'", &dir, |_| {}, |_| {}).unwrap();
374
375 assert_eq!(result.text, "a\nb\nc\n");
376 assert!(!result.truncated);
377 }
378
379 #[test]
380 fn handles_empty_output_command() {
381 let dir = std::env::temp_dir();
382 let result = run_and_capture("true", &dir, |_| {}, |_| {}).unwrap();
383
384 assert!(result.text.is_empty());
385 assert!(!result.truncated);
386 assert_eq!(result.exit_code, Some(0));
387 }
388
389 #[test]
390 fn captures_nonzero_exit_code() {
391 let dir = std::env::temp_dir();
392 let result = run_and_capture("exit 42", &dir, |_| {}, |_| {}).unwrap();
393
394 assert_eq!(result.exit_code, Some(42));
395 }
396
397 #[test]
398 fn respects_cwd() {
399 let tmp = tempfile::tempdir().unwrap();
400 std::fs::write(tmp.path().join("marker.txt"), "found_it").unwrap();
401
402 let result = run_and_capture("cat marker.txt", tmp.path(), |_| {}, |_| {}).unwrap();
403
404 assert!(result.text.contains("found_it"));
405 }
406
407 #[test]
408 fn callbacks_receive_all_lines() {
409 let dir = std::env::temp_dir();
410 let mut stdout_lines = Vec::new();
411 let mut stderr_lines = Vec::new();
412
413 let _ = run_and_capture(
414 "echo out1 && echo out2 && echo err1 >&2",
415 &dir,
416 |line| stdout_lines.push(line.to_string()),
417 |line| stderr_lines.push(line.to_string()),
418 );
419
420 assert_eq!(stdout_lines, vec!["out1", "out2"]);
421 assert_eq!(stderr_lines, vec!["err1"]);
422 }
423
424 #[test]
425 fn truncates_large_output() {
426 let dir = std::env::temp_dir();
427 let result = run_and_capture(
429 "for i in $(seq 1 1000); do printf '%0100d\\n' $i; done",
430 &dir,
431 |_| {},
432 |_| {},
433 )
434 .unwrap();
435
436 assert!(result.truncated);
437 assert!(result.text.len() <= MAX_CAPTURE_BYTES);
438 let line_count = result.text.lines().count();
440 assert!(line_count > 100);
441 assert!(line_count < 1000);
442 }
443
444 #[test]
445 fn invalid_command_returns_error_output() {
446 let dir = std::env::temp_dir();
447 let result = run_and_capture(
448 "nonexistent_command_xyz123 2>&1 || true",
449 &dir,
450 |_| {},
451 |_| {},
452 )
453 .unwrap();
454
455 assert!(
458 result.text.contains("not found") || result.text.contains("No such file"),
459 "Expected error message, got: {}",
460 result.text
461 );
462 }
463
464 #[test]
467 fn full_pipeline_echo_to_context_message() {
468 let dir = std::env::temp_dir();
469 let result = run_and_capture("echo integration_test_marker", &dir, |_| {}, |_| {}).unwrap();
470 let msg = build_context_message("echo integration_test_marker", &result).unwrap();
471
472 if let Message::User(u) = &msg {
473 assert!(u.is_meta);
474 if let ContentBlock::Text { text } = &u.content[0] {
475 assert!(text.starts_with("[Shell output from: echo integration_test_marker]"));
476 assert!(text.contains("integration_test_marker"));
477 } else {
478 panic!("Expected Text block");
479 }
480 } else {
481 panic!("Expected User message");
482 }
483 }
484
485 #[test]
486 fn full_pipeline_empty_command_no_message() {
487 let dir = std::env::temp_dir();
488 let result = run_and_capture("true", &dir, |_| {}, |_| {}).unwrap();
489 let msg = build_context_message("true", &result);
490
491 assert!(msg.is_none());
492 }
493
494 #[test]
495 fn full_pipeline_truncated_output_has_suffix() {
496 let dir = std::env::temp_dir();
497 let result = run_and_capture(
498 "for i in $(seq 1 2000); do printf '%0100d\\n' $i; done",
499 &dir,
500 |_| {},
501 |_| {},
502 )
503 .unwrap();
504
505 assert!(result.truncated);
506
507 let msg = build_context_message("big-output", &result).unwrap();
508 if let Message::User(u) = &msg {
509 if let ContentBlock::Text { text } = &u.content[0] {
510 assert!(text.contains("[output truncated at 50KB]"));
511 assert!(text.starts_with("[Shell output from: big-output]"));
512 } else {
513 panic!("Expected Text block");
514 }
515 } else {
516 panic!("Expected User message");
517 }
518 }
519}