Skip to main content

lean_ctx/core/
diagnostics_store.rs

1//! Active compiler/linter diagnostics as a context-priority signal (#499).
2//!
3//! When `cargo`/`tsc`/`eslint` fail, the files they point at are the most
4//! task-relevant files in the project — the agent will read them next to fix
5//! the build. The shell layer already sees this output (CLI `lean-ctx -c` and
6//! MCP `ctx_shell`); this store extracts the structured `(file, line)` pairs
7//! and makes them available to auto-mode, relevance ranking and the triage.
8//!
9//! Persistence: `~/.lean-ctx/diagnostics.json` — the CLI runs as a separate
10//! process from the MCP server, so an in-memory store would never be seen by
11//! `ctx_read`'s auto-mode. Entries expire after `TTL_SECS`; a succeeding run
12//! of the same tool clears its diagnostics.
13
14use std::collections::HashMap;
15use std::path::PathBuf;
16use std::sync::{Mutex, OnceLock};
17
18use serde::{Deserialize, Serialize};
19
20const STORE_FILE: &str = "diagnostics.json";
21/// Diagnostics older than this are stale — builds move fast.
22const TTL_SECS: u64 = 15 * 60;
23/// Bound per snapshot; one broken refactor can emit hundreds of errors.
24const MAX_DIAGNOSTICS: usize = 200;
25
26static STORE: OnceLock<Mutex<DiagnosticsStore>> = OnceLock::new();
27
28#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
29pub enum Severity {
30    Error,
31    Warning,
32}
33
34#[derive(Debug, Clone, Serialize, Deserialize)]
35pub struct Diagnostic {
36    pub path: String,
37    pub line: Option<u32>,
38    pub severity: Severity,
39    pub tool: String,
40    pub message: String,
41    pub recorded_unix: u64,
42}
43
44#[derive(Debug, Clone, Serialize, Deserialize, Default)]
45pub struct DiagnosticsStore {
46    pub diagnostics: Vec<Diagnostic>,
47    #[serde(skip)]
48    dirty: bool,
49}
50
51/// Which diagnostic tool a shell command belongs to, if any.
52fn tool_of_command(command: &str) -> Option<&'static str> {
53    let c = command.to_ascii_lowercase();
54    if c.contains("cargo build")
55        || c.contains("cargo check")
56        || c.contains("cargo clippy")
57        || c.contains("cargo test")
58    {
59        return Some("cargo");
60    }
61    if c.contains("tsc") {
62        return Some("tsc");
63    }
64    if c.contains("eslint") {
65        return Some("eslint");
66    }
67    None
68}
69
70impl DiagnosticsStore {
71    fn load_from_disk() -> Self {
72        let Ok(raw) = std::fs::read_to_string(store_path()) else {
73            return Self::default();
74        };
75        let mut store: Self = serde_json::from_str(&raw).unwrap_or_default();
76        store.expire(now_unix());
77        store
78    }
79
80    fn expire(&mut self, now: u64) {
81        let before = self.diagnostics.len();
82        self.diagnostics
83            .retain(|d| now.saturating_sub(d.recorded_unix) <= TTL_SECS);
84        if self.diagnostics.len() != before {
85            self.dirty = true;
86        }
87    }
88
89    pub fn clear_for_tool(&mut self, tool: &str) {
90        let before = self.diagnostics.len();
91        self.diagnostics.retain(|d| d.tool != tool);
92        if self.diagnostics.len() != before {
93            self.dirty = true;
94        }
95    }
96
97    pub fn replace_for_tool(&mut self, tool: &str, mut fresh: Vec<Diagnostic>) {
98        self.diagnostics.retain(|d| d.tool != tool);
99        fresh.truncate(MAX_DIAGNOSTICS);
100        self.diagnostics.extend(fresh);
101        self.dirty = true;
102    }
103
104    pub fn has_error(&self, path: &str) -> bool {
105        let norm = crate::core::pathutil::normalize_tool_path(path);
106        self.diagnostics.iter().any(|d| {
107            d.severity == Severity::Error && (norm.ends_with(&d.path) || d.path.ends_with(&norm))
108        })
109    }
110
111    pub fn severity_for(&self, path: &str) -> Option<Severity> {
112        let norm = crate::core::pathutil::normalize_tool_path(path);
113        let mut found: Option<Severity> = None;
114        for d in &self.diagnostics {
115            if norm.ends_with(&d.path) || d.path.ends_with(&norm) {
116                if d.severity == Severity::Error {
117                    return Some(Severity::Error);
118                }
119                found = Some(Severity::Warning);
120            }
121        }
122        found
123    }
124
125    pub fn for_path(&self, path: &str) -> Vec<&Diagnostic> {
126        let norm = crate::core::pathutil::normalize_tool_path(path);
127        self.diagnostics
128            .iter()
129            .filter(|d| norm.ends_with(&d.path) || d.path.ends_with(&norm))
130            .collect()
131    }
132
133    pub fn save(&self) -> std::io::Result<()> {
134        let path = store_path();
135        if let Some(parent) = path.parent() {
136            std::fs::create_dir_all(parent)?;
137        }
138        let json = serde_json::to_string(self)?;
139        let tmp = path.with_extension("tmp");
140        std::fs::write(&tmp, json)?;
141        std::fs::rename(&tmp, &path)
142    }
143}
144
145fn store_path() -> PathBuf {
146    crate::core::data_dir::lean_ctx_data_dir()
147        .unwrap_or_else(|_| PathBuf::from("."))
148        .join(STORE_FILE)
149}
150
151fn now_unix() -> u64 {
152    std::time::SystemTime::now()
153        .duration_since(std::time::UNIX_EPOCH)
154        .map_or(0, |d| d.as_secs())
155}
156
157fn global() -> &'static Mutex<DiagnosticsStore> {
158    STORE.get_or_init(|| Mutex::new(DiagnosticsStore::load_from_disk()))
159}
160
161/// Shell-layer hook: parse diagnostics out of a finished command.
162/// Success clears the tool's previous diagnostics; failure replaces them.
163/// Cheap for non-diagnostic commands (one `contains` probe).
164pub fn record_from_shell(command: &str, output: &str, exit_code: i32) {
165    let Some(tool) = tool_of_command(command) else {
166        return;
167    };
168    let Ok(mut store) = global().lock() else {
169        return;
170    };
171    if exit_code == 0 {
172        store.clear_for_tool(tool);
173    } else {
174        let parsed = parse_output(tool, output);
175        // A failing exit with zero parsed file references (e.g. test assertion
176        // failures) should not wipe real compile errors recorded earlier.
177        if !parsed.is_empty() {
178            store.replace_for_tool(tool, parsed);
179        }
180    }
181    if store.dirty && store.save().is_ok() {
182        store.dirty = false;
183    }
184}
185
186/// Does any tracked file currently carry a compile error?
187pub fn has_error(path: &str) -> bool {
188    global().lock().is_ok_and(|s| s.has_error(path))
189}
190
191pub fn severity_for(path: &str) -> Option<Severity> {
192    global().lock().ok().and_then(|s| s.severity_for(path))
193}
194
195/// Snapshot for ranking/triage consumers: `(path, severity)` pairs.
196pub fn snapshot() -> Vec<(String, Severity)> {
197    global()
198        .lock()
199        .map(|s| {
200            s.diagnostics
201                .iter()
202                .map(|d| (d.path.clone(), d.severity))
203                .collect()
204        })
205        .unwrap_or_default()
206}
207
208/// Diagnostics for one path: `(line, severity, message)` triples.
209pub fn details_for(path: &str) -> Vec<(Option<u32>, Severity, String)> {
210    global()
211        .lock()
212        .map(|s| {
213            s.for_path(path)
214                .into_iter()
215                .map(|d| (d.line, d.severity, d.message.clone()))
216                .collect()
217        })
218        .unwrap_or_default()
219}
220
221fn parse_output(tool: &str, output: &str) -> Vec<Diagnostic> {
222    match tool {
223        "cargo" => parse_cargo(output),
224        "tsc" => parse_tsc(output),
225        "eslint" => parse_eslint(output),
226        _ => Vec::new(),
227    }
228}
229
230fn cap_message(msg: &str) -> String {
231    let trimmed = msg.trim();
232    if trimmed.len() <= 160 {
233        trimmed.to_string()
234    } else {
235        let mut end = 157;
236        while end > 0 && !trimmed.is_char_boundary(end) {
237            end -= 1;
238        }
239        format!("{}...", &trimmed[..end])
240    }
241}
242
243/// Cargo/rustc: severity line (`error[E0308]: ...` / `warning: ...`) followed
244/// by a location line (`  --> src/main.rs:12:5`).
245fn parse_cargo(output: &str) -> Vec<Diagnostic> {
246    let now = now_unix();
247    let mut out = Vec::new();
248    let mut pending: Option<(Severity, String)> = None;
249
250    for line in output.lines() {
251        let trimmed = line.trim_start();
252        if let Some(msg) = trimmed.strip_prefix("error") {
253            // `error[E0308]: ...`, `error: ...` — but not `error_count` etc.
254            if let Some(rest) = msg.split_once(':').map(|(_, r)| r) {
255                if msg.starts_with('[') || msg.starts_with(':') {
256                    pending = Some((Severity::Error, cap_message(rest)));
257                    continue;
258                }
259            }
260        }
261        if let Some(msg) = trimmed.strip_prefix("warning:") {
262            // Skip cargo's summary lines ("warning: `x` generated 3 warnings").
263            if !msg.contains("generated") {
264                pending = Some((Severity::Warning, cap_message(msg)));
265            }
266            continue;
267        }
268        if let Some(loc) = trimmed.strip_prefix("--> ") {
269            if let Some((severity, message)) = pending.take() {
270                let mut parts = loc.rsplitn(3, ':');
271                let _col = parts.next();
272                let line_no = parts.next().and_then(|l| l.parse::<u32>().ok());
273                let path = parts.next().unwrap_or(loc).trim().to_string();
274                if !path.is_empty() {
275                    out.push(Diagnostic {
276                        path,
277                        line: line_no,
278                        severity,
279                        tool: "cargo".into(),
280                        message,
281                        recorded_unix: now,
282                    });
283                }
284            }
285        }
286    }
287    out
288}
289
290/// tsc emits two formats:
291/// `src/a.ts(12,5): error TS2304: ...` and `src/a.ts:12:5 - error TS2304: ...`
292fn parse_tsc(output: &str) -> Vec<Diagnostic> {
293    let now = now_unix();
294    let mut out = Vec::new();
295    for line in output.lines() {
296        let line = line.trim();
297        let (is_error, marker) = if line.contains(": error TS") {
298            (true, ": error TS")
299        } else if line.contains("- error TS") {
300            (true, "- error TS")
301        } else if line.contains(": warning TS") {
302            (false, ": warning TS")
303        } else {
304            continue;
305        };
306        let Some(loc_part) = line.split(marker).next() else {
307            continue;
308        };
309        let message = line
310            .split_once("TS")
311            .and_then(|(_, rest)| rest.split_once(':'))
312            .map(|(_, m)| cap_message(m))
313            .unwrap_or_default();
314
315        let (path, line_no) = if let Some((p, rest)) = loc_part.split_once('(') {
316            let n = rest.split(',').next().and_then(|x| x.parse::<u32>().ok());
317            (p.trim().to_string(), n)
318        } else {
319            let mut parts = loc_part.trim().rsplitn(3, ':');
320            let _col = parts.next();
321            let n = parts.next().and_then(|x| x.parse::<u32>().ok());
322            (parts.next().unwrap_or("").trim().to_string(), n)
323        };
324        if path.is_empty() {
325            continue;
326        }
327        out.push(Diagnostic {
328            path,
329            line: line_no,
330            severity: if is_error {
331                Severity::Error
332            } else {
333                Severity::Warning
334            },
335            tool: "tsc".into(),
336            message,
337            recorded_unix: now,
338        });
339    }
340    out
341}
342
343/// eslint (stylish): file header line, then `  12:5  error  msg  rule`.
344fn parse_eslint(output: &str) -> Vec<Diagnostic> {
345    let now = now_unix();
346    let mut out = Vec::new();
347    let mut current_file: Option<String> = None;
348
349    for line in output.lines() {
350        if line.is_empty() {
351            continue;
352        }
353        let is_header = !line.starts_with(' ')
354            && !line.starts_with('✖')
355            && (line.starts_with('/') || line.contains('/'))
356            && std::path::Path::new(line.trim())
357                .extension()
358                .is_some_and(|e| {
359                    matches!(
360                        e.to_str().unwrap_or(""),
361                        "js" | "jsx" | "ts" | "tsx" | "mjs" | "cjs" | "vue" | "svelte"
362                    )
363                });
364        if is_header {
365            current_file = Some(line.trim().to_string());
366            continue;
367        }
368        let trimmed = line.trim_start();
369        let Some(file) = &current_file else {
370            continue;
371        };
372        let mut cols = trimmed.split_whitespace();
373        let Some(loc) = cols.next() else { continue };
374        let Some(line_no) = loc.split(':').next().and_then(|n| n.parse::<u32>().ok()) else {
375            continue;
376        };
377        let severity = match cols.next() {
378            Some("error") => Severity::Error,
379            Some("warning") => Severity::Warning,
380            _ => continue,
381        };
382        let message = cap_message(&cols.collect::<Vec<_>>().join(" "));
383        out.push(Diagnostic {
384            path: file.clone(),
385            line: Some(line_no),
386            severity,
387            tool: "eslint".into(),
388            message,
389            recorded_unix: now,
390        });
391    }
392    out
393}
394
395/// Ranking boost per path: errors dominate, warnings hint (#499).
396pub fn relevance_boost(path: &str) -> f64 {
397    match severity_for(path) {
398        Some(Severity::Error) => 0.35,
399        Some(Severity::Warning) => 0.10,
400        None => 0.0,
401    }
402}
403
404/// Apply diagnostic boosts to a relevance ranking and re-sort.
405pub fn apply_boost(scores: &mut [crate::core::task_relevance::RelevanceScore]) {
406    let snap = snapshot();
407    if snap.is_empty() {
408        return;
409    }
410    let mut by_path: HashMap<&str, Severity> = HashMap::new();
411    for (p, s) in &snap {
412        let entry = by_path.entry(p.as_str()).or_insert(*s);
413        if *s == Severity::Error {
414            *entry = Severity::Error;
415        }
416    }
417    for score in scores.iter_mut() {
418        let hit = by_path
419            .iter()
420            .find(|(p, _)| score.path.ends_with(*p) || p.ends_with(&score.path))
421            .map(|(_, s)| *s);
422        match hit {
423            Some(Severity::Error) => score.score = (score.score + 0.35).min(1.0),
424            Some(Severity::Warning) => score.score = (score.score + 0.10).min(1.0),
425            None => {}
426        }
427    }
428    scores.sort_by(|a, b| {
429        b.score
430            .partial_cmp(&a.score)
431            .unwrap_or(std::cmp::Ordering::Equal)
432    });
433}
434
435#[cfg(test)]
436mod tests {
437    use super::*;
438
439    const CARGO_OUT: &str = r#"
440   Compiling lean-ctx v3.7.5
441error[E0308]: mismatched types
442  --> src/core/cache.rs:42:9
443   |
44442 |         "x"
445   |         ^^^ expected `usize`, found `&str`
446warning: unused variable: `foo`
447  --> src/tools/ctx_read.rs:10:9
448error: aborting due to 1 previous error
449"#;
450
451    #[test]
452    fn cargo_error_paths_extracted() {
453        let diags = parse_cargo(CARGO_OUT);
454        assert_eq!(diags.len(), 2);
455        assert_eq!(diags[0].path, "src/core/cache.rs");
456        assert_eq!(diags[0].line, Some(42));
457        assert_eq!(diags[0].severity, Severity::Error);
458        assert_eq!(diags[1].path, "src/tools/ctx_read.rs");
459        assert_eq!(diags[1].severity, Severity::Warning);
460    }
461
462    #[test]
463    fn tsc_both_formats_extracted() {
464        let out = "src/app.ts(12,5): error TS2304: Cannot find name 'foo'.\n\
465                   src/lib.ts:7:3 - error TS2345: Argument type mismatch.";
466        let diags = parse_tsc(out);
467        assert_eq!(diags.len(), 2);
468        assert_eq!(diags[0].path, "src/app.ts");
469        assert_eq!(diags[0].line, Some(12));
470        assert_eq!(diags[1].path, "src/lib.ts");
471        assert_eq!(diags[1].line, Some(7));
472        assert!(diags[1].message.contains("Argument type mismatch"));
473    }
474
475    #[test]
476    fn eslint_stylish_extracted() {
477        let out = "/repo/src/index.ts\n  3:1  error  'x' is never used  no-unused-vars\n  9:5  warning  Unexpected console  no-console\n";
478        let diags = parse_eslint(out);
479        assert_eq!(diags.len(), 2);
480        assert_eq!(diags[0].path, "/repo/src/index.ts");
481        assert_eq!(diags[0].line, Some(3));
482        assert_eq!(diags[0].severity, Severity::Error);
483        assert_eq!(diags[1].severity, Severity::Warning);
484    }
485
486    #[test]
487    fn successful_run_clears_tool_diagnostics() {
488        let mut store = DiagnosticsStore::default();
489        store.replace_for_tool("cargo", parse_cargo(CARGO_OUT));
490        assert!(store.has_error("src/core/cache.rs"));
491        store.clear_for_tool("cargo");
492        assert!(!store.has_error("src/core/cache.rs"));
493    }
494
495    #[test]
496    fn expiry_drops_stale_entries() {
497        let mut store = DiagnosticsStore::default();
498        store.replace_for_tool("cargo", parse_cargo(CARGO_OUT));
499        store.expire(now_unix() + TTL_SECS + 10);
500        assert!(store.diagnostics.is_empty());
501    }
502
503    #[test]
504    fn severity_prefers_error_over_warning() {
505        let mut store = DiagnosticsStore::default();
506        let now = now_unix();
507        store.replace_for_tool(
508            "cargo",
509            vec![
510                Diagnostic {
511                    path: "src/a.rs".into(),
512                    line: Some(1),
513                    severity: Severity::Warning,
514                    tool: "cargo".into(),
515                    message: "w".into(),
516                    recorded_unix: now,
517                },
518                Diagnostic {
519                    path: "src/a.rs".into(),
520                    line: Some(9),
521                    severity: Severity::Error,
522                    tool: "cargo".into(),
523                    message: "e".into(),
524                    recorded_unix: now,
525                },
526            ],
527        );
528        assert_eq!(store.severity_for("src/a.rs"), Some(Severity::Error));
529    }
530
531    #[test]
532    fn tool_detection_gates_parsing() {
533        assert_eq!(tool_of_command("cargo build --release"), Some("cargo"));
534        assert_eq!(tool_of_command("npx tsc --noEmit"), Some("tsc"));
535        assert_eq!(tool_of_command("eslint src/"), Some("eslint"));
536        assert_eq!(tool_of_command("git status"), None);
537    }
538
539    #[test]
540    fn test_failure_without_paths_keeps_existing() {
541        let mut store = DiagnosticsStore::default();
542        store.replace_for_tool("cargo", parse_cargo(CARGO_OUT));
543        let n = store.diagnostics.len();
544        // Simulates the record_from_shell guard: empty parse -> no replace.
545        let parsed = parse_cargo("test result: FAILED. 1 passed; 1 failed");
546        assert!(parsed.is_empty());
547        assert_eq!(store.diagnostics.len(), n);
548    }
549}