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 let lines: Vec<&str> = text.lines().collect();
29 if lines.len() < 2 {
30 return Ok(RleResult {
31 text: text.to_string(),
32 runs_collapsed: 0,
33 tokens_saved: 0,
34 });
35 }
36
37 let mut output = Vec::new();
38 let mut runs_collapsed = 0usize;
39 let mut tokens_saved = 0u32;
40 let mut i = 0;
41
42 while i < lines.len() {
43 let mut run_len = 1;
45 while i + run_len < lines.len() && lines[i] == lines[i + run_len] {
46 run_len += 1;
47 }
48
49 if run_len >= min_run_length {
50 output.push(format!("{} [×{}]", lines[i], run_len));
53 runs_collapsed += 1;
54 let line_tokens = (lines[i].len() as u32 + 3) / 4;
55 tokens_saved += line_tokens * (run_len as u32 - 1);
56 i += run_len;
57 } else {
58 output.push(lines[i].to_string());
65 i += 1;
66 }
67 }
68
69 let trailing_newline = text.ends_with('\n');
70 let mut result = output.join("\n");
71 if trailing_newline && !result.ends_with('\n') {
72 result.push('\n');
73 }
74
75 Ok(RleResult {
76 text: result,
77 runs_collapsed,
78 tokens_saved,
79 })
80}
81
82#[derive(Debug, Clone)]
86pub struct LogTemplateResult {
87 pub text: String,
88 pub runs_collapsed: usize,
89 pub tokens_saved: u32,
90}
91
92pub fn log_template_collapse(text: &str, min_run_length: usize) -> Result<LogTemplateResult> {
103 let lines: Vec<&str> = text.lines().collect();
104 if lines.len() < min_run_length {
105 return Ok(LogTemplateResult {
106 text: text.to_string(),
107 runs_collapsed: 0,
108 tokens_saved: 0,
109 });
110 }
111
112 let templates: Vec<String> = lines.iter().map(|l| mask_volatiles(l)).collect();
113
114 let mut output = Vec::new();
115 let mut runs_collapsed = 0usize;
116 let mut tokens_saved = 0u32;
117 let mut i = 0;
118
119 while i < lines.len() {
120 let mut run_len = 1;
121 while i + run_len < lines.len()
124 && templates[i + run_len] == templates[i]
125 && templates[i] != lines[i]
126 && lines[i + run_len] != lines[i]
127 {
128 run_len += 1;
129 }
130
131 if run_len >= min_run_length {
132 output.push(lines[i].to_string());
133 output.push(format!(
134 "[×{} similar lines; only timestamps/durations vary]",
135 run_len - 1
136 ));
137 runs_collapsed += 1;
138 for line in &lines[i + 1..i + run_len] {
139 tokens_saved += (line.len() as u32).div_ceil(4);
140 }
141 i += run_len;
142 } else {
143 output.push(lines[i].to_string());
144 i += 1;
145 }
146 }
147
148 let trailing_newline = text.ends_with('\n');
149 let mut result = output.join("\n");
150 if trailing_newline && !result.ends_with('\n') {
151 result.push('\n');
152 }
153
154 Ok(LogTemplateResult {
155 text: result,
156 runs_collapsed,
157 tokens_saved,
158 })
159}
160
161fn mask_volatiles(line: &str) -> String {
165 let mut out = String::with_capacity(line.len());
166 for token in line.split_inclusive(|c: char| c.is_whitespace()) {
167 let (word, ws) = match token.find(|c: char| c.is_whitespace()) {
168 Some(idx) => token.split_at(idx),
169 None => (token, ""),
170 };
171 let stripped = word.trim_matches(|c: char| "[]()<>,".contains(c));
172 if is_volatile(stripped) {
173 out.push_str(&word.replace(stripped, "‹›"));
174 } else {
175 out.push_str(word);
176 }
177 out.push_str(ws);
178 }
179 out
180}
181
182fn is_volatile(token: &str) -> bool {
183 if token.len() < 2 {
184 return false;
185 }
186 for suffix in ["ms", "µs", "us", "ns", "s", "m"] {
188 if let Some(num) = token.strip_suffix(suffix) {
189 if !num.is_empty()
190 && num.chars().all(|c| c.is_ascii_digit() || c == '.')
191 && num.chars().any(|c| c.is_ascii_digit())
192 {
193 return true;
194 }
195 }
196 }
197 if let Some(num) = token.strip_suffix('%') {
199 if !num.is_empty() && num.chars().all(|c| c.is_ascii_digit() || c == '.') {
200 return true;
201 }
202 }
203 let digits = token.chars().filter(|c| c.is_ascii_digit()).count();
205 if digits >= 4
206 && token
207 .chars()
208 .all(|c| c.is_ascii_digit() || ":-.TZ+".contains(c))
209 && (token.contains(':') || token.matches('-').count() >= 2)
210 {
211 return true;
212 }
213 false
214}
215
216pub fn sliding_window_dedup(text: &str, min_match_words: usize) -> Result<SlidingWindowResult> {
227 let lines: Vec<&str> = text.lines().collect();
228 if lines.len() < 2 {
229 return Ok(SlidingWindowResult {
230 text: text.to_string(),
231 dedup_count: 0,
232 tokens_saved: 0,
233 });
234 }
235
236 let mut phrase_index: std::collections::HashMap<String, usize> = std::collections::HashMap::new();
238 let mut output = Vec::new();
239 let mut dedup_count = 0usize;
240 let mut tokens_saved = 0u32;
241
242 for (line_idx, &line) in lines.iter().enumerate() {
243 let words: Vec<&str> = line.split_whitespace().collect();
244
245 let phrase = words.join(" ");
247 if phrase.len() >= min_match_words * 3 {
248 if let Some(&first_line) = phrase_index.get(&phrase) {
249 output.push(format!("[→L{}]", first_line + 1));
251 dedup_count += 1;
252 tokens_saved += (phrase.len() as u32 + 3) / 4;
253 continue;
254 }
255 }
256
257 if phrase.len() >= min_match_words * 3 {
259 phrase_index.insert(phrase, line_idx);
260 }
261 output.push(line.to_string());
262 }
263
264 let trailing_newline = text.ends_with('\n');
265 let mut result = output.join("\n");
266 if trailing_newline && !result.ends_with('\n') {
267 result.push('\n');
268 }
269
270 Ok(SlidingWindowResult {
271 text: result,
272 dedup_count,
273 tokens_saved,
274 })
275}
276
277#[derive(Debug, Clone)]
279pub struct SlidingWindowResult {
280 pub text: String,
281 pub dedup_count: usize,
282 pub tokens_saved: u32,
283}
284
285#[cfg(test)]
288mod tests {
289 use super::*;
290
291 #[test]
294 fn template_collapses_timestamp_spam() {
295 let input = "\
29612:00:01 worker heartbeat ok in 12ms
29712:00:02 worker heartbeat ok in 14ms
29812:00:03 worker heartbeat ok in 11ms
29912:00:04 worker heartbeat ok in 13ms
300done\n";
301 let result = log_template_collapse(input, 3).unwrap();
302 assert_eq!(result.runs_collapsed, 1);
303 assert!(result.text.contains("12:00:01 worker heartbeat ok in 12ms"), "first line verbatim");
304 assert!(result.text.contains("[×3 similar lines; only timestamps/durations vary]"));
305 assert!(!result.text.contains("12:00:03"), "later volatiles collapsed");
306 assert!(result.text.ends_with("done\n"));
307 }
308
309 #[test]
310 fn template_never_collapses_identifier_bearing_lines() {
311 let input = "\
31512:00:01 pushed commit deadbeef01 to origin
31612:00:02 pushed commit cafebabe02 to origin
31712:00:03 pushed commit 0ddba11f03 to origin
31812:00:04 pushed commit feedface04 to origin\n";
319 let result = log_template_collapse(input, 3).unwrap();
320 assert_eq!(result.runs_collapsed, 0, "{}", result.text);
321 for id in ["deadbeef01", "cafebabe02", "0ddba11f03", "feedface04"] {
322 assert!(result.text.contains(id));
323 }
324 }
325
326 #[test]
327 fn template_never_collapses_distinct_filenames() {
328 let input = "\
329-rw-r--r-- 1 u staff 120 Aug 15 12:00 alpha.rs
330-rw-r--r-- 1 u staff 340 Aug 15 12:01 beta.rs
331-rw-r--r-- 1 u staff 560 Aug 15 12:02 gamma.rs
332-rw-r--r-- 1 u staff 780 Aug 15 12:03 delta.rs\n";
333 let result = log_template_collapse(input, 3).unwrap();
334 for name in ["alpha.rs", "beta.rs", "gamma.rs", "delta.rs"] {
335 assert!(result.text.contains(name), "filename lost: {}", result.text);
336 }
337 }
338
339 #[test]
340 fn template_leaves_identical_lines_to_exact_rle() {
341 let input = "same line\nsame line\nsame line\nsame line\n";
342 let result = log_template_collapse(input, 3).unwrap();
343 assert_eq!(result.runs_collapsed, 0);
344 assert_eq!(result.text, input);
345 }
346
347 #[test]
348 fn template_short_runs_untouched() {
349 let input = "12:00:01 tick\n12:00:02 tick\nother\n";
350 let result = log_template_collapse(input, 3).unwrap();
351 assert_eq!(result.text, input);
352 }
353
354 #[test]
357 fn test_rle_no_repetition() {
358 let result = rle_compress("alpha\nbeta\ngamma\n", 2).unwrap();
359 assert_eq!(result.runs_collapsed, 0);
360 }
361
362 #[test]
363 fn test_rle_exact_repetition() {
364 let input = "ok\nok\nok\nok\nok\n";
365 let result = rle_compress(input, 2).unwrap();
366 assert!(result.runs_collapsed > 0);
367 assert!(result.text.contains("[×5]"), "output: {}", result.text);
368 }
369
370 #[test]
371 fn test_rle_pattern_no_longer_collapses() {
372 let input = "Compiling foo v1.0\nCompiling bar v2.0\nCompiling baz v3.0\ndone\n";
377 let result = rle_compress(input, 2).unwrap();
378 assert_eq!(result.runs_collapsed, 0, "different lines must not be collapsed");
379 assert!(result.text.contains("foo"), "filename 'foo' must survive: {}", result.text);
380 assert!(result.text.contains("bar"), "filename 'bar' must survive: {}", result.text);
381 assert!(result.text.contains("baz"), "filename 'baz' must survive: {}", result.text);
382 }
383
384 #[test]
385 fn test_rle_ls_l_output_preserves_filenames() {
386 let input = "drwxr-xr-x 6 user user 192 Apr 18 10:00 packages\n\
390 drwxr-xr-x 3 user user 96 Apr 18 10:00 configuration\n\
391 drwxr-xr-x 4 user user 128 Apr 18 10:00 documentation\n\
392 drwxr-xr-x 2 user user 64 Apr 18 10:00 environment\n";
393 let result = rle_compress(input, 2).unwrap();
394 for name in &["packages", "configuration", "documentation", "environment"] {
395 assert!(result.text.contains(name),
396 "filename '{}' must be preserved — got:\n{}", name, result.text);
397 }
398 }
399
400 #[test]
401 fn test_rle_mixed_content() {
402 let input = "header\ntest a ... ok\ntest b ... ok\ntest c ... ok\nfooter\n";
403 let result = rle_compress(input, 2).unwrap();
404 assert!(result.text.contains("header"));
405 assert!(result.text.contains("footer"));
406 }
407
408 #[test]
409 fn test_rle_preserves_trailing_newline() {
410 let result = rle_compress("a\na\na\n", 2).unwrap();
411 assert!(result.text.ends_with('\n'));
412 }
413
414 #[test]
415 fn test_rle_empty_input() {
416 let result = rle_compress("", 2).unwrap();
417 assert_eq!(result.text, "");
418 assert_eq!(result.runs_collapsed, 0);
419 }
420
421 #[test]
422 fn test_rle_single_line() {
423 let result = rle_compress("hello\n", 2).unwrap();
424 assert_eq!(result.runs_collapsed, 0);
425 }
426
427 #[test]
428 fn test_rle_min_run_length_respected() {
429 let input = "ok\nok\ndone\n";
430 let result_2 = rle_compress(input, 2).unwrap();
431 let result_3 = rle_compress(input, 3).unwrap();
432 assert!(result_2.runs_collapsed > 0, "run of 2 should collapse with min=2");
433 assert_eq!(result_3.runs_collapsed, 0, "run of 2 should NOT collapse with min=3");
434 }
435
436 #[test]
439 fn test_sliding_window_no_duplicates() {
440 let result = sliding_window_dedup("line 1\nline 2\nline 3\n", 3).unwrap();
441 assert_eq!(result.dedup_count, 0);
442 }
443
444 #[test]
445 fn test_sliding_window_exact_duplicate_lines() {
446 let input = "error: connection refused at port 8080\nsome other line\nerror: connection refused at port 8080\n";
447 let result = sliding_window_dedup(input, 3).unwrap();
448 assert!(result.dedup_count > 0, "should detect duplicate line");
449 assert!(result.text.contains("[→L1]"), "output: {}", result.text);
450 }
451
452 #[test]
453 fn test_sliding_window_preserves_first_occurrence() {
454 let input = "the error message here\nother stuff\nthe error message here\n";
455 let result = sliding_window_dedup(input, 3).unwrap();
456 assert!(result.text.starts_with("the error message here\n"));
458 }
459
460 #[test]
461 fn test_sliding_window_short_lines_ignored() {
462 let input = "ok\nok\nok\n";
463 let result = sliding_window_dedup(input, 3).unwrap();
464 assert_eq!(result.dedup_count, 0);
466 }
467
468 #[test]
469 fn test_sliding_window_empty_input() {
470 let result = sliding_window_dedup("", 3).unwrap();
471 assert_eq!(result.text, "");
472 }
473
474 use proptest::prelude::*;
475
476 proptest! {
477 #[test]
479 fn prop_rle_tokens_saved_non_negative(
480 text in "[a-z]{3,10}(\n[a-z]{3,10}){2,10}\n"
481 ) {
482 let result = rle_compress(&text, 2).unwrap();
483 let _ = result.tokens_saved;
485 prop_assert!(!result.text.is_empty());
487 }
488
489 #[test]
491 fn prop_sliding_window_bounded(
492 text in "[a-z ]{10,50}(\n[a-z ]{10,50}){2,8}\n"
493 ) {
494 let result = sliding_window_dedup(&text, 3).unwrap();
495 let line_count = text.lines().count();
496 prop_assert!(
497 result.dedup_count <= line_count,
498 "dedup count {} exceeds line count {}",
499 result.dedup_count, line_count
500 );
501 }
502 }
503}