dejavu/reduce/
normalize.rs1use regex::Regex;
5use std::sync::LazyLock;
6
7static ISO_TIMESTAMP: LazyLock<Regex> = LazyLock::new(|| {
8 Regex::new(r"\d{4}-\d{2}-\d{2}[T ]\d{2}:\d{2}:\d{2}(?:\.\d+)?(?:Z|[+-]\d{2}:?\d{2})?").unwrap()
9});
10static CLOCK_TIME: LazyLock<Regex> =
11 LazyLock::new(|| Regex::new(r"\b\d{2}:\d{2}:\d{2}(?:\.\d+)?\b").unwrap());
12static DURATION: LazyLock<Regex> =
13 LazyLock::new(|| Regex::new(r"\b\d+(?:\.\d+)?\s?(?:ms|µs|us|ns|s|m)\b").unwrap());
14static TMP_PATH: LazyLock<Regex> =
15 LazyLock::new(|| Regex::new(r"(?:/private/tmp|/tmp|/var/folders)/[^\s:]*").unwrap());
16static PID: LazyLock<Regex> = LazyLock::new(|| Regex::new(r"(?i)\bpid\s+\d+").unwrap());
17static PORT: LazyLock<Regex> =
18 LazyLock::new(|| Regex::new(r"\b(?:localhost|127\.0\.0\.1):\d{2,5}\b").unwrap());
19static BLANKS: LazyLock<Regex> = LazyLock::new(|| Regex::new(r"\n{3,}").unwrap());
20static PM_NOTICE: LazyLock<Regex> = LazyLock::new(|| {
24 Regex::new(
25 r"(?i)^\s*(npm notice\b.*|.*update available!?\s.*→.*|\[?yarn\]? .*new version .*available.*)$",
26 )
27 .unwrap()
28});
29
30const MAX_LINE_CHARS: usize = 2000;
31const LINE_KEEP: usize = 1000;
32
33pub fn normalize(input: &str) -> String {
36 let detabbed = input.replace('\t', " ");
39
40 let stripped = strip_ansi_escapes::strip(detabbed.as_bytes());
42 let text = String::from_utf8_lossy(&stripped);
43
44 let text = text.replace("\r\n", "\n").replace('\r', "\n");
46
47 let text = ISO_TIMESTAMP.replace_all(&text, "<TIMESTAMP>");
49 let text = CLOCK_TIME.replace_all(&text, "<TIME>");
50 let text = DURATION.replace_all(&text, "<DURATION>");
51 let text = TMP_PATH.replace_all(&text, "<TMP_PATH>");
52 let text = PID.replace_all(&text, "pid <PID>");
53 let text = PORT.replace_all(&text, "localhost:<PORT>");
54
55 let mut lines: Vec<String> = Vec::new();
58 for line in text.split('\n') {
59 if is_progress_bar(line) || PM_NOTICE.is_match(line) {
60 continue;
61 }
62 lines.push(truncate_long_line(line.trim_end()));
63 }
64 let joined = lines.join("\n");
65
66 BLANKS.replace_all(&joined, "\n\n").into_owned()
68}
69
70fn is_progress_bar(line: &str) -> bool {
74 let t = line.trim();
75 if t.chars().count() < 8 {
76 return false;
77 }
78 if t.chars().any(|c| ('\u{2588}'..='\u{258F}').contains(&c)) {
79 return true;
80 }
81 if !t.contains('%') {
82 return false;
83 }
84 let total = t.chars().count();
85 let barish = t
86 .chars()
87 .filter(|c| matches!(c, '=' | '>' | '#' | '-' | '.' | ' ' | '[' | ']'))
88 .count();
89 barish * 10 >= total * 7
90}
91
92fn truncate_long_line(line: &str) -> String {
93 if line.chars().count() <= MAX_LINE_CHARS {
94 return line.to_string();
95 }
96 let head: String = line.chars().take(LINE_KEEP).collect();
97 let tail_rev: Vec<char> = line.chars().rev().take(LINE_KEEP).collect();
98 let tail: String = tail_rev.into_iter().rev().collect();
99 format!("{head}…<TRUNCATED>…{tail}")
100}
101
102#[cfg(test)]
103mod tests {
104 use super::*;
105
106 #[test]
107 fn strips_ansi_and_normalizes_newlines() {
108 let out = normalize("\x1b[31mred\x1b[0m\r\ntext\r\n");
109 assert_eq!(out, "red\ntext\n");
110 }
111
112 #[test]
113 fn replaces_volatile_tokens() {
114 let out =
115 normalize("done in 843ms at 2026-07-06T10:41:22.123Z pid 12345 on localhost:5173");
116 assert!(out.contains("<DURATION>"));
117 assert!(out.contains("<TIMESTAMP>"));
118 assert!(out.contains("pid <PID>"));
119 assert!(out.contains("localhost:<PORT>"));
120 assert!(!out.contains("843ms"));
121 assert!(!out.contains("12345"));
122 }
123
124 #[test]
125 fn tmp_paths_and_clock_times() {
126 let out = normalize("wrote /tmp/foo-839201/bar.txt at 12:41:22");
127 assert!(out.contains("<TMP_PATH>"));
128 assert!(out.contains("<TIME>"));
129 }
130
131 #[test]
132 fn collapses_blank_lines_and_trims() {
133 let out = normalize("a \n\n\n\nb");
134 assert_eq!(out, "a\n\nb");
135 }
136
137 #[test]
138 fn equivalent_runs_normalize_identically() {
139 let a = normalize("PASS in 120ms at 10:00:01");
140 let b = normalize("PASS in 456ms at 11:22:33");
141 assert_eq!(a, b);
142 }
143
144 #[test]
145 fn npm_update_notice_is_dropped() {
146 let with_notice = "ok\nnpm notice\nnpm notice New minor version of npm available! 11.16.0 -> 11.18.0\nnpm notice To update run: npm install -g npm@11.18.0\ndone";
147 let without = "ok\ndone";
148 assert_eq!(normalize(with_notice), normalize(without));
149 assert!(normalize("npm warn deprecated foo@1").contains("npm warn"));
151 }
152}