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 if let Some(msg) = extract_pattern(output, r"TS\d{4}: .+") {
103 return Some(DetectedError {
104 category: GotchaCategory::Build,
105 severity: GotchaSeverity::Critical,
106 raw_message: msg,
107 });
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(super) fn command_base(cmd: &str) -> String {
211 let parts: Vec<&str> = cmd.split_whitespace().collect();
212 if parts.len() >= 2 {
213 format!("{} {}", parts[0], parts[1])
214 } else {
215 parts.first().unwrap_or(&"").to_string()
216 }
217}
218
219fn extract_pattern(text: &str, pattern: &str) -> Option<String> {
220 let re = regex::Regex::new(pattern).ok()?;
221 re.find(text).map(|m| m.as_str().to_string())
222}
223
224fn extract_first_error_line(output: &str) -> String {
225 for line in output.lines() {
226 let ll = line.to_lowercase();
227 if ll.contains("error") || ll.contains("failed") || ll.contains("traceback") {
228 let trimmed = line.trim();
229 if trimmed.len() > 200 {
230 return trimmed[..200].to_string();
231 }
232 return trimmed.to_string();
233 }
234 }
235 output.lines().next().unwrap_or("unknown error").to_string()
236}
237
238fn regex_replace(text: &str, pattern: &str, replacement: &str) -> String {
239 match regex::Regex::new(pattern) {
240 Ok(re) => re.replace_all(text, replacement).to_string(),
241 Err(_) => text.to_string(),
242 }
243}