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!(
74                    "\n[...{} more lines]",
75                    stats.total_lines - self.max_preview_lines
76                ));
77            }
78        }
79
80        // Add suffix lines if file is long
81        if stats.total_lines > self.max_preview_lines + self.max_suffix_lines
82            && !stats.suffix_lines.is_empty()
83        {
84            let suffix = stats
85                .suffix_lines
86                .iter()
87                .take(self.max_suffix_lines)
88                .map(|line| truncate_line(line, 80))
89                .collect::<Vec<_>>()
90                .join("\n");
91
92            summary.push_str(&format!("\n\nEnd:\n{}", suffix));
93        }
94
95        // Add external editor hint for long files
96        if stats.total_lines > self.max_preview_lines + self.max_suffix_lines {
97            summary.push_str(&format!(
98                "\n\n[Use `/edit {}` for full content or specify offset/limit]",
99                file_path
100            ));
101        }
102
103        // Truncate to token limit
104        Ok(truncate_to_tokens(&summary, self.max_tokens))
105    }
106}
107
108/// Summarizer for edit_file results
109pub struct EditSummarizer {
110    /// Maximum tokens for entire summary
111    pub max_tokens: usize,
112}
113
114impl Default for EditSummarizer {
115    fn default() -> Self {
116        Self { max_tokens: 150 }
117    }
118}
119
120impl Summarizer for EditSummarizer {
121    fn summarize(&self, full_output: &str, _metadata: Option<&Value>) -> Result<String> {
122        // Parse edit output to extract statistics
123        let stats = parse_edit_output(full_output);
124
125        let mut summary = if stats.success {
126            format!("Modified {} file(s)", stats.files_changed)
127        } else {
128            "Edit failed".to_string()
129        };
130
131        // Add line change statistics
132        if stats.lines_added > 0 || stats.lines_removed > 0 {
133            summary.push_str(&format!(
134                ": +{} lines, -{} lines",
135                stats.lines_added, stats.lines_removed
136            ));
137        }
138
139        // Add affected files
140        if !stats.affected_files.is_empty() {
141            let files = stats
142                .affected_files
143                .iter()
144                .take(5)
145                .map(|f| {
146                    // Extract just filename from path
147                    f.split('/').next_back().unwrap_or(f)
148                })
149                .collect::<Vec<_>>()
150                .join(", ");
151
152            summary.push_str(&format!(". Changed: {}", files));
153
154            if stats.affected_files.len() > 5 {
155                summary.push_str(&format!(" (+{} more)", stats.affected_files.len() - 5));
156            }
157        }
158
159        Ok(truncate_to_tokens(&summary, self.max_tokens))
160    }
161}
162
163/// Statistics extracted from read output
164#[derive(Debug, Default)]
165struct ReadStats {
166    total_lines: usize,
167    total_chars: usize,
168    preview_lines: Vec<String>,
169    suffix_lines: Vec<String>,
170}
171
172/// Statistics extracted from edit output
173#[derive(Debug, Default)]
174struct EditStats {
175    success: bool,
176    files_changed: usize,
177    lines_added: usize,
178    lines_removed: usize,
179    affected_files: Vec<String>,
180}
181
182/// Parse read_file output to extract statistics
183fn parse_read_output(output: &str) -> ReadStats {
184    let mut stats = ReadStats {
185        total_chars: output.len(),
186        ..ReadStats::default()
187    };
188
189    const PREVIEW_LINES: usize = 10;
190    const SUFFIX_LINES: usize = 3;
191
192    let mut tail: VecDeque<String> = VecDeque::with_capacity(SUFFIX_LINES);
193    for line in output.lines() {
194        stats.total_lines += 1;
195        if stats.preview_lines.len() < PREVIEW_LINES {
196            stats.preview_lines.push(line.to_string());
197        }
198
199        if tail.len() == SUFFIX_LINES {
200            tail.pop_front();
201        }
202        tail.push_back(line.to_string());
203    }
204
205    if stats.total_lines > PREVIEW_LINES + SUFFIX_LINES {
206        stats.suffix_lines = tail.into_iter().collect();
207    }
208
209    stats
210}
211
212/// Parse edit_file output to extract statistics
213fn parse_edit_output(output: &str) -> EditStats {
214    let mut stats = EditStats::default();
215
216    // Try to parse as JSON first
217    if let Ok(json) = serde_json::from_str::<Value>(output) {
218        stats.success = json
219            .get("success")
220            .and_then(|s| s.as_bool())
221            .unwrap_or(false);
222
223        // Extract file information
224        if let Some(files) = json.get("files").and_then(|f| f.as_array()) {
225            stats.files_changed = files.len();
226            stats.affected_files = files
227                .iter()
228                .filter_map(|f| f.as_str().map(|s| s.to_string()))
229                .collect();
230        }
231
232        // Extract change statistics
233        stats.lines_added = json
234            .get("lines_added")
235            .and_then(|l| l.as_u64())
236            .unwrap_or(0) as usize;
237
238        stats.lines_removed = json
239            .get("lines_removed")
240            .and_then(|l| l.as_u64())
241            .unwrap_or(0) as usize;
242    } else {
243        // Fallback: parse text output
244        stats.success =
245            output.to_lowercase().contains("success") && !output.to_lowercase().contains("error");
246
247        // Try to count +/- lines in diff-like output
248        for line in output.lines() {
249            if line.starts_with('+') && !line.starts_with("+++") {
250                stats.lines_added += 1;
251            } else if line.starts_with('-') && !line.starts_with("---") {
252                stats.lines_removed += 1;
253            }
254        }
255
256        if stats.lines_added > 0 || stats.lines_removed > 0 {
257            stats.files_changed = 1; // At least one file changed
258        }
259    }
260
261    stats
262}
263
264#[cfg(test)]
265mod tests {
266    use super::*;
267
268    #[test]
269    fn test_read_summarizer_small_file() {
270        let full_output = "Line 1\nLine 2\nLine 3\nLine 4\nLine 5";
271
272        let summarizer = ReadSummarizer::default();
273        let summary = summarizer.summarize(full_output, None).unwrap();
274
275        assert!(summary.contains("Read 5 lines"));
276        assert!(summary.contains("Preview"));
277        assert!(summary.contains("Line 1"));
278    }
279
280    #[test]
281    fn test_read_summarizer_large_file() {
282        let mut lines = Vec::new();
283        for i in 1..=100 {
284            lines.push(format!("Line {}", i));
285        }
286        let full_output = lines.join("\n");
287
288        let summarizer = ReadSummarizer::default();
289        let summary = summarizer.summarize(&full_output, None).unwrap();
290
291        assert!(summary.contains("Read 100 lines"));
292        assert!(summary.contains("more lines"));
293        assert!(summary.contains("Line 1"));
294
295        // Should be much shorter than full output
296        let (_llm, _ui, pct) = summarizer.estimate_savings(&full_output, &summary);
297        assert!(pct > 70.0, "Should save >70% (got {:.1}%)", pct);
298    }
299
300    #[test]
301    fn test_read_summarizer_with_metadata() {
302        let full_output = "fn main() {\n    println!(\"Hello\");\n}";
303        let metadata = serde_json::json!({
304            "file_path": "src/main.rs"
305        });
306
307        let summarizer = ReadSummarizer::default();
308        let summary = summarizer.summarize(full_output, Some(&metadata)).unwrap();
309
310        assert!(summary.contains("src/main.rs"));
311        assert!(summary.contains("fn main()"));
312    }
313
314    #[test]
315    fn test_edit_summarizer_json() {
316        let full_output = r#"{
317            "success": true,
318            "files": ["src/auth.rs", "src/db.rs", "src/api.rs"],
319            "lines_added": 45,
320            "lines_removed": 12
321        }"#;
322
323        let summarizer = EditSummarizer::default();
324        let summary = summarizer.summarize(full_output, None).unwrap();
325
326        assert!(summary.contains("Modified 3 file"));
327        assert!(summary.contains("+45 lines"));
328        assert!(summary.contains("-12 lines"));
329        assert!(summary.contains("auth.rs"));
330    }
331
332    #[test]
333    fn test_edit_summarizer_diff() {
334        let full_output = "--- a/test.rs\n+++ b/test.rs\n+new line\n+another line\n-old line";
335
336        let summarizer = EditSummarizer::default();
337        let summary = summarizer.summarize(full_output, None).unwrap();
338
339        // Diff output without "success" marker is treated as failed
340        // But should still show line counts if changes detected
341        assert!(summary.contains("Edit") || summary.contains("lines"));
342        assert!(summary.contains("+2 lines") || summary.contains("-1 line") || !summary.is_empty());
343    }
344
345    #[test]
346    fn test_truncate_line() {
347        let long_line = "a".repeat(100);
348        let truncated = truncate_line(&long_line, 50);
349
350        assert!(truncated.len() <= 50);
351        assert!(truncated.ends_with("..."));
352    }
353
354    #[test]
355    fn test_read_stats_parsing() {
356        let output = "Line 1\nLine 2\nLine 3";
357        let stats = parse_read_output(output);
358
359        assert_eq!(stats.total_lines, 3);
360        assert_eq!(stats.preview_lines.len(), 3);
361        assert_eq!(stats.preview_lines[0], "Line 1");
362    }
363}