1use std::hash::{DefaultHasher, Hash, Hasher};
6use std::sync::LazyLock;
7
8use regex::Regex;
9
10static KEYWORD: LazyLock<Regex> =
11 LazyLock::new(|| Regex::new(r"(?i)\b(error|failed|failure|panicked|exception|traceback|not found|no such|cannot|denied)\b").unwrap());
12static PATHLIKE: LazyLock<Regex> = LazyLock::new(|| Regex::new(r"[\w.~-]*(/[\w.@+-]+)+/?").unwrap());
13static HEX: LazyLock<Regex> = LazyLock::new(|| Regex::new(r"\b[0-9a-fA-F]{6,}\b").unwrap());
14static NUM: LazyLock<Regex> = LazyLock::new(|| Regex::new(r"\d+").unwrap());
15static WS: LazyLock<Regex> = LazyLock::new(|| Regex::new(r"\s+").unwrap());
16
17fn hash<T: Hash>(t: T) -> u64 {
18 let mut h = DefaultHasher::new();
19 t.hash(&mut h);
20 h.finish()
21}
22
23pub fn normalize_error_line(line: &str) -> String {
25 let s = PATHLIKE.replace_all(line, "P");
26 let s = HEX.replace_all(&s, "H");
27 let s = NUM.replace_all(&s, "N");
28 let s = WS.replace_all(&s, " ");
29 s.trim().to_lowercase().chars().take(NORMAL_CHARS).collect()
30}
31
32const NORMAL_CHARS: usize = 160;
35const LINE_CHARS: usize = 200;
37
38static FAILURE: LazyLock<Regex> = LazyLock::new(|| {
39 Regex::new(r"(?m)^(Traceback \(most recent call last\)|FAILED|FAIL:|ERROR:|error(\[\w+\])?:|\w*(Error|Exception): |thread '.*' panicked|npm ERR!|fatal: )")
40 .unwrap()
41});
42
43pub fn looks_failed(output: &str) -> bool {
46 FAILURE.is_match(output)
47}
48
49static SPECIFIC: LazyLock<Regex> = LazyLock::new(|| {
51 Regex::new(r"^\s*(?:E\s+)?(?:[\w.]*(?:Error|Exception|Exit)\b\s*:\s*\S|error(?:\[\w+\])?:\s*\S|fatal:\s*\S|npm ERR!\s*\S|.*\bcommand not found\b|.*\bNo such file or directory\b|.*\bnot found\b.*)").unwrap()
52});
53static GENERIC: LazyLock<Regex> = LazyLock::new(|| {
55 Regex::new(r"(?i)^\s*(traceback \(most recent call last\)|build failed|failure: build failed|\* what went wrong|exit code \d+|failed \(|error: process completed|> task .* failed|\d+ (tests? )?failed|error:?\s*$)").unwrap()
56});
57static HARNESS: LazyLock<Regex> = LazyLock::new(|| {
59 Regex::new(r"(?i)(permission to use \w+ has been denied|claude code is running in|<tool_use_error>|user (rejected|doesn't want)|interrupted by user)")
60 .unwrap()
61});
62
63pub fn error_line(output: &str) -> Option<String> {
67 if HARNESS.is_match(output) {
68 return None;
69 }
70 let specific = output.lines().rev().find(|l| SPECIFIC.is_match(l) && !GENERIC.is_match(l));
71 specific.or_else(|| output.lines().find(|l| KEYWORD.is_match(l) && !GENERIC.is_match(l))).map(|l| l.trim().chars().take(LINE_CHARS).collect())
72}
73
74pub fn error_fingerprint(output: &str) -> Option<u64> {
76 error_line(output).map(|l| normalize_error_line(&l)).filter(|s| s.len() >= 8).map(hash)
77}
78
79static TEST_FAILURE: LazyLock<Regex> = LazyLock::new(|| {
81 Regex::new(
82 r"(?i)(assertion(failed)?error|assert(ion)? failed|test(s)? failed|failed tests|expected:? .* but (was|got)|panicked at .*assert|\btests? .* FAILED$)",
83 )
84 .unwrap()
85});
86
87pub fn is_test_failure(line: &str) -> bool {
89 TEST_FAILURE.is_match(line)
90}
91
92pub fn invocation(cmd: &str) -> String {
96 let mut out = Vec::new();
97 let mut skip_next = false;
98 for t in cmd.split_whitespace() {
99 if skip_next {
100 skip_next = false;
101 continue;
102 }
103 if matches!(t, "--tests" | "-k" | "-p" | "--package" | "-m" | "--filter" | "-t") {
104 skip_next = true;
105 continue;
106 }
107 if t.contains('=') && !t.starts_with('-') && t.chars().next().is_some_and(|c| c.is_ascii_uppercase()) {
108 out.push(t.to_string()); } else if t.starts_with('-') {
110 out.push(t.split('=').next().unwrap_or(t).to_string());
111 } else if out.len() < 2 && !t.contains('/') && !t.contains('\'') && !t.contains('"') {
112 out.push(t.to_string());
113 }
114 }
115 out.join(" ")
116}
117
118const TOOLS: &[&str] = &[
119 "gradle",
120 "gradlew",
121 "mvn",
122 "mvnw",
123 "cargo",
124 "npm",
125 "pnpm",
126 "yarn",
127 "npx",
128 "pytest",
129 "uv",
130 "pip",
131 "pip3",
132 "poetry",
133 "go",
134 "make",
135 "docker",
136 "kubectl",
137 "terraform",
138 "gh",
139 "java",
140 "javac",
141 "node",
142 "deno",
143 "bun",
144 "set",
145 "source",
146 "lake",
147 "tsc",
148];
149
150pub fn is_tool_shape(shape: &str) -> bool {
153 let mut it = shape.split_whitespace();
154 let prog = it.next().unwrap_or("").trim_start_matches("./");
155 let sub = it.next().unwrap_or("");
156 TOOLS.contains(&prog) || prog.ends_with("gradlew") || prog.ends_with("mvnw") || (prog.starts_with("python") && TOOLS.contains(&sub))
157}
158
159pub fn command_shape(cmd: &str) -> String {
162 let first = cmd
163 .split(['\n', ';', '|'])
164 .flat_map(|seg| seg.split("&&"))
165 .map(str::trim)
166 .find(|seg| !seg.is_empty() && !seg.starts_with("cd ") && !seg.starts_with('#'))
167 .unwrap_or("");
168 let mut toks = first.split_whitespace().filter(|t| !t.contains('=') || t.starts_with('-'));
169 let prog = toks.next().unwrap_or("").rsplit('/').next().unwrap_or("").to_string();
170 match toks.find(|t| !t.starts_with('-')) {
171 Some(sub) if sub.chars().all(|c| c.is_ascii_alphanumeric() || c == '-' || c == ':') => format!("{prog} {sub}"),
172 _ => prog,
173 }
174}
175
176const READERS: &[&str] = &["cat", "sed", "head", "tail", "nl", "bat", "less", "awk", "wc", "jq", "rg", "grep"];
177
178pub fn only_reads(cmd: &str) -> bool {
180 cmd.split(['\n', ';', '|'])
181 .flat_map(|s| s.split("&&"))
182 .filter_map(|seg| seg.split_whitespace().next())
183 .map(|p| p.rsplit('/').next().unwrap_or(p))
184 .all(|p| READERS.contains(&p) || matches!(p, "cd" | "ls" | "echo" | "printf" | "find" | "tree" | "pwd"))
185}
186
187pub fn bash_read_paths(cmd: &str) -> Vec<String> {
191 let mut out = Vec::new();
192 for seg in cmd.split(['\n', ';', '|']).flat_map(|s| s.split("&&")) {
193 let mut toks = seg.split_whitespace();
194 let Some(prog) = toks.next().map(|p| p.rsplit('/').next().unwrap_or(p)) else { continue };
195 if !READERS.contains(&prog) {
196 continue;
197 }
198 let toks: Vec<&str> = toks.collect();
199 let skip_first = matches!(prog, "rg" | "grep" | "sed" | "awk" | "jq");
201 let takes_arg: &[&str] = match prog {
202 "head" | "tail" => &["-n", "-c"],
203 "rg" | "grep" => &["-e", "-f", "-A", "-B", "-C", "-m", "-g", "-t", "--type", "--glob"],
204 "sed" | "awk" => &["-e", "-f", "-v"],
205 _ => &[],
206 };
207 let mut first_positional = true;
208 for (i, t) in toks.iter().enumerate() {
209 if t.starts_with('-') || t.starts_with('>') || t.starts_with('<') || t.starts_with('$') {
210 continue;
211 }
212 if i > 0 && takes_arg.contains(&toks[i - 1]) {
213 first_positional &= !matches!(toks[i - 1], "-e" | "-f");
215 continue;
216 }
217 if first_positional && skip_first {
218 first_positional = false;
219 continue;
220 }
221 first_positional = false;
222 let t = t.trim_matches(|c| c == '"' || c == '\'');
223 let looks_like_file = t
224 .rsplit('/')
225 .next()
226 .is_some_and(|base| base.contains('.') && !base.starts_with('.') || base.chars().all(|c| c.is_ascii_uppercase() || c == '_') && base.len() > 3);
227 if looks_like_file && !t.contains(['*', '?', '{', '(', '`', '\\', '=']) && !t.ends_with(".") {
228 out.push(t.to_string());
229 }
230 }
231 }
232 out
233}
234
235#[cfg(test)]
236mod tests {
237 use super::*;
238
239 #[test]
240 fn failure_behind_pipe() {
241 assert!(looks_failed(" File \"x.py\", line 3\nValueError: naive datetime"));
242 assert!(looks_failed("Ran 3 tests\n\nFAILED (errors=1)"));
243 assert!(looks_failed("error[E0425]: cannot find value"));
244 assert!(!looks_failed("Ran 3 tests in 0.001s\n\nOK"));
245 assert!(only_reads("cat a.py | head; ls src"));
246 assert!(!only_reads("cat a.py; python3 -m unittest | tail"));
247 }
248
249 #[test]
250 fn shell_reads() {
251 assert_eq!(bash_read_paths("sed -n '1,80p' src/main.rs | head"), vec!["src/main.rs"]);
252 assert_eq!(bash_read_paths("cat Cargo.toml README.md"), vec!["Cargo.toml", "README.md"]);
253 assert_eq!(bash_read_paths("rg -n 'fn parse' src/lib.rs"), vec!["src/lib.rs"]);
254 assert_eq!(bash_read_paths("cargo test && ls src"), Vec::<String>::new());
255 assert_eq!(bash_read_paths("head -n 20 /abs/x.py 2>/dev/null"), vec!["/abs/x.py"]);
256 }
257
258 #[test]
259 fn same_error_different_details() {
260 let a = "error[E0425]: cannot find value `x` in /tmp/a1/src/main.rs:12:5";
261 let b = "error[E0425]: cannot find value `x` in /home/user/r/src/main.rs:99:1";
262 assert_eq!(error_fingerprint(a), error_fingerprint(b));
263 assert_ne!(error_fingerprint(a), error_fingerprint("error: linker `cc` not found"));
264 assert_eq!(error_fingerprint("all good\nok"), None);
265 }
266
267 #[test]
268 fn specific_error_line_wins() {
269 let py = "Traceback (most recent call last):\n File \"x.py\", line 3\nModuleNotFoundError: No module named 'aiortc'";
270 assert_eq!(error_line(py).unwrap(), "ModuleNotFoundError: No module named 'aiortc'");
271 let other = "Traceback (most recent call last):\n File \"y.py\", line 9\nKeyError: 'id'";
272 assert_ne!(error_fingerprint(py), error_fingerprint(other), "generic header must not collide");
273 assert_eq!(error_line("Permission to use Bash has been denied because Claude Code is running in don't ask mode"), None);
274 assert!(is_tool_shape("./gradlew test") && is_tool_shape("cargo test") && !is_tool_shape("python3") && !is_tool_shape("grep"));
275 }
276
277 #[test]
278 fn command_shapes() {
279 assert_eq!(command_shape("cd foo && cargo test -p x -- --nocapture"), "cargo test");
280 assert_eq!(command_shape("RUST_LOG=debug /usr/bin/npm run build"), "npm run");
281 assert_eq!(command_shape("ls -la"), "ls");
282 assert_eq!(command_shape("git status | head"), "git status");
283 }
284}