lean_ctx/core/patterns/
grep.rs1use 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}