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