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_command(&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: {}ms\n", duration_ms));
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
93 .total_lines
94 .saturating_sub(self.max_head_lines + self.max_tail_lines);
95 if omitted > 0 {
96 summary.push_str(&format!("[...{} more lines]\n", omitted));
97 }
98 }
99 }
100
101 if result.total_lines > self.max_head_lines + 1 && !result.tail_lines.is_empty() {
103 summary.push_str("\nLast lines:\n");
104 for line in &result.tail_lines {
105 summary.push_str(&truncate_line(line, 120));
106 summary.push('\n');
107 }
108 }
109 } else if !result.stderr.is_empty() {
110 summary.push_str("\nError output:\n");
112 for line in result.stderr.lines().take(self.max_head_lines) {
113 summary.push_str(&truncate_line(line, 120));
114 summary.push('\n');
115 }
116 } else {
117 summary.push_str("\n(No output)\n");
118 }
119
120 Ok(truncate_to_tokens(&summary, self.max_tokens))
121 }
122}
123
124#[derive(Debug, Default)]
126struct BashResult {
127 command: Option<String>,
128 exit_code: i32,
129 success: bool,
130 duration_ms: Option<u64>,
131 total_lines: usize,
132 total_bytes: usize,
133 head_lines: Vec<String>,
134 tail_lines: Vec<String>,
135 stderr: String,
136}
137
138fn parse_bash_output(output: &str, metadata: Option<&Value>) -> BashResult {
140 let mut result = BashResult::default();
141
142 if let Ok(json) = serde_json::from_str::<Value>(output) {
144 result.command = json
145 .get("command")
146 .and_then(|c| c.as_str())
147 .map(|s| s.to_string());
148
149 result.exit_code = json
150 .get("exit_code")
151 .or_else(|| json.get("exitCode"))
152 .and_then(|e| e.as_i64())
153 .unwrap_or(0) as i32;
154
155 result.success = json
156 .get("success")
157 .and_then(|s| s.as_bool())
158 .unwrap_or(result.exit_code == 0);
159
160 result.duration_ms = json
161 .get("duration_ms")
162 .or_else(|| json.get("durationMs"))
163 .and_then(|d| d.as_u64());
164
165 if let Some(stdout) = json.get("stdout").or_else(|| json.get("output")) {
167 let stdout_str = if let Some(s) = stdout.as_str() {
168 s
169 } else {
170 &serde_json::to_string_pretty(stdout).unwrap_or_default()
171 };
172
173 parse_output_lines(stdout_str, &mut result);
174 }
175
176 if let Some(stderr) = json.get("stderr").or_else(|| json.get("error")) {
178 result.stderr = stderr.as_str().unwrap_or("").to_string();
179 }
180 } else {
181 parse_output_lines(output, &mut result);
183 result.success = !output.to_lowercase().contains("error");
184
185 if let Some(meta) = metadata {
187 result.command = meta
188 .get("command")
189 .and_then(|c| c.as_str())
190 .map(|s| s.to_string());
191 }
192 }
193
194 result
195}
196
197fn parse_output_lines(output: &str, result: &mut BashResult) {
199 const HEAD_LINES: usize = 5;
200 const TAIL_LINES: usize = 3;
201
202 result.total_lines = 0;
203 result.total_bytes = output.len();
204 result.head_lines.clear();
205 result.tail_lines.clear();
206
207 let mut tail: VecDeque<String> = VecDeque::with_capacity(TAIL_LINES);
208 for line in output.lines() {
209 result.total_lines += 1;
210 if result.head_lines.len() < HEAD_LINES {
211 result.head_lines.push(line.to_string());
212 }
213
214 if TAIL_LINES > 0 {
215 if tail.len() == TAIL_LINES {
216 tail.pop_front();
217 }
218 tail.push_back(line.to_string());
219 }
220 }
221
222 if result.total_lines > HEAD_LINES + TAIL_LINES {
224 result.tail_lines = tail.into_iter().collect();
225 }
226}
227
228fn truncate_command(cmd: &str, max_len: usize) -> String {
230 if cmd.len() <= max_len {
231 cmd.to_string()
232 } else {
233 let target = max_len.saturating_sub(3);
234 let end = cmd
235 .char_indices()
236 .map(|(i, _)| i)
237 .rfind(|&i| i <= target)
238 .unwrap_or(0);
239 format!("{}...", &cmd[..end])
240 }
241}
242
243#[cfg(test)]
244mod tests {
245 use super::*;
246
247 #[test]
248 fn test_bash_summarizer_json_success() {
249 let full_output = r#"{
250 "command": "ls -la /tmp",
251 "exit_code": 0,
252 "success": true,
253 "duration_ms": 42,
254 "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"
255 }"#;
256
257 let summarizer = BashSummarizer::default();
258 let summary = summarizer.summarize(full_output, None).unwrap();
259
260 assert!(summary.contains("Command: ls -la /tmp"));
261 assert!(summary.contains("Exit code: 0 (success)"));
262 assert!(summary.contains("Duration: 42ms"));
263 assert!(summary.contains("Output: 4 lines"));
264 assert!(summary.contains("total 100"));
265
266 let (_llm, _ui, pct) = summarizer.estimate_savings(full_output, &summary);
268 assert!(pct > 15.0, "Should save >15% (got {:.1}%)", pct);
269 }
270
271 #[test]
272 fn test_bash_summarizer_json_failure() {
273 let full_output = r#"{
274 "command": "cat nonexistent.txt",
275 "exit_code": 1,
276 "success": false,
277 "stderr": "cat: nonexistent.txt: No such file or directory"
278 }"#;
279
280 let summarizer = BashSummarizer::default();
281 let summary = summarizer.summarize(full_output, None).unwrap();
282
283 assert!(summary.contains("Exit code: 1 (failed)"));
284 assert!(summary.contains("cat nonexistent.txt"));
285 assert!(summary.contains("Error output:") || summary.contains("No such file"));
286 }
287
288 #[test]
289 fn test_bash_summarizer_large_output() {
290 let mut lines = Vec::new();
291 for i in 1..=100 {
292 lines.push(format!("Line {}: Some output here", i));
293 }
294 let stdout = lines.join("\n");
295
296 let full_output = serde_json::json!({
297 "command": "generate_output",
298 "exit_code": 0,
299 "success": true,
300 "stdout": stdout
301 })
302 .to_string();
303
304 let summarizer = BashSummarizer::default();
305 let summary = summarizer.summarize(&full_output, None).unwrap();
306
307 assert!(summary.contains("Output: 100 lines"));
308 assert!(summary.contains("Line 1:"));
309 assert!(summary.contains("more lines"));
310
311 let (_llm, _ui, pct) = summarizer.estimate_savings(&full_output, &summary);
313 assert!(
314 pct > 70.0,
315 "Should save >70% on large output (got {:.1}%)",
316 pct
317 );
318 }
319
320 #[test]
321 fn test_bash_summarizer_plain_text() {
322 let full_output = "Hello World\nLine 2\nLine 3";
323
324 let summarizer = BashSummarizer::default();
325 let summary = summarizer.summarize(full_output, None).unwrap();
326
327 assert!(summary.contains("Output: 3 lines"));
328 assert!(summary.contains("Hello World"));
329 }
330
331 #[test]
332 fn test_bash_summarizer_with_metadata() {
333 let full_output = "Command output here";
334 let metadata = serde_json::json!({
335 "command": "echo 'test'"
336 });
337
338 let summarizer = BashSummarizer::default();
339 let summary = summarizer.summarize(full_output, Some(&metadata)).unwrap();
340
341 assert!(summary.contains("echo 'test'"));
342 }
343
344 #[test]
345 fn test_truncate_command() {
346 let long_cmd = "a".repeat(200);
347 let truncated = truncate_command(&long_cmd, 50);
348
349 assert!(truncated.len() <= 50);
350 assert!(truncated.ends_with("..."));
351 }
352
353 #[test]
354 fn test_parse_bash_output_json() {
355 let output = r#"{"command": "test", "exit_code": 0, "stdout": "ok"}"#;
356 let result = parse_bash_output(output, None);
357
358 assert_eq!(result.command, Some("test".to_string()));
359 assert_eq!(result.exit_code, 0);
360 assert!(result.success);
361 }
362
363 #[test]
364 fn test_parse_output_lines() {
365 let mut result = BashResult::default();
366 let output = "Line 1\nLine 2\nLine 3";
367 parse_output_lines(output, &mut result);
368
369 assert_eq!(result.total_lines, 3);
370 assert_eq!(result.head_lines.len(), 3);
371 assert_eq!(result.head_lines[0], "Line 1");
372 }
373}