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 — used to refresh the agent
281    /// status bar on each request drain without materializing a Vec.
282    pub fn error_warning_counts(&self) -> (usize, usize) {
283        let mut errors = 0usize;
284        let mut warnings = 0usize;
285        for entry in self.entries.values() {
286            for diagnostic in &entry.diagnostics {
287                match diagnostic.severity {
288                    DiagnosticSeverity::Error => errors += 1,
289                    DiagnosticSeverity::Warning => warnings += 1,
290                    _ => {}
291                }
292            }
293        }
294        (errors, warnings)
295    }
296
297    /// Drop all entries for a server kind (e.g., on server crash/restart).
298    /// Prefer `clear_for_server` for real manager cleanup so peer roots of the
299    /// same kind are not wiped.
300    pub fn clear_server(&mut self, server: ServerKind) {
301        self.entries
302            .retain(|(stored_key, _), _| stored_key.kind != server);
303        self.order
304            .retain(|(stored_key, _)| stored_key.kind != server);
305        self.last_publish_at_for_file
306            .retain(|(stored_key, _), _| stored_key.kind != server);
307    }
308
309    /// Drop one cached report for a specific server/file pair.
310    pub fn clear_for_server_file(&mut self, key: &ServerKey, file: &Path) {
311        let cache_key = (key.clone(), file.to_path_buf());
312        self.entries.remove(&cache_key);
313        self.order.retain(|entry_key| entry_key != &cache_key);
314        self.last_publish_at_for_file.remove(&cache_key);
315    }
316
317    /// Drop every cached report for a file across all servers. Used when a file
318    /// is deleted/renamed away — its diagnostics would otherwise linger in the
319    /// warm set forever (no server republishes for a path that no longer
320    /// exists), inflating the error/warning counts surfaced in the status bar
321    /// and `aft_inspect`. Returns true if any entry was removed.
322    pub fn clear_for_file(&mut self, file: &Path) -> bool {
323        let before = self.entries.len();
324        self.entries
325            .retain(|(_, stored_file), _| stored_file != file);
326        let removed = self.entries.len() != before;
327        if removed {
328            self.order.retain(|(_, stored_file)| stored_file != file);
329            self.last_publish_at_for_file
330                .retain(|(_, stored_file), _| stored_file != file);
331        }
332        removed
333    }
334
335    /// Drop all entries for a specific server instance.
336    pub fn clear_for_server(&mut self, key: &ServerKey) {
337        self.entries.retain(|(k, _), _| k != key);
338        self.order.retain(|(k, _)| k != key);
339        self.last_publish_at_for_file.retain(|(k, _), _| k != key);
340    }
341
342    /// Backward-compatible alias for tests/callers that already used the
343    /// instance-scoped name.
344    pub fn clear_server_instance(&mut self, key: &ServerKey) {
345        self.clear_for_server(key);
346    }
347
348    /// Remove the least-recently-used entry, returning its key for telemetry.
349    fn evict_lru(&mut self) -> Option<(ServerKey, PathBuf)> {
350        if self.order.is_empty() {
351            return None;
352        }
353        let evicted = self.order.remove(0);
354        self.entries.remove(&evicted);
355        self.last_publish_at_for_file.remove(&evicted);
356        Some(evicted)
357    }
358
359    fn touch_existing(&mut self, key: &(ServerKey, PathBuf)) {
360        if let Some(idx) = self.order.iter().position(|k| k == key) {
361            let removed = self.order.remove(idx);
362            self.order.push(removed);
363        }
364    }
365}
366
367impl Default for DiagnosticsStore {
368    fn default() -> Self {
369        Self::new()
370    }
371}
372
373/// Convert LSP diagnostics to our stored format.
374/// LSP uses 0-based line/character; we convert to 1-based.
375pub fn from_lsp_diagnostics(
376    file: PathBuf,
377    lsp_diagnostics: Vec<lsp_types::Diagnostic>,
378) -> Vec<StoredDiagnostic> {
379    lsp_diagnostics
380        .into_iter()
381        .map(|diagnostic| StoredDiagnostic {
382            file: file.clone(),
383            line: diagnostic.range.start.line + 1,
384            column: diagnostic.range.start.character + 1,
385            end_line: diagnostic.range.end.line + 1,
386            end_column: diagnostic.range.end.character + 1,
387            severity: match diagnostic.severity {
388                Some(lsp_types::DiagnosticSeverity::ERROR) => DiagnosticSeverity::Error,
389                Some(lsp_types::DiagnosticSeverity::WARNING) => DiagnosticSeverity::Warning,
390                Some(lsp_types::DiagnosticSeverity::INFORMATION) => DiagnosticSeverity::Information,
391                Some(lsp_types::DiagnosticSeverity::HINT) => DiagnosticSeverity::Hint,
392                _ => DiagnosticSeverity::Warning,
393            },
394            message: diagnostic.message,
395            code: diagnostic.code.map(|code| match code {
396                lsp_types::NumberOrString::Number(value) => value.to_string(),
397                lsp_types::NumberOrString::String(value) => value,
398            }),
399            source: diagnostic.source,
400        })
401        .collect()
402}
403
404#[cfg(test)]
405mod tests {
406    use std::path::{Path, PathBuf};
407
408    use lsp_types::{
409        Diagnostic, DiagnosticSeverity as LspDiagnosticSeverity, NumberOrString, Position, Range,
410    };
411
412    use super::{from_lsp_diagnostics, DiagnosticSeverity, DiagnosticsStore, StoredDiagnostic};
413    use crate::lsp::registry::ServerKind;
414    use crate::lsp::roots::ServerKey;
415
416    fn server_key(kind: ServerKind) -> ServerKey {
417        ServerKey {
418            kind,
419            root: PathBuf::from("/tmp/repo"),
420        }
421    }
422
423    fn diag(file: &str, line: u32, msg: &str, sev: DiagnosticSeverity) -> StoredDiagnostic {
424        StoredDiagnostic {
425            file: PathBuf::from(file),
426            line,
427            column: 1,
428            end_line: line,
429            end_column: 2,
430            severity: sev,
431            message: msg.into(),
432            code: None,
433            source: None,
434        }
435    }
436
437    #[test]
438    fn converts_lsp_positions_to_one_based() {
439        let file = PathBuf::from("/tmp/demo.rs");
440        let diagnostics = from_lsp_diagnostics(
441            file.clone(),
442            vec![Diagnostic {
443                range: Range::new(Position::new(0, 0), Position::new(1, 4)),
444                severity: Some(LspDiagnosticSeverity::ERROR),
445                code: Some(NumberOrString::String("E1".into())),
446                code_description: None,
447                source: Some("fake".into()),
448                message: "boom".into(),
449                related_information: None,
450                tags: None,
451                data: None,
452            }],
453        );
454
455        assert_eq!(diagnostics.len(), 1);
456        assert_eq!(diagnostics[0].file, file);
457        assert_eq!(diagnostics[0].line, 1);
458        assert_eq!(diagnostics[0].column, 1);
459        assert_eq!(diagnostics[0].end_line, 2);
460        assert_eq!(diagnostics[0].end_column, 5);
461        assert_eq!(diagnostics[0].severity, DiagnosticSeverity::Error);
462        assert_eq!(diagnostics[0].code.as_deref(), Some("E1"));
463    }
464
465    #[test]
466    fn publish_replaces_existing_file_diagnostics() {
467        let file = PathBuf::from("/tmp/demo.rs");
468        let mut store = DiagnosticsStore::new();
469        let key = server_key(ServerKind::Rust);
470
471        store.publish(
472            key.clone(),
473            file.clone(),
474            vec![diag(
475                "/tmp/demo.rs",
476                1,
477                "first",
478                DiagnosticSeverity::Warning,
479            )],
480        );
481        store.publish(
482            key.clone(),
483            file.clone(),
484            vec![diag("/tmp/demo.rs", 2, "second", DiagnosticSeverity::Error)],
485        );
486
487        let stored = store.for_file(&file);
488        assert_eq!(stored.len(), 1);
489        assert_eq!(stored[0].message, "second");
490    }
491
492    #[test]
493    fn empty_publish_is_preserved_as_checked_clean() {
494        // The whole point of the v0.16 audit fix: empty publish ≠ deletion.
495        // Agents need to be able to ask "has this file been checked yet?"
496        // and get a truthful answer.
497        let file = PathBuf::from("/tmp/clean.rs");
498        let mut store = DiagnosticsStore::new();
499        let key = server_key(ServerKind::Rust);
500
501        // First publish has an issue.
502        store.publish(
503            key.clone(),
504            file.clone(),
505            vec![diag(
506                "/tmp/clean.rs",
507                5,
508                "fix me",
509                DiagnosticSeverity::Warning,
510            )],
511        );
512        assert!(store.has_any_report_for_file(&file));
513        assert_eq!(store.for_file(&file).len(), 1);
514
515        // Second publish is empty (the fix worked). Entry is preserved as
516        // "checked clean" rather than deleted.
517        store.publish(key.clone(), file.clone(), Vec::new());
518        assert!(
519            store.has_any_report_for_file(&file),
520            "checked-clean must be distinguishable from never-checked"
521        );
522        assert_eq!(store.for_file(&file).len(), 0);
523
524        let entries = store.entries_for_file(&file);
525        assert_eq!(entries.len(), 1);
526        assert!(entries[0].1.epoch > 0);
527    }
528
529    #[test]
530    fn never_checked_returns_no_report() {
531        let store = DiagnosticsStore::new();
532        let file = PathBuf::from("/tmp/never.rs");
533        assert!(!store.has_any_report_for_file(&file));
534        assert!(store.for_file(&file).is_empty());
535    }
536
537    #[test]
538    fn per_server_state_is_tracked_independently() {
539        let file = PathBuf::from("/tmp/multi.py");
540        let mut store = DiagnosticsStore::new();
541        let pyright_key = server_key(ServerKind::Python);
542        let ty_key = server_key(ServerKind::Ty);
543
544        store.publish(
545            pyright_key,
546            file.clone(),
547            vec![diag(
548                "/tmp/multi.py",
549                1,
550                "pyright says X",
551                DiagnosticSeverity::Error,
552            )],
553        );
554        store.publish(
555            ty_key,
556            file.clone(),
557            vec![diag(
558                "/tmp/multi.py",
559                2,
560                "ty says Y",
561                DiagnosticSeverity::Warning,
562            )],
563        );
564
565        let messages: Vec<&str> = store
566            .for_file(&file)
567            .into_iter()
568            .map(|d| d.message.as_str())
569            .collect();
570
571        assert_eq!(messages.len(), 2, "both servers' reports preserved");
572        assert!(messages.iter().any(|m| m == &"pyright says X"));
573        assert!(messages.iter().any(|m| m == &"ty says Y"));
574    }
575
576    #[test]
577    fn clear_for_server_file_removes_only_exact_entry() {
578        let file_a = PathBuf::from("/tmp/a.rs");
579        let file_b = PathBuf::from("/tmp/b.rs");
580        let mut store = DiagnosticsStore::new();
581        let rust_key = server_key(ServerKind::Rust);
582        let py_key = server_key(ServerKind::Python);
583
584        store.publish(
585            rust_key.clone(),
586            file_a.clone(),
587            vec![diag("/tmp/a.rs", 1, "rust a", DiagnosticSeverity::Error)],
588        );
589        store.publish(
590            rust_key.clone(),
591            file_b.clone(),
592            vec![diag("/tmp/b.rs", 1, "rust b", DiagnosticSeverity::Warning)],
593        );
594        store.publish(
595            py_key.clone(),
596            file_a.clone(),
597            vec![diag("/tmp/a.rs", 2, "py a", DiagnosticSeverity::Warning)],
598        );
599
600        store.clear_for_server_file(&rust_key, &file_a);
601
602        assert!(!store.has_report_for_server_file(&rust_key, &file_a));
603        assert!(store.has_report_for_server_file(&rust_key, &file_b));
604        assert!(store.has_report_for_server_file(&py_key, &file_a));
605    }
606
607    #[test]
608    fn lru_evicts_oldest_when_capacity_exceeded() {
609        let mut store = DiagnosticsStore::with_capacity(2);
610        let key = server_key(ServerKind::Rust);
611
612        store.publish(
613            key.clone(),
614            PathBuf::from("/a.rs"),
615            vec![diag("/a.rs", 1, "a", DiagnosticSeverity::Warning)],
616        );
617        store.publish(
618            key.clone(),
619            PathBuf::from("/b.rs"),
620            vec![diag("/b.rs", 1, "b", DiagnosticSeverity::Warning)],
621        );
622        assert_eq!(store.len(), 2);
623
624        // Inserting a third entry should evict /a.rs (oldest).
625        store.publish(
626            key.clone(),
627            PathBuf::from("/c.rs"),
628            vec![diag("/c.rs", 1, "c", DiagnosticSeverity::Warning)],
629        );
630        assert_eq!(store.len(), 2);
631        assert!(!store.has_any_report_for_file(Path::new("/a.rs")));
632        assert!(store.has_any_report_for_file(Path::new("/b.rs")));
633        assert!(store.has_any_report_for_file(Path::new("/c.rs")));
634    }
635
636    #[test]
637    fn touching_existing_entry_moves_it_to_end_of_lru() {
638        let mut store = DiagnosticsStore::with_capacity(2);
639        let key = server_key(ServerKind::Rust);
640
641        store.publish(
642            key.clone(),
643            PathBuf::from("/a.rs"),
644            vec![diag("/a.rs", 1, "a", DiagnosticSeverity::Warning)],
645        );
646        store.publish(
647            key.clone(),
648            PathBuf::from("/b.rs"),
649            vec![diag("/b.rs", 1, "b", DiagnosticSeverity::Warning)],
650        );
651
652        // Re-publish /a.rs — this should refresh its LRU position so it's
653        // newer than /b.rs. Inserting /c.rs should now evict /b.rs.
654        store.publish(
655            key.clone(),
656            PathBuf::from("/a.rs"),
657            vec![diag("/a.rs", 1, "a2", DiagnosticSeverity::Error)],
658        );
659        store.publish(
660            key.clone(),
661            PathBuf::from("/c.rs"),
662            vec![diag("/c.rs", 1, "c", DiagnosticSeverity::Warning)],
663        );
664
665        assert!(store.has_any_report_for_file(Path::new("/a.rs")));
666        assert!(!store.has_any_report_for_file(Path::new("/b.rs")));
667        assert!(store.has_any_report_for_file(Path::new("/c.rs")));
668    }
669
670    #[test]
671    fn capacity_zero_disables_eviction() {
672        let mut store = DiagnosticsStore::with_capacity(0);
673        let key = server_key(ServerKind::Rust);
674
675        for i in 0..50 {
676            store.publish(
677                key.clone(),
678                PathBuf::from(format!("/f{i}.rs")),
679                vec![diag(
680                    &format!("/f{i}.rs"),
681                    1,
682                    "x",
683                    DiagnosticSeverity::Warning,
684                )],
685            );
686        }
687        assert_eq!(store.len(), 50);
688    }
689
690    #[test]
691    fn set_capacity_evicts_on_shrink() {
692        let mut store = DiagnosticsStore::with_capacity(0);
693        let key = server_key(ServerKind::Rust);
694        for i in 0..10 {
695            store.publish(
696                key.clone(),
697                PathBuf::from(format!("/f{i}.rs")),
698                vec![diag(
699                    &format!("/f{i}.rs"),
700                    1,
701                    "x",
702                    DiagnosticSeverity::Warning,
703                )],
704            );
705        }
706        assert_eq!(store.len(), 10);
707
708        store.set_capacity(3);
709        assert_eq!(store.len(), 3);
710        // Most recent 3 should remain (/f7.rs, /f8.rs, /f9.rs).
711        assert!(store.has_any_report_for_file(Path::new("/f9.rs")));
712        assert!(!store.has_any_report_for_file(Path::new("/f0.rs")));
713    }
714
715    #[test]
716    fn epoch_increments_monotonically() {
717        let mut store = DiagnosticsStore::new();
718        let key = server_key(ServerKind::Rust);
719        let file = PathBuf::from("/e.rs");
720
721        store.publish(key.clone(), file.clone(), Vec::new());
722        let e1 = store.entries_for_file(&file)[0].1.epoch;
723
724        store.publish(key.clone(), file.clone(), Vec::new());
725        let e2 = store.entries_for_file(&file)[0].1.epoch;
726
727        assert!(e2 > e1, "epoch must increase on republish");
728    }
729
730    #[test]
731    fn result_id_is_round_tripped() {
732        let mut store = DiagnosticsStore::new();
733        let key = server_key(ServerKind::Rust);
734        let file = PathBuf::from("/r.rs");
735
736        store.publish_with_result_id(
737            key.clone(),
738            file.clone(),
739            Vec::new(),
740            Some("rev-42".to_string()),
741        );
742
743        let entries = store.entries_for_file(&file);
744        assert_eq!(entries[0].1.result_id.as_deref(), Some("rev-42"));
745    }
746
747    #[test]
748    fn clear_server_drops_all_entries_for_kind() {
749        let mut store = DiagnosticsStore::new();
750        let py_key = server_key(ServerKind::Python);
751        let rust_key = server_key(ServerKind::Rust);
752
753        store.publish(
754            py_key.clone(),
755            PathBuf::from("/a.py"),
756            vec![diag("/a.py", 1, "x", DiagnosticSeverity::Error)],
757        );
758        store.publish(
759            rust_key.clone(),
760            PathBuf::from("/b.rs"),
761            vec![diag("/b.rs", 1, "y", DiagnosticSeverity::Error)],
762        );
763
764        store.clear_server(ServerKind::Python);
765        assert!(!store.has_any_report_for_file(Path::new("/a.py")));
766        assert!(store.has_any_report_for_file(Path::new("/b.rs")));
767    }
768
769    #[test]
770    fn clear_for_file_drops_every_server_entry_and_updates_counts() {
771        let mut store = DiagnosticsStore::new();
772        let py_key = server_key(ServerKind::Python);
773        let biome_key = server_key(ServerKind::Biome);
774
775        // Two servers both report for the SAME deleted file, plus an unrelated
776        // file that must survive.
777        store.publish(
778            py_key,
779            PathBuf::from("/gone.ts"),
780            vec![diag("/gone.ts", 4, "type error", DiagnosticSeverity::Error)],
781        );
782        store.publish(
783            biome_key,
784            PathBuf::from("/gone.ts"),
785            vec![diag(
786                "/gone.ts",
787                7,
788                "lint warning",
789                DiagnosticSeverity::Warning,
790            )],
791        );
792        store.publish(
793            server_key(ServerKind::Rust),
794            PathBuf::from("/keep.rs"),
795            vec![diag("/keep.rs", 1, "live error", DiagnosticSeverity::Error)],
796        );
797
798        assert_eq!(store.error_warning_counts(), (2, 1));
799
800        // Clearing the deleted file drops both server entries for it.
801        let removed = store.clear_for_file(Path::new("/gone.ts"));
802        assert!(removed);
803        assert!(!store.has_any_report_for_file(Path::new("/gone.ts")));
804        // The unrelated file's diagnostic is untouched.
805        assert!(store.has_any_report_for_file(Path::new("/keep.rs")));
806        assert_eq!(store.error_warning_counts(), (1, 0));
807
808        // Clearing again is a no-op (nothing left for that file).
809        assert!(!store.clear_for_file(Path::new("/gone.ts")));
810    }
811}