Skip to main content

lean_ctx/core/gotcha_tracker/
detect.rs

1use super::model::{GotchaCategory, GotchaSeverity};
2
3// ---------------------------------------------------------------------------
4// Error pattern detection
5// ---------------------------------------------------------------------------
6
7pub 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    // Rust / Cargo
18    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    // npm / pnpm / yarn
43    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    // Node.js
56    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    // Python
75    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    // Go
90    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    // TypeScript / tsc
101    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    // Docker
112    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    // Git
124    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    // pytest
137    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    // Jest / Vitest
147    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    // Make / CMake
158    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    // Generic: non-zero exit + substantial stderr
170    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
186// ---------------------------------------------------------------------------
187// Signature normalization
188// ---------------------------------------------------------------------------
189
190pub 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
206// ---------------------------------------------------------------------------
207// Helpers
208// ---------------------------------------------------------------------------
209
210pub(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}