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