Skip to main content

lean_ctx/core/patterns/
log_dedup.rs

1macro_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
14/// Word-boundary error matcher. Substring matching wrongly flagged identifiers
15/// like `pending_errors` (commit subjects, code symbols) as error lines; `\b`
16/// keeps real signals (`ERROR:`, `panic`, `1 error`) while ignoring tokens where
17/// the word is glued to other word characters (incl. `_`).
18fn 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            // Single oversized block: keep head + tail with an explicit omission
136            // marker. The previous "last 15 unique lines" tail-only cut dropped
137            // the leading lines *silently* (#479) — indistinguishable from real
138            // output — so the model never knew context was lost.
139            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
152/// Append a bounded `head` + `[N lines omitted]` marker + `tail` slice of
153/// `formatted` to `parts`. Shared by the single- and multi-block truncation
154/// paths so neither can drop lines silently (#479): the omission is always
155/// stated explicitly. Outputs verbatim when nothing needs omitting.
156fn 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        // One oversized block (no separators) of distinct lines. The old path
191        // kept only the last 15 lines with no marker (#479) — the leading lines
192        // vanished silently. The omission must now be explicit, with head + tail
193        // context preserved.
194        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        // 11 commit-subject-like lines; one contains "pending_errors" as an
302        // identifier — it must NOT be counted as an error line.
303        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}