Skip to main content

lean_ctx/core/patterns/
grep.rs

1use std::collections::HashMap;
2
3pub fn compress(output: &str) -> Option<String> {
4    let lines: Vec<&str> = output.lines().collect();
5    if lines.len() < 3 {
6        return None;
7    }
8
9    let mut by_file: HashMap<&str, Vec<(usize, &str)>> = HashMap::new();
10    let mut total_matches = 0usize;
11
12    for line in &lines {
13        if let Some((file, rest)) = parse_grep_line(line) {
14            total_matches += 1;
15            let line_num = extract_line_num(rest);
16            let content = strip_line_num(rest);
17            by_file.entry(file).or_default().push((line_num, content));
18        }
19    }
20
21    if total_matches == 0 {
22        return None;
23    }
24
25    let max_matches_per_file = if total_matches > 200 { 5 } else { 10 };
26
27    let mut result = format!("{total_matches} matches in {}F:\n", by_file.len());
28    let mut sorted_files: Vec<_> = by_file.iter().collect();
29    sorted_files.sort_by_key(|(_, matches)| std::cmp::Reverse(matches.len()));
30
31    for (file, matches) in &sorted_files {
32        let short = shorten_path(file);
33        result.push_str(&format!("\n{short} ({}):", matches.len()));
34        let show = matches.iter().take(max_matches_per_file);
35        for (ln, content) in show {
36            let trimmed = content.trim();
37            let short_content = if trimmed.len() > 120 {
38                let truncated: String = trimmed.chars().take(119).collect();
39                format!("{truncated}…")
40            } else {
41                trimmed.to_string()
42            };
43            if *ln > 0 {
44                result.push_str(&format!("\n  {ln}: {short_content}"));
45            } else {
46                result.push_str(&format!("\n  {short_content}"));
47            }
48        }
49        if matches.len() > max_matches_per_file {
50            result.push_str(&format!(
51                "\n  ... +{} more",
52                matches.len() - max_matches_per_file
53            ));
54        }
55    }
56
57    if result.len() >= output.len() {
58        return None;
59    }
60
61    Some(result)
62}
63
64fn parse_grep_line(line: &str) -> Option<(&str, &str)> {
65    if let Some(pos) = line.find(':') {
66        let file = &line[..pos];
67        if file.contains('/') || file.contains('.') {
68            let rest = &line[pos + 1..];
69            return Some((file, rest));
70        }
71    }
72    None
73}
74
75fn extract_line_num(rest: &str) -> usize {
76    if let Some(pos) = rest.find(':') {
77        rest[..pos].parse().unwrap_or(0)
78    } else {
79        0
80    }
81}
82
83fn strip_line_num(rest: &str) -> &str {
84    if let Some(pos) = rest.find(':') {
85        if rest[..pos].chars().all(|c| c.is_ascii_digit()) {
86            return &rest[pos + 1..];
87        }
88    }
89    rest
90}
91
92fn shorten_path(path: &str) -> &str {
93    path.strip_prefix("./").unwrap_or(path)
94}
95
96#[cfg(test)]
97mod tests {
98    use super::*;
99
100    #[test]
101    fn small_grep_output_is_not_claimed_without_matches() {
102        assert!(compress("hello\nworld").is_none());
103    }
104
105    #[test]
106    fn small_grep_output_still_compresses() {
107        let output = (0..20)
108            .map(|i| format!("src/main.rs:{i}: let x = {i};"))
109            .collect::<Vec<_>>()
110            .join("\n");
111        let result = compress(&output);
112        assert!(result.is_some());
113        let compressed = result.unwrap();
114        assert!(
115            compressed.contains("20 matches in 1F:"),
116            "should group by file: {compressed}"
117        );
118        assert!(
119            compressed.len() < output.len(),
120            "should compress: {} vs {}",
121            compressed.len(),
122            output.len()
123        );
124    }
125
126    #[test]
127    fn large_output_reduces_per_file_lines() {
128        let mut lines = Vec::new();
129        for i in 0..250 {
130            lines.push(format!("src/a.rs:{i}: line content {i}"));
131        }
132        let output = lines.join("\n");
133        let result = compress(&output).unwrap();
134        assert!(
135            result.contains("... +245 more"),
136            "should show +more for large output: {result}"
137        );
138    }
139
140    #[test]
141    fn non_grep_output_returns_none() {
142        let output = "no file:line pattern here\njust regular text\nmore text\nand more";
143        assert!(compress(output).is_none());
144    }
145
146    #[test]
147    fn tiny_grep_output_returns_none_if_inflation() {
148        let output = "a.rs:1:x\nb.rs:2:y\nc.rs:3:z\n";
149        let result = compress(output);
150        if let Some(ref compressed) = result {
151            assert!(
152                compressed.len() < output.len(),
153                "must never inflate: compressed={} vs original={}",
154                compressed.len(),
155                output.len()
156            );
157        }
158    }
159
160    #[test]
161    fn multi_file_many_matches_compresses_well() {
162        let mut lines = Vec::new();
163        for i in 0..50 {
164            lines.push(format!(
165                "src/models/user.rs:{}: pub fn method_{i}() {{}}",
166                i + 1
167            ));
168        }
169        for i in 0..30 {
170            lines.push(format!(
171                "src/controllers/auth.rs:{}: let val = method_{i}();",
172                i + 1
173            ));
174        }
175        let output = lines.join("\n");
176        let result = compress(&output).expect("80 matches should compress");
177        assert!(
178            result.len() < output.len(),
179            "must compress: {} vs {}",
180            result.len(),
181            output.len()
182        );
183        assert!(result.contains("80 matches in 2F:"));
184        assert!(result.contains("src/models/user.rs (50):"));
185        assert!(result.contains("src/controllers/auth.rs (30):"));
186    }
187
188    #[test]
189    fn many_single_match_files_falls_back_to_none() {
190        let lines: Vec<String> = (1..=30)
191            .map(|i| format!("src/file{i}.rs:42: fn search_result()"))
192            .collect();
193        let output = lines.join("\n");
194        let result = compress(&output);
195        if let Some(ref c) = result {
196            assert!(
197                c.len() < output.len(),
198                "if claimed, must be shorter: {} vs {}",
199                c.len(),
200                output.len()
201            );
202        }
203    }
204
205    #[test]
206    fn never_returns_inflated_output() {
207        for count in [3, 5, 10, 15, 25, 50] {
208            let lines: Vec<String> = (0..count).map(|i| format!("f{i}.rs:{i}:x")).collect();
209            let output = lines.join("\n");
210            if let Some(ref c) = compress(&output) {
211                assert!(
212                    c.len() < output.len(),
213                    "count={count}: inflated {} vs {}",
214                    c.len(),
215                    output.len()
216                );
217            }
218        }
219    }
220}