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