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 push_bounded(&mut parts, &formatted, 5, 10);
140 } else if has_multiple_blocks && formatted.len() > 20 {
141 push_bounded(&mut parts, &formatted, 5, 5);
142 } else {
143 for line in &formatted {
144 parts.push(line.clone());
145 }
146 }
147 }
148
149 Some(parts.join("\n"))
150}
151
152fn push_bounded(parts: &mut Vec<String>, formatted: &[String], head: usize, tail: usize) {
157 if formatted.len() <= head + tail {
158 parts.extend(formatted.iter().cloned());
159 return;
160 }
161 parts.extend(formatted.iter().take(head).cloned());
162 parts.push(format!("[{} lines omitted]", formatted.len() - head - tail));
163 parts.extend(formatted.iter().skip(formatted.len() - tail).cloned());
164}
165
166#[cfg(test)]
167mod tests {
168 use super::*;
169
170 #[test]
171 fn short_output_returns_none() {
172 let output = "line1\nline2\nline3";
173 assert!(compress(output).is_none());
174 }
175
176 #[test]
177 fn deduplicates_consecutive_lines() {
178 let lines = vec!["INFO Processing request"; 15];
179 let output = lines.join("\n");
180 let result = compress(&output).unwrap();
181 assert!(result.contains("(x15)"), "must show repeat count: {result}");
182 assert!(
183 result.contains("15 lines"),
184 "must show total lines: {result}"
185 );
186 }
187
188 #[test]
189 fn single_block_truncation_is_not_silent() {
190 let output = (1..=120)
195 .map(|i| format!("Line {i:04} distinct content here"))
196 .collect::<Vec<_>>()
197 .join("\n");
198 let result = compress(&output).unwrap();
199 assert!(
200 result.contains("lines omitted]"),
201 "omission must be explicit, not silent: {result}"
202 );
203 assert!(
204 result.contains("Line 0001"),
205 "head context must be kept: {result}"
206 );
207 assert!(result.contains("Line 0120"), "tail must be kept: {result}");
208 assert!(
209 !result.contains("last 15 unique lines"),
210 "old silent tail-only format must be gone: {result}"
211 );
212 }
213
214 #[test]
215 fn respects_block_separators_equals() {
216 let mut lines = vec!["=== commit aaaa001 ==="];
217 lines.extend(vec!["file_a.rs | 10 +++++"; 5]);
218 lines.push("=== commit aaaa002 ===");
219 lines.extend(vec!["file_b.rs | 20 ++++++++++"; 5]);
220 let output = lines.join("\n");
221 let result = compress(&output).unwrap();
222 assert!(
223 result.contains("=== commit aaaa001 ==="),
224 "first block separator must be preserved: {result}"
225 );
226 assert!(
227 result.contains("=== commit aaaa002 ==="),
228 "second block separator must be preserved: {result}"
229 );
230 assert!(
231 result.contains("file_a.rs"),
232 "first block content must be preserved: {result}"
233 );
234 assert!(
235 result.contains("file_b.rs"),
236 "second block content must be preserved: {result}"
237 );
238 }
239
240 #[test]
241 fn does_not_merge_across_blocks() {
242 let lines = vec![
243 "=== block 1 ===",
244 "same line",
245 "same line",
246 "same line",
247 "=== block 2 ===",
248 "same line",
249 "same line",
250 "=== block 3 ===",
251 "same line",
252 "same line",
253 "different line here",
254 ];
255 let output = lines.join("\n");
256 let result = compress(&output).unwrap();
257 assert!(
258 result.contains("=== block 1 ==="),
259 "block 1 must exist: {result}"
260 );
261 assert!(
262 result.contains("=== block 2 ==="),
263 "block 2 must exist: {result}"
264 );
265 assert!(
266 result.contains("=== block 3 ==="),
267 "block 3 must exist: {result}"
268 );
269 let count_same = result.matches("same line").count();
270 assert!(
271 count_same >= 3,
272 "each block must have its own 'same line' entry, got {count_same}: {result}"
273 );
274 }
275
276 #[test]
277 fn git_commit_separator_detected() {
278 assert!(is_block_separator("commit abc1234def5678"));
279 assert!(is_block_separator("commit 1a2b3c4d5e6f7890"));
280 assert!(!is_block_separator("committed to fixing"));
281 }
282
283 #[test]
284 fn diff_separator_detected() {
285 assert!(is_block_separator("diff --git a/file.rs b/file.rs"));
286 assert!(!is_block_separator("different approach"));
287 }
288
289 #[test]
290 fn triple_equals_dashes_detected() {
291 assert!(is_block_separator("==="));
292 assert!(is_block_separator("=========="));
293 assert!(is_block_separator("---"));
294 assert!(is_block_separator("-----------"));
295 assert!(is_block_separator("=== test block ==="));
296 assert!(is_block_separator("--- a/file.rs"));
297 }
298
299 #[test]
300 fn identifier_with_error_substring_not_flagged() {
301 let mut lines: Vec<String> = (0..11)
304 .map(|i| format!("abc{i:03} feat: add module number {i}"))
305 .collect();
306 lines[3] = "abc003 fix: persist pending_errors for fail->fix correlation".to_string();
307 let output = lines.join("\n");
308 let result = compress(&output).unwrap();
309 assert!(
310 !result.contains("errors:"),
311 "identifier substring must not trigger error section: {result}"
312 );
313 }
314
315 #[test]
316 fn real_error_word_still_flagged() {
317 let mut lines = vec!["INFO doing work".to_string(); 10];
318 lines.push("ERROR: connection refused".to_string());
319 let result = compress(&lines.join("\n")).unwrap();
320 assert!(
321 result.contains("1 errors:"),
322 "real error must flag: {result}"
323 );
324 }
325
326 #[test]
327 fn error_lines_preserved_across_blocks() {
328 let lines = vec![
329 "=== step 1 ===",
330 "ok line",
331 "ok line",
332 "ok line",
333 "ERROR: something failed",
334 "ok line",
335 "ok line",
336 "ok line",
337 "=== step 2 ===",
338 "ok line 2",
339 "ok line 2",
340 "ok line 2",
341 "ok line 2",
342 "ok line 2",
343 "ok line 2",
344 ];
345 let output = lines.join("\n");
346 let result = compress(&output).unwrap();
347 assert!(
348 result.contains("1 errors:"),
349 "error count must be shown: {result}"
350 );
351 assert!(
352 result.contains("ERROR: something failed"),
353 "error line must be preserved: {result}"
354 );
355 }
356
357 #[test]
358 fn git_show_loop_not_deduplicated() {
359 let commits = [
360 (
361 "aaaa001",
362 "accounts_test.exs | 70 ++",
363 "schema_test.exs | 30 ++",
364 ),
365 ("aaaa002", "query_test.exs | 45 ++", "api_test.exs | 12 ++"),
366 ("aaaa003", "main_test.exs | 55 ++", "helper_test.exs | 8 ++"),
367 ];
368 let mut lines = Vec::new();
369 for (sha, file1, file2) in &commits {
370 lines.push(format!("=== {sha} ==="));
371 lines.push(file1.to_string());
372 lines.push(file2.to_string());
373 lines.push("2 files changed".to_string());
374 lines.push(String::new());
375 }
376 let output = lines.join("\n");
377 let result = compress(&output).unwrap();
378 assert!(
379 result.contains("aaaa001") && result.contains("aaaa002") && result.contains("aaaa003"),
380 "all commit separators must be preserved: {result}"
381 );
382 assert!(
383 result.contains("accounts_test.exs"),
384 "first commit files must be present: {result}"
385 );
386 assert!(
387 result.contains("query_test.exs"),
388 "second commit files must be present: {result}"
389 );
390 assert!(
391 result.contains("main_test.exs"),
392 "third commit files must be present: {result}"
393 );
394 }
395}