vtcode_core/tools/summarizers/
execution.rs1use super::{Summarizer, truncate_line, truncate_to_tokens};
19use anyhow::Result;
20use serde_json::Value;
21use std::collections::VecDeque;
22
23pub struct BashSummarizer {
25 pub max_head_lines: usize,
27 pub max_tail_lines: usize,
29 pub max_tokens: usize,
31}
32
33impl Default for BashSummarizer {
34 fn default() -> Self {
35 Self {
36 max_head_lines: 20,
37 max_tail_lines: 10,
38 max_tokens: 500, }
40 }
41}
42
43impl Summarizer for BashSummarizer {
44 fn summarize(&self, full_output: &str, metadata: Option<&Value>) -> Result<String> {
45 let result = parse_bash_output(full_output, metadata);
47
48 let mut summary = String::new();
50
51 if let Some(cmd) = result.command {
53 summary.push_str(&format!("Command: {}\n", truncate_line(&cmd, 100)));
54 }
55
56 summary.push_str(&format!(
58 "Exit code: {} ({})\n",
59 result.exit_code,
60 if result.success { "success" } else { "failed" }
61 ));
62
63 if let Some(duration_ms) = result.duration_ms {
65 if duration_ms > 1000 {
66 summary.push_str(&format!("Duration: {:.1}s\n", duration_ms as f64 / 1000.0));
67 } else {
68 summary.push_str(&format!("Duration: {duration_ms}ms\n"));
69 }
70 }
71
72 if result.total_lines > 0 {
74 summary.push_str(&format!("\nOutput: {} lines", result.total_lines));
75
76 if result.total_bytes > 10_000 {
77 let kb = result.total_bytes / 1024;
78 summary.push_str(&format!(" ({kb} KB)"));
79 }
80
81 summary.push('\n');
82
83 if !result.head_lines.is_empty() {
85 summary.push_str("\nFirst lines:\n");
86 for line in &result.head_lines {
87 summary.push_str(&truncate_line(line, 120));
88 summary.push('\n');
89 }
90
91 if result.total_lines > self.max_head_lines {
92 let omitted = result.total_lines.saturating_sub(self.max_head_lines + self.max_tail_lines);
93 if omitted > 0 {
94 summary.push_str(&format!("[...{omitted} more lines]\n"));
95 }
96 }
97 }
98
99 if result.total_lines > self.max_head_lines + 1 && !result.tail_lines.is_empty() {
101 summary.push_str("\nLast lines:\n");
102 for line in &result.tail_lines {
103 summary.push_str(&truncate_line(line, 120));
104 summary.push('\n');
105 }
106 }
107 } else if !result.stderr.is_empty() {
108 summary.push_str("\nError output:\n");
110 for line in result.stderr.lines().take(self.max_head_lines) {
111 summary.push_str(&truncate_line(line, 120));
112 summary.push('\n');
113 }
114 } else {
115 summary.push_str("\n(No output)\n");
116 }
117
118 Ok(truncate_to_tokens(&summary, self.max_tokens))
119 }
120}
121
122#[derive(Debug, Default)]
124struct BashResult {
125 command: Option<String>,
126 exit_code: i32,
127 success: bool,
128 duration_ms: Option<u64>,
129 total_lines: usize,
130 total_bytes: usize,
131 head_lines: Vec<String>,
132 tail_lines: Vec<String>,
133 stderr: String,
134}
135
136fn parse_bash_output(output: &str, metadata: Option<&Value>) -> BashResult {
138 let mut result = BashResult::default();
139
140 if let Ok(json) = serde_json::from_str::<Value>(output) {
142 result.command = json.get("command").and_then(|c| c.as_str()).map(|s| s.to_string());
143
144 result.exit_code = json
145 .get("exit_code")
146 .or_else(|| json.get("exitCode"))
147 .and_then(|e| e.as_i64())
148 .unwrap_or(0) as i32;
149
150 result.success = json.get("success").and_then(|s| s.as_bool()).unwrap_or(result.exit_code == 0);
151
152 result.duration_ms = json
153 .get("duration_ms")
154 .or_else(|| json.get("durationMs"))
155 .and_then(|d| d.as_u64());
156
157 if let Some(stdout) = json.get("stdout").or_else(|| json.get("output")) {
159 let stdout_str = if let Some(s) = stdout.as_str() {
160 s
161 } else {
162 &serde_json::to_string_pretty(stdout).unwrap_or_default()
163 };
164
165 parse_output_lines(stdout_str, &mut result);
166 }
167
168 if let Some(stderr) = json.get("stderr").or_else(|| json.get("error")) {
170 result.stderr = stderr.as_str().unwrap_or("").to_string();
171 }
172 } else {
173 parse_output_lines(output, &mut result);
175 result.success = !output.to_lowercase().contains("error");
176
177 if let Some(meta) = metadata {
179 result.command = meta.get("command").and_then(|c| c.as_str()).map(|s| s.to_string());
180 }
181 }
182
183 result
184}
185
186fn parse_output_lines(output: &str, result: &mut BashResult) {
188 const HEAD_LINES: usize = 5;
189 const TAIL_LINES: usize = 3;
190
191 result.total_lines = 0;
192 result.total_bytes = output.len();
193 result.head_lines.clear();
194 result.tail_lines.clear();
195
196 let mut tail: VecDeque<String> = VecDeque::with_capacity(TAIL_LINES);
197 for line in output.lines() {
198 result.total_lines += 1;
199 if result.head_lines.len() < HEAD_LINES {
200 result.head_lines.push(line.to_string());
201 }
202
203 if TAIL_LINES > 0 {
204 if tail.len() == TAIL_LINES {
205 tail.pop_front();
206 }
207 tail.push_back(line.to_string());
208 }
209 }
210
211 if result.total_lines > HEAD_LINES + TAIL_LINES {
213 result.tail_lines = tail.into_iter().collect();
214 }
215}
216
217#[cfg(test)]
218mod tests {
219 use super::*;
220
221 #[test]
222 fn test_bash_summarizer_json_success() {
223 let full_output = r#"{
224 "command": "ls -la /tmp",
225 "exit_code": 0,
226 "success": true,
227 "duration_ms": 42,
228 "stdout": "total 100\ndrwx------ 5 user wheel 160 Dec 21 10:30 .\ndrwxr-xr-x 6 root wheel 192 Dec 20 08:00 ..\n-rw-r--r-- 1 user wheel 512 Dec 21 10:30 file.txt"
229 }"#;
230
231 let summarizer = BashSummarizer::default();
232 let summary = summarizer.summarize(full_output, None).unwrap();
233
234 assert!(summary.contains("Command: ls -la /tmp"));
235 assert!(summary.contains("Exit code: 0 (success)"));
236 assert!(summary.contains("Duration: 42ms"));
237 assert!(summary.contains("Output: 4 lines"));
238 assert!(summary.contains("total 100"));
239
240 let savings = summarizer.estimate_savings(full_output, &summary);
242 assert!(savings.savings_percent > 10.0, "Should save >10% (got {:.1}%)", savings.savings_percent);
243 }
244
245 #[test]
246 fn test_bash_summarizer_json_failure() {
247 let full_output = r#"{
248 "command": "cat nonexistent.txt",
249 "exit_code": 1,
250 "success": false,
251 "stderr": "cat: nonexistent.txt: No such file or directory"
252 }"#;
253
254 let summarizer = BashSummarizer::default();
255 let summary = summarizer.summarize(full_output, None).unwrap();
256
257 assert!(summary.contains("Exit code: 1 (failed)"));
258 assert!(summary.contains("cat nonexistent.txt"));
259 assert!(summary.contains("Error output:") || summary.contains("No such file"));
260 }
261
262 #[test]
263 fn test_bash_summarizer_large_output() {
264 let mut lines = Vec::new();
265 for i in 1..=100 {
266 lines.push(format!("Line {i}: Some output here"));
267 }
268 let stdout = lines.join("\n");
269
270 let full_output = serde_json::json!({
271 "command": "generate_output",
272 "exit_code": 0,
273 "success": true,
274 "stdout": stdout
275 })
276 .to_string();
277
278 let summarizer = BashSummarizer::default();
279 let summary = summarizer.summarize(&full_output, None).unwrap();
280
281 assert!(summary.contains("Output: 100 lines"));
282 assert!(summary.contains("Line 1:"));
283 assert!(summary.contains("more lines"));
284
285 let savings = summarizer.estimate_savings(&full_output, &summary);
287 assert!(
288 savings.savings_percent > 70.0,
289 "Should save >70% on large output (got {:.1}%)",
290 savings.savings_percent
291 );
292 }
293
294 #[test]
295 fn test_bash_summarizer_plain_text() {
296 let full_output = "Hello World\nLine 2\nLine 3";
297
298 let summarizer = BashSummarizer::default();
299 let summary = summarizer.summarize(full_output, None).unwrap();
300
301 assert!(summary.contains("Output: 3 lines"));
302 assert!(summary.contains("Hello World"));
303 }
304
305 #[test]
306 fn test_bash_summarizer_with_metadata() {
307 let full_output = "Command output here";
308 let metadata = serde_json::json!({
309 "command": "echo 'test'"
310 });
311
312 let summarizer = BashSummarizer::default();
313 let summary = summarizer.summarize(full_output, Some(&metadata)).unwrap();
314
315 assert!(summary.contains("echo 'test'"));
316 }
317
318 #[test]
319 fn test_truncate_command() {
320 let long_cmd = "a".repeat(200);
321 let truncated = truncate_line(&long_cmd, 50);
322
323 assert!(truncated.len() <= 50);
324 assert!(truncated.ends_with("..."));
325 }
326
327 #[test]
328 fn truncate_line_rounds_down_to_char_boundary_and_borrows_short_input() {
329 assert_eq!(truncate_line("AB日本語", 6), "AB...");
331 assert!(matches!(truncate_line("short", 10), std::borrow::Cow::Borrowed("short")));
332 assert_eq!(truncate_line("abcdef", 5), "ab...");
333 }
334
335 #[test]
336 fn test_parse_bash_output_json() {
337 let output = r#"{"command": "test", "exit_code": 0, "stdout": "ok"}"#;
338 let result = parse_bash_output(output, None);
339
340 assert_eq!(result.command, Some("test".to_string()));
341 assert_eq!(result.exit_code, 0);
342 assert!(result.success);
343 }
344
345 #[test]
346 fn test_parse_output_lines() {
347 let mut result = BashResult::default();
348 let output = "Line 1\nLine 2\nLine 3";
349 parse_output_lines(output, &mut result);
350
351 assert_eq!(result.total_lines, 3);
352 assert_eq!(result.head_lines.len(), 3);
353 assert_eq!(result.head_lines[0], "Line 1");
354 }
355}