use super::{Summarizer, truncate_to_tokens};
use anyhow::Result;
use hashbrown::HashMap;
pub struct GrepSummarizer {
pub max_files: usize,
pub max_symbols: usize,
pub max_tokens: usize,
}
impl Default for GrepSummarizer {
fn default() -> Self {
Self {
max_files: 5,
max_symbols: 5,
max_tokens: 100,
}
}
}
impl Summarizer for GrepSummarizer {
fn summarize(
&self,
full_output: &str,
_metadata: Option<&serde_json::Value>,
) -> Result<String> {
let stats = parse_grep_output(full_output);
let mut summary = format!(
"Found {} matches in {} files",
stats.total_matches, stats.unique_files
);
if !stats.top_files.is_empty() {
let file_list: Vec<String> = stats
.top_files
.iter()
.take(self.max_files)
.map(|(file, count)| format!("{} ({})", file, count))
.collect();
summary.push_str(&format!(". Key files: {}", file_list.join(", ")));
}
if !stats.symbols.is_empty() {
let symbol_list: Vec<&str> = stats
.symbols
.iter()
.take(self.max_symbols)
.map(|s| s.as_str())
.collect();
summary.push_str(&format!(". Pattern in: {}", symbol_list.join(", ")));
}
Ok(truncate_to_tokens(&summary, self.max_tokens))
}
}
pub struct ListSummarizer {
pub max_dirs: usize,
pub max_files: usize,
pub max_tokens: usize,
}
impl Default for ListSummarizer {
fn default() -> Self {
Self {
max_dirs: 3,
max_files: 10,
max_tokens: 80,
}
}
}
impl Summarizer for ListSummarizer {
fn summarize(
&self,
full_output: &str,
_metadata: Option<&serde_json::Value>,
) -> Result<String> {
let stats = parse_list_output(full_output);
let mut summary = format!(
"Listed {} items ({} files, {} directories)",
stats.total_items, stats.file_count, stats.dir_count
);
if !stats.sample_files.is_empty() {
let files: Vec<&str> = stats
.sample_files
.iter()
.take(self.max_files)
.map(|s| s.as_str())
.collect();
summary.push_str(&format!(". Files: {}", files.join(", ")));
}
Ok(truncate_to_tokens(&summary, self.max_tokens))
}
}
#[derive(Debug, Default)]
struct GrepStats {
total_matches: usize,
unique_files: usize,
top_files: Vec<(String, usize)>, symbols: Vec<String>, }
#[derive(Debug, Default)]
struct ListStats {
total_items: usize,
file_count: usize,
dir_count: usize,
sample_files: Vec<String>,
}
fn parse_grep_output(output: &str) -> GrepStats {
let mut stats = GrepStats::default();
let mut file_matches: HashMap<String, usize> = HashMap::new();
let mut symbols_set: hashbrown::HashSet<String> = hashbrown::HashSet::new();
for line in output.lines() {
stats.total_matches += 1;
if let Some(colon_pos) = line.find(':') {
let file = &line[..colon_pos];
if !file.is_empty() {
*file_matches.entry(file.to_string()).or_insert(0) += 1;
if let Some(slash_pos) = file.rfind('/') {
let filename = &file[slash_pos + 1..];
if filename.len() < 30 {
*file_matches.entry(filename.to_string()).or_insert(0) += 1;
}
}
}
let content = &line[colon_pos..];
extract_symbols(content, &mut symbols_set);
}
}
stats.unique_files = file_matches.len();
let mut sorted_files: Vec<(String, usize)> = file_matches.into_iter().collect();
sorted_files.sort_by(|a, b| b.1.cmp(&a.1));
stats.top_files = sorted_files.into_iter().take(10).collect();
stats.symbols = symbols_set.into_iter().take(10).collect();
stats
}
fn parse_list_output(output: &str) -> ListStats {
let mut stats = ListStats::default();
for line in output.lines() {
stats.total_items += 1;
if line.ends_with('/') || line.contains("[dir]") || line.contains("DIR") {
stats.dir_count += 1;
} else {
stats.file_count += 1;
if let Some(name) = line.split('/').next_back()
&& !name.is_empty()
&& name.len() < 50
{
stats.sample_files.push(name.to_string());
}
}
}
stats
}
fn extract_symbols(line: &str, symbols: &mut hashbrown::HashSet<String>) {
if let Some(fn_pos) = line.find("fn ") {
let after_fn = &line[fn_pos + 3..];
if let Some(paren_pos) = after_fn.find('(') {
let name = after_fn[..paren_pos].trim();
if !name.is_empty() && name.len() < 30 {
symbols.insert(format!("{}()", name));
}
}
}
for keyword in &["struct ", "impl ", "trait ", "enum "] {
if let Some(pos) = line.find(keyword) {
let after_kw = &line[pos + keyword.len()..];
if let Some(first_word) = after_kw.split_whitespace().next()
&& first_word.len() < 30
&& !first_word.contains('{')
{
symbols.insert(first_word.to_string());
}
}
}
}
#[cfg(test)]
mod tests {
use super::super::estimate_tokens;
use super::*;
#[test]
fn test_grep_summarizer() {
let full_output = "\
src/tools/grep.rs:45: pub fn execute_grep(pattern: &str) -> Result<String> {
src/tools/grep.rs:67: let matches = grep_impl(pattern)?;
src/tools/grep.rs:89: fn grep_impl(pattern: &str) -> Result<Vec<Match>> {
src/tools/list.rs:23: // Uses grep internally for filtering
src/main.rs:100: grep.execute(\"test\")?;
";
let summarizer = GrepSummarizer::default();
let summary = summarizer.summarize(full_output, None).unwrap();
assert!(summary.contains("Found 5 matches"));
assert!(summary.contains("files"));
assert!(estimate_tokens(&summary) < 100);
let (llm, ui, pct) = summarizer.estimate_savings(full_output, &summary);
assert!(
pct > 20.0,
"Should save >20% (got {:.1}%, {} → {} tokens)",
pct,
ui,
llm
);
assert!(llm < ui);
}
#[test]
fn test_list_summarizer() {
let full_output = "\
src/main.rs
src/lib.rs
src/tools/
src/tools/grep.rs
src/tools/list.rs
tests/
tests/integration.rs
README.md
";
let summarizer = ListSummarizer::default();
let summary = summarizer.summarize(full_output, None).unwrap();
assert!(summary.contains("Listed 8 items"));
assert!(summary.contains("files"));
assert!(summary.contains("directories"));
assert!(estimate_tokens(&summary) < 100);
}
#[test]
fn test_grep_stats_parsing() {
let output = "\
src/tools/grep.rs:45: pub fn execute_grep(pattern: &str) -> Result<String> {
src/tools/grep.rs:67: let matches = grep_impl(pattern)?;
src/tools/list.rs:23: // comment
";
let stats = parse_grep_output(output);
assert_eq!(stats.total_matches, 3);
assert!(stats.unique_files > 0);
assert!(!stats.top_files.is_empty());
}
#[test]
fn test_symbol_extraction() {
let mut symbols = hashbrown::HashSet::new();
extract_symbols(" pub fn execute_grep(pattern: &str)", &mut symbols);
assert!(symbols.contains("execute_grep()"));
extract_symbols("impl GrepTool {", &mut symbols);
assert!(symbols.contains("GrepTool"));
extract_symbols("pub struct MyStruct {", &mut symbols);
assert!(symbols.contains("MyStruct"));
}
#[test]
fn test_list_stats_parsing() {
let output = "file1.rs\nfile2.rs\nsrc/\ntests/\nREADME.md";
let stats = parse_list_output(output);
assert_eq!(stats.total_items, 5);
assert_eq!(stats.dir_count, 2); assert_eq!(stats.file_count, 3);
}
#[test]
fn test_large_grep_output() {
let mut output = String::new();
for i in 0..200 {
output.push_str(&format!("src/file{}.rs:{}: match line\n", i % 20, i));
}
let summarizer = GrepSummarizer::default();
let summary = summarizer.summarize(&output, None).unwrap();
assert!(estimate_tokens(&summary) < 150);
assert!(summary.contains("Found 200 matches"));
let (_llm, _ui, pct) = summarizer.estimate_savings(&output, &summary);
assert!(pct > 95.0, "Should save >95% on large output");
}
}