1use crate::error::Result;
11
12#[derive(Debug, Clone)]
14pub struct RleResult {
15 pub text: String,
17 pub runs_collapsed: usize,
19 pub tokens_saved: u32,
21}
22
23pub fn rle_compress(text: &str, min_run_length: usize) -> Result<RleResult> {
28 rle_compress_with_original(text, None, min_run_length)
29}
30
31pub(crate) fn rle_compress_with_original(
32 text: &str,
33 original: Option<&str>,
34 min_run_length: usize,
35) -> Result<RleResult> {
36 let lines: Vec<&str> = text.lines().collect();
37 if lines.len() < 2 {
38 return Ok(RleResult {
39 text: text.to_string(),
40 runs_collapsed: 0,
41 tokens_saved: 0,
42 });
43 }
44
45 let mut original_runs = original.map(line_runs).unwrap_or_default().into_iter();
46 let mut output = Vec::new();
47 let mut runs_collapsed = 0usize;
48 let mut tokens_saved = 0u32;
49 let mut i = 0;
50
51 while i < lines.len() {
52 let mut run_len = 1;
54 while i + run_len < lines.len() && lines[i] == lines[i + run_len] {
55 run_len += 1;
56 }
57 let count = match original_runs.next() {
58 Some((line, n)) if line == lines[i] => n,
59 _ => run_len,
60 };
61
62 if run_len >= min_run_length {
63 output.push(format!("{} [×{}]", lines[i], count));
66 runs_collapsed += 1;
67 let line_tokens = (lines[i].len() as u32 + 3) / 4;
68 tokens_saved += line_tokens * (run_len as u32 - 1);
69 i += run_len;
70 } else {
71 output.extend(lines[i..i + run_len].iter().map(|line| line.to_string()));
78 i += run_len;
79 }
80 }
81
82 let trailing_newline = text.ends_with('\n');
83 let mut result = output.join("\n");
84 if trailing_newline && !result.ends_with('\n') {
85 result.push('\n');
86 }
87
88 Ok(RleResult {
89 text: result,
90 runs_collapsed,
91 tokens_saved,
92 })
93}
94
95fn line_runs(text: &str) -> Vec<(&str, usize)> {
96 let mut runs: Vec<(&str, usize)> = Vec::new();
97 for line in text.lines() {
98 match runs.last_mut() {
99 Some((prev, n)) if *prev == line => *n += 1,
100 _ => runs.push((line, 1)),
101 }
102 }
103 runs
104}
105
106#[derive(Debug, Clone)]
110pub struct LogTemplateResult {
111 pub text: String,
112 pub runs_collapsed: usize,
113 pub tokens_saved: u32,
114}
115
116pub fn log_template_collapse(text: &str, min_run_length: usize) -> Result<LogTemplateResult> {
127 let lines: Vec<&str> = text.lines().collect();
128 if lines.len() < min_run_length {
129 return Ok(LogTemplateResult {
130 text: text.to_string(),
131 runs_collapsed: 0,
132 tokens_saved: 0,
133 });
134 }
135
136 let templates: Vec<String> = lines.iter().map(|l| mask_volatiles(l)).collect();
137
138 let mut output = Vec::new();
139 let mut runs_collapsed = 0usize;
140 let mut tokens_saved = 0u32;
141 let mut i = 0;
142
143 while i < lines.len() {
144 let mut run_len = 1;
145 while i + run_len < lines.len()
148 && templates[i + run_len] == templates[i]
149 && templates[i] != lines[i]
150 && lines[i + run_len] != lines[i]
151 {
152 run_len += 1;
153 }
154
155 if run_len >= min_run_length {
156 output.push(lines[i].to_string());
157 output.push(format!(
158 "[×{} similar lines; only timestamps/durations vary]",
159 run_len - 1
160 ));
161 runs_collapsed += 1;
162 for line in &lines[i + 1..i + run_len] {
163 tokens_saved += (line.len() as u32).div_ceil(4);
164 }
165 i += run_len;
166 } else {
167 output.push(lines[i].to_string());
168 i += 1;
169 }
170 }
171
172 let trailing_newline = text.ends_with('\n');
173 let mut result = output.join("\n");
174 if trailing_newline && !result.ends_with('\n') {
175 result.push('\n');
176 }
177
178 Ok(LogTemplateResult {
179 text: result,
180 runs_collapsed,
181 tokens_saved,
182 })
183}
184
185fn mask_volatiles(line: &str) -> String {
189 let mut out = String::with_capacity(line.len());
190 for token in line.split_inclusive(|c: char| c.is_whitespace()) {
191 let (word, ws) = match token.find(|c: char| c.is_whitespace()) {
192 Some(idx) => token.split_at(idx),
193 None => (token, ""),
194 };
195 let stripped = word.trim_matches(|c: char| "[]()<>,".contains(c));
196 if is_volatile(stripped) {
197 out.push_str(&word.replace(stripped, "‹›"));
198 } else {
199 out.push_str(word);
200 }
201 out.push_str(ws);
202 }
203 out
204}
205
206fn is_volatile(token: &str) -> bool {
207 if token.len() < 2 {
208 return false;
209 }
210 for suffix in ["ms", "µs", "us", "ns", "s", "m"] {
212 if let Some(num) = token.strip_suffix(suffix) {
213 if !num.is_empty()
214 && num.chars().all(|c| c.is_ascii_digit() || c == '.')
215 && num.chars().any(|c| c.is_ascii_digit())
216 {
217 return true;
218 }
219 }
220 }
221 if let Some(num) = token.strip_suffix('%') {
223 if !num.is_empty() && num.chars().all(|c| c.is_ascii_digit() || c == '.') {
224 return true;
225 }
226 }
227 let digits = token.chars().filter(|c| c.is_ascii_digit()).count();
229 if digits >= 4
230 && token
231 .chars()
232 .all(|c| c.is_ascii_digit() || ":-.TZ+".contains(c))
233 && (token.contains(':') || token.matches('-').count() >= 2)
234 {
235 return true;
236 }
237 false
238}
239
240pub fn sliding_window_dedup(text: &str, min_match_words: usize) -> Result<SlidingWindowResult> {
251 let lines: Vec<&str> = text.lines().collect();
252 if lines.len() < 2 {
253 return Ok(SlidingWindowResult {
254 text: text.to_string(),
255 dedup_count: 0,
256 tokens_saved: 0,
257 });
258 }
259
260 let mut phrase_index: std::collections::HashMap<String, usize> = std::collections::HashMap::new();
262 let mut output = Vec::new();
263 let mut dedup_count = 0usize;
264 let mut tokens_saved = 0u32;
265
266 for (line_idx, &line) in lines.iter().enumerate() {
267 let words: Vec<&str> = line.split_whitespace().collect();
268
269 let phrase = words.join(" ");
271 if phrase.len() >= min_match_words * 3 {
272 if let Some(&first_line) = phrase_index.get(&phrase) {
273 output.push(format!("[→L{}]", first_line + 1));
275 dedup_count += 1;
276 tokens_saved += (phrase.len() as u32 + 3) / 4;
277 continue;
278 }
279 }
280
281 if phrase.len() >= min_match_words * 3 {
283 phrase_index.insert(phrase, line_idx);
284 }
285 output.push(line.to_string());
286 }
287
288 let trailing_newline = text.ends_with('\n');
289 let mut result = output.join("\n");
290 if trailing_newline && !result.ends_with('\n') {
291 result.push('\n');
292 }
293
294 Ok(SlidingWindowResult {
295 text: result,
296 dedup_count,
297 tokens_saved,
298 })
299}
300
301#[derive(Debug, Clone)]
303pub struct SlidingWindowResult {
304 pub text: String,
305 pub dedup_count: usize,
306 pub tokens_saved: u32,
307}
308
309#[cfg(test)]
312mod tests {
313 use super::*;
314
315 #[test]
318 fn template_collapses_timestamp_spam() {
319 let input = "\
32012:00:01 worker heartbeat ok in 12ms
32112:00:02 worker heartbeat ok in 14ms
32212:00:03 worker heartbeat ok in 11ms
32312:00:04 worker heartbeat ok in 13ms
324done\n";
325 let result = log_template_collapse(input, 3).unwrap();
326 assert_eq!(result.runs_collapsed, 1);
327 assert!(result.text.contains("12:00:01 worker heartbeat ok in 12ms"), "first line verbatim");
328 assert!(result.text.contains("[×3 similar lines; only timestamps/durations vary]"));
329 assert!(!result.text.contains("12:00:03"), "later volatiles collapsed");
330 assert!(result.text.ends_with("done\n"));
331 }
332
333 #[test]
334 fn template_never_collapses_identifier_bearing_lines() {
335 let input = "\
33912:00:01 pushed commit deadbeef01 to origin
34012:00:02 pushed commit cafebabe02 to origin
34112:00:03 pushed commit 0ddba11f03 to origin
34212:00:04 pushed commit feedface04 to origin\n";
343 let result = log_template_collapse(input, 3).unwrap();
344 assert_eq!(result.runs_collapsed, 0, "{}", result.text);
345 for id in ["deadbeef01", "cafebabe02", "0ddba11f03", "feedface04"] {
346 assert!(result.text.contains(id));
347 }
348 }
349
350 #[test]
351 fn template_never_collapses_distinct_filenames() {
352 let input = "\
353-rw-r--r-- 1 u staff 120 Aug 15 12:00 alpha.rs
354-rw-r--r-- 1 u staff 340 Aug 15 12:01 beta.rs
355-rw-r--r-- 1 u staff 560 Aug 15 12:02 gamma.rs
356-rw-r--r-- 1 u staff 780 Aug 15 12:03 delta.rs\n";
357 let result = log_template_collapse(input, 3).unwrap();
358 for name in ["alpha.rs", "beta.rs", "gamma.rs", "delta.rs"] {
359 assert!(result.text.contains(name), "filename lost: {}", result.text);
360 }
361 }
362
363 #[test]
364 fn template_leaves_identical_lines_to_exact_rle() {
365 let input = "same line\nsame line\nsame line\nsame line\n";
366 let result = log_template_collapse(input, 3).unwrap();
367 assert_eq!(result.runs_collapsed, 0);
368 assert_eq!(result.text, input);
369 }
370
371 #[test]
372 fn template_short_runs_untouched() {
373 let input = "12:00:01 tick\n12:00:02 tick\nother\n";
374 let result = log_template_collapse(input, 3).unwrap();
375 assert_eq!(result.text, input);
376 }
377
378 #[test]
381 fn test_rle_no_repetition() {
382 let result = rle_compress("alpha\nbeta\ngamma\n", 2).unwrap();
383 assert_eq!(result.runs_collapsed, 0);
384 }
385
386 #[test]
387 fn test_rle_exact_repetition() {
388 let input = "ok\nok\nok\nok\nok\n";
389 let result = rle_compress(input, 2).unwrap();
390 assert!(result.runs_collapsed > 0);
391 assert!(result.text.contains("[×5]"), "output: {}", result.text);
392 }
393
394 #[test]
395 fn test_rle_pattern_no_longer_collapses() {
396 let input = "Compiling foo v1.0\nCompiling bar v2.0\nCompiling baz v3.0\ndone\n";
401 let result = rle_compress(input, 2).unwrap();
402 assert_eq!(result.runs_collapsed, 0, "different lines must not be collapsed");
403 assert!(result.text.contains("foo"), "filename 'foo' must survive: {}", result.text);
404 assert!(result.text.contains("bar"), "filename 'bar' must survive: {}", result.text);
405 assert!(result.text.contains("baz"), "filename 'baz' must survive: {}", result.text);
406 }
407
408 #[test]
409 fn test_rle_ls_l_output_preserves_filenames() {
410 let input = "drwxr-xr-x 6 user user 192 Apr 18 10:00 packages\n\
414 drwxr-xr-x 3 user user 96 Apr 18 10:00 configuration\n\
415 drwxr-xr-x 4 user user 128 Apr 18 10:00 documentation\n\
416 drwxr-xr-x 2 user user 64 Apr 18 10:00 environment\n";
417 let result = rle_compress(input, 2).unwrap();
418 for name in &["packages", "configuration", "documentation", "environment"] {
419 assert!(result.text.contains(name),
420 "filename '{}' must be preserved — got:\n{}", name, result.text);
421 }
422 }
423
424 #[test]
425 fn test_rle_mixed_content() {
426 let input = "header\ntest a ... ok\ntest b ... ok\ntest c ... ok\nfooter\n";
427 let result = rle_compress(input, 2).unwrap();
428 assert!(result.text.contains("header"));
429 assert!(result.text.contains("footer"));
430 }
431
432 #[test]
433 fn test_rle_preserves_trailing_newline() {
434 let result = rle_compress("a\na\na\n", 2).unwrap();
435 assert!(result.text.ends_with('\n'));
436 }
437
438 #[test]
439 fn test_rle_empty_input() {
440 let result = rle_compress("", 2).unwrap();
441 assert_eq!(result.text, "");
442 assert_eq!(result.runs_collapsed, 0);
443 }
444
445 #[test]
446 fn test_rle_single_line() {
447 let result = rle_compress("hello\n", 2).unwrap();
448 assert_eq!(result.runs_collapsed, 0);
449 }
450
451 #[test]
452 fn test_rle_min_run_length_respected() {
453 let input = "ok\nok\ndone\n";
454 let result_2 = rle_compress(input, 2).unwrap();
455 let result_3 = rle_compress(input, 3).unwrap();
456 assert!(result_2.runs_collapsed > 0, "run of 2 should collapse with min=2");
457 assert_eq!(result_3.runs_collapsed, 0, "run of 2 should NOT collapse with min=3");
458 }
459
460 #[test]
461 fn test_rle_counts_runs_of_the_original() {
462 let original = format!("x\nx\n{}b\n{}", "a\n".repeat(10), "c\n".repeat(3));
463 let condensed = "x\nx\na\na\na\nb\nc\nc\nc\n";
464 let result = rle_compress_with_original(condensed, Some(&original), 3).unwrap();
465 assert_eq!(result.text, "x\nx\na [×10]\nb\nc [×3]\n");
466 assert_eq!(rle_compress(condensed, 3).unwrap().text, "x\nx\na [×3]\nb\nc [×3]\n");
467 }
468
469 #[test]
472 fn test_sliding_window_no_duplicates() {
473 let result = sliding_window_dedup("line 1\nline 2\nline 3\n", 3).unwrap();
474 assert_eq!(result.dedup_count, 0);
475 }
476
477 #[test]
478 fn test_sliding_window_exact_duplicate_lines() {
479 let input = "error: connection refused at port 8080\nsome other line\nerror: connection refused at port 8080\n";
480 let result = sliding_window_dedup(input, 3).unwrap();
481 assert!(result.dedup_count > 0, "should detect duplicate line");
482 assert!(result.text.contains("[→L1]"), "output: {}", result.text);
483 }
484
485 #[test]
486 fn test_sliding_window_preserves_first_occurrence() {
487 let input = "the error message here\nother stuff\nthe error message here\n";
488 let result = sliding_window_dedup(input, 3).unwrap();
489 assert!(result.text.starts_with("the error message here\n"));
491 }
492
493 #[test]
494 fn test_sliding_window_short_lines_ignored() {
495 let input = "ok\nok\nok\n";
496 let result = sliding_window_dedup(input, 3).unwrap();
497 assert_eq!(result.dedup_count, 0);
499 }
500
501 #[test]
502 fn test_sliding_window_empty_input() {
503 let result = sliding_window_dedup("", 3).unwrap();
504 assert_eq!(result.text, "");
505 }
506
507 use proptest::prelude::*;
508
509 proptest! {
510 #[test]
512 fn prop_rle_tokens_saved_non_negative(
513 text in "[a-z]{3,10}(\n[a-z]{3,10}){2,10}\n"
514 ) {
515 let result = rle_compress(&text, 2).unwrap();
516 let _ = result.tokens_saved;
518 prop_assert!(!result.text.is_empty());
520 }
521
522 #[test]
524 fn prop_sliding_window_bounded(
525 text in "[a-z ]{10,50}(\n[a-z ]{10,50}){2,8}\n"
526 ) {
527 let result = sliding_window_dedup(&text, 3).unwrap();
528 let line_count = text.lines().count();
529 prop_assert!(
530 result.dedup_count <= line_count,
531 "dedup count {} exceeds line count {}",
532 result.dedup_count, line_count
533 );
534 }
535 }
536}