Skip to main content

vtcode_core/tools/summarizers/
file_ops.rs

1//! File operation result summarization
2//!
3//! Summarizes read_file and edit_file outputs from full content
4//! into concise summaries suitable for LLM context.
5//!
6//! ## Strategy
7//!
8//! Instead of sending full file contents (potentially thousands of lines),
9//! send structural information:
10//! - "Read 450 lines from src/main.rs. Preview: [first 10 lines]"
11//! - "Modified 3 files: +45 lines, -12 lines. Changed: auth.rs, db.rs, api.rs"
12//!
13//! Target: ~100-200 tokens vs potentially thousands
14
15use super::{Summarizer, truncate_line, truncate_to_tokens};
16use anyhow::Result;
17use serde_json::Value;
18use std::collections::VecDeque;
19
20/// Summarizer for read_file results
21pub struct ReadSummarizer {
22    /// Maximum number of preview lines to show (from start)
23    pub max_preview_lines: usize,
24    /// Maximum number of suffix lines to show (from end)
25    pub max_suffix_lines: usize,
26    /// Maximum tokens for entire summary
27    pub max_tokens: usize,
28}
29
30impl Default for ReadSummarizer {
31    fn default() -> Self {
32        Self {
33            max_preview_lines: 20,
34            max_suffix_lines: 10,
35            max_tokens: 500, // ~1000 chars for token efficiency
36        }
37    }
38}
39
40impl Summarizer for ReadSummarizer {
41    fn summarize(&self, full_output: &str, metadata: Option<&Value>) -> Result<String> {
42        // Try to extract file path from metadata if available
43        let file_path = metadata
44            .and_then(|m| m.get("file_path"))
45            .and_then(|f| f.as_str())
46            .unwrap_or("file");
47
48        // Parse the output to get file stats
49        let stats = parse_read_output(full_output);
50
51        // Build concise summary
52        let mut summary = format!("Read {} lines from {}", stats.total_lines, file_path);
53
54        // Add file size if significant
55        if stats.total_chars > 10_000 {
56            let kb = stats.total_chars / 1024;
57            summary.push_str(&format!(" ({kb} KB)"));
58        }
59
60        // Add preview of first lines
61        if !stats.preview_lines.is_empty() {
62            let preview = stats
63                .preview_lines
64                .iter()
65                .take(self.max_preview_lines)
66                .map(|line| truncate_line(line, 80))
67                .collect::<Vec<_>>()
68                .join("\n");
69
70            summary.push_str(&format!("\n\nPreview:\n{preview}"));
71
72            if stats.total_lines > self.max_preview_lines {
73                summary.push_str(&format!("\n[...{} more lines]", stats.total_lines - self.max_preview_lines));
74            }
75        }
76
77        // Add suffix lines if file is long
78        if stats.total_lines > self.max_preview_lines + self.max_suffix_lines && !stats.suffix_lines.is_empty() {
79            let suffix = stats
80                .suffix_lines
81                .iter()
82                .take(self.max_suffix_lines)
83                .map(|line| truncate_line(line, 80))
84                .collect::<Vec<_>>()
85                .join("\n");
86
87            summary.push_str(&format!("\n\nEnd:\n{suffix}"));
88        }
89
90        // Add guidance for long files
91        if stats.total_lines > self.max_preview_lines + self.max_suffix_lines {
92            summary.push_str(&format!(
93                "\n\n[Full file: {} lines. This preview is sufficient for most tasks. \
94                 Only re-read with offset/limit if you need specific lines not shown above.]",
95                stats.total_lines
96            ));
97        }
98
99        // Truncate to token limit
100        Ok(truncate_to_tokens(&summary, self.max_tokens))
101    }
102}
103
104/// Summarizer for edit_file results
105pub struct EditSummarizer {
106    /// Maximum tokens for entire summary
107    pub max_tokens: usize,
108}
109
110impl Default for EditSummarizer {
111    fn default() -> Self {
112        Self { max_tokens: 150 }
113    }
114}
115
116impl Summarizer for EditSummarizer {
117    fn summarize(&self, full_output: &str, _metadata: Option<&Value>) -> Result<String> {
118        // Parse edit output to extract statistics
119        let stats = parse_edit_output(full_output);
120
121        let mut summary = if stats.success {
122            format!("Modified {} file(s)", stats.files_changed)
123        } else {
124            "Edit failed".to_string()
125        };
126
127        // Add line change statistics
128        if stats.lines_added > 0 || stats.lines_removed > 0 {
129            summary.push_str(&format!(": +{} lines, -{} lines", stats.lines_added, stats.lines_removed));
130        }
131
132        // Add affected files
133        if !stats.affected_files.is_empty() {
134            let files = stats
135                .affected_files
136                .iter()
137                .take(5)
138                .map(|f| {
139                    // Extract just filename from path
140                    f.split('/').next_back().unwrap_or(f)
141                })
142                .collect::<Vec<_>>()
143                .join(", ");
144
145            summary.push_str(&format!(". Changed: {files}"));
146
147            if stats.affected_files.len() > 5 {
148                summary.push_str(&format!(" (+{} more)", stats.affected_files.len() - 5));
149            }
150        }
151
152        Ok(truncate_to_tokens(&summary, self.max_tokens))
153    }
154}
155
156/// Statistics extracted from read output
157#[derive(Debug, Default)]
158struct ReadStats {
159    total_lines: usize,
160    total_chars: usize,
161    preview_lines: Vec<String>,
162    suffix_lines: Vec<String>,
163}
164
165/// Statistics extracted from edit output
166#[derive(Debug, Default)]
167struct EditStats {
168    success: bool,
169    files_changed: usize,
170    lines_added: usize,
171    lines_removed: usize,
172    affected_files: Vec<String>,
173}
174
175/// Parse read_file output to extract statistics
176fn parse_read_output(output: &str) -> ReadStats {
177    let mut stats = ReadStats { total_chars: output.len(), ..ReadStats::default() };
178
179    const PREVIEW_LINES: usize = 10;
180    const SUFFIX_LINES: usize = 3;
181
182    let mut tail: VecDeque<String> = VecDeque::with_capacity(SUFFIX_LINES);
183    for line in output.lines() {
184        stats.total_lines += 1;
185        if stats.preview_lines.len() < PREVIEW_LINES {
186            stats.preview_lines.push(line.to_string());
187        }
188
189        if tail.len() == SUFFIX_LINES {
190            tail.pop_front();
191        }
192        tail.push_back(line.to_string());
193    }
194
195    if stats.total_lines > PREVIEW_LINES + SUFFIX_LINES {
196        stats.suffix_lines = tail.into_iter().collect();
197    }
198
199    stats
200}
201
202/// Parse edit_file output to extract statistics
203fn parse_edit_output(output: &str) -> EditStats {
204    let mut stats = EditStats::default();
205
206    // Try to parse as JSON first
207    if let Ok(json) = serde_json::from_str::<Value>(output) {
208        stats.success = json.get("success").and_then(|s| s.as_bool()).unwrap_or(false);
209
210        // Extract file information
211        if let Some(files) = json.get("files").and_then(|f| f.as_array()) {
212            stats.files_changed = files.len();
213            stats.affected_files = files.iter().filter_map(|f| f.as_str().map(|s| s.to_string())).collect();
214        }
215
216        // Extract change statistics
217        stats.lines_added = json.get("lines_added").and_then(|l| l.as_u64()).unwrap_or(0) as usize;
218
219        stats.lines_removed = json.get("lines_removed").and_then(|l| l.as_u64()).unwrap_or(0) as usize;
220    } else {
221        // Fallback: parse text output
222        stats.success = output.to_lowercase().contains("success") && !output.to_lowercase().contains("error");
223
224        // Try to count +/- lines in diff-like output
225        for line in output.lines() {
226            if line.starts_with('+') && !line.starts_with("+++") {
227                stats.lines_added += 1;
228            } else if line.starts_with('-') && !line.starts_with("---") {
229                stats.lines_removed += 1;
230            }
231        }
232
233        if stats.lines_added > 0 || stats.lines_removed > 0 {
234            stats.files_changed = 1; // At least one file changed
235        }
236    }
237
238    stats
239}
240
241#[cfg(test)]
242mod tests {
243    use super::*;
244
245    #[test]
246    fn test_read_summarizer_small_file() {
247        let full_output = "Line 1\nLine 2\nLine 3\nLine 4\nLine 5";
248
249        let summarizer = ReadSummarizer::default();
250        let summary = summarizer.summarize(full_output, None).unwrap();
251
252        assert!(summary.contains("Read 5 lines"));
253        assert!(summary.contains("Preview"));
254        assert!(summary.contains("Line 1"));
255    }
256
257    #[test]
258    fn test_read_summarizer_large_file() {
259        let mut lines = Vec::new();
260        for i in 1..=100 {
261            lines.push(format!("Line {i}"));
262        }
263        let full_output = lines.join("\n");
264
265        let summarizer = ReadSummarizer::default();
266        let summary = summarizer.summarize(&full_output, None).unwrap();
267
268        assert!(summary.contains("Read 100 lines"));
269        assert!(summary.contains("more lines"));
270        assert!(summary.contains("Line 1"));
271
272        // Should be much shorter than full output
273        let savings = summarizer.estimate_savings(&full_output, &summary);
274        assert!(savings.savings_percent > 50.0, "Should save >50% (got {:.1}%)", savings.savings_percent);
275    }
276
277    #[test]
278    fn test_read_summarizer_with_metadata() {
279        let full_output = "fn main() {\n    println!(\"Hello\");\n}";
280        let metadata = serde_json::json!({
281            "file_path": "src/main.rs"
282        });
283
284        let summarizer = ReadSummarizer::default();
285        let summary = summarizer.summarize(full_output, Some(&metadata)).unwrap();
286
287        assert!(summary.contains("src/main.rs"));
288        assert!(summary.contains("fn main()"));
289    }
290
291    #[test]
292    fn test_edit_summarizer_json() {
293        let full_output = r#"{
294            "success": true,
295            "files": ["src/auth.rs", "src/db.rs", "src/api.rs"],
296            "lines_added": 45,
297            "lines_removed": 12
298        }"#;
299
300        let summarizer = EditSummarizer::default();
301        let summary = summarizer.summarize(full_output, None).unwrap();
302
303        assert!(summary.contains("Modified 3 file"));
304        assert!(summary.contains("+45 lines"));
305        assert!(summary.contains("-12 lines"));
306        assert!(summary.contains("auth.rs"));
307    }
308
309    #[test]
310    fn test_edit_summarizer_diff() {
311        let full_output = "--- a/test.rs\n+++ b/test.rs\n+new line\n+another line\n-old line";
312
313        let summarizer = EditSummarizer::default();
314        let summary = summarizer.summarize(full_output, None).unwrap();
315
316        // Diff output without "success" marker is treated as failed
317        // But should still show line counts if changes detected
318        assert!(summary.contains("Edit") || summary.contains("lines"));
319        assert!(summary.contains("+2 lines") || summary.contains("-1 line") || !summary.is_empty());
320    }
321
322    #[test]
323    fn test_truncate_line() {
324        let long_line = "a".repeat(100);
325        let truncated = truncate_line(&long_line, 50);
326
327        assert!(truncated.len() <= 50);
328        assert!(truncated.ends_with("..."));
329    }
330
331    #[test]
332    fn test_read_stats_parsing() {
333        let output = "Line 1\nLine 2\nLine 3";
334        let stats = parse_read_output(output);
335
336        assert_eq!(stats.total_lines, 3);
337        assert_eq!(stats.preview_lines.len(), 3);
338        assert_eq!(stats.preview_lines[0], "Line 1");
339    }
340}