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