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        && 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    // 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
210/// True for the build/test/dependency/runtime command families this module can
211/// correlate (mirrors the per-tool gates in `detect_error_pattern`). The
212/// shell-layer hook uses it to skip all disk work for ordinary commands
213/// (`ls`, `cat`, `echo`, …) — a false positive only costs a cheap store load,
214/// never a wrong gotcha (`detect_error_pattern` still gates the real signal).
215pub 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    // Tools usually invoked via a runner (`npx tsc`, `poetry run pytest`, …).
225    ["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}