1use std::collections::HashMap;
15use std::path::PathBuf;
16use std::sync::{Mutex, OnceLock};
17
18use serde::{Deserialize, Serialize};
19
20const STORE_FILE: &str = "diagnostics.json";
21const TTL_SECS: u64 = 15 * 60;
23const 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
51fn 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
161pub 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 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
186pub 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
195pub 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
208pub 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
243fn 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 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 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
290fn 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
343fn 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) = ¤t_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
395pub 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
404pub 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 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}