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 and
307    /// de-duplicating diagnostics that multiple servers reported for the same
308    /// location. This matches `aft_inspect`'s warm semantics
309    /// (`inspect/diagnostics_category.rs`: project-root filter +
310    /// tsconfig-membership skip + `sort_and_dedup`) so the agent status bar's
311    /// E/W agree with `aft_inspect`/`tsc` instead of counting build-excluded
312    /// files and double-counting multi-server overlaps.
313    ///
314    /// The store itself holds no tsconfig/project policy — the caller encodes
315    /// it in `keep` (see `LspManager::filtered_error_warning_counts`). `keep`
316    /// is `FnMut` because the membership cache resolves lazily.
317    pub fn filtered_error_warning_counts(
318        &self,
319        mut keep: impl FnMut(&Path) -> bool,
320    ) -> (usize, usize) {
321        // Dedup key mirrors `sort_and_dedup` in inspect/diagnostics_category.rs
322        // exactly (file, range, severity, message, source) so the bar and
323        // inspect collapse the same multi-server overlaps.
324        let mut seen: std::collections::HashSet<(
325            &Path,
326            u32,
327            u32,
328            u32,
329            u32,
330            &str,
331            &str,
332            Option<&str>,
333        )> = std::collections::HashSet::new();
334        let mut errors = 0usize;
335        let mut warnings = 0usize;
336        for ((_, file), entry) in &self.entries {
337            // All diagnostics in an entry share the entry's file, so the keep
338            // predicate (the cost center: tsconfig resolution) runs once per
339            // (server, file) entry, not once per diagnostic.
340            if !keep(file) {
341                continue;
342            }
343            for diagnostic in &entry.diagnostics {
344                let dedup_key = (
345                    diagnostic.file.as_path(),
346                    diagnostic.line,
347                    diagnostic.column,
348                    diagnostic.end_line,
349                    diagnostic.end_column,
350                    diagnostic.severity.as_str(),
351                    diagnostic.message.as_str(),
352                    diagnostic.source.as_deref(),
353                );
354                if !seen.insert(dedup_key) {
355                    continue;
356                }
357                match diagnostic.severity {
358                    DiagnosticSeverity::Error => errors += 1,
359                    DiagnosticSeverity::Warning => warnings += 1,
360                    _ => {}
361                }
362            }
363        }
364        (errors, warnings)
365    }
366
367    /// Drop all entries for a server kind (e.g., on server crash/restart).
368    /// Prefer `clear_for_server` for real manager cleanup so peer roots of the
369    /// same kind are not wiped.
370    pub fn clear_server(&mut self, server: ServerKind) {
371        self.entries
372            .retain(|(stored_key, _), _| stored_key.kind != server);
373        self.order
374            .retain(|(stored_key, _)| stored_key.kind != server);
375        self.last_publish_at_for_file
376            .retain(|(stored_key, _), _| stored_key.kind != server);
377    }
378
379    /// Drop one cached report for a specific server/file pair.
380    pub fn clear_for_server_file(&mut self, key: &ServerKey, file: &Path) {
381        let cache_key = (key.clone(), file.to_path_buf());
382        self.entries.remove(&cache_key);
383        self.order.retain(|entry_key| entry_key != &cache_key);
384        self.last_publish_at_for_file.remove(&cache_key);
385    }
386
387    /// Drop every cached report for a file across all servers. Used when a file
388    /// is deleted/renamed away — its diagnostics would otherwise linger in the
389    /// warm set forever (no server republishes for a path that no longer
390    /// exists), inflating the error/warning counts surfaced in the status bar
391    /// and `aft_inspect`. Returns true if any entry was removed.
392    pub fn clear_for_file(&mut self, file: &Path) -> bool {
393        let before = self.entries.len();
394        self.entries
395            .retain(|(_, stored_file), _| stored_file != file);
396        let removed = self.entries.len() != before;
397        if removed {
398            self.order.retain(|(_, stored_file)| stored_file != file);
399            self.last_publish_at_for_file
400                .retain(|(_, stored_file), _| stored_file != file);
401        }
402        removed
403    }
404
405    /// Drop all entries for a specific server instance.
406    pub fn clear_for_server(&mut self, key: &ServerKey) {
407        self.entries.retain(|(k, _), _| k != key);
408        self.order.retain(|(k, _)| k != key);
409        self.last_publish_at_for_file.retain(|(k, _), _| k != key);
410    }
411
412    /// Backward-compatible alias for tests/callers that already used the
413    /// instance-scoped name.
414    pub fn clear_server_instance(&mut self, key: &ServerKey) {
415        self.clear_for_server(key);
416    }
417
418    /// Remove the least-recently-used entry, returning its key for telemetry.
419    fn evict_lru(&mut self) -> Option<(ServerKey, PathBuf)> {
420        if self.order.is_empty() {
421            return None;
422        }
423        let evicted = self.order.remove(0);
424        self.entries.remove(&evicted);
425        self.last_publish_at_for_file.remove(&evicted);
426        Some(evicted)
427    }
428
429    fn touch_existing(&mut self, key: &(ServerKey, PathBuf)) {
430        if let Some(idx) = self.order.iter().position(|k| k == key) {
431            let removed = self.order.remove(idx);
432            self.order.push(removed);
433        }
434    }
435}
436
437impl Default for DiagnosticsStore {
438    fn default() -> Self {
439        Self::new()
440    }
441}
442
443/// Convert LSP diagnostics to our stored format.
444/// LSP uses 0-based line/character; we convert to 1-based.
445pub fn from_lsp_diagnostics(
446    file: PathBuf,
447    lsp_diagnostics: Vec<lsp_types::Diagnostic>,
448) -> Vec<StoredDiagnostic> {
449    lsp_diagnostics
450        .into_iter()
451        .map(|diagnostic| StoredDiagnostic {
452            file: file.clone(),
453            line: diagnostic.range.start.line + 1,
454            column: diagnostic.range.start.character + 1,
455            end_line: diagnostic.range.end.line + 1,
456            end_column: diagnostic.range.end.character + 1,
457            severity: match diagnostic.severity {
458                Some(lsp_types::DiagnosticSeverity::ERROR) => DiagnosticSeverity::Error,
459                Some(lsp_types::DiagnosticSeverity::WARNING) => DiagnosticSeverity::Warning,
460                Some(lsp_types::DiagnosticSeverity::INFORMATION) => DiagnosticSeverity::Information,
461                Some(lsp_types::DiagnosticSeverity::HINT) => DiagnosticSeverity::Hint,
462                _ => DiagnosticSeverity::Warning,
463            },
464            message: diagnostic.message,
465            code: diagnostic.code.map(|code| match code {
466                lsp_types::NumberOrString::Number(value) => value.to_string(),
467                lsp_types::NumberOrString::String(value) => value,
468            }),
469            source: diagnostic.source,
470        })
471        .collect()
472}
473
474#[cfg(test)]
475mod tests {
476    use std::path::{Path, PathBuf};
477
478    use lsp_types::{
479        Diagnostic, DiagnosticSeverity as LspDiagnosticSeverity, NumberOrString, Position, Range,
480    };
481
482    use super::{from_lsp_diagnostics, DiagnosticSeverity, DiagnosticsStore, StoredDiagnostic};
483    use crate::lsp::registry::ServerKind;
484    use crate::lsp::roots::ServerKey;
485
486    fn server_key(kind: ServerKind) -> ServerKey {
487        ServerKey {
488            kind,
489            root: PathBuf::from("/tmp/repo"),
490        }
491    }
492
493    fn diag(file: &str, line: u32, msg: &str, sev: DiagnosticSeverity) -> StoredDiagnostic {
494        StoredDiagnostic {
495            file: PathBuf::from(file),
496            line,
497            column: 1,
498            end_line: line,
499            end_column: 2,
500            severity: sev,
501            message: msg.into(),
502            code: None,
503            source: None,
504        }
505    }
506
507    #[test]
508    fn converts_lsp_positions_to_one_based() {
509        let file = PathBuf::from("/tmp/demo.rs");
510        let diagnostics = from_lsp_diagnostics(
511            file.clone(),
512            vec![Diagnostic {
513                range: Range::new(Position::new(0, 0), Position::new(1, 4)),
514                severity: Some(LspDiagnosticSeverity::ERROR),
515                code: Some(NumberOrString::String("E1".into())),
516                code_description: None,
517                source: Some("fake".into()),
518                message: "boom".into(),
519                related_information: None,
520                tags: None,
521                data: None,
522            }],
523        );
524
525        assert_eq!(diagnostics.len(), 1);
526        assert_eq!(diagnostics[0].file, file);
527        assert_eq!(diagnostics[0].line, 1);
528        assert_eq!(diagnostics[0].column, 1);
529        assert_eq!(diagnostics[0].end_line, 2);
530        assert_eq!(diagnostics[0].end_column, 5);
531        assert_eq!(diagnostics[0].severity, DiagnosticSeverity::Error);
532        assert_eq!(diagnostics[0].code.as_deref(), Some("E1"));
533    }
534
535    #[test]
536    fn publish_replaces_existing_file_diagnostics() {
537        let file = PathBuf::from("/tmp/demo.rs");
538        let mut store = DiagnosticsStore::new();
539        let key = server_key(ServerKind::Rust);
540
541        store.publish(
542            key.clone(),
543            file.clone(),
544            vec![diag(
545                "/tmp/demo.rs",
546                1,
547                "first",
548                DiagnosticSeverity::Warning,
549            )],
550        );
551        store.publish(
552            key.clone(),
553            file.clone(),
554            vec![diag("/tmp/demo.rs", 2, "second", DiagnosticSeverity::Error)],
555        );
556
557        let stored = store.for_file(&file);
558        assert_eq!(stored.len(), 1);
559        assert_eq!(stored[0].message, "second");
560    }
561
562    #[test]
563    fn empty_publish_is_preserved_as_checked_clean() {
564        // The whole point of the v0.16 audit fix: empty publish ≠ deletion.
565        // Agents need to be able to ask "has this file been checked yet?"
566        // and get a truthful answer.
567        let file = PathBuf::from("/tmp/clean.rs");
568        let mut store = DiagnosticsStore::new();
569        let key = server_key(ServerKind::Rust);
570
571        // First publish has an issue.
572        store.publish(
573            key.clone(),
574            file.clone(),
575            vec![diag(
576                "/tmp/clean.rs",
577                5,
578                "fix me",
579                DiagnosticSeverity::Warning,
580            )],
581        );
582        assert!(store.has_any_report_for_file(&file));
583        assert_eq!(store.for_file(&file).len(), 1);
584
585        // Second publish is empty (the fix worked). Entry is preserved as
586        // "checked clean" rather than deleted.
587        store.publish(key.clone(), file.clone(), Vec::new());
588        assert!(
589            store.has_any_report_for_file(&file),
590            "checked-clean must be distinguishable from never-checked"
591        );
592        assert_eq!(store.for_file(&file).len(), 0);
593
594        let entries = store.entries_for_file(&file);
595        assert_eq!(entries.len(), 1);
596        assert!(entries[0].1.epoch > 0);
597    }
598
599    #[test]
600    fn never_checked_returns_no_report() {
601        let store = DiagnosticsStore::new();
602        let file = PathBuf::from("/tmp/never.rs");
603        assert!(!store.has_any_report_for_file(&file));
604        assert!(store.for_file(&file).is_empty());
605    }
606
607    #[test]
608    fn per_server_state_is_tracked_independently() {
609        let file = PathBuf::from("/tmp/multi.py");
610        let mut store = DiagnosticsStore::new();
611        let pyright_key = server_key(ServerKind::Python);
612        let ty_key = server_key(ServerKind::Ty);
613
614        store.publish(
615            pyright_key,
616            file.clone(),
617            vec![diag(
618                "/tmp/multi.py",
619                1,
620                "pyright says X",
621                DiagnosticSeverity::Error,
622            )],
623        );
624        store.publish(
625            ty_key,
626            file.clone(),
627            vec![diag(
628                "/tmp/multi.py",
629                2,
630                "ty says Y",
631                DiagnosticSeverity::Warning,
632            )],
633        );
634
635        let messages: Vec<&str> = store
636            .for_file(&file)
637            .into_iter()
638            .map(|d| d.message.as_str())
639            .collect();
640
641        assert_eq!(messages.len(), 2, "both servers' reports preserved");
642        assert!(messages.iter().any(|m| m == &"pyright says X"));
643        assert!(messages.iter().any(|m| m == &"ty says Y"));
644    }
645
646    #[test]
647    fn clear_for_server_file_removes_only_exact_entry() {
648        let file_a = PathBuf::from("/tmp/a.rs");
649        let file_b = PathBuf::from("/tmp/b.rs");
650        let mut store = DiagnosticsStore::new();
651        let rust_key = server_key(ServerKind::Rust);
652        let py_key = server_key(ServerKind::Python);
653
654        store.publish(
655            rust_key.clone(),
656            file_a.clone(),
657            vec![diag("/tmp/a.rs", 1, "rust a", DiagnosticSeverity::Error)],
658        );
659        store.publish(
660            rust_key.clone(),
661            file_b.clone(),
662            vec![diag("/tmp/b.rs", 1, "rust b", DiagnosticSeverity::Warning)],
663        );
664        store.publish(
665            py_key.clone(),
666            file_a.clone(),
667            vec![diag("/tmp/a.rs", 2, "py a", DiagnosticSeverity::Warning)],
668        );
669
670        store.clear_for_server_file(&rust_key, &file_a);
671
672        assert!(!store.has_report_for_server_file(&rust_key, &file_a));
673        assert!(store.has_report_for_server_file(&rust_key, &file_b));
674        assert!(store.has_report_for_server_file(&py_key, &file_a));
675    }
676
677    #[test]
678    fn lru_evicts_oldest_when_capacity_exceeded() {
679        let mut store = DiagnosticsStore::with_capacity(2);
680        let key = server_key(ServerKind::Rust);
681
682        store.publish(
683            key.clone(),
684            PathBuf::from("/a.rs"),
685            vec![diag("/a.rs", 1, "a", DiagnosticSeverity::Warning)],
686        );
687        store.publish(
688            key.clone(),
689            PathBuf::from("/b.rs"),
690            vec![diag("/b.rs", 1, "b", DiagnosticSeverity::Warning)],
691        );
692        assert_eq!(store.len(), 2);
693
694        // Inserting a third entry should evict /a.rs (oldest).
695        store.publish(
696            key.clone(),
697            PathBuf::from("/c.rs"),
698            vec![diag("/c.rs", 1, "c", DiagnosticSeverity::Warning)],
699        );
700        assert_eq!(store.len(), 2);
701        assert!(!store.has_any_report_for_file(Path::new("/a.rs")));
702        assert!(store.has_any_report_for_file(Path::new("/b.rs")));
703        assert!(store.has_any_report_for_file(Path::new("/c.rs")));
704    }
705
706    #[test]
707    fn touching_existing_entry_moves_it_to_end_of_lru() {
708        let mut store = DiagnosticsStore::with_capacity(2);
709        let key = server_key(ServerKind::Rust);
710
711        store.publish(
712            key.clone(),
713            PathBuf::from("/a.rs"),
714            vec![diag("/a.rs", 1, "a", DiagnosticSeverity::Warning)],
715        );
716        store.publish(
717            key.clone(),
718            PathBuf::from("/b.rs"),
719            vec![diag("/b.rs", 1, "b", DiagnosticSeverity::Warning)],
720        );
721
722        // Re-publish /a.rs — this should refresh its LRU position so it's
723        // newer than /b.rs. Inserting /c.rs should now evict /b.rs.
724        store.publish(
725            key.clone(),
726            PathBuf::from("/a.rs"),
727            vec![diag("/a.rs", 1, "a2", DiagnosticSeverity::Error)],
728        );
729        store.publish(
730            key.clone(),
731            PathBuf::from("/c.rs"),
732            vec![diag("/c.rs", 1, "c", DiagnosticSeverity::Warning)],
733        );
734
735        assert!(store.has_any_report_for_file(Path::new("/a.rs")));
736        assert!(!store.has_any_report_for_file(Path::new("/b.rs")));
737        assert!(store.has_any_report_for_file(Path::new("/c.rs")));
738    }
739
740    #[test]
741    fn capacity_zero_disables_eviction() {
742        let mut store = DiagnosticsStore::with_capacity(0);
743        let key = server_key(ServerKind::Rust);
744
745        for i in 0..50 {
746            store.publish(
747                key.clone(),
748                PathBuf::from(format!("/f{i}.rs")),
749                vec![diag(
750                    &format!("/f{i}.rs"),
751                    1,
752                    "x",
753                    DiagnosticSeverity::Warning,
754                )],
755            );
756        }
757        assert_eq!(store.len(), 50);
758    }
759
760    #[test]
761    fn set_capacity_evicts_on_shrink() {
762        let mut store = DiagnosticsStore::with_capacity(0);
763        let key = server_key(ServerKind::Rust);
764        for i in 0..10 {
765            store.publish(
766                key.clone(),
767                PathBuf::from(format!("/f{i}.rs")),
768                vec![diag(
769                    &format!("/f{i}.rs"),
770                    1,
771                    "x",
772                    DiagnosticSeverity::Warning,
773                )],
774            );
775        }
776        assert_eq!(store.len(), 10);
777
778        store.set_capacity(3);
779        assert_eq!(store.len(), 3);
780        // Most recent 3 should remain (/f7.rs, /f8.rs, /f9.rs).
781        assert!(store.has_any_report_for_file(Path::new("/f9.rs")));
782        assert!(!store.has_any_report_for_file(Path::new("/f0.rs")));
783    }
784
785    #[test]
786    fn epoch_increments_monotonically() {
787        let mut store = DiagnosticsStore::new();
788        let key = server_key(ServerKind::Rust);
789        let file = PathBuf::from("/e.rs");
790
791        store.publish(key.clone(), file.clone(), Vec::new());
792        let e1 = store.entries_for_file(&file)[0].1.epoch;
793
794        store.publish(key.clone(), file.clone(), Vec::new());
795        let e2 = store.entries_for_file(&file)[0].1.epoch;
796
797        assert!(e2 > e1, "epoch must increase on republish");
798    }
799
800    #[test]
801    fn result_id_is_round_tripped() {
802        let mut store = DiagnosticsStore::new();
803        let key = server_key(ServerKind::Rust);
804        let file = PathBuf::from("/r.rs");
805
806        store.publish_with_result_id(
807            key.clone(),
808            file.clone(),
809            Vec::new(),
810            Some("rev-42".to_string()),
811        );
812
813        let entries = store.entries_for_file(&file);
814        assert_eq!(entries[0].1.result_id.as_deref(), Some("rev-42"));
815    }
816
817    #[test]
818    fn clear_server_drops_all_entries_for_kind() {
819        let mut store = DiagnosticsStore::new();
820        let py_key = server_key(ServerKind::Python);
821        let rust_key = server_key(ServerKind::Rust);
822
823        store.publish(
824            py_key.clone(),
825            PathBuf::from("/a.py"),
826            vec![diag("/a.py", 1, "x", DiagnosticSeverity::Error)],
827        );
828        store.publish(
829            rust_key.clone(),
830            PathBuf::from("/b.rs"),
831            vec![diag("/b.rs", 1, "y", DiagnosticSeverity::Error)],
832        );
833
834        store.clear_server(ServerKind::Python);
835        assert!(!store.has_any_report_for_file(Path::new("/a.py")));
836        assert!(store.has_any_report_for_file(Path::new("/b.rs")));
837    }
838
839    #[test]
840    fn clear_for_file_drops_every_server_entry_and_updates_counts() {
841        let mut store = DiagnosticsStore::new();
842        let py_key = server_key(ServerKind::Python);
843        let biome_key = server_key(ServerKind::Biome);
844
845        // Two servers both report for the SAME deleted file, plus an unrelated
846        // file that must survive.
847        store.publish(
848            py_key,
849            PathBuf::from("/gone.ts"),
850            vec![diag("/gone.ts", 4, "type error", DiagnosticSeverity::Error)],
851        );
852        store.publish(
853            biome_key,
854            PathBuf::from("/gone.ts"),
855            vec![diag(
856                "/gone.ts",
857                7,
858                "lint warning",
859                DiagnosticSeverity::Warning,
860            )],
861        );
862        store.publish(
863            server_key(ServerKind::Rust),
864            PathBuf::from("/keep.rs"),
865            vec![diag("/keep.rs", 1, "live error", DiagnosticSeverity::Error)],
866        );
867
868        assert_eq!(store.error_warning_counts(), (2, 1));
869
870        // Clearing the deleted file drops both server entries for it.
871        let removed = store.clear_for_file(Path::new("/gone.ts"));
872        assert!(removed);
873        assert!(!store.has_any_report_for_file(Path::new("/gone.ts")));
874        // The unrelated file's diagnostic is untouched.
875        assert!(store.has_any_report_for_file(Path::new("/keep.rs")));
876        assert_eq!(store.error_warning_counts(), (1, 0));
877
878        // Clearing again is a no-op (nothing left for that file).
879        assert!(!store.clear_for_file(Path::new("/gone.ts")));
880    }
881
882    #[test]
883    fn filtered_counts_apply_keep_predicate() {
884        let mut store = DiagnosticsStore::new();
885        store.publish(
886            server_key(ServerKind::TypeScript),
887            PathBuf::from("/repo/src/app.ts"),
888            vec![diag(
889                "/repo/src/app.ts",
890                1,
891                "in build",
892                DiagnosticSeverity::Error,
893            )],
894        );
895        store.publish(
896            server_key(ServerKind::TypeScript),
897            PathBuf::from("/repo/src/app.test.ts"),
898            vec![diag(
899                "/repo/src/app.test.ts",
900                1,
901                "excluded",
902                DiagnosticSeverity::Error,
903            )],
904        );
905
906        // Raw count sees both files.
907        assert_eq!(store.error_warning_counts(), (2, 0));
908        // Filtered count drops the build-excluded test file.
909        let counts = store.filtered_error_warning_counts(|file| !file.ends_with("app.test.ts"));
910        assert_eq!(counts, (1, 0));
911    }
912
913    #[test]
914    fn filtered_counts_dedup_across_servers() {
915        let mut store = DiagnosticsStore::new();
916        let file = "/repo/src/app.ts";
917        // Two different servers report the SAME diagnostic (same file/range/
918        // severity/message/source) for one file — e.g. tsserver + a linter that
919        // both surface an identical issue. Raw counting double-counts; the
920        // status-bar count must collapse to one (matching inspect sort_and_dedup).
921        store.publish(
922            server_key(ServerKind::TypeScript),
923            PathBuf::from(file),
924            vec![diag(file, 7, "dup", DiagnosticSeverity::Error)],
925        );
926        store.publish(
927            server_key(ServerKind::Biome),
928            PathBuf::from(file),
929            vec![diag(file, 7, "dup", DiagnosticSeverity::Error)],
930        );
931
932        assert_eq!(store.error_warning_counts(), (2, 0));
933        assert_eq!(store.filtered_error_warning_counts(|_| true), (1, 0));
934    }
935
936    #[test]
937    fn filtered_counts_keep_distinct_diagnostics_same_file() {
938        let mut store = DiagnosticsStore::new();
939        let file = "/repo/src/app.ts";
940        // Two servers, genuinely different diagnostics on the same file — both
941        // must be counted (dedup keys on location+message+source, not file).
942        store.publish(
943            server_key(ServerKind::TypeScript),
944            PathBuf::from(file),
945            vec![diag(file, 7, "type error", DiagnosticSeverity::Error)],
946        );
947        store.publish(
948            server_key(ServerKind::Biome),
949            PathBuf::from(file),
950            vec![diag(file, 12, "lint warn", DiagnosticSeverity::Warning)],
951        );
952        assert_eq!(store.filtered_error_warning_counts(|_| true), (1, 1));
953    }
954}