Skip to main content

lean_ctx_patterns/
log_dedup.rs

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