lean_ctx/core/patterns/
log_dedup.rs1macro_rules! static_regex {
2 ($pattern:expr_2021) => {{
3 static RE: std::sync::OnceLock<regex::Regex> = std::sync::OnceLock::new();
4 RE.get_or_init(|| {
5 regex::Regex::new($pattern).expect(concat!("BUG: invalid static regex: ", $pattern))
6 })
7 }};
8}
9
10fn timestamp_re() -> &'static regex::Regex {
11 static_regex!(r"^\[?\d{4}[-/]\d{2}[-/]\d{2}[T ]\d{2}:\d{2}:\d{2}[^\]\s]*\]?\s*")
12}
13
14fn error_re() -> &'static regex::Regex {
19 static_regex!(r"(?i)\b(errors?|critical|fatal|panic|exception)\b")
20}
21
22fn is_block_separator(line: &str) -> bool {
23 let t = line.trim();
24 if t.is_empty() {
25 return false;
26 }
27 if t.len() >= 3 && t.chars().all(|c| c == '=' || c == '-') {
28 return true;
29 }
30 if t.starts_with("===") || t.starts_with("---") {
31 return true;
32 }
33 if t.starts_with("commit ")
34 && t.len() >= 12
35 && t[7..].starts_with(|c: char| c.is_ascii_hexdigit())
36 {
37 return true;
38 }
39 if t.starts_with("diff --git ") {
40 return true;
41 }
42 if t.starts_with("##") || t.starts_with("Step ") || t.starts_with("STEP ") {
43 return true;
44 }
45 false
46}
47
48struct Block {
49 separator: Option<String>,
50 entries: Vec<(String, u32)>,
51}
52
53pub fn compress(output: &str) -> Option<String> {
54 let lines: Vec<&str> = output.lines().collect();
55 if lines.len() <= 10 {
56 return None;
57 }
58
59 let mut blocks: Vec<Block> = Vec::new();
60 let mut current = Block {
61 separator: None,
62 entries: Vec::new(),
63 };
64 let mut error_lines = Vec::new();
65 let total_lines = lines.len();
66
67 for line in &lines {
68 let stripped = timestamp_re().replace(line, "").trim().to_string();
69 if stripped.is_empty() {
70 continue;
71 }
72
73 if is_block_separator(&stripped) {
74 if !current.entries.is_empty() || current.separator.is_some() {
75 blocks.push(current);
76 }
77 current = Block {
78 separator: Some(stripped.clone()),
79 entries: Vec::new(),
80 };
81 continue;
82 }
83
84 if error_re().is_match(&stripped) {
85 error_lines.push(stripped.clone());
86 }
87
88 if let Some(last) = current.entries.last_mut()
89 && last.0 == stripped
90 {
91 last.1 += 1;
92 continue;
93 }
94 current.entries.push((stripped, 1));
95 }
96 if !current.entries.is_empty() || current.separator.is_some() {
97 blocks.push(current);
98 }
99
100 let total_unique: usize = blocks.iter().map(|b| b.entries.len()).sum();
101
102 let mut parts = Vec::new();
103 parts.push(format!("{total_lines} lines → {total_unique} unique"));
104
105 if !error_lines.is_empty() {
106 parts.push(format!("{} errors:", error_lines.len()));
107 for e in error_lines.iter().take(5) {
108 parts.push(format!(" {e}"));
109 }
110 if error_lines.len() > 5 {
111 parts.push(format!(" ... +{} more errors", error_lines.len() - 5));
112 }
113 }
114
115 let has_multiple_blocks = blocks.len() > 1;
116
117 for block in &blocks {
118 if let Some(sep) = &block.separator {
119 parts.push(sep.clone());
120 }
121
122 let formatted: Vec<String> = block
123 .entries
124 .iter()
125 .map(|(line, count)| {
126 if *count > 1 {
127 format!("{line} (x{count})")
128 } else {
129 line.clone()
130 }
131 })
132 .collect();
133
134 if !has_multiple_blocks && formatted.len() > 30 {
135 let tail = &formatted[formatted.len() - 15..];
136 parts.push(format!("last 15 unique lines:\n{}", tail.join("\n")));
137 } else if has_multiple_blocks && formatted.len() > 20 {
138 for line in formatted.iter().take(5) {
139 parts.push(line.clone());
140 }
141 let omitted = formatted.len() - 10;
142 parts.push(format!("[{omitted} lines omitted]"));
143 for line in formatted.iter().skip(formatted.len() - 5) {
144 parts.push(line.clone());
145 }
146 } else {
147 for line in &formatted {
148 parts.push(line.clone());
149 }
150 }
151 }
152
153 Some(parts.join("\n"))
154}
155
156#[cfg(test)]
157mod tests {
158 use super::*;
159
160 #[test]
161 fn short_output_returns_none() {
162 let output = "line1\nline2\nline3";
163 assert!(compress(output).is_none());
164 }
165
166 #[test]
167 fn deduplicates_consecutive_lines() {
168 let lines = vec!["INFO Processing request"; 15];
169 let output = lines.join("\n");
170 let result = compress(&output).unwrap();
171 assert!(result.contains("(x15)"), "must show repeat count: {result}");
172 assert!(
173 result.contains("15 lines"),
174 "must show total lines: {result}"
175 );
176 }
177
178 #[test]
179 fn respects_block_separators_equals() {
180 let mut lines = vec!["=== commit aaaa001 ==="];
181 lines.extend(vec!["file_a.rs | 10 +++++"; 5]);
182 lines.push("=== commit aaaa002 ===");
183 lines.extend(vec!["file_b.rs | 20 ++++++++++"; 5]);
184 let output = lines.join("\n");
185 let result = compress(&output).unwrap();
186 assert!(
187 result.contains("=== commit aaaa001 ==="),
188 "first block separator must be preserved: {result}"
189 );
190 assert!(
191 result.contains("=== commit aaaa002 ==="),
192 "second block separator must be preserved: {result}"
193 );
194 assert!(
195 result.contains("file_a.rs"),
196 "first block content must be preserved: {result}"
197 );
198 assert!(
199 result.contains("file_b.rs"),
200 "second block content must be preserved: {result}"
201 );
202 }
203
204 #[test]
205 fn does_not_merge_across_blocks() {
206 let lines = vec![
207 "=== block 1 ===",
208 "same line",
209 "same line",
210 "same line",
211 "=== block 2 ===",
212 "same line",
213 "same line",
214 "=== block 3 ===",
215 "same line",
216 "same line",
217 "different line here",
218 ];
219 let output = lines.join("\n");
220 let result = compress(&output).unwrap();
221 assert!(
222 result.contains("=== block 1 ==="),
223 "block 1 must exist: {result}"
224 );
225 assert!(
226 result.contains("=== block 2 ==="),
227 "block 2 must exist: {result}"
228 );
229 assert!(
230 result.contains("=== block 3 ==="),
231 "block 3 must exist: {result}"
232 );
233 let count_same = result.matches("same line").count();
234 assert!(
235 count_same >= 3,
236 "each block must have its own 'same line' entry, got {count_same}: {result}"
237 );
238 }
239
240 #[test]
241 fn git_commit_separator_detected() {
242 assert!(is_block_separator("commit abc1234def5678"));
243 assert!(is_block_separator("commit 1a2b3c4d5e6f7890"));
244 assert!(!is_block_separator("committed to fixing"));
245 }
246
247 #[test]
248 fn diff_separator_detected() {
249 assert!(is_block_separator("diff --git a/file.rs b/file.rs"));
250 assert!(!is_block_separator("different approach"));
251 }
252
253 #[test]
254 fn triple_equals_dashes_detected() {
255 assert!(is_block_separator("==="));
256 assert!(is_block_separator("=========="));
257 assert!(is_block_separator("---"));
258 assert!(is_block_separator("-----------"));
259 assert!(is_block_separator("=== test block ==="));
260 assert!(is_block_separator("--- a/file.rs"));
261 }
262
263 #[test]
264 fn identifier_with_error_substring_not_flagged() {
265 let mut lines: Vec<String> = (0..11)
268 .map(|i| format!("abc{i:03} feat: add module number {i}"))
269 .collect();
270 lines[3] = "abc003 fix: persist pending_errors for fail->fix correlation".to_string();
271 let output = lines.join("\n");
272 let result = compress(&output).unwrap();
273 assert!(
274 !result.contains("errors:"),
275 "identifier substring must not trigger error section: {result}"
276 );
277 }
278
279 #[test]
280 fn real_error_word_still_flagged() {
281 let mut lines = vec!["INFO doing work".to_string(); 10];
282 lines.push("ERROR: connection refused".to_string());
283 let result = compress(&lines.join("\n")).unwrap();
284 assert!(
285 result.contains("1 errors:"),
286 "real error must flag: {result}"
287 );
288 }
289
290 #[test]
291 fn error_lines_preserved_across_blocks() {
292 let lines = vec![
293 "=== step 1 ===",
294 "ok line",
295 "ok line",
296 "ok line",
297 "ERROR: something failed",
298 "ok line",
299 "ok line",
300 "ok line",
301 "=== step 2 ===",
302 "ok line 2",
303 "ok line 2",
304 "ok line 2",
305 "ok line 2",
306 "ok line 2",
307 "ok line 2",
308 ];
309 let output = lines.join("\n");
310 let result = compress(&output).unwrap();
311 assert!(
312 result.contains("1 errors:"),
313 "error count must be shown: {result}"
314 );
315 assert!(
316 result.contains("ERROR: something failed"),
317 "error line must be preserved: {result}"
318 );
319 }
320
321 #[test]
322 fn git_show_loop_not_deduplicated() {
323 let commits = [
324 (
325 "aaaa001",
326 "accounts_test.exs | 70 ++",
327 "schema_test.exs | 30 ++",
328 ),
329 ("aaaa002", "query_test.exs | 45 ++", "api_test.exs | 12 ++"),
330 ("aaaa003", "main_test.exs | 55 ++", "helper_test.exs | 8 ++"),
331 ];
332 let mut lines = Vec::new();
333 for (sha, file1, file2) in &commits {
334 lines.push(format!("=== {sha} ==="));
335 lines.push(file1.to_string());
336 lines.push(file2.to_string());
337 lines.push("2 files changed".to_string());
338 lines.push(String::new());
339 }
340 let output = lines.join("\n");
341 let result = compress(&output).unwrap();
342 assert!(
343 result.contains("aaaa001") && result.contains("aaaa002") && result.contains("aaaa003"),
344 "all commit separators must be preserved: {result}"
345 );
346 assert!(
347 result.contains("accounts_test.exs"),
348 "first commit files must be present: {result}"
349 );
350 assert!(
351 result.contains("query_test.exs"),
352 "second commit files must be present: {result}"
353 );
354 assert!(
355 result.contains("main_test.exs"),
356 "third commit files must be present: {result}"
357 );
358 }
359}