lean_ctx/core/gotcha_tracker/
detect.rs1use super::model::{GotchaCategory, GotchaSeverity};
2
3pub struct DetectedError {
8 pub category: GotchaCategory,
9 pub severity: GotchaSeverity,
10 pub raw_message: String,
11}
12
13pub fn detect_error_pattern(output: &str, command: &str, exit_code: i32) -> Option<DetectedError> {
14 let cmd_lower = command.to_lowercase();
15 let out_lower = output.to_lowercase();
16
17 if cmd_lower.starts_with("cargo ") || cmd_lower.contains("rustc") {
19 if let Some(msg) = extract_pattern(output, r"error\[E\d{4}\]: .+") {
20 return Some(DetectedError {
21 category: GotchaCategory::Build,
22 severity: GotchaSeverity::Critical,
23 raw_message: msg,
24 });
25 }
26 if out_lower.contains("cannot find") || out_lower.contains("mismatched types") {
27 return Some(DetectedError {
28 category: GotchaCategory::Build,
29 severity: GotchaSeverity::Critical,
30 raw_message: extract_first_error_line(output),
31 });
32 }
33 if out_lower.contains("test result: failed") || out_lower.contains("failures:") {
34 return Some(DetectedError {
35 category: GotchaCategory::Test,
36 severity: GotchaSeverity::Critical,
37 raw_message: extract_first_error_line(output),
38 });
39 }
40 }
41
42 if (cmd_lower.starts_with("npm ")
44 || cmd_lower.starts_with("pnpm ")
45 || cmd_lower.starts_with("yarn "))
46 && (out_lower.contains("err!") || out_lower.contains("eresolve"))
47 {
48 return Some(DetectedError {
49 category: GotchaCategory::Dependency,
50 severity: GotchaSeverity::Critical,
51 raw_message: extract_first_error_line(output),
52 });
53 }
54
55 if cmd_lower.starts_with("node ") || cmd_lower.contains("tsx ") || cmd_lower.contains("ts-node")
57 {
58 for pat in &[
59 "syntaxerror",
60 "typeerror",
61 "referenceerror",
62 "cannot find module",
63 ] {
64 if out_lower.contains(pat) {
65 return Some(DetectedError {
66 category: GotchaCategory::Runtime,
67 severity: GotchaSeverity::Critical,
68 raw_message: extract_first_error_line(output),
69 });
70 }
71 }
72 }
73
74 if (cmd_lower.starts_with("python")
76 || cmd_lower.starts_with("pip ")
77 || cmd_lower.starts_with("uv "))
78 && (out_lower.contains("traceback")
79 || out_lower.contains("importerror")
80 || out_lower.contains("modulenotfounderror"))
81 {
82 return Some(DetectedError {
83 category: GotchaCategory::Runtime,
84 severity: GotchaSeverity::Critical,
85 raw_message: extract_first_error_line(output),
86 });
87 }
88
89 if cmd_lower.starts_with("go ")
91 && (out_lower.contains("cannot use") || out_lower.contains("undefined:"))
92 {
93 return Some(DetectedError {
94 category: GotchaCategory::Build,
95 severity: GotchaSeverity::Critical,
96 raw_message: extract_first_error_line(output),
97 });
98 }
99
100 if (cmd_lower.contains("tsc") || cmd_lower.contains("typescript"))
102 && let Some(msg) = extract_pattern(output, r"TS\d{4}: .+")
103 {
104 return Some(DetectedError {
105 category: GotchaCategory::Build,
106 severity: GotchaSeverity::Critical,
107 raw_message: msg,
108 });
109 }
110
111 if cmd_lower.starts_with("docker ")
113 && out_lower.contains("error")
114 && (out_lower.contains("failed to") || out_lower.contains("copy failed"))
115 {
116 return Some(DetectedError {
117 category: GotchaCategory::Build,
118 severity: GotchaSeverity::Critical,
119 raw_message: extract_first_error_line(output),
120 });
121 }
122
123 if cmd_lower.starts_with("git ")
125 && (out_lower.contains("conflict")
126 || out_lower.contains("rejected")
127 || out_lower.contains("diverged"))
128 {
129 return Some(DetectedError {
130 category: GotchaCategory::Config,
131 severity: GotchaSeverity::Warning,
132 raw_message: extract_first_error_line(output),
133 });
134 }
135
136 if cmd_lower.contains("pytest") && (out_lower.contains("failed") || out_lower.contains("error"))
138 {
139 return Some(DetectedError {
140 category: GotchaCategory::Test,
141 severity: GotchaSeverity::Critical,
142 raw_message: extract_first_error_line(output),
143 });
144 }
145
146 if (cmd_lower.contains("jest") || cmd_lower.contains("vitest"))
148 && (out_lower.contains("fail") || out_lower.contains("typeerror"))
149 {
150 return Some(DetectedError {
151 category: GotchaCategory::Test,
152 severity: GotchaSeverity::Critical,
153 raw_message: extract_first_error_line(output),
154 });
155 }
156
157 if (cmd_lower.starts_with("make") || cmd_lower.contains("cmake"))
159 && out_lower.contains("error")
160 && (out_lower.contains("undefined reference") || out_lower.contains("no rule"))
161 {
162 return Some(DetectedError {
163 category: GotchaCategory::Build,
164 severity: GotchaSeverity::Critical,
165 raw_message: extract_first_error_line(output),
166 });
167 }
168
169 if exit_code != 0
171 && output.len() > 50
172 && (out_lower.contains("error")
173 || out_lower.contains("fatal")
174 || out_lower.contains("failed"))
175 {
176 return Some(DetectedError {
177 category: GotchaCategory::Runtime,
178 severity: GotchaSeverity::Warning,
179 raw_message: extract_first_error_line(output),
180 });
181 }
182
183 None
184}
185
186pub fn normalize_error_signature(raw: &str) -> String {
191 let mut sig = raw.to_string();
192
193 sig = regex_replace(&sig, r"(/[A-Za-z][\w.-]*/)+", "");
194 sig = regex_replace(&sig, r"[A-Z]:\\[\w\\.-]+\\", "");
195 sig = regex_replace(&sig, r":\d+:\d+", ":_:_");
196 sig = regex_replace(&sig, r"line \d+", "line _");
197 sig = regex_replace(&sig, r"\s+", " ");
198
199 if sig.len() > 200 {
200 sig.truncate(200);
201 }
202
203 sig.trim().to_string()
204}
205
206pub fn is_correlatable_command(command: &str) -> bool {
216 let c = command.trim_start().to_lowercase();
217 const PREFIXES: &[&str] = &[
218 "cargo ", "rustc", "npm ", "pnpm ", "yarn ", "node ", "tsx ", "ts-node", "python", "pip ",
219 "uv ", "go ", "docker ", "git ", "make", "cmake", "gradle", "mvn ",
220 ];
221 if PREFIXES.iter().any(|p| c.starts_with(p)) {
222 return true;
223 }
224 ["tsc", "pytest", "jest", "vitest", "eslint"]
226 .iter()
227 .any(|t| c.contains(t))
228}
229
230pub(super) fn command_base(cmd: &str) -> String {
231 let parts: Vec<&str> = cmd.split_whitespace().collect();
232 if parts.len() >= 2 {
233 format!("{} {}", parts[0], parts[1])
234 } else {
235 parts.first().unwrap_or(&"").to_string()
236 }
237}
238
239fn extract_pattern(text: &str, pattern: &str) -> Option<String> {
240 let re = regex::Regex::new(pattern).ok()?;
241 re.find(text).map(|m| m.as_str().to_string())
242}
243
244fn extract_first_error_line(output: &str) -> String {
245 for line in output.lines() {
246 let ll = line.to_lowercase();
247 if ll.contains("error") || ll.contains("failed") || ll.contains("traceback") {
248 let trimmed = line.trim();
249 if trimmed.len() > 200 {
250 return trimmed[..trimmed.floor_char_boundary(200)].to_string();
251 }
252 return trimmed.to_string();
253 }
254 }
255 output.lines().next().unwrap_or("unknown error").to_string()
256}
257
258fn regex_replace(text: &str, pattern: &str, replacement: &str) -> String {
259 match regex::Regex::new(pattern) {
260 Ok(re) => re.replace_all(text, replacement).to_string(),
261 Err(_) => text.to_string(),
262 }
263}