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 snapshot_detailed() -> Vec<(String, Option<u32>, Severity, String, String)> {
210 global()
211 .lock()
212 .map(|s| {
213 s.diagnostics
214 .iter()
215 .map(|d| {
216 (
217 d.path.clone(),
218 d.line,
219 d.severity,
220 d.message.clone(),
221 d.tool.clone(),
222 )
223 })
224 .collect()
225 })
226 .unwrap_or_default()
227}
228
229pub fn details_for(path: &str) -> Vec<(Option<u32>, Severity, String)> {
231 global()
232 .lock()
233 .map(|s| {
234 s.for_path(path)
235 .into_iter()
236 .map(|d| (d.line, d.severity, d.message.clone()))
237 .collect()
238 })
239 .unwrap_or_default()
240}
241
242fn parse_output(tool: &str, output: &str) -> Vec<Diagnostic> {
243 match tool {
244 "cargo" => parse_cargo(output),
245 "tsc" => parse_tsc(output),
246 "eslint" => parse_eslint(output),
247 _ => Vec::new(),
248 }
249}
250
251fn cap_message(msg: &str) -> String {
252 let trimmed = msg.trim();
253 if trimmed.len() <= 160 {
254 trimmed.to_string()
255 } else {
256 let mut end = 157;
257 while end > 0 && !trimmed.is_char_boundary(end) {
258 end -= 1;
259 }
260 format!("{}...", &trimmed[..end])
261 }
262}
263
264fn parse_cargo(output: &str) -> Vec<Diagnostic> {
267 let now = now_unix();
268 let mut out = Vec::new();
269 let mut pending: Option<(Severity, String)> = None;
270
271 for line in output.lines() {
272 let trimmed = line.trim_start();
273 if let Some(msg) = trimmed.strip_prefix("error") {
274 if let Some(rest) = msg.split_once(':').map(|(_, r)| r)
276 && (msg.starts_with('[') || msg.starts_with(':'))
277 {
278 pending = Some((Severity::Error, cap_message(rest)));
279 continue;
280 }
281 }
282 if let Some(msg) = trimmed.strip_prefix("warning:") {
283 if !msg.contains("generated") {
285 pending = Some((Severity::Warning, cap_message(msg)));
286 }
287 continue;
288 }
289 if let Some(loc) = trimmed.strip_prefix("--> ")
290 && let Some((severity, message)) = pending.take()
291 {
292 let mut parts = loc.rsplitn(3, ':');
293 let _col = parts.next();
294 let line_no = parts.next().and_then(|l| l.parse::<u32>().ok());
295 let path = parts.next().unwrap_or(loc).trim().to_string();
296 if !path.is_empty() {
297 out.push(Diagnostic {
298 path,
299 line: line_no,
300 severity,
301 tool: "cargo".into(),
302 message,
303 recorded_unix: now,
304 });
305 }
306 }
307 }
308 out
309}
310
311fn parse_tsc(output: &str) -> Vec<Diagnostic> {
314 let now = now_unix();
315 let mut out = Vec::new();
316 for line in output.lines() {
317 let line = line.trim();
318 let (is_error, marker) = if line.contains(": error TS") {
319 (true, ": error TS")
320 } else if line.contains("- error TS") {
321 (true, "- error TS")
322 } else if line.contains(": warning TS") {
323 (false, ": warning TS")
324 } else {
325 continue;
326 };
327 let Some(loc_part) = line.split(marker).next() else {
328 continue;
329 };
330 let message = line
331 .split_once("TS")
332 .and_then(|(_, rest)| rest.split_once(':'))
333 .map(|(_, m)| cap_message(m))
334 .unwrap_or_default();
335
336 let (path, line_no) = if let Some((p, rest)) = loc_part.split_once('(') {
337 let n = rest.split(',').next().and_then(|x| x.parse::<u32>().ok());
338 (p.trim().to_string(), n)
339 } else {
340 let mut parts = loc_part.trim().rsplitn(3, ':');
341 let _col = parts.next();
342 let n = parts.next().and_then(|x| x.parse::<u32>().ok());
343 (parts.next().unwrap_or("").trim().to_string(), n)
344 };
345 if path.is_empty() {
346 continue;
347 }
348 out.push(Diagnostic {
349 path,
350 line: line_no,
351 severity: if is_error {
352 Severity::Error
353 } else {
354 Severity::Warning
355 },
356 tool: "tsc".into(),
357 message,
358 recorded_unix: now,
359 });
360 }
361 out
362}
363
364fn parse_eslint(output: &str) -> Vec<Diagnostic> {
366 let now = now_unix();
367 let mut out = Vec::new();
368 let mut current_file: Option<String> = None;
369
370 for line in output.lines() {
371 if line.is_empty() {
372 continue;
373 }
374 let is_header = !line.starts_with(' ')
375 && !line.starts_with('✖')
376 && (line.starts_with('/') || line.contains('/'))
377 && std::path::Path::new(line.trim())
378 .extension()
379 .is_some_and(|e| {
380 matches!(
381 e.to_str().unwrap_or(""),
382 "js" | "jsx" | "ts" | "tsx" | "mjs" | "cjs" | "vue" | "svelte"
383 )
384 });
385 if is_header {
386 current_file = Some(line.trim().to_string());
387 continue;
388 }
389 let trimmed = line.trim_start();
390 let Some(file) = ¤t_file else {
391 continue;
392 };
393 let mut cols = trimmed.split_whitespace();
394 let Some(loc) = cols.next() else { continue };
395 let Some(line_no) = loc.split(':').next().and_then(|n| n.parse::<u32>().ok()) else {
396 continue;
397 };
398 let severity = match cols.next() {
399 Some("error") => Severity::Error,
400 Some("warning") => Severity::Warning,
401 _ => continue,
402 };
403 let message = cap_message(&cols.collect::<Vec<_>>().join(" "));
404 out.push(Diagnostic {
405 path: file.clone(),
406 line: Some(line_no),
407 severity,
408 tool: "eslint".into(),
409 message,
410 recorded_unix: now,
411 });
412 }
413 out
414}
415
416pub fn relevance_boost(path: &str) -> f64 {
418 match severity_for(path) {
419 Some(Severity::Error) => 0.35,
420 Some(Severity::Warning) => 0.10,
421 None => 0.0,
422 }
423}
424
425pub fn apply_boost(scores: &mut [crate::core::task_relevance::RelevanceScore]) {
427 let snap = snapshot();
428 if snap.is_empty() {
429 return;
430 }
431 let mut by_path: HashMap<&str, Severity> = HashMap::new();
432 for (p, s) in &snap {
433 let entry = by_path.entry(p.as_str()).or_insert(*s);
434 if *s == Severity::Error {
435 *entry = Severity::Error;
436 }
437 }
438 for score in scores.iter_mut() {
439 let hit = by_path
440 .iter()
441 .find(|(p, _)| score.path.ends_with(*p) || p.ends_with(&score.path))
442 .map(|(_, s)| *s);
443 match hit {
444 Some(Severity::Error) => score.score = (score.score + 0.35).min(1.0),
445 Some(Severity::Warning) => score.score = (score.score + 0.10).min(1.0),
446 None => {}
447 }
448 }
449 scores.sort_by(|a, b| {
450 b.score
451 .partial_cmp(&a.score)
452 .unwrap_or(std::cmp::Ordering::Equal)
453 });
454}
455
456#[cfg(test)]
457pub mod tests {
458 use super::*;
459
460 const CARGO_OUT: &str = r#"
461 Compiling lean-ctx v3.7.5
462error[E0308]: mismatched types
463 --> src/core/cache.rs:42:9
464 |
46542 | "x"
466 | ^^^ expected `usize`, found `&str`
467warning: unused variable: `foo`
468 --> src/tools/ctx_read.rs:10:9
469error: aborting due to 1 previous error
470"#;
471
472 #[test]
473 fn cargo_error_paths_extracted() {
474 let diags = parse_cargo(CARGO_OUT);
475 assert_eq!(diags.len(), 2);
476 assert_eq!(diags[0].path, "src/core/cache.rs");
477 assert_eq!(diags[0].line, Some(42));
478 assert_eq!(diags[0].severity, Severity::Error);
479 assert_eq!(diags[1].path, "src/tools/ctx_read.rs");
480 assert_eq!(diags[1].severity, Severity::Warning);
481 }
482
483 #[test]
484 fn tsc_both_formats_extracted() {
485 let out = "src/app.ts(12,5): error TS2304: Cannot find name 'foo'.\n\
486 src/lib.ts:7:3 - error TS2345: Argument type mismatch.";
487 let diags = parse_tsc(out);
488 assert_eq!(diags.len(), 2);
489 assert_eq!(diags[0].path, "src/app.ts");
490 assert_eq!(diags[0].line, Some(12));
491 assert_eq!(diags[1].path, "src/lib.ts");
492 assert_eq!(diags[1].line, Some(7));
493 assert!(diags[1].message.contains("Argument type mismatch"));
494 }
495
496 #[test]
497 fn eslint_stylish_extracted() {
498 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";
499 let diags = parse_eslint(out);
500 assert_eq!(diags.len(), 2);
501 assert_eq!(diags[0].path, "/repo/src/index.ts");
502 assert_eq!(diags[0].line, Some(3));
503 assert_eq!(diags[0].severity, Severity::Error);
504 assert_eq!(diags[1].severity, Severity::Warning);
505 }
506
507 #[test]
508 fn successful_run_clears_tool_diagnostics() {
509 let mut store = DiagnosticsStore::default();
510 store.replace_for_tool("cargo", parse_cargo(CARGO_OUT));
511 assert!(store.has_error("src/core/cache.rs"));
512 store.clear_for_tool("cargo");
513 assert!(!store.has_error("src/core/cache.rs"));
514 }
515
516 #[test]
517 fn expiry_drops_stale_entries() {
518 let mut store = DiagnosticsStore::default();
519 store.replace_for_tool("cargo", parse_cargo(CARGO_OUT));
520 store.expire(now_unix() + TTL_SECS + 10);
521 assert!(store.diagnostics.is_empty());
522 }
523
524 #[test]
525 fn severity_prefers_error_over_warning() {
526 let mut store = DiagnosticsStore::default();
527 let now = now_unix();
528 store.replace_for_tool(
529 "cargo",
530 vec![
531 Diagnostic {
532 path: "src/a.rs".into(),
533 line: Some(1),
534 severity: Severity::Warning,
535 tool: "cargo".into(),
536 message: "w".into(),
537 recorded_unix: now,
538 },
539 Diagnostic {
540 path: "src/a.rs".into(),
541 line: Some(9),
542 severity: Severity::Error,
543 tool: "cargo".into(),
544 message: "e".into(),
545 recorded_unix: now,
546 },
547 ],
548 );
549 assert_eq!(store.severity_for("src/a.rs"), Some(Severity::Error));
550 }
551
552 #[test]
553 fn tool_detection_gates_parsing() {
554 assert_eq!(tool_of_command("cargo build --release"), Some("cargo"));
555 assert_eq!(tool_of_command("npx tsc --noEmit"), Some("tsc"));
556 assert_eq!(tool_of_command("eslint src/"), Some("eslint"));
557 assert_eq!(tool_of_command("git status"), None);
558 }
559
560 #[test]
561 fn test_failure_without_paths_keeps_existing() {
562 let mut store = DiagnosticsStore::default();
563 store.replace_for_tool("cargo", parse_cargo(CARGO_OUT));
564 let n = store.diagnostics.len();
565 let parsed = parse_cargo("test result: FAILED. 1 passed; 1 failed");
567 assert!(parsed.is_empty());
568 assert_eq!(store.diagnostics.len(), n);
569 }
570}