Skip to main content

aft/lsp/
diagnostics.rs

1use std::collections::{HashMap, HashSet};
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    /// True after the filesystem watcher sees this file change outside AFT's
63    /// text sync path and before a publish or pull response proves the cached
64    /// diagnostics still describe the current file contents. Stale entries stay
65    /// in the store so resultIds and server coverage are not lost, but warm
66    /// readers must not count or display them as current diagnostics.
67    pub stale: bool,
68    /// True while rust-analyzer has not reported that its workspace analysis is
69    /// quiescent. These diagnostics are useful leads, but are not authoritative
70    /// enough to contribute to counts until the server has settled.
71    pub provisional: bool,
72}
73
74/// Stores diagnostics from all LSP servers, keyed per `(ServerKey, file)`.
75///
76/// Key design points (driven by the v0.16 LSP audit):
77///
78/// 1. **Per-server state.** A single file can be served by multiple LSP
79///    servers (e.g., pyright + ty, or tsserver + ESLint). The cache key is
80///    `(ServerKey, PathBuf)` so each server's view is tracked independently.
81///
82/// 2. **Empty publishes are kept.** Earlier the store deleted entries on
83///    empty publishes, making "checked clean" indistinguishable from "never
84///    checked". Now we preserve the entry with `epoch = ...` so callers can
85///    answer the question honestly.
86///
87/// 3. **LRU cap.** `capacity` (default 5000, configurable via
88///    `Config::diagnostic_cache_size`) bounds memory. Set to 0 to disable.
89///    On insert when at capacity, the least-recently-touched entry is
90///    evicted. Eviction is tracked so directory-mode callers can list
91///    those files as `unchecked` rather than silently lose them.
92pub struct DiagnosticsStore {
93    /// Primary store keyed by `(ServerKey, canonical file path)`.
94    entries: HashMap<(ServerKey, PathBuf), DiagnosticEntry>,
95    /// Secondary lookup from a file to every server with a cached report.
96    /// Every mutation of `entries` must update this index in the same operation.
97    by_file: HashMap<PathBuf, HashSet<ServerKey>>,
98    /// Insertion/access order for LRU eviction. Most-recently-touched
99    /// entries are at the END of the vector.
100    order: Vec<(ServerKey, PathBuf)>,
101    /// Maximum number of entries before LRU eviction kicks in. 0 = no cap.
102    capacity: usize,
103    /// Monotonic epoch counter. Incremented on every publish.
104    next_epoch: u64,
105    /// Monotonic identity for any diagnostics-store mutation. Status-bar
106    /// aggregation uses it to skip unchanged project filtering work.
107    generation: u64,
108    /// Last time a server published/replaced diagnostics for a specific file.
109    /// Used as a per-file freshness proof for push-only servers.
110    last_publish_at_for_file: HashMap<(ServerKey, PathBuf), Instant>,
111}
112
113impl DiagnosticsStore {
114    pub fn new() -> Self {
115        Self::with_capacity(5000)
116    }
117
118    pub fn with_capacity(capacity: usize) -> Self {
119        Self {
120            entries: HashMap::new(),
121            by_file: HashMap::new(),
122            order: Vec::new(),
123            capacity,
124            next_epoch: 0,
125            generation: 0,
126            last_publish_at_for_file: HashMap::new(),
127        }
128    }
129
130    /// Set or change the LRU cap. If the new cap is smaller than the
131    /// current entry count, the oldest entries are evicted immediately
132    /// to fit.
133    pub fn set_capacity(&mut self, capacity: usize) {
134        if self.capacity != capacity {
135            self.generation = self.generation.wrapping_add(1);
136        }
137        self.capacity = capacity;
138        if capacity > 0 {
139            while self.entries.len() > capacity {
140                self.evict_lru();
141            }
142        }
143        self.debug_assert_index_consistent();
144    }
145
146    /// Number of currently-tracked entries.
147    pub fn len(&self) -> usize {
148        self.entries.len()
149    }
150
151    pub fn generation(&self) -> u64 {
152        self.generation
153    }
154
155    pub fn estimated_memory(&self) -> crate::memory::MemoryEstimate {
156        let mut diagnostic_count = 0usize;
157        let entry_bytes = self
158            .entries
159            .iter()
160            .fold(0u64, |bytes, ((server, path), entry)| {
161                diagnostic_count = diagnostic_count.saturating_add(entry.diagnostics.len());
162                let diagnostics_bytes = entry.diagnostics.iter().fold(0u64, |bytes, diagnostic| {
163                    bytes
164                        .saturating_add(std::mem::size_of::<StoredDiagnostic>() as u64)
165                        .saturating_add(crate::memory::path_bytes(&diagnostic.file))
166                        .saturating_add(crate::memory::usize_to_u64(diagnostic.message.len()))
167                        .saturating_add(
168                            diagnostic
169                                .code
170                                .as_ref()
171                                .map(|code| crate::memory::usize_to_u64(code.len()))
172                                .unwrap_or(0),
173                        )
174                        .saturating_add(
175                            diagnostic
176                                .source
177                                .as_ref()
178                                .map(|source| crate::memory::usize_to_u64(source.len()))
179                                .unwrap_or(0),
180                        )
181                });
182                bytes
183                    .saturating_add(std::mem::size_of_val(server) as u64)
184                    .saturating_add(crate::memory::path_bytes(&server.root))
185                    .saturating_add(std::mem::size_of::<PathBuf>() as u64)
186                    .saturating_add(crate::memory::path_bytes(path))
187                    .saturating_add(std::mem::size_of::<DiagnosticEntry>() as u64)
188                    .saturating_add(
189                        entry
190                            .result_id
191                            .as_ref()
192                            .map(|result_id| crate::memory::usize_to_u64(result_id.len()))
193                            .unwrap_or(0),
194                    )
195                    .saturating_add(diagnostics_bytes)
196            });
197        let order_bytes = self.order.iter().fold(0u64, |bytes, (server, path)| {
198            bytes
199                .saturating_add(std::mem::size_of_val(server) as u64)
200                .saturating_add(crate::memory::path_bytes(&server.root))
201                .saturating_add(std::mem::size_of::<PathBuf>() as u64)
202                .saturating_add(crate::memory::path_bytes(path))
203        });
204        let publish_bytes =
205            self.last_publish_at_for_file
206                .iter()
207                .fold(0u64, |bytes, ((server, path), _)| {
208                    bytes
209                        .saturating_add(std::mem::size_of_val(server) as u64)
210                        .saturating_add(crate::memory::path_bytes(&server.root))
211                        .saturating_add(std::mem::size_of::<PathBuf>() as u64)
212                        .saturating_add(crate::memory::path_bytes(path))
213                        .saturating_add(std::mem::size_of::<Instant>() as u64)
214                });
215        let by_file_bytes = self.by_file.iter().fold(0u64, |bytes, (path, servers)| {
216            let server_bytes = servers.iter().fold(0u64, |server_bytes, server| {
217                server_bytes
218                    .saturating_add(std::mem::size_of_val(server) as u64)
219                    .saturating_add(crate::memory::path_bytes(&server.root))
220            });
221            bytes
222                .saturating_add(std::mem::size_of::<PathBuf>() as u64)
223                .saturating_add(crate::memory::path_bytes(path))
224                .saturating_add(server_bytes)
225        });
226        crate::memory::MemoryEstimate::estimated(
227            entry_bytes
228                .saturating_add(order_bytes)
229                .saturating_add(publish_bytes)
230                .saturating_add(by_file_bytes),
231        )
232        .count("diagnostic_entries", self.entries.len())
233        .count("diagnostics", diagnostic_count)
234    }
235
236    /// The current LRU cap (0 = unbounded). Test-only accessor used to verify
237    /// the `lsp.diagnostic_cache_size` config wiring.
238    #[cfg(test)]
239    pub fn capacity_for_test(&self) -> usize {
240        self.capacity
241    }
242
243    pub fn is_empty(&self) -> bool {
244        self.entries.is_empty()
245    }
246
247    /// True if any entry is currently usable, including an empty checked-clean
248    /// report. Watcher-stale entries do not prove current diagnostics.
249    pub fn has_any_fresh_report(&self) -> bool {
250        self.entries.values().any(|entry| !entry.stale)
251    }
252
253    /// Replace diagnostics for a `(server_kind, file)` pair using the
254    /// server's lifecycle root from the active manager. Empty diagnostics
255    /// are preserved as "checked clean" (NOT deleted as before).
256    ///
257    /// Note: the `(server, file)` key uses `ServerKey { kind, root }` so
258    /// concurrent multi-workspace usage doesn't collapse different roots.
259    /// Callers without the root (legacy push handler) should call
260    /// `publish_with_kind` which derives the key.
261    pub fn publish(
262        &mut self,
263        server: ServerKey,
264        file: PathBuf,
265        diagnostics: Vec<StoredDiagnostic>,
266    ) {
267        self.publish_with_result_id(server, file, diagnostics, None);
268    }
269
270    /// Replace diagnostics and record a pull `resultId` for the next
271    /// request. Empty diagnostics are preserved as "checked clean".
272    pub fn publish_with_result_id(
273        &mut self,
274        server: ServerKey,
275        file: PathBuf,
276        diagnostics: Vec<StoredDiagnostic>,
277        result_id: Option<String>,
278    ) {
279        self.publish_full(server, file, diagnostics, result_id, None);
280    }
281
282    /// Replace diagnostics with full provenance (resultId + document version).
283    /// `version` should be the LSP `version` field from `publishDiagnostics`
284    /// when the server provided one, or `None` otherwise.
285    pub fn publish_full(
286        &mut self,
287        server: ServerKey,
288        file: PathBuf,
289        diagnostics: Vec<StoredDiagnostic>,
290        result_id: Option<String>,
291        version: Option<i32>,
292    ) {
293        self.publish_full_with_provisional(server, file, diagnostics, result_id, version, false);
294    }
295
296    /// Replace diagnostics while recording whether the server was still warming
297    /// when it produced them.
298    pub fn publish_full_with_provisional(
299        &mut self,
300        server: ServerKey,
301        file: PathBuf,
302        diagnostics: Vec<StoredDiagnostic>,
303        result_id: Option<String>,
304        version: Option<i32>,
305        provisional: bool,
306    ) {
307        let key = (server, file);
308        self.next_epoch = self.next_epoch.saturating_add(1);
309        self.generation = self.generation.wrapping_add(1);
310        let entry = DiagnosticEntry {
311            diagnostics,
312            epoch: self.next_epoch,
313            result_id,
314            version,
315            stale: false,
316            provisional,
317        };
318
319        self.last_publish_at_for_file
320            .insert(key.clone(), Instant::now());
321
322        if self.entries.contains_key(&key) {
323            self.entries.insert(key.clone(), entry);
324            self.index_entry(&key);
325            self.touch_existing(&key);
326        } else {
327            // New entry — apply LRU cap before inserting.
328            if self.capacity > 0 && self.entries.len() >= self.capacity {
329                self.evict_lru();
330            }
331            self.entries.insert(key.clone(), entry);
332            self.index_entry(&key);
333            self.order.push(key);
334        }
335        self.debug_assert_index_consistent();
336    }
337
338    /// Compatibility wrapper for the legacy push path that knows only the
339    /// `ServerKind`. Builds a `ServerKey` with an empty root, which is
340    /// adequate for the single-root-per-kind case the manager currently
341    /// uses for push diagnostics. Multi-root callers should use
342    /// `publish` directly with a real `ServerKey`.
343    pub fn publish_with_kind(
344        &mut self,
345        kind: ServerKind,
346        file: PathBuf,
347        diagnostics: Vec<StoredDiagnostic>,
348    ) {
349        let key = ServerKey {
350            kind,
351            root: PathBuf::new(),
352        };
353        self.publish(key, file, diagnostics);
354    }
355
356    /// Get current diagnostics for a specific file (across all servers).
357    /// Watcher-stale entries are kept for bookkeeping but are not surfaced.
358    pub fn for_file(&self, file: &Path) -> Vec<&StoredDiagnostic> {
359        let Some(servers) = self.by_file.get(file) else {
360            return Vec::new();
361        };
362        let file = file.to_path_buf();
363        let mut diagnostics = Vec::new();
364        for server in servers {
365            if let Some(entry) = self.entries.get(&(server.clone(), file.clone())) {
366                if !entry.stale {
367                    diagnostics.extend(&entry.diagnostics);
368                }
369            }
370        }
371        diagnostics
372    }
373
374    /// Current diagnostics for a file with the entry-level readiness marker.
375    /// The marker is kept separate from [`StoredDiagnostic`] because readiness
376    /// describes the server report, not an individual diagnostic.
377    pub fn for_file_with_provisional(&self, file: &Path) -> Vec<(&StoredDiagnostic, bool)> {
378        let Some(servers) = self.by_file.get(file) else {
379            return Vec::new();
380        };
381        let file = file.to_path_buf();
382        let mut diagnostics = Vec::new();
383        for server in servers {
384            if let Some(entry) = self.entries.get(&(server.clone(), file.clone())) {
385                if !entry.stale {
386                    diagnostics.extend(
387                        entry
388                            .diagnostics
389                            .iter()
390                            .map(|diagnostic| (diagnostic, entry.provisional)),
391                    );
392                }
393            }
394        }
395        diagnostics
396    }
397
398    /// Get the full per-server entry for a file. Useful when callers need
399    /// to know epoch/resultId, not just the diagnostics array.
400    pub fn entries_for_file(&self, file: &Path) -> Vec<(&ServerKey, &DiagnosticEntry)> {
401        let Some(servers) = self.by_file.get(file) else {
402            return Vec::new();
403        };
404        let file = file.to_path_buf();
405        servers
406            .iter()
407            .filter_map(|server| {
408                self.entries
409                    .get_key_value(&(server.clone(), file.clone()))
410                    .map(|((stored_server, _), entry)| (stored_server, entry))
411            })
412            .collect()
413    }
414
415    /// True if any server has an entry (fresh or stale) for this file.
416    pub fn has_any_report_for_file(&self, file: &Path) -> bool {
417        self.by_file.contains_key(file)
418    }
419
420    /// True if any server has a non-stale report for this file.
421    pub fn has_any_fresh_report_for_file(&self, file: &Path) -> bool {
422        let Some(servers) = self.by_file.get(file) else {
423            return false;
424        };
425        let file = file.to_path_buf();
426        servers.iter().any(|server| {
427            self.entries
428                .get(&(server.clone(), file.clone()))
429                .is_some_and(|entry| !entry.stale)
430        })
431    }
432
433    /// True if this exact server instance has an entry (fresh or stale) for
434    /// this exact file. Pull diagnostics use stale entries as the previous
435    /// resultId cache when asking the server whether diagnostics are unchanged.
436    pub fn has_report_for_server_file(&self, server: &ServerKey, file: &Path) -> bool {
437        self.entries
438            .contains_key(&(server.clone(), file.to_path_buf()))
439    }
440
441    /// True if this exact server instance has a non-stale report for this file.
442    pub fn has_fresh_report_for_server_file(&self, server: &ServerKey, file: &Path) -> bool {
443        self.entries
444            .get(&(server.clone(), file.to_path_buf()))
445            .is_some_and(|entry| !entry.stale)
446    }
447
448    /// True if this exact server instance published/replaced diagnostics for
449    /// this exact file after `since`. This is intentionally per `(kind, root,
450    /// file)`; a publish for another file must not prove freshness here.
451    pub fn has_publish_for_file_after(
452        &self,
453        server: &ServerKey,
454        file: &Path,
455        since: Instant,
456    ) -> bool {
457        self.last_publish_at_for_file
458            .get(&(server.clone(), file.to_path_buf()))
459            .is_some_and(|published_at| {
460                *published_at >= since && self.has_fresh_report_for_server_file(server, file)
461            })
462    }
463
464    /// Get current diagnostics for files under a directory.
465    pub fn for_directory(&self, dir: &Path) -> Vec<&StoredDiagnostic> {
466        self.entries
467            .iter()
468            .filter(|((_, stored_file), entry)| stored_file.starts_with(dir) && !entry.stale)
469            .flat_map(|(_, entry)| entry.diagnostics.iter())
470            .collect()
471    }
472
473    /// Current diagnostics under a directory with the entry-level readiness
474    /// marker preserved for inspect's provisional framing.
475    pub fn for_directory_with_provisional(&self, dir: &Path) -> Vec<(&StoredDiagnostic, bool)> {
476        self.entries
477            .iter()
478            .filter(|((_, stored_file), entry)| stored_file.starts_with(dir) && !entry.stale)
479            .flat_map(|(_, entry)| {
480                entry
481                    .diagnostics
482                    .iter()
483                    .map(|diagnostic| (diagnostic, entry.provisional))
484            })
485            .collect()
486    }
487
488    /// All current diagnostics, flattened. Watcher-stale entries are hidden.
489    pub fn all(&self) -> Vec<&StoredDiagnostic> {
490        self.entries
491            .values()
492            .filter(|entry| !entry.stale)
493            .flat_map(|entry| entry.diagnostics.iter())
494            .collect()
495    }
496
497    /// All current diagnostics with the entry-level readiness marker.
498    pub fn all_with_provisional(&self) -> Vec<(&StoredDiagnostic, bool)> {
499        self.entries
500            .values()
501            .filter(|entry| !entry.stale)
502            .flat_map(|entry| {
503                entry
504                    .diagnostics
505                    .iter()
506                    .map(|diagnostic| (diagnostic, entry.provisional))
507            })
508            .collect()
509    }
510
511    /// Count of errors and warnings across the entire warm set (every file any
512    /// server has published for). Allocation-free — the raw, unfiltered union.
513    /// Callers that want the agent-status-bar semantics (project-root scoped,
514    /// tsconfig-membership filtered, cross-server deduped) should use
515    /// [`filtered_error_warning_counts`](Self::filtered_error_warning_counts).
516    pub fn error_warning_counts(&self) -> (usize, usize) {
517        self.error_warning_counts_with_provisional().0
518    }
519
520    /// Raw warm-set counts plus whether any current entry is provisional.
521    pub fn error_warning_counts_with_provisional(&self) -> ((usize, usize), bool) {
522        let mut errors = 0usize;
523        let mut warnings = 0usize;
524        let mut has_provisional = false;
525        for entry in self.entries.values() {
526            if entry.provisional {
527                has_provisional = true;
528            }
529            if entry.stale || entry.provisional {
530                continue;
531            }
532            for diagnostic in &entry.diagnostics {
533                match diagnostic.severity {
534                    DiagnosticSeverity::Error => errors += 1,
535                    DiagnosticSeverity::Warning => warnings += 1,
536                    _ => {}
537                }
538            }
539        }
540        ((errors, warnings), has_provisional)
541    }
542
543    /// Error/warning counts after applying a per-file `keep` predicate,
544    /// excluding environmental/setup diagnostics (see `environmental.rs`),
545    /// and de-duplicating diagnostics that multiple servers reported for the
546    /// same location. This matches `aft_inspect`'s warm semantics
547    /// (`inspect/diagnostics_category.rs`: project-root filter +
548    /// tsconfig-membership skip + environmental filter + `sort_and_dedup`) so
549    /// the agent status bar's E/W agree with `aft_inspect`/`tsc` instead of
550    /// counting build-excluded files and double-counting multi-server overlaps.
551    ///
552    /// The store itself holds no tsconfig/project policy — the caller encodes
553    /// it in `keep` (see `LspManager::filtered_error_warning_counts`). `keep`
554    /// is `FnMut` because the membership cache resolves lazily.
555    pub fn filtered_error_warning_counts(&self, keep: impl FnMut(&Path) -> bool) -> (usize, usize) {
556        self.filtered_error_warning_counts_with_provisional(keep).0
557    }
558
559    /// Return status-bar counts plus whether a kept entry is still provisional.
560    /// The boolean lets the context retain the previous authoritative E/W values
561    /// instead of replacing them with zero while an analyzer warms up.
562    pub fn filtered_error_warning_counts_with_provisional(
563        &self,
564        mut keep: impl FnMut(&Path) -> bool,
565    ) -> ((usize, usize), bool) {
566        // Dedup key mirrors `sort_and_dedup` in inspect/diagnostics_category.rs
567        // exactly (file, range, severity, message, source) so the bar and
568        // inspect collapse the same multi-server overlaps.
569        let mut seen: std::collections::HashSet<(
570            &Path,
571            u32,
572            u32,
573            u32,
574            u32,
575            &str,
576            &str,
577            Option<&str>,
578        )> = std::collections::HashSet::new();
579        let mut errors = 0usize;
580        let mut warnings = 0usize;
581        let mut has_provisional = false;
582        for ((_, file), entry) in &self.entries {
583            if entry.provisional && keep(file) {
584                has_provisional = true;
585            }
586            if entry.stale || entry.provisional {
587                continue;
588            }
589            // All diagnostics in an entry share the entry's file, so the keep
590            // predicate (the cost center: tsconfig resolution) runs once per
591            // (server, file) entry, not once per diagnostic.
592            if !keep(file) {
593                continue;
594            }
595            for diagnostic in &entry.diagnostics {
596                if crate::lsp::environmental::is_environmental_diagnostic(diagnostic) {
597                    continue;
598                }
599                let dedup_key = (
600                    diagnostic.file.as_path(),
601                    diagnostic.line,
602                    diagnostic.column,
603                    diagnostic.end_line,
604                    diagnostic.end_column,
605                    diagnostic.severity.as_str(),
606                    diagnostic.message.as_str(),
607                    diagnostic.source.as_deref(),
608                );
609                if !seen.insert(dedup_key) {
610                    continue;
611                }
612                match diagnostic.severity {
613                    DiagnosticSeverity::Error => errors += 1,
614                    DiagnosticSeverity::Warning => warnings += 1,
615                    _ => {}
616                }
617            }
618        }
619        ((errors, warnings), has_provisional)
620    }
621
622    /// Drop all entries for a server kind (e.g., on server crash/restart).
623    /// Prefer `clear_for_server` for real manager cleanup so peer roots of the
624    /// same kind are not wiped.
625    pub fn clear_server(&mut self, server: ServerKind) {
626        let before = self.entries.len();
627        self.entries
628            .retain(|(stored_key, _), _| stored_key.kind != server);
629        self.order
630            .retain(|(stored_key, _)| stored_key.kind != server);
631        self.last_publish_at_for_file
632            .retain(|(stored_key, _), _| stored_key.kind != server);
633        self.by_file.retain(|_, servers| {
634            servers.retain(|stored_key| stored_key.kind != server);
635            !servers.is_empty()
636        });
637        if self.entries.len() != before {
638            self.generation = self.generation.wrapping_add(1);
639        }
640        self.debug_assert_index_consistent();
641    }
642
643    /// Drop one cached report for a specific server/file pair.
644    pub fn clear_for_server_file(&mut self, key: &ServerKey, file: &Path) {
645        let cache_key = (key.clone(), file.to_path_buf());
646        if self.entries.remove(&cache_key).is_some() {
647            self.unindex_entry(&cache_key);
648            self.generation = self.generation.wrapping_add(1);
649        }
650        self.order.retain(|entry_key| entry_key != &cache_key);
651        self.last_publish_at_for_file.remove(&cache_key);
652        self.debug_assert_index_consistent();
653    }
654
655    /// Drop every cached report for a file across all servers. Used when a file
656    /// is deleted/renamed away — its diagnostics would otherwise linger in the
657    /// warm set forever (no server republishes for a path that no longer
658    /// exists), inflating the error/warning counts surfaced in the status bar
659    /// and `aft_inspect`. Returns true if any entry was removed.
660    pub fn clear_for_file(&mut self, file: &Path) -> bool {
661        let Some(servers) = self.by_file.remove(file) else {
662            self.debug_assert_index_consistent();
663            return false;
664        };
665        let mut removed = false;
666        for server in servers {
667            let cache_key = (server, file.to_path_buf());
668            removed |= self.entries.remove(&cache_key).is_some();
669            self.last_publish_at_for_file.remove(&cache_key);
670        }
671        if removed {
672            self.generation = self.generation.wrapping_add(1);
673            self.order.retain(|(_, stored_file)| stored_file != file);
674        }
675        self.debug_assert_index_consistent();
676        removed
677    }
678
679    /// Mark every cached report for a file stale without evicting it.
680    ///
681    /// This is used for watcher-observed external edits: the previous
682    /// diagnostics may still be useful as a pull `previousResultId`, but warm
683    /// readers must stop counting them until a server publish or pull response
684    /// proves freshness. Returns `(had_entries, changed)` where `changed` is true
685    /// only if at least one previously-fresh entry became stale.
686    pub fn mark_stale_for_file(&mut self, file: &Path) -> (bool, bool) {
687        let Some(servers) = self.by_file.get(file) else {
688            return (false, false);
689        };
690        let had_entries = !servers.is_empty();
691        let mut changed = false;
692        for server in servers {
693            let cache_key = (server.clone(), file.to_path_buf());
694            if let Some(entry) = self
695                .entries
696                .get_mut(&cache_key)
697                .filter(|entry| !entry.stale)
698            {
699                entry.stale = true;
700                changed = true;
701            }
702        }
703        if changed {
704            self.generation = self.generation.wrapping_add(1);
705        }
706        self.debug_assert_index_consistent();
707        (had_entries, changed)
708    }
709
710    /// Mark one cached report fresh after a server response proves it still
711    /// describes the current document (for example a pull `kind: unchanged`).
712    pub fn mark_fresh_for_server_file(&mut self, key: &ServerKey, file: &Path) -> bool {
713        let cache_key = (key.clone(), file.to_path_buf());
714        let Some(entry) = self.entries.get_mut(&cache_key) else {
715            return false;
716        };
717        let changed = entry.stale;
718        entry.stale = false;
719        if changed {
720            self.generation = self.generation.wrapping_add(1);
721        }
722        self.touch_existing(&cache_key);
723        changed
724    }
725
726    /// Mark all provisional reports from one server stale when it first becomes
727    /// quiescent. The server will publish fresh reports after this transition;
728    /// keeping the old reports stale avoids treating them as revalidated.
729    pub fn mark_provisional_for_server_stale(&mut self, key: &ServerKey) -> bool {
730        let mut changed = false;
731        for ((stored_key, _), entry) in &mut self.entries {
732            if stored_key == key && entry.provisional && !entry.stale {
733                entry.stale = true;
734                changed = true;
735            }
736        }
737        if changed {
738            self.generation = self.generation.wrapping_add(1);
739        }
740        self.debug_assert_index_consistent();
741        changed
742    }
743
744    /// Clear the readiness marker after a pull response is received from a
745    /// quiescent server. This is separate from `mark_fresh` because a pull
746    /// response received while warming must remain provisional.
747    pub fn clear_provisional_for_server_file(&mut self, key: &ServerKey, file: &Path) -> bool {
748        let cache_key = (key.clone(), file.to_path_buf());
749        let Some(entry) = self.entries.get_mut(&cache_key) else {
750            return false;
751        };
752        if !entry.provisional {
753            return false;
754        }
755        entry.provisional = false;
756        self.generation = self.generation.wrapping_add(1);
757        true
758    }
759
760    /// Drop all entries for a specific server instance.
761    pub fn clear_for_server(&mut self, key: &ServerKey) {
762        let before = self.entries.len();
763        self.entries.retain(|(k, _), _| k != key);
764        self.order.retain(|(k, _)| k != key);
765        self.last_publish_at_for_file.retain(|(k, _), _| k != key);
766        self.by_file.retain(|_, servers| {
767            servers.remove(key);
768            !servers.is_empty()
769        });
770        if self.entries.len() != before {
771            self.generation = self.generation.wrapping_add(1);
772        }
773        self.debug_assert_index_consistent();
774    }
775
776    /// Backward-compatible alias for tests/callers that already used the
777    /// instance-scoped name.
778    pub fn clear_server_instance(&mut self, key: &ServerKey) {
779        self.clear_for_server(key);
780    }
781
782    /// Remove the least-recently-used entry, returning its key for telemetry.
783    fn evict_lru(&mut self) -> Option<(ServerKey, PathBuf)> {
784        if self.order.is_empty() {
785            return None;
786        }
787        let evicted = self.order.remove(0);
788        self.entries.remove(&evicted);
789        self.unindex_entry(&evicted);
790        self.last_publish_at_for_file.remove(&evicted);
791        self.debug_assert_index_consistent();
792        Some(evicted)
793    }
794
795    fn touch_existing(&mut self, key: &(ServerKey, PathBuf)) {
796        if let Some(idx) = self.order.iter().position(|k| k == key) {
797            let removed = self.order.remove(idx);
798            self.order.push(removed);
799        }
800    }
801
802    fn index_entry(&mut self, (server, file): &(ServerKey, PathBuf)) {
803        self.by_file
804            .entry(file.clone())
805            .or_default()
806            .insert(server.clone());
807    }
808
809    fn unindex_entry(&mut self, (server, file): &(ServerKey, PathBuf)) {
810        let remove_file = self.by_file.get_mut(file).is_some_and(|servers| {
811            servers.remove(server);
812            servers.is_empty()
813        });
814        if remove_file {
815            self.by_file.remove(file);
816        }
817    }
818
819    fn debug_assert_index_consistent(&self) {
820        #[cfg(debug_assertions)]
821        {
822            let indexed_entries = self.by_file.values().map(HashSet::len).sum::<usize>();
823            debug_assert_eq!(indexed_entries, self.entries.len());
824            for (server, file) in self.entries.keys() {
825                debug_assert!(self
826                    .by_file
827                    .get(file)
828                    .is_some_and(|servers| servers.contains(server)));
829            }
830            for (file, servers) in &self.by_file {
831                debug_assert!(!servers.is_empty());
832                for server in servers {
833                    debug_assert!(self.entries.contains_key(&(server.clone(), file.clone())));
834                }
835            }
836        }
837    }
838
839    #[cfg(test)]
840    fn mark_stale_for_file_linear_reference(&self, file: &Path) -> (bool, bool) {
841        let mut had_entries = false;
842        let mut changed = false;
843        for ((_, stored_file), entry) in &self.entries {
844            if stored_file == file {
845                had_entries = true;
846                changed |= !entry.stale;
847            }
848        }
849        (had_entries, changed)
850    }
851}
852
853impl Default for DiagnosticsStore {
854    fn default() -> Self {
855        Self::new()
856    }
857}
858
859/// Convert LSP diagnostics to our stored format.
860/// LSP uses 0-based line/character; we convert to 1-based.
861pub fn from_lsp_diagnostics(
862    file: PathBuf,
863    lsp_diagnostics: Vec<lsp_types::Diagnostic>,
864) -> Vec<StoredDiagnostic> {
865    lsp_diagnostics
866        .into_iter()
867        .map(|diagnostic| StoredDiagnostic {
868            file: file.clone(),
869            line: diagnostic.range.start.line + 1,
870            column: diagnostic.range.start.character + 1,
871            end_line: diagnostic.range.end.line + 1,
872            end_column: diagnostic.range.end.character + 1,
873            severity: match diagnostic.severity {
874                Some(lsp_types::DiagnosticSeverity::ERROR) => DiagnosticSeverity::Error,
875                Some(lsp_types::DiagnosticSeverity::WARNING) => DiagnosticSeverity::Warning,
876                Some(lsp_types::DiagnosticSeverity::INFORMATION) => DiagnosticSeverity::Information,
877                Some(lsp_types::DiagnosticSeverity::HINT) => DiagnosticSeverity::Hint,
878                _ => DiagnosticSeverity::Warning,
879            },
880            message: diagnostic.message,
881            code: diagnostic.code.map(|code| match code {
882                lsp_types::NumberOrString::Number(value) => value.to_string(),
883                lsp_types::NumberOrString::String(value) => value,
884            }),
885            source: diagnostic.source,
886        })
887        .collect()
888}
889
890#[cfg(test)]
891mod tests {
892    use std::path::{Path, PathBuf};
893
894    use lsp_types::{
895        Diagnostic, DiagnosticSeverity as LspDiagnosticSeverity, NumberOrString, Position, Range,
896    };
897
898    use super::{from_lsp_diagnostics, DiagnosticSeverity, DiagnosticsStore, StoredDiagnostic};
899    use crate::lsp::registry::ServerKind;
900    use crate::lsp::roots::ServerKey;
901
902    fn server_key(kind: ServerKind) -> ServerKey {
903        ServerKey {
904            kind,
905            root: PathBuf::from("/tmp/repo"),
906        }
907    }
908
909    fn diag(file: &str, line: u32, msg: &str, sev: DiagnosticSeverity) -> StoredDiagnostic {
910        StoredDiagnostic {
911            file: PathBuf::from(file),
912            line,
913            column: 1,
914            end_line: line,
915            end_column: 2,
916            severity: sev,
917            message: msg.into(),
918            code: None,
919            source: None,
920        }
921    }
922
923    #[test]
924    fn diagnostics_memory_estimate_is_zero_when_empty_and_nonzero_when_populated() {
925        let mut store = DiagnosticsStore::new();
926        assert_eq!(store.estimated_memory().estimated_bytes, Some(0));
927        let file = PathBuf::from("/tmp/memory.rs");
928        store.publish(
929            server_key(ServerKind::Rust),
930            file.clone(),
931            vec![diag(
932                file.to_str().unwrap(),
933                1,
934                "resident diagnostic message",
935                DiagnosticSeverity::Warning,
936            )],
937        );
938        let estimate = store.estimated_memory();
939        assert!(estimate.estimated_bytes.unwrap() > 0);
940        assert_eq!(estimate.counts["diagnostic_entries"], 1);
941        assert_eq!(estimate.counts["diagnostics"], 1);
942    }
943
944    #[test]
945    fn converts_lsp_positions_to_one_based() {
946        let file = PathBuf::from("/tmp/demo.rs");
947        let diagnostics = from_lsp_diagnostics(
948            file.clone(),
949            vec![Diagnostic {
950                range: Range::new(Position::new(0, 0), Position::new(1, 4)),
951                severity: Some(LspDiagnosticSeverity::ERROR),
952                code: Some(NumberOrString::String("E1".into())),
953                code_description: None,
954                source: Some("fake".into()),
955                message: "boom".into(),
956                related_information: None,
957                tags: None,
958                data: None,
959            }],
960        );
961
962        assert_eq!(diagnostics.len(), 1);
963        assert_eq!(diagnostics[0].file, file);
964        assert_eq!(diagnostics[0].line, 1);
965        assert_eq!(diagnostics[0].column, 1);
966        assert_eq!(diagnostics[0].end_line, 2);
967        assert_eq!(diagnostics[0].end_column, 5);
968        assert_eq!(diagnostics[0].severity, DiagnosticSeverity::Error);
969        assert_eq!(diagnostics[0].code.as_deref(), Some("E1"));
970    }
971
972    #[test]
973    fn publish_replaces_existing_file_diagnostics() {
974        let file = PathBuf::from("/tmp/demo.rs");
975        let mut store = DiagnosticsStore::new();
976        let key = server_key(ServerKind::Rust);
977
978        store.publish(
979            key.clone(),
980            file.clone(),
981            vec![diag(
982                "/tmp/demo.rs",
983                1,
984                "first",
985                DiagnosticSeverity::Warning,
986            )],
987        );
988        store.publish(
989            key.clone(),
990            file.clone(),
991            vec![diag("/tmp/demo.rs", 2, "second", DiagnosticSeverity::Error)],
992        );
993
994        let stored = store.for_file(&file);
995        assert_eq!(stored.len(), 1);
996        assert_eq!(stored[0].message, "second");
997    }
998
999    #[test]
1000    fn empty_publish_is_preserved_as_checked_clean() {
1001        // The whole point of the v0.16 audit fix: empty publish ≠ deletion.
1002        // Agents need to be able to ask "has this file been checked yet?"
1003        // and get a truthful answer.
1004        let file = PathBuf::from("/tmp/clean.rs");
1005        let mut store = DiagnosticsStore::new();
1006        let key = server_key(ServerKind::Rust);
1007
1008        // First publish has an issue.
1009        store.publish(
1010            key.clone(),
1011            file.clone(),
1012            vec![diag(
1013                "/tmp/clean.rs",
1014                5,
1015                "fix me",
1016                DiagnosticSeverity::Warning,
1017            )],
1018        );
1019        assert!(store.has_any_report_for_file(&file));
1020        assert_eq!(store.for_file(&file).len(), 1);
1021
1022        // Second publish is empty (the fix worked). Entry is preserved as
1023        // "checked clean" rather than deleted.
1024        store.publish(key.clone(), file.clone(), Vec::new());
1025        assert!(
1026            store.has_any_report_for_file(&file),
1027            "checked-clean must be distinguishable from never-checked"
1028        );
1029        assert_eq!(store.for_file(&file).len(), 0);
1030
1031        let entries = store.entries_for_file(&file);
1032        assert_eq!(entries.len(), 1);
1033        assert!(entries[0].1.epoch > 0);
1034    }
1035
1036    #[test]
1037    fn never_checked_returns_no_report() {
1038        let store = DiagnosticsStore::new();
1039        let file = PathBuf::from("/tmp/never.rs");
1040        assert!(!store.has_any_report_for_file(&file));
1041        assert!(store.for_file(&file).is_empty());
1042    }
1043
1044    #[test]
1045    fn stale_entries_are_hidden_but_preserved_for_refresh() {
1046        let file = PathBuf::from("/tmp/stale.rs");
1047        let mut store = DiagnosticsStore::new();
1048        let key = server_key(ServerKind::Rust);
1049        store.publish(
1050            key.clone(),
1051            file.clone(),
1052            vec![diag("/tmp/stale.rs", 1, "old", DiagnosticSeverity::Error)],
1053        );
1054
1055        let (had_entries, changed) = store.mark_stale_for_file(&file);
1056
1057        assert!(had_entries);
1058        assert!(changed);
1059        assert!(store.has_any_report_for_file(&file));
1060        assert!(!store.has_any_fresh_report_for_file(&file));
1061        assert!(store.for_file(&file).is_empty());
1062        assert!(store.all().is_empty());
1063        assert_eq!(store.error_warning_counts(), (0, 0));
1064        assert_eq!(store.entries_for_file(&file).len(), 1);
1065
1066        assert!(store.mark_fresh_for_server_file(&key, &file));
1067        assert!(store.has_any_fresh_report_for_file(&file));
1068        assert_eq!(store.for_file(&file).len(), 1);
1069        assert_eq!(store.error_warning_counts(), (1, 0));
1070    }
1071
1072    #[test]
1073    fn per_server_state_is_tracked_independently() {
1074        let file = PathBuf::from("/tmp/multi.py");
1075        let mut store = DiagnosticsStore::new();
1076        let pyright_key = server_key(ServerKind::Python);
1077        let ty_key = server_key(ServerKind::Ty);
1078
1079        store.publish(
1080            pyright_key,
1081            file.clone(),
1082            vec![diag(
1083                "/tmp/multi.py",
1084                1,
1085                "pyright says X",
1086                DiagnosticSeverity::Error,
1087            )],
1088        );
1089        store.publish(
1090            ty_key,
1091            file.clone(),
1092            vec![diag(
1093                "/tmp/multi.py",
1094                2,
1095                "ty says Y",
1096                DiagnosticSeverity::Warning,
1097            )],
1098        );
1099
1100        let messages: Vec<&str> = store
1101            .for_file(&file)
1102            .into_iter()
1103            .map(|d| d.message.as_str())
1104            .collect();
1105
1106        assert_eq!(messages.len(), 2, "both servers' reports preserved");
1107        assert!(messages.iter().any(|m| m == &"pyright says X"));
1108        assert!(messages.iter().any(|m| m == &"ty says Y"));
1109    }
1110
1111    #[test]
1112    fn clear_for_server_file_removes_only_exact_entry() {
1113        let file_a = PathBuf::from("/tmp/a.rs");
1114        let file_b = PathBuf::from("/tmp/b.rs");
1115        let mut store = DiagnosticsStore::new();
1116        let rust_key = server_key(ServerKind::Rust);
1117        let py_key = server_key(ServerKind::Python);
1118
1119        store.publish(
1120            rust_key.clone(),
1121            file_a.clone(),
1122            vec![diag("/tmp/a.rs", 1, "rust a", DiagnosticSeverity::Error)],
1123        );
1124        store.publish(
1125            rust_key.clone(),
1126            file_b.clone(),
1127            vec![diag("/tmp/b.rs", 1, "rust b", DiagnosticSeverity::Warning)],
1128        );
1129        store.publish(
1130            py_key.clone(),
1131            file_a.clone(),
1132            vec![diag("/tmp/a.rs", 2, "py a", DiagnosticSeverity::Warning)],
1133        );
1134
1135        store.clear_for_server_file(&rust_key, &file_a);
1136
1137        assert!(!store.has_report_for_server_file(&rust_key, &file_a));
1138        assert!(store.has_report_for_server_file(&rust_key, &file_b));
1139        assert!(store.has_report_for_server_file(&py_key, &file_a));
1140    }
1141
1142    #[test]
1143    fn lru_evicts_oldest_when_capacity_exceeded() {
1144        let mut store = DiagnosticsStore::with_capacity(2);
1145        let key = server_key(ServerKind::Rust);
1146
1147        store.publish(
1148            key.clone(),
1149            PathBuf::from("/a.rs"),
1150            vec![diag("/a.rs", 1, "a", DiagnosticSeverity::Warning)],
1151        );
1152        store.publish(
1153            key.clone(),
1154            PathBuf::from("/b.rs"),
1155            vec![diag("/b.rs", 1, "b", DiagnosticSeverity::Warning)],
1156        );
1157        assert_eq!(store.len(), 2);
1158
1159        // Inserting a third entry should evict /a.rs (oldest).
1160        store.publish(
1161            key.clone(),
1162            PathBuf::from("/c.rs"),
1163            vec![diag("/c.rs", 1, "c", DiagnosticSeverity::Warning)],
1164        );
1165        assert_eq!(store.len(), 2);
1166        assert!(!store.has_any_report_for_file(Path::new("/a.rs")));
1167        assert!(!store.by_file.contains_key(Path::new("/a.rs")));
1168        assert!(store.has_any_report_for_file(Path::new("/b.rs")));
1169        assert!(store.has_any_report_for_file(Path::new("/c.rs")));
1170        store.debug_assert_index_consistent();
1171    }
1172
1173    #[test]
1174    fn secondary_index_stays_consistent_through_seeded_mutation_storm() {
1175        fn next_random(seed: &mut u64) -> u64 {
1176            *seed = seed
1177                .wrapping_mul(6_364_136_223_846_793_005)
1178                .wrapping_add(1_442_695_040_888_963_407);
1179            *seed
1180        }
1181
1182        let servers = [
1183            server_key(ServerKind::Rust),
1184            server_key(ServerKind::TypeScript),
1185            server_key(ServerKind::Python),
1186            server_key(ServerKind::Biome),
1187        ];
1188        let files = (0..11)
1189            .map(|index| PathBuf::from(format!("/tmp/index-{index}.rs")))
1190            .collect::<Vec<_>>();
1191        let mut store = DiagnosticsStore::with_capacity(7);
1192        let mut seed = 0x05ee_dd1a_6005_71c5_u64;
1193        let mut operation_counts = [0usize; 7];
1194        let mut stale_hits = 0usize;
1195
1196        for step in 0..1_000 {
1197            let operation = (next_random(&mut seed) % operation_counts.len() as u64) as usize;
1198            operation_counts[operation] += 1;
1199            let server = servers[(next_random(&mut seed) % servers.len() as u64) as usize].clone();
1200            let file = files[(next_random(&mut seed) % files.len() as u64) as usize].clone();
1201
1202            match operation {
1203                0 | 1 => store.publish(
1204                    server,
1205                    file.clone(),
1206                    vec![diag(
1207                        file.to_str().unwrap(),
1208                        step + 1,
1209                        "seeded diagnostic",
1210                        DiagnosticSeverity::Warning,
1211                    )],
1212                ),
1213                2 => {
1214                    let expected = store.mark_stale_for_file_linear_reference(&file);
1215                    let actual = store.mark_stale_for_file(&file);
1216                    assert_eq!(actual, expected);
1217                    stale_hits += usize::from(actual.0);
1218                }
1219                3 => {
1220                    store.clear_for_server_file(&server, &file);
1221                }
1222                4 => {
1223                    store.clear_for_file(&file);
1224                }
1225                5 => {
1226                    store.clear_for_server(&server);
1227                }
1228                6 => {
1229                    store.clear_server(server.kind);
1230                }
1231                _ => unreachable!(),
1232            }
1233
1234            store.debug_assert_index_consistent();
1235            assert!(store.len() <= 7);
1236        }
1237
1238        assert!(operation_counts.into_iter().all(|count| count > 0));
1239        assert!(
1240            stale_hits > 0,
1241            "seeded sequence must stale existing entries"
1242        );
1243    }
1244
1245    #[test]
1246    fn touching_existing_entry_moves_it_to_end_of_lru() {
1247        let mut store = DiagnosticsStore::with_capacity(2);
1248        let key = server_key(ServerKind::Rust);
1249
1250        store.publish(
1251            key.clone(),
1252            PathBuf::from("/a.rs"),
1253            vec![diag("/a.rs", 1, "a", DiagnosticSeverity::Warning)],
1254        );
1255        store.publish(
1256            key.clone(),
1257            PathBuf::from("/b.rs"),
1258            vec![diag("/b.rs", 1, "b", DiagnosticSeverity::Warning)],
1259        );
1260
1261        // Re-publish /a.rs — this should refresh its LRU position so it's
1262        // newer than /b.rs. Inserting /c.rs should now evict /b.rs.
1263        store.publish(
1264            key.clone(),
1265            PathBuf::from("/a.rs"),
1266            vec![diag("/a.rs", 1, "a2", DiagnosticSeverity::Error)],
1267        );
1268        store.publish(
1269            key.clone(),
1270            PathBuf::from("/c.rs"),
1271            vec![diag("/c.rs", 1, "c", DiagnosticSeverity::Warning)],
1272        );
1273
1274        assert!(store.has_any_report_for_file(Path::new("/a.rs")));
1275        assert!(!store.has_any_report_for_file(Path::new("/b.rs")));
1276        assert!(store.has_any_report_for_file(Path::new("/c.rs")));
1277    }
1278
1279    #[test]
1280    fn capacity_zero_disables_eviction() {
1281        let mut store = DiagnosticsStore::with_capacity(0);
1282        let key = server_key(ServerKind::Rust);
1283
1284        for i in 0..50 {
1285            store.publish(
1286                key.clone(),
1287                PathBuf::from(format!("/f{i}.rs")),
1288                vec![diag(
1289                    &format!("/f{i}.rs"),
1290                    1,
1291                    "x",
1292                    DiagnosticSeverity::Warning,
1293                )],
1294            );
1295        }
1296        assert_eq!(store.len(), 50);
1297    }
1298
1299    #[test]
1300    fn set_capacity_evicts_on_shrink() {
1301        let mut store = DiagnosticsStore::with_capacity(0);
1302        let key = server_key(ServerKind::Rust);
1303        for i in 0..10 {
1304            store.publish(
1305                key.clone(),
1306                PathBuf::from(format!("/f{i}.rs")),
1307                vec![diag(
1308                    &format!("/f{i}.rs"),
1309                    1,
1310                    "x",
1311                    DiagnosticSeverity::Warning,
1312                )],
1313            );
1314        }
1315        assert_eq!(store.len(), 10);
1316
1317        store.set_capacity(3);
1318        assert_eq!(store.len(), 3);
1319        // Most recent 3 should remain (/f7.rs, /f8.rs, /f9.rs).
1320        assert!(store.has_any_report_for_file(Path::new("/f9.rs")));
1321        assert!(!store.has_any_report_for_file(Path::new("/f0.rs")));
1322    }
1323
1324    #[test]
1325    fn epoch_increments_monotonically() {
1326        let mut store = DiagnosticsStore::new();
1327        let key = server_key(ServerKind::Rust);
1328        let file = PathBuf::from("/e.rs");
1329
1330        store.publish(key.clone(), file.clone(), Vec::new());
1331        let e1 = store.entries_for_file(&file)[0].1.epoch;
1332
1333        store.publish(key.clone(), file.clone(), Vec::new());
1334        let e2 = store.entries_for_file(&file)[0].1.epoch;
1335
1336        assert!(e2 > e1, "epoch must increase on republish");
1337    }
1338
1339    #[test]
1340    fn result_id_is_round_tripped() {
1341        let mut store = DiagnosticsStore::new();
1342        let key = server_key(ServerKind::Rust);
1343        let file = PathBuf::from("/r.rs");
1344
1345        store.publish_with_result_id(
1346            key.clone(),
1347            file.clone(),
1348            Vec::new(),
1349            Some("rev-42".to_string()),
1350        );
1351
1352        let entries = store.entries_for_file(&file);
1353        assert_eq!(entries[0].1.result_id.as_deref(), Some("rev-42"));
1354    }
1355
1356    #[test]
1357    fn clear_server_drops_all_entries_for_kind() {
1358        let mut store = DiagnosticsStore::new();
1359        let py_key = server_key(ServerKind::Python);
1360        let rust_key = server_key(ServerKind::Rust);
1361
1362        store.publish(
1363            py_key.clone(),
1364            PathBuf::from("/a.py"),
1365            vec![diag("/a.py", 1, "x", DiagnosticSeverity::Error)],
1366        );
1367        store.publish(
1368            rust_key.clone(),
1369            PathBuf::from("/b.rs"),
1370            vec![diag("/b.rs", 1, "y", DiagnosticSeverity::Error)],
1371        );
1372
1373        store.clear_server(ServerKind::Python);
1374        assert!(!store.has_any_report_for_file(Path::new("/a.py")));
1375        assert!(store.has_any_report_for_file(Path::new("/b.rs")));
1376    }
1377
1378    #[test]
1379    fn clear_for_file_drops_every_server_entry_and_updates_counts() {
1380        let mut store = DiagnosticsStore::new();
1381        let py_key = server_key(ServerKind::Python);
1382        let biome_key = server_key(ServerKind::Biome);
1383
1384        // Two servers both report for the SAME deleted file, plus an unrelated
1385        // file that must survive.
1386        store.publish(
1387            py_key,
1388            PathBuf::from("/gone.ts"),
1389            vec![diag("/gone.ts", 4, "type error", DiagnosticSeverity::Error)],
1390        );
1391        store.publish(
1392            biome_key,
1393            PathBuf::from("/gone.ts"),
1394            vec![diag(
1395                "/gone.ts",
1396                7,
1397                "lint warning",
1398                DiagnosticSeverity::Warning,
1399            )],
1400        );
1401        store.publish(
1402            server_key(ServerKind::Rust),
1403            PathBuf::from("/keep.rs"),
1404            vec![diag("/keep.rs", 1, "live error", DiagnosticSeverity::Error)],
1405        );
1406
1407        assert_eq!(store.error_warning_counts(), (2, 1));
1408
1409        // Clearing the deleted file drops both server entries for it.
1410        let removed = store.clear_for_file(Path::new("/gone.ts"));
1411        assert!(removed);
1412        assert!(!store.has_any_report_for_file(Path::new("/gone.ts")));
1413        // The unrelated file's diagnostic is untouched.
1414        assert!(store.has_any_report_for_file(Path::new("/keep.rs")));
1415        assert_eq!(store.error_warning_counts(), (1, 0));
1416
1417        // Clearing again is a no-op (nothing left for that file).
1418        assert!(!store.clear_for_file(Path::new("/gone.ts")));
1419    }
1420
1421    #[test]
1422    fn filtered_counts_apply_keep_predicate() {
1423        let mut store = DiagnosticsStore::new();
1424        store.publish(
1425            server_key(ServerKind::TypeScript),
1426            PathBuf::from("/repo/src/app.ts"),
1427            vec![diag(
1428                "/repo/src/app.ts",
1429                1,
1430                "in build",
1431                DiagnosticSeverity::Error,
1432            )],
1433        );
1434        store.publish(
1435            server_key(ServerKind::TypeScript),
1436            PathBuf::from("/repo/src/app.test.ts"),
1437            vec![diag(
1438                "/repo/src/app.test.ts",
1439                1,
1440                "excluded",
1441                DiagnosticSeverity::Error,
1442            )],
1443        );
1444
1445        // Raw count sees both files.
1446        assert_eq!(store.error_warning_counts(), (2, 0));
1447        // Filtered count drops the build-excluded test file.
1448        let counts = store.filtered_error_warning_counts(|file| !file.ends_with("app.test.ts"));
1449        assert_eq!(counts, (1, 0));
1450    }
1451
1452    #[test]
1453    fn filtered_counts_dedup_across_servers() {
1454        let mut store = DiagnosticsStore::new();
1455        let file = "/repo/src/app.ts";
1456        // Two different servers report the SAME diagnostic (same file/range/
1457        // severity/message/source) for one file — e.g. tsserver + a linter that
1458        // both surface an identical issue. Raw counting double-counts; the
1459        // status-bar count must collapse to one (matching inspect sort_and_dedup).
1460        store.publish(
1461            server_key(ServerKind::TypeScript),
1462            PathBuf::from(file),
1463            vec![diag(file, 7, "dup", DiagnosticSeverity::Error)],
1464        );
1465        store.publish(
1466            server_key(ServerKind::Biome),
1467            PathBuf::from(file),
1468            vec![diag(file, 7, "dup", DiagnosticSeverity::Error)],
1469        );
1470
1471        assert_eq!(store.error_warning_counts(), (2, 0));
1472        assert_eq!(store.filtered_error_warning_counts(|_| true), (1, 0));
1473    }
1474
1475    #[test]
1476    fn filtered_counts_keep_distinct_diagnostics_same_file() {
1477        let mut store = DiagnosticsStore::new();
1478        let file = "/repo/src/app.ts";
1479        // Two servers, genuinely different diagnostics on the same file — both
1480        // must be counted (dedup keys on location+message+source, not file).
1481        store.publish(
1482            server_key(ServerKind::TypeScript),
1483            PathBuf::from(file),
1484            vec![diag(file, 7, "type error", DiagnosticSeverity::Error)],
1485        );
1486        store.publish(
1487            server_key(ServerKind::Biome),
1488            PathBuf::from(file),
1489            vec![diag(file, 12, "lint warn", DiagnosticSeverity::Warning)],
1490        );
1491        assert_eq!(store.filtered_error_warning_counts(|_| true), (1, 1));
1492    }
1493
1494    #[test]
1495    fn filtered_counts_exclude_environmental_diagnostics() {
1496        let mut store = DiagnosticsStore::new();
1497        let file = "/repo/src/app.ts";
1498        store.publish(
1499            server_key(ServerKind::TypeScript),
1500            PathBuf::from(file),
1501            vec![
1502                diag(
1503                    file,
1504                    1,
1505                    "Cannot find name 'foo'.",
1506                    DiagnosticSeverity::Error,
1507                ),
1508                diag(
1509                    file,
1510                    2,
1511                    "Failed to load schema from https://cdn.example/pkg/schema.json",
1512                    DiagnosticSeverity::Error,
1513                ),
1514            ],
1515        );
1516        assert_eq!(store.error_warning_counts(), (2, 0));
1517        assert_eq!(
1518            store.filtered_error_warning_counts(|_| true),
1519            (1, 0),
1520            "environmental schema-fetch must not inflate E count"
1521        );
1522    }
1523
1524    #[test]
1525    fn environmental_flap_does_not_change_filtered_counts() {
1526        let mut store = DiagnosticsStore::new();
1527        let file = "/repo/package.json";
1528        let key = server_key(ServerKind::TypeScript);
1529        let env_msg =
1530            "Failed to fetch schema from https://json.schemastore.org/package.json: network";
1531
1532        assert_eq!(store.filtered_error_warning_counts(|_| true), (0, 0));
1533
1534        store.publish(
1535            key.clone(),
1536            PathBuf::from(file),
1537            vec![diag(file, 1, env_msg, DiagnosticSeverity::Error)],
1538        );
1539        assert_eq!(
1540            store.filtered_error_warning_counts(|_| true),
1541            (0, 0),
1542            "publish environmental diagnostic must not change filtered E/W"
1543        );
1544
1545        store.publish(key, PathBuf::from(file), vec![]);
1546        assert_eq!(
1547            store.filtered_error_warning_counts(|_| true),
1548            (0, 0),
1549            "removing environmental diagnostic must not change filtered E/W"
1550        );
1551    }
1552
1553    #[test]
1554    fn mixed_syntax_and_schema_fetch_counts_one_error() {
1555        let mut store = DiagnosticsStore::new();
1556        let file = "/repo/src/mixed.ts";
1557        store.publish(
1558            server_key(ServerKind::TypeScript),
1559            PathBuf::from(file),
1560            vec![
1561                diag(
1562                    file,
1563                    3,
1564                    "Cannot find name 'bar'.",
1565                    DiagnosticSeverity::Error,
1566                ),
1567                diag(
1568                    file,
1569                    1,
1570                    "Failed to resolve schema https://example.com/x.json",
1571                    DiagnosticSeverity::Error,
1572                ),
1573            ],
1574        );
1575        assert_eq!(
1576            store.filtered_error_warning_counts(|_| true),
1577            (1, 0),
1578            "classifier is per-diagnostic: one real syntax error => E1"
1579        );
1580    }
1581}