Skip to main content

aft/lsp/
diagnostics.rs

1use std::collections::HashMap;
2use std::path::{Path, PathBuf};
3use std::time::Instant;
4
5use crate::lsp::registry::ServerKind;
6use crate::lsp::roots::ServerKey;
7
8/// A single diagnostic from an LSP server.
9#[derive(Debug, Clone, PartialEq, Eq)]
10pub struct StoredDiagnostic {
11    pub file: PathBuf,
12    pub line: u32,
13    pub column: u32,
14    pub end_line: u32,
15    pub end_column: u32,
16    pub severity: DiagnosticSeverity,
17    pub message: String,
18    pub code: Option<String>,
19    pub source: Option<String>,
20}
21
22#[derive(Debug, Clone, Copy, PartialEq, Eq)]
23pub enum DiagnosticSeverity {
24    Error,
25    Warning,
26    Information,
27    Hint,
28}
29
30impl DiagnosticSeverity {
31    pub fn as_str(self) -> &'static str {
32        match self {
33            Self::Error => "error",
34            Self::Warning => "warning",
35            Self::Information => "information",
36            Self::Hint => "hint",
37        }
38    }
39}
40
41/// One server's published diagnostics for one file, plus bookkeeping that
42/// distinguishes "checked clean" (`diagnostics.is_empty()` AND
43/// `epoch.is_some()`) from "never checked" (entry not present).
44#[derive(Debug, Clone)]
45pub struct DiagnosticEntry {
46    pub diagnostics: Vec<StoredDiagnostic>,
47    /// Monotonic epoch when this entry was last replaced by a publish or
48    /// pull response. Used by callers to tell "fresh" results apart from
49    /// stale cache contents.
50    pub epoch: u64,
51    /// Optional resultId from a pull response. Sent back as `previousResultId`
52    /// on the next pull request to enable `kind: "unchanged"` short-circuiting.
53    pub result_id: Option<String>,
54    /// Document version this publish/pull was tagged against, when the
55    /// server provided one. Servers that participate in versioned text
56    /// document sync echo `version` on `publishDiagnostics`; we store it
57    /// so post-edit waiters can reject stale publishes deterministically
58    /// (`version == target_version`) instead of relying on epoch ordering
59    /// alone, which has a race when an old-version publish arrives after
60    /// the pre-edit drain. `None` = server didn't tag the publish.
61    pub version: Option<i32>,
62    /// True after the filesystem watcher sees this file change outside AFT's
63    /// text sync path and before a publish or pull response proves the cached
64    /// diagnostics still describe the current file contents. Stale entries stay
65    /// in the store so resultIds and server coverage are not lost, but warm
66    /// readers must not count or display them as current diagnostics.
67    pub stale: bool,
68}
69
70/// Stores diagnostics from all LSP servers, keyed per `(ServerKey, file)`.
71///
72/// Key design points (driven by the v0.16 LSP audit):
73///
74/// 1. **Per-server state.** A single file can be served by multiple LSP
75///    servers (e.g., pyright + ty, or tsserver + ESLint). The cache key is
76///    `(ServerKey, PathBuf)` so each server's view is tracked independently.
77///
78/// 2. **Empty publishes are kept.** Earlier the store deleted entries on
79///    empty publishes, making "checked clean" indistinguishable from "never
80///    checked". Now we preserve the entry with `epoch = ...` so callers can
81///    answer the question honestly.
82///
83/// 3. **LRU cap.** `capacity` (default 5000, configurable via
84///    `Config::diagnostic_cache_size`) bounds memory. Set to 0 to disable.
85///    On insert when at capacity, the least-recently-touched entry is
86///    evicted. Eviction is tracked so directory-mode callers can list
87///    those files as `unchecked` rather than silently lose them.
88pub struct DiagnosticsStore {
89    /// Primary store keyed by `(ServerKey, canonical file path)`.
90    entries: HashMap<(ServerKey, PathBuf), DiagnosticEntry>,
91    /// Insertion/access order for LRU eviction. Most-recently-touched
92    /// entries are at the END of the vector.
93    order: Vec<(ServerKey, PathBuf)>,
94    /// Maximum number of entries before LRU eviction kicks in. 0 = no cap.
95    capacity: usize,
96    /// Monotonic epoch counter. Incremented on every publish.
97    next_epoch: u64,
98    /// Last time a server published/replaced diagnostics for a specific file.
99    /// Used as a per-file freshness proof for push-only servers.
100    last_publish_at_for_file: HashMap<(ServerKey, PathBuf), Instant>,
101}
102
103impl DiagnosticsStore {
104    pub fn new() -> Self {
105        Self::with_capacity(5000)
106    }
107
108    pub fn with_capacity(capacity: usize) -> Self {
109        Self {
110            entries: HashMap::new(),
111            order: Vec::new(),
112            capacity,
113            next_epoch: 0,
114            last_publish_at_for_file: HashMap::new(),
115        }
116    }
117
118    /// Set or change the LRU cap. If the new cap is smaller than the
119    /// current entry count, the oldest entries are evicted immediately
120    /// to fit.
121    pub fn set_capacity(&mut self, capacity: usize) {
122        self.capacity = capacity;
123        if capacity > 0 {
124            while self.entries.len() > capacity {
125                self.evict_lru();
126            }
127        }
128    }
129
130    /// Number of currently-tracked entries.
131    pub fn len(&self) -> usize {
132        self.entries.len()
133    }
134
135    /// The current LRU cap (0 = unbounded). Test-only accessor used to verify
136    /// the `lsp.diagnostic_cache_size` config wiring.
137    #[cfg(test)]
138    pub fn capacity_for_test(&self) -> usize {
139        self.capacity
140    }
141
142    pub fn is_empty(&self) -> bool {
143        self.entries.is_empty()
144    }
145
146    /// True if any entry is currently usable, including an empty checked-clean
147    /// report. Watcher-stale entries do not prove current diagnostics.
148    pub fn has_any_fresh_report(&self) -> bool {
149        self.entries.values().any(|entry| !entry.stale)
150    }
151
152    /// Replace diagnostics for a `(server_kind, file)` pair using the
153    /// server's lifecycle root from the active manager. Empty diagnostics
154    /// are preserved as "checked clean" (NOT deleted as before).
155    ///
156    /// Note: the `(server, file)` key uses `ServerKey { kind, root }` so
157    /// concurrent multi-workspace usage doesn't collapse different roots.
158    /// Callers without the root (legacy push handler) should call
159    /// `publish_with_kind` which derives the key.
160    pub fn publish(
161        &mut self,
162        server: ServerKey,
163        file: PathBuf,
164        diagnostics: Vec<StoredDiagnostic>,
165    ) {
166        self.publish_with_result_id(server, file, diagnostics, None);
167    }
168
169    /// Replace diagnostics and record a pull `resultId` for the next
170    /// request. Empty diagnostics are preserved as "checked clean".
171    pub fn publish_with_result_id(
172        &mut self,
173        server: ServerKey,
174        file: PathBuf,
175        diagnostics: Vec<StoredDiagnostic>,
176        result_id: Option<String>,
177    ) {
178        self.publish_full(server, file, diagnostics, result_id, None);
179    }
180
181    /// Replace diagnostics with full provenance (resultId + document version).
182    /// `version` should be the LSP `version` field from `publishDiagnostics`
183    /// when the server provided one, or `None` otherwise.
184    pub fn publish_full(
185        &mut self,
186        server: ServerKey,
187        file: PathBuf,
188        diagnostics: Vec<StoredDiagnostic>,
189        result_id: Option<String>,
190        version: Option<i32>,
191    ) {
192        let key = (server, file);
193        self.next_epoch = self.next_epoch.saturating_add(1);
194        let entry = DiagnosticEntry {
195            diagnostics,
196            epoch: self.next_epoch,
197            result_id,
198            version,
199            stale: false,
200        };
201
202        self.last_publish_at_for_file
203            .insert(key.clone(), Instant::now());
204
205        if self.entries.contains_key(&key) {
206            self.entries.insert(key.clone(), entry);
207            self.touch_existing(&key);
208        } else {
209            // New entry — apply LRU cap before inserting.
210            if self.capacity > 0 && self.entries.len() >= self.capacity {
211                self.evict_lru();
212            }
213            self.entries.insert(key.clone(), entry);
214            self.order.push(key);
215        }
216    }
217
218    /// Compatibility wrapper for the legacy push path that knows only the
219    /// `ServerKind`. Builds a `ServerKey` with an empty root, which is
220    /// adequate for the single-root-per-kind case the manager currently
221    /// uses for push diagnostics. Multi-root callers should use
222    /// `publish` directly with a real `ServerKey`.
223    pub fn publish_with_kind(
224        &mut self,
225        kind: ServerKind,
226        file: PathBuf,
227        diagnostics: Vec<StoredDiagnostic>,
228    ) {
229        let key = ServerKey {
230            kind,
231            root: PathBuf::new(),
232        };
233        self.publish(key, file, diagnostics);
234    }
235
236    /// Get current diagnostics for a specific file (across all servers).
237    /// Watcher-stale entries are kept for bookkeeping but are not surfaced.
238    pub fn for_file(&self, file: &Path) -> Vec<&StoredDiagnostic> {
239        self.entries
240            .iter()
241            .filter(|((_, stored_file), entry)| stored_file == file && !entry.stale)
242            .flat_map(|(_, entry)| entry.diagnostics.iter())
243            .collect()
244    }
245
246    /// Get the full per-server entry for a file. Useful when callers need
247    /// to know epoch/resultId, not just the diagnostics array.
248    pub fn entries_for_file(&self, file: &Path) -> Vec<(&ServerKey, &DiagnosticEntry)> {
249        self.entries
250            .iter()
251            .filter(|((_, stored_file), _)| stored_file == file)
252            .map(|((key, _), entry)| (key, entry))
253            .collect()
254    }
255
256    /// True if any server has an entry (fresh or stale) for this file.
257    pub fn has_any_report_for_file(&self, file: &Path) -> bool {
258        self.entries.keys().any(|(_, f)| f == file)
259    }
260
261    /// True if any server has a non-stale report for this file.
262    pub fn has_any_fresh_report_for_file(&self, file: &Path) -> bool {
263        self.entries
264            .iter()
265            .any(|((_, stored_file), entry)| stored_file == file && !entry.stale)
266    }
267
268    /// True if this exact server instance has an entry (fresh or stale) for
269    /// this exact file. Pull diagnostics use stale entries as the previous
270    /// resultId cache when asking the server whether diagnostics are unchanged.
271    pub fn has_report_for_server_file(&self, server: &ServerKey, file: &Path) -> bool {
272        self.entries
273            .contains_key(&(server.clone(), file.to_path_buf()))
274    }
275
276    /// True if this exact server instance has a non-stale report for this file.
277    pub fn has_fresh_report_for_server_file(&self, server: &ServerKey, file: &Path) -> bool {
278        self.entries
279            .get(&(server.clone(), file.to_path_buf()))
280            .is_some_and(|entry| !entry.stale)
281    }
282
283    /// True if this exact server instance published/replaced diagnostics for
284    /// this exact file after `since`. This is intentionally per `(kind, root,
285    /// file)`; a publish for another file must not prove freshness here.
286    pub fn has_publish_for_file_after(
287        &self,
288        server: &ServerKey,
289        file: &Path,
290        since: Instant,
291    ) -> bool {
292        self.last_publish_at_for_file
293            .get(&(server.clone(), file.to_path_buf()))
294            .is_some_and(|published_at| {
295                *published_at >= since && self.has_fresh_report_for_server_file(server, file)
296            })
297    }
298
299    /// Get current diagnostics for files under a directory.
300    pub fn for_directory(&self, dir: &Path) -> Vec<&StoredDiagnostic> {
301        self.entries
302            .iter()
303            .filter(|((_, stored_file), entry)| stored_file.starts_with(dir) && !entry.stale)
304            .flat_map(|(_, entry)| entry.diagnostics.iter())
305            .collect()
306    }
307
308    /// All current diagnostics, flattened. Watcher-stale entries are hidden.
309    pub fn all(&self) -> Vec<&StoredDiagnostic> {
310        self.entries
311            .values()
312            .filter(|entry| !entry.stale)
313            .flat_map(|entry| entry.diagnostics.iter())
314            .collect()
315    }
316
317    /// Count of errors and warnings across the entire warm set (every file any
318    /// server has published for). Allocation-free — the raw, unfiltered union.
319    /// Callers that want the agent-status-bar semantics (project-root scoped,
320    /// tsconfig-membership filtered, cross-server deduped) should use
321    /// [`filtered_error_warning_counts`](Self::filtered_error_warning_counts).
322    pub fn error_warning_counts(&self) -> (usize, usize) {
323        let mut errors = 0usize;
324        let mut warnings = 0usize;
325        for entry in self.entries.values() {
326            if entry.stale {
327                continue;
328            }
329            for diagnostic in &entry.diagnostics {
330                match diagnostic.severity {
331                    DiagnosticSeverity::Error => errors += 1,
332                    DiagnosticSeverity::Warning => warnings += 1,
333                    _ => {}
334                }
335            }
336        }
337        (errors, warnings)
338    }
339
340    /// Error/warning counts after applying a per-file `keep` predicate,
341    /// excluding environmental/setup diagnostics (see `environmental.rs`),
342    /// and de-duplicating diagnostics that multiple servers reported for the
343    /// same location. This matches `aft_inspect`'s warm semantics
344    /// (`inspect/diagnostics_category.rs`: project-root filter +
345    /// tsconfig-membership skip + environmental filter + `sort_and_dedup`) so
346    /// the agent status bar's E/W agree with `aft_inspect`/`tsc` instead of
347    /// counting build-excluded files and double-counting multi-server overlaps.
348    ///
349    /// The store itself holds no tsconfig/project policy — the caller encodes
350    /// it in `keep` (see `LspManager::filtered_error_warning_counts`). `keep`
351    /// is `FnMut` because the membership cache resolves lazily.
352    pub fn filtered_error_warning_counts(
353        &self,
354        mut keep: impl FnMut(&Path) -> bool,
355    ) -> (usize, usize) {
356        // Dedup key mirrors `sort_and_dedup` in inspect/diagnostics_category.rs
357        // exactly (file, range, severity, message, source) so the bar and
358        // inspect collapse the same multi-server overlaps.
359        let mut seen: std::collections::HashSet<(
360            &Path,
361            u32,
362            u32,
363            u32,
364            u32,
365            &str,
366            &str,
367            Option<&str>,
368        )> = std::collections::HashSet::new();
369        let mut errors = 0usize;
370        let mut warnings = 0usize;
371        for ((_, file), entry) in &self.entries {
372            if entry.stale {
373                continue;
374            }
375            // All diagnostics in an entry share the entry's file, so the keep
376            // predicate (the cost center: tsconfig resolution) runs once per
377            // (server, file) entry, not once per diagnostic.
378            if !keep(file) {
379                continue;
380            }
381            for diagnostic in &entry.diagnostics {
382                if crate::lsp::environmental::is_environmental_diagnostic(diagnostic) {
383                    continue;
384                }
385                let dedup_key = (
386                    diagnostic.file.as_path(),
387                    diagnostic.line,
388                    diagnostic.column,
389                    diagnostic.end_line,
390                    diagnostic.end_column,
391                    diagnostic.severity.as_str(),
392                    diagnostic.message.as_str(),
393                    diagnostic.source.as_deref(),
394                );
395                if !seen.insert(dedup_key) {
396                    continue;
397                }
398                match diagnostic.severity {
399                    DiagnosticSeverity::Error => errors += 1,
400                    DiagnosticSeverity::Warning => warnings += 1,
401                    _ => {}
402                }
403            }
404        }
405        (errors, warnings)
406    }
407
408    /// Drop all entries for a server kind (e.g., on server crash/restart).
409    /// Prefer `clear_for_server` for real manager cleanup so peer roots of the
410    /// same kind are not wiped.
411    pub fn clear_server(&mut self, server: ServerKind) {
412        self.entries
413            .retain(|(stored_key, _), _| stored_key.kind != server);
414        self.order
415            .retain(|(stored_key, _)| stored_key.kind != server);
416        self.last_publish_at_for_file
417            .retain(|(stored_key, _), _| stored_key.kind != server);
418    }
419
420    /// Drop one cached report for a specific server/file pair.
421    pub fn clear_for_server_file(&mut self, key: &ServerKey, file: &Path) {
422        let cache_key = (key.clone(), file.to_path_buf());
423        self.entries.remove(&cache_key);
424        self.order.retain(|entry_key| entry_key != &cache_key);
425        self.last_publish_at_for_file.remove(&cache_key);
426    }
427
428    /// Drop every cached report for a file across all servers. Used when a file
429    /// is deleted/renamed away — its diagnostics would otherwise linger in the
430    /// warm set forever (no server republishes for a path that no longer
431    /// exists), inflating the error/warning counts surfaced in the status bar
432    /// and `aft_inspect`. Returns true if any entry was removed.
433    pub fn clear_for_file(&mut self, file: &Path) -> bool {
434        let before = self.entries.len();
435        self.entries
436            .retain(|(_, stored_file), _| stored_file != file);
437        let removed = self.entries.len() != before;
438        if removed {
439            self.order.retain(|(_, stored_file)| stored_file != file);
440            self.last_publish_at_for_file
441                .retain(|(_, stored_file), _| stored_file != file);
442        }
443        removed
444    }
445
446    /// Mark every cached report for a file stale without evicting it.
447    ///
448    /// This is used for watcher-observed external edits: the previous
449    /// diagnostics may still be useful as a pull `previousResultId`, but warm
450    /// readers must stop counting them until a server publish or pull response
451    /// proves freshness. Returns `(had_entries, changed)` where `changed` is true
452    /// only if at least one previously-fresh entry became stale.
453    pub fn mark_stale_for_file(&mut self, file: &Path) -> (bool, bool) {
454        let mut had_entries = false;
455        let mut changed = false;
456        for ((_, stored_file), entry) in &mut self.entries {
457            if stored_file != file {
458                continue;
459            }
460            had_entries = true;
461            if !entry.stale {
462                entry.stale = true;
463                changed = true;
464            }
465        }
466        (had_entries, changed)
467    }
468
469    /// Mark one cached report fresh after a server response proves it still
470    /// describes the current document (for example a pull `kind: unchanged`).
471    pub fn mark_fresh_for_server_file(&mut self, key: &ServerKey, file: &Path) -> bool {
472        let cache_key = (key.clone(), file.to_path_buf());
473        let Some(entry) = self.entries.get_mut(&cache_key) else {
474            return false;
475        };
476        let changed = entry.stale;
477        entry.stale = false;
478        self.touch_existing(&cache_key);
479        changed
480    }
481
482    /// Drop all entries for a specific server instance.
483    pub fn clear_for_server(&mut self, key: &ServerKey) {
484        self.entries.retain(|(k, _), _| k != key);
485        self.order.retain(|(k, _)| k != key);
486        self.last_publish_at_for_file.retain(|(k, _), _| k != key);
487    }
488
489    /// Backward-compatible alias for tests/callers that already used the
490    /// instance-scoped name.
491    pub fn clear_server_instance(&mut self, key: &ServerKey) {
492        self.clear_for_server(key);
493    }
494
495    /// Remove the least-recently-used entry, returning its key for telemetry.
496    fn evict_lru(&mut self) -> Option<(ServerKey, PathBuf)> {
497        if self.order.is_empty() {
498            return None;
499        }
500        let evicted = self.order.remove(0);
501        self.entries.remove(&evicted);
502        self.last_publish_at_for_file.remove(&evicted);
503        Some(evicted)
504    }
505
506    fn touch_existing(&mut self, key: &(ServerKey, PathBuf)) {
507        if let Some(idx) = self.order.iter().position(|k| k == key) {
508            let removed = self.order.remove(idx);
509            self.order.push(removed);
510        }
511    }
512}
513
514impl Default for DiagnosticsStore {
515    fn default() -> Self {
516        Self::new()
517    }
518}
519
520/// Convert LSP diagnostics to our stored format.
521/// LSP uses 0-based line/character; we convert to 1-based.
522pub fn from_lsp_diagnostics(
523    file: PathBuf,
524    lsp_diagnostics: Vec<lsp_types::Diagnostic>,
525) -> Vec<StoredDiagnostic> {
526    lsp_diagnostics
527        .into_iter()
528        .map(|diagnostic| StoredDiagnostic {
529            file: file.clone(),
530            line: diagnostic.range.start.line + 1,
531            column: diagnostic.range.start.character + 1,
532            end_line: diagnostic.range.end.line + 1,
533            end_column: diagnostic.range.end.character + 1,
534            severity: match diagnostic.severity {
535                Some(lsp_types::DiagnosticSeverity::ERROR) => DiagnosticSeverity::Error,
536                Some(lsp_types::DiagnosticSeverity::WARNING) => DiagnosticSeverity::Warning,
537                Some(lsp_types::DiagnosticSeverity::INFORMATION) => DiagnosticSeverity::Information,
538                Some(lsp_types::DiagnosticSeverity::HINT) => DiagnosticSeverity::Hint,
539                _ => DiagnosticSeverity::Warning,
540            },
541            message: diagnostic.message,
542            code: diagnostic.code.map(|code| match code {
543                lsp_types::NumberOrString::Number(value) => value.to_string(),
544                lsp_types::NumberOrString::String(value) => value,
545            }),
546            source: diagnostic.source,
547        })
548        .collect()
549}
550
551#[cfg(test)]
552mod tests {
553    use std::path::{Path, PathBuf};
554
555    use lsp_types::{
556        Diagnostic, DiagnosticSeverity as LspDiagnosticSeverity, NumberOrString, Position, Range,
557    };
558
559    use super::{from_lsp_diagnostics, DiagnosticSeverity, DiagnosticsStore, StoredDiagnostic};
560    use crate::lsp::registry::ServerKind;
561    use crate::lsp::roots::ServerKey;
562
563    fn server_key(kind: ServerKind) -> ServerKey {
564        ServerKey {
565            kind,
566            root: PathBuf::from("/tmp/repo"),
567        }
568    }
569
570    fn diag(file: &str, line: u32, msg: &str, sev: DiagnosticSeverity) -> StoredDiagnostic {
571        StoredDiagnostic {
572            file: PathBuf::from(file),
573            line,
574            column: 1,
575            end_line: line,
576            end_column: 2,
577            severity: sev,
578            message: msg.into(),
579            code: None,
580            source: None,
581        }
582    }
583
584    #[test]
585    fn converts_lsp_positions_to_one_based() {
586        let file = PathBuf::from("/tmp/demo.rs");
587        let diagnostics = from_lsp_diagnostics(
588            file.clone(),
589            vec![Diagnostic {
590                range: Range::new(Position::new(0, 0), Position::new(1, 4)),
591                severity: Some(LspDiagnosticSeverity::ERROR),
592                code: Some(NumberOrString::String("E1".into())),
593                code_description: None,
594                source: Some("fake".into()),
595                message: "boom".into(),
596                related_information: None,
597                tags: None,
598                data: None,
599            }],
600        );
601
602        assert_eq!(diagnostics.len(), 1);
603        assert_eq!(diagnostics[0].file, file);
604        assert_eq!(diagnostics[0].line, 1);
605        assert_eq!(diagnostics[0].column, 1);
606        assert_eq!(diagnostics[0].end_line, 2);
607        assert_eq!(diagnostics[0].end_column, 5);
608        assert_eq!(diagnostics[0].severity, DiagnosticSeverity::Error);
609        assert_eq!(diagnostics[0].code.as_deref(), Some("E1"));
610    }
611
612    #[test]
613    fn publish_replaces_existing_file_diagnostics() {
614        let file = PathBuf::from("/tmp/demo.rs");
615        let mut store = DiagnosticsStore::new();
616        let key = server_key(ServerKind::Rust);
617
618        store.publish(
619            key.clone(),
620            file.clone(),
621            vec![diag(
622                "/tmp/demo.rs",
623                1,
624                "first",
625                DiagnosticSeverity::Warning,
626            )],
627        );
628        store.publish(
629            key.clone(),
630            file.clone(),
631            vec![diag("/tmp/demo.rs", 2, "second", DiagnosticSeverity::Error)],
632        );
633
634        let stored = store.for_file(&file);
635        assert_eq!(stored.len(), 1);
636        assert_eq!(stored[0].message, "second");
637    }
638
639    #[test]
640    fn empty_publish_is_preserved_as_checked_clean() {
641        // The whole point of the v0.16 audit fix: empty publish ≠ deletion.
642        // Agents need to be able to ask "has this file been checked yet?"
643        // and get a truthful answer.
644        let file = PathBuf::from("/tmp/clean.rs");
645        let mut store = DiagnosticsStore::new();
646        let key = server_key(ServerKind::Rust);
647
648        // First publish has an issue.
649        store.publish(
650            key.clone(),
651            file.clone(),
652            vec![diag(
653                "/tmp/clean.rs",
654                5,
655                "fix me",
656                DiagnosticSeverity::Warning,
657            )],
658        );
659        assert!(store.has_any_report_for_file(&file));
660        assert_eq!(store.for_file(&file).len(), 1);
661
662        // Second publish is empty (the fix worked). Entry is preserved as
663        // "checked clean" rather than deleted.
664        store.publish(key.clone(), file.clone(), Vec::new());
665        assert!(
666            store.has_any_report_for_file(&file),
667            "checked-clean must be distinguishable from never-checked"
668        );
669        assert_eq!(store.for_file(&file).len(), 0);
670
671        let entries = store.entries_for_file(&file);
672        assert_eq!(entries.len(), 1);
673        assert!(entries[0].1.epoch > 0);
674    }
675
676    #[test]
677    fn never_checked_returns_no_report() {
678        let store = DiagnosticsStore::new();
679        let file = PathBuf::from("/tmp/never.rs");
680        assert!(!store.has_any_report_for_file(&file));
681        assert!(store.for_file(&file).is_empty());
682    }
683
684    #[test]
685    fn stale_entries_are_hidden_but_preserved_for_refresh() {
686        let file = PathBuf::from("/tmp/stale.rs");
687        let mut store = DiagnosticsStore::new();
688        let key = server_key(ServerKind::Rust);
689        store.publish(
690            key.clone(),
691            file.clone(),
692            vec![diag("/tmp/stale.rs", 1, "old", DiagnosticSeverity::Error)],
693        );
694
695        let (had_entries, changed) = store.mark_stale_for_file(&file);
696
697        assert!(had_entries);
698        assert!(changed);
699        assert!(store.has_any_report_for_file(&file));
700        assert!(!store.has_any_fresh_report_for_file(&file));
701        assert!(store.for_file(&file).is_empty());
702        assert!(store.all().is_empty());
703        assert_eq!(store.error_warning_counts(), (0, 0));
704        assert_eq!(store.entries_for_file(&file).len(), 1);
705
706        assert!(store.mark_fresh_for_server_file(&key, &file));
707        assert!(store.has_any_fresh_report_for_file(&file));
708        assert_eq!(store.for_file(&file).len(), 1);
709        assert_eq!(store.error_warning_counts(), (1, 0));
710    }
711
712    #[test]
713    fn per_server_state_is_tracked_independently() {
714        let file = PathBuf::from("/tmp/multi.py");
715        let mut store = DiagnosticsStore::new();
716        let pyright_key = server_key(ServerKind::Python);
717        let ty_key = server_key(ServerKind::Ty);
718
719        store.publish(
720            pyright_key,
721            file.clone(),
722            vec![diag(
723                "/tmp/multi.py",
724                1,
725                "pyright says X",
726                DiagnosticSeverity::Error,
727            )],
728        );
729        store.publish(
730            ty_key,
731            file.clone(),
732            vec![diag(
733                "/tmp/multi.py",
734                2,
735                "ty says Y",
736                DiagnosticSeverity::Warning,
737            )],
738        );
739
740        let messages: Vec<&str> = store
741            .for_file(&file)
742            .into_iter()
743            .map(|d| d.message.as_str())
744            .collect();
745
746        assert_eq!(messages.len(), 2, "both servers' reports preserved");
747        assert!(messages.iter().any(|m| m == &"pyright says X"));
748        assert!(messages.iter().any(|m| m == &"ty says Y"));
749    }
750
751    #[test]
752    fn clear_for_server_file_removes_only_exact_entry() {
753        let file_a = PathBuf::from("/tmp/a.rs");
754        let file_b = PathBuf::from("/tmp/b.rs");
755        let mut store = DiagnosticsStore::new();
756        let rust_key = server_key(ServerKind::Rust);
757        let py_key = server_key(ServerKind::Python);
758
759        store.publish(
760            rust_key.clone(),
761            file_a.clone(),
762            vec![diag("/tmp/a.rs", 1, "rust a", DiagnosticSeverity::Error)],
763        );
764        store.publish(
765            rust_key.clone(),
766            file_b.clone(),
767            vec![diag("/tmp/b.rs", 1, "rust b", DiagnosticSeverity::Warning)],
768        );
769        store.publish(
770            py_key.clone(),
771            file_a.clone(),
772            vec![diag("/tmp/a.rs", 2, "py a", DiagnosticSeverity::Warning)],
773        );
774
775        store.clear_for_server_file(&rust_key, &file_a);
776
777        assert!(!store.has_report_for_server_file(&rust_key, &file_a));
778        assert!(store.has_report_for_server_file(&rust_key, &file_b));
779        assert!(store.has_report_for_server_file(&py_key, &file_a));
780    }
781
782    #[test]
783    fn lru_evicts_oldest_when_capacity_exceeded() {
784        let mut store = DiagnosticsStore::with_capacity(2);
785        let key = server_key(ServerKind::Rust);
786
787        store.publish(
788            key.clone(),
789            PathBuf::from("/a.rs"),
790            vec![diag("/a.rs", 1, "a", DiagnosticSeverity::Warning)],
791        );
792        store.publish(
793            key.clone(),
794            PathBuf::from("/b.rs"),
795            vec![diag("/b.rs", 1, "b", DiagnosticSeverity::Warning)],
796        );
797        assert_eq!(store.len(), 2);
798
799        // Inserting a third entry should evict /a.rs (oldest).
800        store.publish(
801            key.clone(),
802            PathBuf::from("/c.rs"),
803            vec![diag("/c.rs", 1, "c", DiagnosticSeverity::Warning)],
804        );
805        assert_eq!(store.len(), 2);
806        assert!(!store.has_any_report_for_file(Path::new("/a.rs")));
807        assert!(store.has_any_report_for_file(Path::new("/b.rs")));
808        assert!(store.has_any_report_for_file(Path::new("/c.rs")));
809    }
810
811    #[test]
812    fn touching_existing_entry_moves_it_to_end_of_lru() {
813        let mut store = DiagnosticsStore::with_capacity(2);
814        let key = server_key(ServerKind::Rust);
815
816        store.publish(
817            key.clone(),
818            PathBuf::from("/a.rs"),
819            vec![diag("/a.rs", 1, "a", DiagnosticSeverity::Warning)],
820        );
821        store.publish(
822            key.clone(),
823            PathBuf::from("/b.rs"),
824            vec![diag("/b.rs", 1, "b", DiagnosticSeverity::Warning)],
825        );
826
827        // Re-publish /a.rs — this should refresh its LRU position so it's
828        // newer than /b.rs. Inserting /c.rs should now evict /b.rs.
829        store.publish(
830            key.clone(),
831            PathBuf::from("/a.rs"),
832            vec![diag("/a.rs", 1, "a2", DiagnosticSeverity::Error)],
833        );
834        store.publish(
835            key.clone(),
836            PathBuf::from("/c.rs"),
837            vec![diag("/c.rs", 1, "c", DiagnosticSeverity::Warning)],
838        );
839
840        assert!(store.has_any_report_for_file(Path::new("/a.rs")));
841        assert!(!store.has_any_report_for_file(Path::new("/b.rs")));
842        assert!(store.has_any_report_for_file(Path::new("/c.rs")));
843    }
844
845    #[test]
846    fn capacity_zero_disables_eviction() {
847        let mut store = DiagnosticsStore::with_capacity(0);
848        let key = server_key(ServerKind::Rust);
849
850        for i in 0..50 {
851            store.publish(
852                key.clone(),
853                PathBuf::from(format!("/f{i}.rs")),
854                vec![diag(
855                    &format!("/f{i}.rs"),
856                    1,
857                    "x",
858                    DiagnosticSeverity::Warning,
859                )],
860            );
861        }
862        assert_eq!(store.len(), 50);
863    }
864
865    #[test]
866    fn set_capacity_evicts_on_shrink() {
867        let mut store = DiagnosticsStore::with_capacity(0);
868        let key = server_key(ServerKind::Rust);
869        for i in 0..10 {
870            store.publish(
871                key.clone(),
872                PathBuf::from(format!("/f{i}.rs")),
873                vec![diag(
874                    &format!("/f{i}.rs"),
875                    1,
876                    "x",
877                    DiagnosticSeverity::Warning,
878                )],
879            );
880        }
881        assert_eq!(store.len(), 10);
882
883        store.set_capacity(3);
884        assert_eq!(store.len(), 3);
885        // Most recent 3 should remain (/f7.rs, /f8.rs, /f9.rs).
886        assert!(store.has_any_report_for_file(Path::new("/f9.rs")));
887        assert!(!store.has_any_report_for_file(Path::new("/f0.rs")));
888    }
889
890    #[test]
891    fn epoch_increments_monotonically() {
892        let mut store = DiagnosticsStore::new();
893        let key = server_key(ServerKind::Rust);
894        let file = PathBuf::from("/e.rs");
895
896        store.publish(key.clone(), file.clone(), Vec::new());
897        let e1 = store.entries_for_file(&file)[0].1.epoch;
898
899        store.publish(key.clone(), file.clone(), Vec::new());
900        let e2 = store.entries_for_file(&file)[0].1.epoch;
901
902        assert!(e2 > e1, "epoch must increase on republish");
903    }
904
905    #[test]
906    fn result_id_is_round_tripped() {
907        let mut store = DiagnosticsStore::new();
908        let key = server_key(ServerKind::Rust);
909        let file = PathBuf::from("/r.rs");
910
911        store.publish_with_result_id(
912            key.clone(),
913            file.clone(),
914            Vec::new(),
915            Some("rev-42".to_string()),
916        );
917
918        let entries = store.entries_for_file(&file);
919        assert_eq!(entries[0].1.result_id.as_deref(), Some("rev-42"));
920    }
921
922    #[test]
923    fn clear_server_drops_all_entries_for_kind() {
924        let mut store = DiagnosticsStore::new();
925        let py_key = server_key(ServerKind::Python);
926        let rust_key = server_key(ServerKind::Rust);
927
928        store.publish(
929            py_key.clone(),
930            PathBuf::from("/a.py"),
931            vec![diag("/a.py", 1, "x", DiagnosticSeverity::Error)],
932        );
933        store.publish(
934            rust_key.clone(),
935            PathBuf::from("/b.rs"),
936            vec![diag("/b.rs", 1, "y", DiagnosticSeverity::Error)],
937        );
938
939        store.clear_server(ServerKind::Python);
940        assert!(!store.has_any_report_for_file(Path::new("/a.py")));
941        assert!(store.has_any_report_for_file(Path::new("/b.rs")));
942    }
943
944    #[test]
945    fn clear_for_file_drops_every_server_entry_and_updates_counts() {
946        let mut store = DiagnosticsStore::new();
947        let py_key = server_key(ServerKind::Python);
948        let biome_key = server_key(ServerKind::Biome);
949
950        // Two servers both report for the SAME deleted file, plus an unrelated
951        // file that must survive.
952        store.publish(
953            py_key,
954            PathBuf::from("/gone.ts"),
955            vec![diag("/gone.ts", 4, "type error", DiagnosticSeverity::Error)],
956        );
957        store.publish(
958            biome_key,
959            PathBuf::from("/gone.ts"),
960            vec![diag(
961                "/gone.ts",
962                7,
963                "lint warning",
964                DiagnosticSeverity::Warning,
965            )],
966        );
967        store.publish(
968            server_key(ServerKind::Rust),
969            PathBuf::from("/keep.rs"),
970            vec![diag("/keep.rs", 1, "live error", DiagnosticSeverity::Error)],
971        );
972
973        assert_eq!(store.error_warning_counts(), (2, 1));
974
975        // Clearing the deleted file drops both server entries for it.
976        let removed = store.clear_for_file(Path::new("/gone.ts"));
977        assert!(removed);
978        assert!(!store.has_any_report_for_file(Path::new("/gone.ts")));
979        // The unrelated file's diagnostic is untouched.
980        assert!(store.has_any_report_for_file(Path::new("/keep.rs")));
981        assert_eq!(store.error_warning_counts(), (1, 0));
982
983        // Clearing again is a no-op (nothing left for that file).
984        assert!(!store.clear_for_file(Path::new("/gone.ts")));
985    }
986
987    #[test]
988    fn filtered_counts_apply_keep_predicate() {
989        let mut store = DiagnosticsStore::new();
990        store.publish(
991            server_key(ServerKind::TypeScript),
992            PathBuf::from("/repo/src/app.ts"),
993            vec![diag(
994                "/repo/src/app.ts",
995                1,
996                "in build",
997                DiagnosticSeverity::Error,
998            )],
999        );
1000        store.publish(
1001            server_key(ServerKind::TypeScript),
1002            PathBuf::from("/repo/src/app.test.ts"),
1003            vec![diag(
1004                "/repo/src/app.test.ts",
1005                1,
1006                "excluded",
1007                DiagnosticSeverity::Error,
1008            )],
1009        );
1010
1011        // Raw count sees both files.
1012        assert_eq!(store.error_warning_counts(), (2, 0));
1013        // Filtered count drops the build-excluded test file.
1014        let counts = store.filtered_error_warning_counts(|file| !file.ends_with("app.test.ts"));
1015        assert_eq!(counts, (1, 0));
1016    }
1017
1018    #[test]
1019    fn filtered_counts_dedup_across_servers() {
1020        let mut store = DiagnosticsStore::new();
1021        let file = "/repo/src/app.ts";
1022        // Two different servers report the SAME diagnostic (same file/range/
1023        // severity/message/source) for one file — e.g. tsserver + a linter that
1024        // both surface an identical issue. Raw counting double-counts; the
1025        // status-bar count must collapse to one (matching inspect sort_and_dedup).
1026        store.publish(
1027            server_key(ServerKind::TypeScript),
1028            PathBuf::from(file),
1029            vec![diag(file, 7, "dup", DiagnosticSeverity::Error)],
1030        );
1031        store.publish(
1032            server_key(ServerKind::Biome),
1033            PathBuf::from(file),
1034            vec![diag(file, 7, "dup", DiagnosticSeverity::Error)],
1035        );
1036
1037        assert_eq!(store.error_warning_counts(), (2, 0));
1038        assert_eq!(store.filtered_error_warning_counts(|_| true), (1, 0));
1039    }
1040
1041    #[test]
1042    fn filtered_counts_keep_distinct_diagnostics_same_file() {
1043        let mut store = DiagnosticsStore::new();
1044        let file = "/repo/src/app.ts";
1045        // Two servers, genuinely different diagnostics on the same file — both
1046        // must be counted (dedup keys on location+message+source, not file).
1047        store.publish(
1048            server_key(ServerKind::TypeScript),
1049            PathBuf::from(file),
1050            vec![diag(file, 7, "type error", DiagnosticSeverity::Error)],
1051        );
1052        store.publish(
1053            server_key(ServerKind::Biome),
1054            PathBuf::from(file),
1055            vec![diag(file, 12, "lint warn", DiagnosticSeverity::Warning)],
1056        );
1057        assert_eq!(store.filtered_error_warning_counts(|_| true), (1, 1));
1058    }
1059
1060    #[test]
1061    fn filtered_counts_exclude_environmental_diagnostics() {
1062        let mut store = DiagnosticsStore::new();
1063        let file = "/repo/src/app.ts";
1064        store.publish(
1065            server_key(ServerKind::TypeScript),
1066            PathBuf::from(file),
1067            vec![
1068                diag(
1069                    file,
1070                    1,
1071                    "Cannot find name 'foo'.",
1072                    DiagnosticSeverity::Error,
1073                ),
1074                diag(
1075                    file,
1076                    2,
1077                    "Failed to load schema from https://cdn.example/pkg/schema.json",
1078                    DiagnosticSeverity::Error,
1079                ),
1080            ],
1081        );
1082        assert_eq!(store.error_warning_counts(), (2, 0));
1083        assert_eq!(
1084            store.filtered_error_warning_counts(|_| true),
1085            (1, 0),
1086            "environmental schema-fetch must not inflate E count"
1087        );
1088    }
1089
1090    #[test]
1091    fn environmental_flap_does_not_change_filtered_counts() {
1092        let mut store = DiagnosticsStore::new();
1093        let file = "/repo/package.json";
1094        let key = server_key(ServerKind::TypeScript);
1095        let env_msg =
1096            "Failed to fetch schema from https://json.schemastore.org/package.json: network";
1097
1098        assert_eq!(store.filtered_error_warning_counts(|_| true), (0, 0));
1099
1100        store.publish(
1101            key.clone(),
1102            PathBuf::from(file),
1103            vec![diag(file, 1, env_msg, DiagnosticSeverity::Error)],
1104        );
1105        assert_eq!(
1106            store.filtered_error_warning_counts(|_| true),
1107            (0, 0),
1108            "publish environmental diagnostic must not change filtered E/W"
1109        );
1110
1111        store.publish(key, PathBuf::from(file), vec![]);
1112        assert_eq!(
1113            store.filtered_error_warning_counts(|_| true),
1114            (0, 0),
1115            "removing environmental diagnostic must not change filtered E/W"
1116        );
1117    }
1118
1119    #[test]
1120    fn mixed_syntax_and_schema_fetch_counts_one_error() {
1121        let mut store = DiagnosticsStore::new();
1122        let file = "/repo/src/mixed.ts";
1123        store.publish(
1124            server_key(ServerKind::TypeScript),
1125            PathBuf::from(file),
1126            vec![
1127                diag(
1128                    file,
1129                    3,
1130                    "Cannot find name 'bar'.",
1131                    DiagnosticSeverity::Error,
1132                ),
1133                diag(
1134                    file,
1135                    1,
1136                    "Failed to resolve schema https://example.com/x.json",
1137                    DiagnosticSeverity::Error,
1138                ),
1139            ],
1140        );
1141        assert_eq!(
1142            store.filtered_error_warning_counts(|_| true),
1143            (1, 0),
1144            "classifier is per-diagnostic: one real syntax error => E1"
1145        );
1146    }
1147}