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}
63
64/// Stores diagnostics from all LSP servers, keyed per `(ServerKey, file)`.
65///
66/// Key design points (driven by the v0.16 LSP audit):
67///
68/// 1. **Per-server state.** A single file can be served by multiple LSP
69///    servers (e.g., pyright + ty, or tsserver + ESLint). The cache key is
70///    `(ServerKey, PathBuf)` so each server's view is tracked independently.
71///
72/// 2. **Empty publishes are kept.** Earlier the store deleted entries on
73///    empty publishes, making "checked clean" indistinguishable from "never
74///    checked". Now we preserve the entry with `epoch = ...` so callers can
75///    answer the question honestly.
76///
77/// 3. **LRU cap.** `capacity` (default 5000, configurable via
78///    `Config::diagnostic_cache_size`) bounds memory. Set to 0 to disable.
79///    On insert when at capacity, the least-recently-touched entry is
80///    evicted. Eviction is tracked so directory-mode callers can list
81///    those files as `unchecked` rather than silently lose them.
82pub struct DiagnosticsStore {
83    /// Primary store keyed by `(ServerKey, canonical file path)`.
84    entries: HashMap<(ServerKey, PathBuf), DiagnosticEntry>,
85    /// Insertion/access order for LRU eviction. Most-recently-touched
86    /// entries are at the END of the vector.
87    order: Vec<(ServerKey, PathBuf)>,
88    /// Maximum number of entries before LRU eviction kicks in. 0 = no cap.
89    capacity: usize,
90    /// Monotonic epoch counter. Incremented on every publish.
91    next_epoch: u64,
92    /// Last time a server published/replaced diagnostics for a specific file.
93    /// Used as a per-file freshness proof for push-only servers.
94    last_publish_at_for_file: HashMap<(ServerKey, PathBuf), Instant>,
95}
96
97impl DiagnosticsStore {
98    pub fn new() -> Self {
99        Self::with_capacity(5000)
100    }
101
102    pub fn with_capacity(capacity: usize) -> Self {
103        Self {
104            entries: HashMap::new(),
105            order: Vec::new(),
106            capacity,
107            next_epoch: 0,
108            last_publish_at_for_file: HashMap::new(),
109        }
110    }
111
112    /// Set or change the LRU cap. If the new cap is smaller than the
113    /// current entry count, the oldest entries are evicted immediately
114    /// to fit.
115    pub fn set_capacity(&mut self, capacity: usize) {
116        self.capacity = capacity;
117        if capacity > 0 {
118            while self.entries.len() > capacity {
119                self.evict_lru();
120            }
121        }
122    }
123
124    /// Number of currently-tracked entries.
125    pub fn len(&self) -> usize {
126        self.entries.len()
127    }
128
129    pub fn is_empty(&self) -> bool {
130        self.entries.is_empty()
131    }
132
133    /// Replace diagnostics for a `(server_kind, file)` pair using the
134    /// server's lifecycle root from the active manager. Empty diagnostics
135    /// are preserved as "checked clean" (NOT deleted as before).
136    ///
137    /// Note: the `(server, file)` key uses `ServerKey { kind, root }` so
138    /// concurrent multi-workspace usage doesn't collapse different roots.
139    /// Callers without the root (legacy push handler) should call
140    /// `publish_with_kind` which derives the key.
141    pub fn publish(
142        &mut self,
143        server: ServerKey,
144        file: PathBuf,
145        diagnostics: Vec<StoredDiagnostic>,
146    ) {
147        self.publish_with_result_id(server, file, diagnostics, None);
148    }
149
150    /// Replace diagnostics and record a pull `resultId` for the next
151    /// request. Empty diagnostics are preserved as "checked clean".
152    pub fn publish_with_result_id(
153        &mut self,
154        server: ServerKey,
155        file: PathBuf,
156        diagnostics: Vec<StoredDiagnostic>,
157        result_id: Option<String>,
158    ) {
159        self.publish_full(server, file, diagnostics, result_id, None);
160    }
161
162    /// Replace diagnostics with full provenance (resultId + document version).
163    /// `version` should be the LSP `version` field from `publishDiagnostics`
164    /// when the server provided one, or `None` otherwise.
165    pub fn publish_full(
166        &mut self,
167        server: ServerKey,
168        file: PathBuf,
169        diagnostics: Vec<StoredDiagnostic>,
170        result_id: Option<String>,
171        version: Option<i32>,
172    ) {
173        let key = (server, file);
174        self.next_epoch = self.next_epoch.saturating_add(1);
175        let entry = DiagnosticEntry {
176            diagnostics,
177            epoch: self.next_epoch,
178            result_id,
179            version,
180        };
181
182        self.last_publish_at_for_file
183            .insert(key.clone(), Instant::now());
184
185        if self.entries.contains_key(&key) {
186            self.entries.insert(key.clone(), entry);
187            self.touch_existing(&key);
188        } else {
189            // New entry — apply LRU cap before inserting.
190            if self.capacity > 0 && self.entries.len() >= self.capacity {
191                self.evict_lru();
192            }
193            self.entries.insert(key.clone(), entry);
194            self.order.push(key);
195        }
196    }
197
198    /// Compatibility wrapper for the legacy push path that knows only the
199    /// `ServerKind`. Builds a `ServerKey` with an empty root, which is
200    /// adequate for the single-root-per-kind case the manager currently
201    /// uses for push diagnostics. Multi-root callers should use
202    /// `publish` directly with a real `ServerKey`.
203    pub fn publish_with_kind(
204        &mut self,
205        kind: ServerKind,
206        file: PathBuf,
207        diagnostics: Vec<StoredDiagnostic>,
208    ) {
209        let key = ServerKey {
210            kind,
211            root: PathBuf::new(),
212        };
213        self.publish(key, file, diagnostics);
214    }
215
216    /// Get all diagnostics for a specific file (across all servers).
217    /// Updates LRU position for each touched entry.
218    pub fn for_file(&self, file: &Path) -> Vec<&StoredDiagnostic> {
219        self.entries
220            .iter()
221            .filter(|((_, stored_file), _)| stored_file == file)
222            .flat_map(|(_, entry)| entry.diagnostics.iter())
223            .collect()
224    }
225
226    /// Get the full per-server entry for a file. Useful when callers need
227    /// to know epoch/resultId, not just the diagnostics array.
228    pub fn entries_for_file(&self, file: &Path) -> Vec<(&ServerKey, &DiagnosticEntry)> {
229        self.entries
230            .iter()
231            .filter(|((_, stored_file), _)| stored_file == file)
232            .map(|((key, _), entry)| (key, entry))
233            .collect()
234    }
235
236    /// True if any server has reported (even an empty result) for this file.
237    pub fn has_any_report_for_file(&self, file: &Path) -> bool {
238        self.entries.keys().any(|(_, f)| f == file)
239    }
240
241    /// True if this exact server instance has reported (even an empty result)
242    /// for this exact file.
243    pub fn has_report_for_server_file(&self, server: &ServerKey, file: &Path) -> bool {
244        self.entries
245            .contains_key(&(server.clone(), file.to_path_buf()))
246    }
247
248    /// True if this exact server instance published/replaced diagnostics for
249    /// this exact file after `since`. This is intentionally per `(kind, root,
250    /// file)`; a publish for another file must not prove freshness here.
251    pub fn has_publish_for_file_after(
252        &self,
253        server: &ServerKey,
254        file: &Path,
255        since: Instant,
256    ) -> bool {
257        self.last_publish_at_for_file
258            .get(&(server.clone(), file.to_path_buf()))
259            .is_some_and(|published_at| *published_at >= since)
260    }
261
262    /// Get all diagnostics for files under a directory.
263    pub fn for_directory(&self, dir: &Path) -> Vec<&StoredDiagnostic> {
264        self.entries
265            .iter()
266            .filter(|((_, stored_file), _)| stored_file.starts_with(dir))
267            .flat_map(|(_, entry)| entry.diagnostics.iter())
268            .collect()
269    }
270
271    /// All stored diagnostics, flattened.
272    pub fn all(&self) -> Vec<&StoredDiagnostic> {
273        self.entries
274            .values()
275            .flat_map(|entry| entry.diagnostics.iter())
276            .collect()
277    }
278
279    /// Count of errors and warnings across the entire warm set (every file any
280    /// server has published for). Allocation-free — the raw, unfiltered union.
281    /// Callers that want the agent-status-bar semantics (project-root scoped,
282    /// tsconfig-membership filtered, cross-server deduped) should use
283    /// [`filtered_error_warning_counts`](Self::filtered_error_warning_counts).
284    pub fn error_warning_counts(&self) -> (usize, usize) {
285        let mut errors = 0usize;
286        let mut warnings = 0usize;
287        for entry in self.entries.values() {
288            for diagnostic in &entry.diagnostics {
289                match diagnostic.severity {
290                    DiagnosticSeverity::Error => errors += 1,
291                    DiagnosticSeverity::Warning => warnings += 1,
292                    _ => {}
293                }
294            }
295        }
296        (errors, warnings)
297    }
298
299    /// Error/warning counts after applying a per-file `keep` predicate and
300    /// de-duplicating diagnostics that multiple servers reported for the same
301    /// location. This matches `aft_inspect`'s warm semantics
302    /// (`inspect/diagnostics_category.rs`: project-root filter +
303    /// tsconfig-membership skip + `sort_and_dedup`) so the agent status bar's
304    /// E/W agree with `aft_inspect`/`tsc` instead of counting build-excluded
305    /// files and double-counting multi-server overlaps.
306    ///
307    /// The store itself holds no tsconfig/project policy — the caller encodes
308    /// it in `keep` (see `LspManager::filtered_error_warning_counts`). `keep`
309    /// is `FnMut` because the membership cache resolves lazily.
310    pub fn filtered_error_warning_counts(
311        &self,
312        mut keep: impl FnMut(&Path) -> bool,
313    ) -> (usize, usize) {
314        // Dedup key mirrors `sort_and_dedup` in inspect/diagnostics_category.rs
315        // exactly (file, range, severity, message, source) so the bar and
316        // inspect collapse the same multi-server overlaps.
317        let mut seen: std::collections::HashSet<(
318            &Path,
319            u32,
320            u32,
321            u32,
322            u32,
323            &str,
324            &str,
325            Option<&str>,
326        )> = std::collections::HashSet::new();
327        let mut errors = 0usize;
328        let mut warnings = 0usize;
329        for ((_, file), entry) in &self.entries {
330            // All diagnostics in an entry share the entry's file, so the keep
331            // predicate (the cost center: tsconfig resolution) runs once per
332            // (server, file) entry, not once per diagnostic.
333            if !keep(file) {
334                continue;
335            }
336            for diagnostic in &entry.diagnostics {
337                let dedup_key = (
338                    diagnostic.file.as_path(),
339                    diagnostic.line,
340                    diagnostic.column,
341                    diagnostic.end_line,
342                    diagnostic.end_column,
343                    diagnostic.severity.as_str(),
344                    diagnostic.message.as_str(),
345                    diagnostic.source.as_deref(),
346                );
347                if !seen.insert(dedup_key) {
348                    continue;
349                }
350                match diagnostic.severity {
351                    DiagnosticSeverity::Error => errors += 1,
352                    DiagnosticSeverity::Warning => warnings += 1,
353                    _ => {}
354                }
355            }
356        }
357        (errors, warnings)
358    }
359
360    /// Drop all entries for a server kind (e.g., on server crash/restart).
361    /// Prefer `clear_for_server` for real manager cleanup so peer roots of the
362    /// same kind are not wiped.
363    pub fn clear_server(&mut self, server: ServerKind) {
364        self.entries
365            .retain(|(stored_key, _), _| stored_key.kind != server);
366        self.order
367            .retain(|(stored_key, _)| stored_key.kind != server);
368        self.last_publish_at_for_file
369            .retain(|(stored_key, _), _| stored_key.kind != server);
370    }
371
372    /// Drop one cached report for a specific server/file pair.
373    pub fn clear_for_server_file(&mut self, key: &ServerKey, file: &Path) {
374        let cache_key = (key.clone(), file.to_path_buf());
375        self.entries.remove(&cache_key);
376        self.order.retain(|entry_key| entry_key != &cache_key);
377        self.last_publish_at_for_file.remove(&cache_key);
378    }
379
380    /// Drop every cached report for a file across all servers. Used when a file
381    /// is deleted/renamed away — its diagnostics would otherwise linger in the
382    /// warm set forever (no server republishes for a path that no longer
383    /// exists), inflating the error/warning counts surfaced in the status bar
384    /// and `aft_inspect`. Returns true if any entry was removed.
385    pub fn clear_for_file(&mut self, file: &Path) -> bool {
386        let before = self.entries.len();
387        self.entries
388            .retain(|(_, stored_file), _| stored_file != file);
389        let removed = self.entries.len() != before;
390        if removed {
391            self.order.retain(|(_, stored_file)| stored_file != file);
392            self.last_publish_at_for_file
393                .retain(|(_, stored_file), _| stored_file != file);
394        }
395        removed
396    }
397
398    /// Drop all entries for a specific server instance.
399    pub fn clear_for_server(&mut self, key: &ServerKey) {
400        self.entries.retain(|(k, _), _| k != key);
401        self.order.retain(|(k, _)| k != key);
402        self.last_publish_at_for_file.retain(|(k, _), _| k != key);
403    }
404
405    /// Backward-compatible alias for tests/callers that already used the
406    /// instance-scoped name.
407    pub fn clear_server_instance(&mut self, key: &ServerKey) {
408        self.clear_for_server(key);
409    }
410
411    /// Remove the least-recently-used entry, returning its key for telemetry.
412    fn evict_lru(&mut self) -> Option<(ServerKey, PathBuf)> {
413        if self.order.is_empty() {
414            return None;
415        }
416        let evicted = self.order.remove(0);
417        self.entries.remove(&evicted);
418        self.last_publish_at_for_file.remove(&evicted);
419        Some(evicted)
420    }
421
422    fn touch_existing(&mut self, key: &(ServerKey, PathBuf)) {
423        if let Some(idx) = self.order.iter().position(|k| k == key) {
424            let removed = self.order.remove(idx);
425            self.order.push(removed);
426        }
427    }
428}
429
430impl Default for DiagnosticsStore {
431    fn default() -> Self {
432        Self::new()
433    }
434}
435
436/// Convert LSP diagnostics to our stored format.
437/// LSP uses 0-based line/character; we convert to 1-based.
438pub fn from_lsp_diagnostics(
439    file: PathBuf,
440    lsp_diagnostics: Vec<lsp_types::Diagnostic>,
441) -> Vec<StoredDiagnostic> {
442    lsp_diagnostics
443        .into_iter()
444        .map(|diagnostic| StoredDiagnostic {
445            file: file.clone(),
446            line: diagnostic.range.start.line + 1,
447            column: diagnostic.range.start.character + 1,
448            end_line: diagnostic.range.end.line + 1,
449            end_column: diagnostic.range.end.character + 1,
450            severity: match diagnostic.severity {
451                Some(lsp_types::DiagnosticSeverity::ERROR) => DiagnosticSeverity::Error,
452                Some(lsp_types::DiagnosticSeverity::WARNING) => DiagnosticSeverity::Warning,
453                Some(lsp_types::DiagnosticSeverity::INFORMATION) => DiagnosticSeverity::Information,
454                Some(lsp_types::DiagnosticSeverity::HINT) => DiagnosticSeverity::Hint,
455                _ => DiagnosticSeverity::Warning,
456            },
457            message: diagnostic.message,
458            code: diagnostic.code.map(|code| match code {
459                lsp_types::NumberOrString::Number(value) => value.to_string(),
460                lsp_types::NumberOrString::String(value) => value,
461            }),
462            source: diagnostic.source,
463        })
464        .collect()
465}
466
467#[cfg(test)]
468mod tests {
469    use std::path::{Path, PathBuf};
470
471    use lsp_types::{
472        Diagnostic, DiagnosticSeverity as LspDiagnosticSeverity, NumberOrString, Position, Range,
473    };
474
475    use super::{from_lsp_diagnostics, DiagnosticSeverity, DiagnosticsStore, StoredDiagnostic};
476    use crate::lsp::registry::ServerKind;
477    use crate::lsp::roots::ServerKey;
478
479    fn server_key(kind: ServerKind) -> ServerKey {
480        ServerKey {
481            kind,
482            root: PathBuf::from("/tmp/repo"),
483        }
484    }
485
486    fn diag(file: &str, line: u32, msg: &str, sev: DiagnosticSeverity) -> StoredDiagnostic {
487        StoredDiagnostic {
488            file: PathBuf::from(file),
489            line,
490            column: 1,
491            end_line: line,
492            end_column: 2,
493            severity: sev,
494            message: msg.into(),
495            code: None,
496            source: None,
497        }
498    }
499
500    #[test]
501    fn converts_lsp_positions_to_one_based() {
502        let file = PathBuf::from("/tmp/demo.rs");
503        let diagnostics = from_lsp_diagnostics(
504            file.clone(),
505            vec![Diagnostic {
506                range: Range::new(Position::new(0, 0), Position::new(1, 4)),
507                severity: Some(LspDiagnosticSeverity::ERROR),
508                code: Some(NumberOrString::String("E1".into())),
509                code_description: None,
510                source: Some("fake".into()),
511                message: "boom".into(),
512                related_information: None,
513                tags: None,
514                data: None,
515            }],
516        );
517
518        assert_eq!(diagnostics.len(), 1);
519        assert_eq!(diagnostics[0].file, file);
520        assert_eq!(diagnostics[0].line, 1);
521        assert_eq!(diagnostics[0].column, 1);
522        assert_eq!(diagnostics[0].end_line, 2);
523        assert_eq!(diagnostics[0].end_column, 5);
524        assert_eq!(diagnostics[0].severity, DiagnosticSeverity::Error);
525        assert_eq!(diagnostics[0].code.as_deref(), Some("E1"));
526    }
527
528    #[test]
529    fn publish_replaces_existing_file_diagnostics() {
530        let file = PathBuf::from("/tmp/demo.rs");
531        let mut store = DiagnosticsStore::new();
532        let key = server_key(ServerKind::Rust);
533
534        store.publish(
535            key.clone(),
536            file.clone(),
537            vec![diag(
538                "/tmp/demo.rs",
539                1,
540                "first",
541                DiagnosticSeverity::Warning,
542            )],
543        );
544        store.publish(
545            key.clone(),
546            file.clone(),
547            vec![diag("/tmp/demo.rs", 2, "second", DiagnosticSeverity::Error)],
548        );
549
550        let stored = store.for_file(&file);
551        assert_eq!(stored.len(), 1);
552        assert_eq!(stored[0].message, "second");
553    }
554
555    #[test]
556    fn empty_publish_is_preserved_as_checked_clean() {
557        // The whole point of the v0.16 audit fix: empty publish ≠ deletion.
558        // Agents need to be able to ask "has this file been checked yet?"
559        // and get a truthful answer.
560        let file = PathBuf::from("/tmp/clean.rs");
561        let mut store = DiagnosticsStore::new();
562        let key = server_key(ServerKind::Rust);
563
564        // First publish has an issue.
565        store.publish(
566            key.clone(),
567            file.clone(),
568            vec![diag(
569                "/tmp/clean.rs",
570                5,
571                "fix me",
572                DiagnosticSeverity::Warning,
573            )],
574        );
575        assert!(store.has_any_report_for_file(&file));
576        assert_eq!(store.for_file(&file).len(), 1);
577
578        // Second publish is empty (the fix worked). Entry is preserved as
579        // "checked clean" rather than deleted.
580        store.publish(key.clone(), file.clone(), Vec::new());
581        assert!(
582            store.has_any_report_for_file(&file),
583            "checked-clean must be distinguishable from never-checked"
584        );
585        assert_eq!(store.for_file(&file).len(), 0);
586
587        let entries = store.entries_for_file(&file);
588        assert_eq!(entries.len(), 1);
589        assert!(entries[0].1.epoch > 0);
590    }
591
592    #[test]
593    fn never_checked_returns_no_report() {
594        let store = DiagnosticsStore::new();
595        let file = PathBuf::from("/tmp/never.rs");
596        assert!(!store.has_any_report_for_file(&file));
597        assert!(store.for_file(&file).is_empty());
598    }
599
600    #[test]
601    fn per_server_state_is_tracked_independently() {
602        let file = PathBuf::from("/tmp/multi.py");
603        let mut store = DiagnosticsStore::new();
604        let pyright_key = server_key(ServerKind::Python);
605        let ty_key = server_key(ServerKind::Ty);
606
607        store.publish(
608            pyright_key,
609            file.clone(),
610            vec![diag(
611                "/tmp/multi.py",
612                1,
613                "pyright says X",
614                DiagnosticSeverity::Error,
615            )],
616        );
617        store.publish(
618            ty_key,
619            file.clone(),
620            vec![diag(
621                "/tmp/multi.py",
622                2,
623                "ty says Y",
624                DiagnosticSeverity::Warning,
625            )],
626        );
627
628        let messages: Vec<&str> = store
629            .for_file(&file)
630            .into_iter()
631            .map(|d| d.message.as_str())
632            .collect();
633
634        assert_eq!(messages.len(), 2, "both servers' reports preserved");
635        assert!(messages.iter().any(|m| m == &"pyright says X"));
636        assert!(messages.iter().any(|m| m == &"ty says Y"));
637    }
638
639    #[test]
640    fn clear_for_server_file_removes_only_exact_entry() {
641        let file_a = PathBuf::from("/tmp/a.rs");
642        let file_b = PathBuf::from("/tmp/b.rs");
643        let mut store = DiagnosticsStore::new();
644        let rust_key = server_key(ServerKind::Rust);
645        let py_key = server_key(ServerKind::Python);
646
647        store.publish(
648            rust_key.clone(),
649            file_a.clone(),
650            vec![diag("/tmp/a.rs", 1, "rust a", DiagnosticSeverity::Error)],
651        );
652        store.publish(
653            rust_key.clone(),
654            file_b.clone(),
655            vec![diag("/tmp/b.rs", 1, "rust b", DiagnosticSeverity::Warning)],
656        );
657        store.publish(
658            py_key.clone(),
659            file_a.clone(),
660            vec![diag("/tmp/a.rs", 2, "py a", DiagnosticSeverity::Warning)],
661        );
662
663        store.clear_for_server_file(&rust_key, &file_a);
664
665        assert!(!store.has_report_for_server_file(&rust_key, &file_a));
666        assert!(store.has_report_for_server_file(&rust_key, &file_b));
667        assert!(store.has_report_for_server_file(&py_key, &file_a));
668    }
669
670    #[test]
671    fn lru_evicts_oldest_when_capacity_exceeded() {
672        let mut store = DiagnosticsStore::with_capacity(2);
673        let key = server_key(ServerKind::Rust);
674
675        store.publish(
676            key.clone(),
677            PathBuf::from("/a.rs"),
678            vec![diag("/a.rs", 1, "a", DiagnosticSeverity::Warning)],
679        );
680        store.publish(
681            key.clone(),
682            PathBuf::from("/b.rs"),
683            vec![diag("/b.rs", 1, "b", DiagnosticSeverity::Warning)],
684        );
685        assert_eq!(store.len(), 2);
686
687        // Inserting a third entry should evict /a.rs (oldest).
688        store.publish(
689            key.clone(),
690            PathBuf::from("/c.rs"),
691            vec![diag("/c.rs", 1, "c", DiagnosticSeverity::Warning)],
692        );
693        assert_eq!(store.len(), 2);
694        assert!(!store.has_any_report_for_file(Path::new("/a.rs")));
695        assert!(store.has_any_report_for_file(Path::new("/b.rs")));
696        assert!(store.has_any_report_for_file(Path::new("/c.rs")));
697    }
698
699    #[test]
700    fn touching_existing_entry_moves_it_to_end_of_lru() {
701        let mut store = DiagnosticsStore::with_capacity(2);
702        let key = server_key(ServerKind::Rust);
703
704        store.publish(
705            key.clone(),
706            PathBuf::from("/a.rs"),
707            vec![diag("/a.rs", 1, "a", DiagnosticSeverity::Warning)],
708        );
709        store.publish(
710            key.clone(),
711            PathBuf::from("/b.rs"),
712            vec![diag("/b.rs", 1, "b", DiagnosticSeverity::Warning)],
713        );
714
715        // Re-publish /a.rs — this should refresh its LRU position so it's
716        // newer than /b.rs. Inserting /c.rs should now evict /b.rs.
717        store.publish(
718            key.clone(),
719            PathBuf::from("/a.rs"),
720            vec![diag("/a.rs", 1, "a2", DiagnosticSeverity::Error)],
721        );
722        store.publish(
723            key.clone(),
724            PathBuf::from("/c.rs"),
725            vec![diag("/c.rs", 1, "c", DiagnosticSeverity::Warning)],
726        );
727
728        assert!(store.has_any_report_for_file(Path::new("/a.rs")));
729        assert!(!store.has_any_report_for_file(Path::new("/b.rs")));
730        assert!(store.has_any_report_for_file(Path::new("/c.rs")));
731    }
732
733    #[test]
734    fn capacity_zero_disables_eviction() {
735        let mut store = DiagnosticsStore::with_capacity(0);
736        let key = server_key(ServerKind::Rust);
737
738        for i in 0..50 {
739            store.publish(
740                key.clone(),
741                PathBuf::from(format!("/f{i}.rs")),
742                vec![diag(
743                    &format!("/f{i}.rs"),
744                    1,
745                    "x",
746                    DiagnosticSeverity::Warning,
747                )],
748            );
749        }
750        assert_eq!(store.len(), 50);
751    }
752
753    #[test]
754    fn set_capacity_evicts_on_shrink() {
755        let mut store = DiagnosticsStore::with_capacity(0);
756        let key = server_key(ServerKind::Rust);
757        for i in 0..10 {
758            store.publish(
759                key.clone(),
760                PathBuf::from(format!("/f{i}.rs")),
761                vec![diag(
762                    &format!("/f{i}.rs"),
763                    1,
764                    "x",
765                    DiagnosticSeverity::Warning,
766                )],
767            );
768        }
769        assert_eq!(store.len(), 10);
770
771        store.set_capacity(3);
772        assert_eq!(store.len(), 3);
773        // Most recent 3 should remain (/f7.rs, /f8.rs, /f9.rs).
774        assert!(store.has_any_report_for_file(Path::new("/f9.rs")));
775        assert!(!store.has_any_report_for_file(Path::new("/f0.rs")));
776    }
777
778    #[test]
779    fn epoch_increments_monotonically() {
780        let mut store = DiagnosticsStore::new();
781        let key = server_key(ServerKind::Rust);
782        let file = PathBuf::from("/e.rs");
783
784        store.publish(key.clone(), file.clone(), Vec::new());
785        let e1 = store.entries_for_file(&file)[0].1.epoch;
786
787        store.publish(key.clone(), file.clone(), Vec::new());
788        let e2 = store.entries_for_file(&file)[0].1.epoch;
789
790        assert!(e2 > e1, "epoch must increase on republish");
791    }
792
793    #[test]
794    fn result_id_is_round_tripped() {
795        let mut store = DiagnosticsStore::new();
796        let key = server_key(ServerKind::Rust);
797        let file = PathBuf::from("/r.rs");
798
799        store.publish_with_result_id(
800            key.clone(),
801            file.clone(),
802            Vec::new(),
803            Some("rev-42".to_string()),
804        );
805
806        let entries = store.entries_for_file(&file);
807        assert_eq!(entries[0].1.result_id.as_deref(), Some("rev-42"));
808    }
809
810    #[test]
811    fn clear_server_drops_all_entries_for_kind() {
812        let mut store = DiagnosticsStore::new();
813        let py_key = server_key(ServerKind::Python);
814        let rust_key = server_key(ServerKind::Rust);
815
816        store.publish(
817            py_key.clone(),
818            PathBuf::from("/a.py"),
819            vec![diag("/a.py", 1, "x", DiagnosticSeverity::Error)],
820        );
821        store.publish(
822            rust_key.clone(),
823            PathBuf::from("/b.rs"),
824            vec![diag("/b.rs", 1, "y", DiagnosticSeverity::Error)],
825        );
826
827        store.clear_server(ServerKind::Python);
828        assert!(!store.has_any_report_for_file(Path::new("/a.py")));
829        assert!(store.has_any_report_for_file(Path::new("/b.rs")));
830    }
831
832    #[test]
833    fn clear_for_file_drops_every_server_entry_and_updates_counts() {
834        let mut store = DiagnosticsStore::new();
835        let py_key = server_key(ServerKind::Python);
836        let biome_key = server_key(ServerKind::Biome);
837
838        // Two servers both report for the SAME deleted file, plus an unrelated
839        // file that must survive.
840        store.publish(
841            py_key,
842            PathBuf::from("/gone.ts"),
843            vec![diag("/gone.ts", 4, "type error", DiagnosticSeverity::Error)],
844        );
845        store.publish(
846            biome_key,
847            PathBuf::from("/gone.ts"),
848            vec![diag(
849                "/gone.ts",
850                7,
851                "lint warning",
852                DiagnosticSeverity::Warning,
853            )],
854        );
855        store.publish(
856            server_key(ServerKind::Rust),
857            PathBuf::from("/keep.rs"),
858            vec![diag("/keep.rs", 1, "live error", DiagnosticSeverity::Error)],
859        );
860
861        assert_eq!(store.error_warning_counts(), (2, 1));
862
863        // Clearing the deleted file drops both server entries for it.
864        let removed = store.clear_for_file(Path::new("/gone.ts"));
865        assert!(removed);
866        assert!(!store.has_any_report_for_file(Path::new("/gone.ts")));
867        // The unrelated file's diagnostic is untouched.
868        assert!(store.has_any_report_for_file(Path::new("/keep.rs")));
869        assert_eq!(store.error_warning_counts(), (1, 0));
870
871        // Clearing again is a no-op (nothing left for that file).
872        assert!(!store.clear_for_file(Path::new("/gone.ts")));
873    }
874
875    #[test]
876    fn filtered_counts_apply_keep_predicate() {
877        let mut store = DiagnosticsStore::new();
878        store.publish(
879            server_key(ServerKind::TypeScript),
880            PathBuf::from("/repo/src/app.ts"),
881            vec![diag(
882                "/repo/src/app.ts",
883                1,
884                "in build",
885                DiagnosticSeverity::Error,
886            )],
887        );
888        store.publish(
889            server_key(ServerKind::TypeScript),
890            PathBuf::from("/repo/src/app.test.ts"),
891            vec![diag(
892                "/repo/src/app.test.ts",
893                1,
894                "excluded",
895                DiagnosticSeverity::Error,
896            )],
897        );
898
899        // Raw count sees both files.
900        assert_eq!(store.error_warning_counts(), (2, 0));
901        // Filtered count drops the build-excluded test file.
902        let counts = store.filtered_error_warning_counts(|file| !file.ends_with("app.test.ts"));
903        assert_eq!(counts, (1, 0));
904    }
905
906    #[test]
907    fn filtered_counts_dedup_across_servers() {
908        let mut store = DiagnosticsStore::new();
909        let file = "/repo/src/app.ts";
910        // Two different servers report the SAME diagnostic (same file/range/
911        // severity/message/source) for one file — e.g. tsserver + a linter that
912        // both surface an identical issue. Raw counting double-counts; the
913        // status-bar count must collapse to one (matching inspect sort_and_dedup).
914        store.publish(
915            server_key(ServerKind::TypeScript),
916            PathBuf::from(file),
917            vec![diag(file, 7, "dup", DiagnosticSeverity::Error)],
918        );
919        store.publish(
920            server_key(ServerKind::Biome),
921            PathBuf::from(file),
922            vec![diag(file, 7, "dup", DiagnosticSeverity::Error)],
923        );
924
925        assert_eq!(store.error_warning_counts(), (2, 0));
926        assert_eq!(store.filtered_error_warning_counts(|_| true), (1, 0));
927    }
928
929    #[test]
930    fn filtered_counts_keep_distinct_diagnostics_same_file() {
931        let mut store = DiagnosticsStore::new();
932        let file = "/repo/src/app.ts";
933        // Two servers, genuinely different diagnostics on the same file — both
934        // must be counted (dedup keys on location+message+source, not file).
935        store.publish(
936            server_key(ServerKind::TypeScript),
937            PathBuf::from(file),
938            vec![diag(file, 7, "type error", DiagnosticSeverity::Error)],
939        );
940        store.publish(
941            server_key(ServerKind::Biome),
942            PathBuf::from(file),
943            vec![diag(file, 12, "lint warn", DiagnosticSeverity::Warning)],
944        );
945        assert_eq!(store.filtered_error_warning_counts(|_| true), (1, 1));
946    }
947}