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    /// Promote the latest provisional report for each file when its server reaches
727    /// quiescence. Each store entry is already the server's latest replacement
728    /// publish, so the settle boundary makes it authoritative without requiring a
729    /// later publish. Independent watcher staleness is preserved.
730    pub fn promote_provisional_for_server(&mut self, key: &ServerKey) -> bool {
731        let mut changed = false;
732        for ((stored_key, _), entry) in &mut self.entries {
733            if stored_key == key && entry.provisional {
734                entry.provisional = false;
735                changed = true;
736            }
737        }
738        if changed {
739            self.generation = self.generation.wrapping_add(1);
740        }
741        self.debug_assert_index_consistent();
742        changed
743    }
744
745    /// Clear the readiness marker after a pull response is received from a
746    /// quiescent server. This is separate from `mark_fresh` because a pull
747    /// response received while warming must remain provisional.
748    pub fn clear_provisional_for_server_file(&mut self, key: &ServerKey, file: &Path) -> bool {
749        let cache_key = (key.clone(), file.to_path_buf());
750        let Some(entry) = self.entries.get_mut(&cache_key) else {
751            return false;
752        };
753        if !entry.provisional {
754            return false;
755        }
756        entry.provisional = false;
757        self.generation = self.generation.wrapping_add(1);
758        true
759    }
760
761    /// Drop all entries for a specific server instance.
762    pub fn clear_for_server(&mut self, key: &ServerKey) {
763        let before = self.entries.len();
764        self.entries.retain(|(k, _), _| k != key);
765        self.order.retain(|(k, _)| k != key);
766        self.last_publish_at_for_file.retain(|(k, _), _| k != key);
767        self.by_file.retain(|_, servers| {
768            servers.remove(key);
769            !servers.is_empty()
770        });
771        if self.entries.len() != before {
772            self.generation = self.generation.wrapping_add(1);
773        }
774        self.debug_assert_index_consistent();
775    }
776
777    /// Backward-compatible alias for tests/callers that already used the
778    /// instance-scoped name.
779    pub fn clear_server_instance(&mut self, key: &ServerKey) {
780        self.clear_for_server(key);
781    }
782
783    /// Remove the least-recently-used entry, returning its key for telemetry.
784    fn evict_lru(&mut self) -> Option<(ServerKey, PathBuf)> {
785        if self.order.is_empty() {
786            return None;
787        }
788        let evicted = self.order.remove(0);
789        self.entries.remove(&evicted);
790        self.unindex_entry(&evicted);
791        self.last_publish_at_for_file.remove(&evicted);
792        self.debug_assert_index_consistent();
793        Some(evicted)
794    }
795
796    fn touch_existing(&mut self, key: &(ServerKey, PathBuf)) {
797        if let Some(idx) = self.order.iter().position(|k| k == key) {
798            let removed = self.order.remove(idx);
799            self.order.push(removed);
800        }
801    }
802
803    fn index_entry(&mut self, (server, file): &(ServerKey, PathBuf)) {
804        self.by_file
805            .entry(file.clone())
806            .or_default()
807            .insert(server.clone());
808    }
809
810    fn unindex_entry(&mut self, (server, file): &(ServerKey, PathBuf)) {
811        let remove_file = self.by_file.get_mut(file).is_some_and(|servers| {
812            servers.remove(server);
813            servers.is_empty()
814        });
815        if remove_file {
816            self.by_file.remove(file);
817        }
818    }
819
820    fn debug_assert_index_consistent(&self) {
821        #[cfg(debug_assertions)]
822        {
823            let indexed_entries = self.by_file.values().map(HashSet::len).sum::<usize>();
824            debug_assert_eq!(indexed_entries, self.entries.len());
825            for (server, file) in self.entries.keys() {
826                debug_assert!(self
827                    .by_file
828                    .get(file)
829                    .is_some_and(|servers| servers.contains(server)));
830            }
831            for (file, servers) in &self.by_file {
832                debug_assert!(!servers.is_empty());
833                for server in servers {
834                    debug_assert!(self.entries.contains_key(&(server.clone(), file.clone())));
835                }
836            }
837        }
838    }
839
840    #[cfg(test)]
841    fn mark_stale_for_file_linear_reference(&self, file: &Path) -> (bool, bool) {
842        let mut had_entries = false;
843        let mut changed = false;
844        for ((_, stored_file), entry) in &self.entries {
845            if stored_file == file {
846                had_entries = true;
847                changed |= !entry.stale;
848            }
849        }
850        (had_entries, changed)
851    }
852}
853
854impl Default for DiagnosticsStore {
855    fn default() -> Self {
856        Self::new()
857    }
858}
859
860/// Convert LSP diagnostics to our stored format.
861/// LSP uses 0-based line/character; we convert to 1-based.
862pub fn from_lsp_diagnostics(
863    file: PathBuf,
864    lsp_diagnostics: Vec<lsp_types::Diagnostic>,
865) -> Vec<StoredDiagnostic> {
866    lsp_diagnostics
867        .into_iter()
868        .map(|diagnostic| StoredDiagnostic {
869            file: file.clone(),
870            line: diagnostic.range.start.line + 1,
871            column: diagnostic.range.start.character + 1,
872            end_line: diagnostic.range.end.line + 1,
873            end_column: diagnostic.range.end.character + 1,
874            severity: match diagnostic.severity {
875                Some(lsp_types::DiagnosticSeverity::ERROR) => DiagnosticSeverity::Error,
876                Some(lsp_types::DiagnosticSeverity::WARNING) => DiagnosticSeverity::Warning,
877                Some(lsp_types::DiagnosticSeverity::INFORMATION) => DiagnosticSeverity::Information,
878                Some(lsp_types::DiagnosticSeverity::HINT) => DiagnosticSeverity::Hint,
879                _ => DiagnosticSeverity::Warning,
880            },
881            message: diagnostic.message,
882            code: diagnostic.code.map(|code| match code {
883                lsp_types::NumberOrString::Number(value) => value.to_string(),
884                lsp_types::NumberOrString::String(value) => value,
885            }),
886            source: diagnostic.source,
887        })
888        .collect()
889}
890
891#[cfg(test)]
892mod tests {
893    use std::path::{Path, PathBuf};
894
895    use lsp_types::{
896        Diagnostic, DiagnosticSeverity as LspDiagnosticSeverity, NumberOrString, Position, Range,
897    };
898
899    use super::{from_lsp_diagnostics, DiagnosticSeverity, DiagnosticsStore, StoredDiagnostic};
900    use crate::lsp::registry::ServerKind;
901    use crate::lsp::roots::ServerKey;
902
903    fn server_key(kind: ServerKind) -> ServerKey {
904        ServerKey {
905            kind,
906            root: PathBuf::from("/tmp/repo"),
907        }
908    }
909
910    fn diag(file: &str, line: u32, msg: &str, sev: DiagnosticSeverity) -> StoredDiagnostic {
911        StoredDiagnostic {
912            file: PathBuf::from(file),
913            line,
914            column: 1,
915            end_line: line,
916            end_column: 2,
917            severity: sev,
918            message: msg.into(),
919            code: None,
920            source: None,
921        }
922    }
923
924    #[test]
925    fn diagnostics_memory_estimate_is_zero_when_empty_and_nonzero_when_populated() {
926        let mut store = DiagnosticsStore::new();
927        assert_eq!(store.estimated_memory().estimated_bytes, Some(0));
928        let file = PathBuf::from("/tmp/memory.rs");
929        store.publish(
930            server_key(ServerKind::Rust),
931            file.clone(),
932            vec![diag(
933                file.to_str().unwrap(),
934                1,
935                "resident diagnostic message",
936                DiagnosticSeverity::Warning,
937            )],
938        );
939        let estimate = store.estimated_memory();
940        assert!(estimate.estimated_bytes.unwrap() > 0);
941        assert_eq!(estimate.counts["diagnostic_entries"], 1);
942        assert_eq!(estimate.counts["diagnostics"], 1);
943    }
944
945    #[test]
946    fn converts_lsp_positions_to_one_based() {
947        let file = PathBuf::from("/tmp/demo.rs");
948        let diagnostics = from_lsp_diagnostics(
949            file.clone(),
950            vec![Diagnostic {
951                range: Range::new(Position::new(0, 0), Position::new(1, 4)),
952                severity: Some(LspDiagnosticSeverity::ERROR),
953                code: Some(NumberOrString::String("E1".into())),
954                code_description: None,
955                source: Some("fake".into()),
956                message: "boom".into(),
957                related_information: None,
958                tags: None,
959                data: None,
960            }],
961        );
962
963        assert_eq!(diagnostics.len(), 1);
964        assert_eq!(diagnostics[0].file, file);
965        assert_eq!(diagnostics[0].line, 1);
966        assert_eq!(diagnostics[0].column, 1);
967        assert_eq!(diagnostics[0].end_line, 2);
968        assert_eq!(diagnostics[0].end_column, 5);
969        assert_eq!(diagnostics[0].severity, DiagnosticSeverity::Error);
970        assert_eq!(diagnostics[0].code.as_deref(), Some("E1"));
971    }
972
973    #[test]
974    fn publish_replaces_existing_file_diagnostics() {
975        let file = PathBuf::from("/tmp/demo.rs");
976        let mut store = DiagnosticsStore::new();
977        let key = server_key(ServerKind::Rust);
978
979        store.publish(
980            key.clone(),
981            file.clone(),
982            vec![diag(
983                "/tmp/demo.rs",
984                1,
985                "first",
986                DiagnosticSeverity::Warning,
987            )],
988        );
989        store.publish(
990            key.clone(),
991            file.clone(),
992            vec![diag("/tmp/demo.rs", 2, "second", DiagnosticSeverity::Error)],
993        );
994
995        let stored = store.for_file(&file);
996        assert_eq!(stored.len(), 1);
997        assert_eq!(stored[0].message, "second");
998    }
999
1000    #[test]
1001    fn empty_publish_is_preserved_as_checked_clean() {
1002        // The whole point of the v0.16 audit fix: empty publish ≠ deletion.
1003        // Agents need to be able to ask "has this file been checked yet?"
1004        // and get a truthful answer.
1005        let file = PathBuf::from("/tmp/clean.rs");
1006        let mut store = DiagnosticsStore::new();
1007        let key = server_key(ServerKind::Rust);
1008
1009        // First publish has an issue.
1010        store.publish(
1011            key.clone(),
1012            file.clone(),
1013            vec![diag(
1014                "/tmp/clean.rs",
1015                5,
1016                "fix me",
1017                DiagnosticSeverity::Warning,
1018            )],
1019        );
1020        assert!(store.has_any_report_for_file(&file));
1021        assert_eq!(store.for_file(&file).len(), 1);
1022
1023        // Second publish is empty (the fix worked). Entry is preserved as
1024        // "checked clean" rather than deleted.
1025        store.publish(key.clone(), file.clone(), Vec::new());
1026        assert!(
1027            store.has_any_report_for_file(&file),
1028            "checked-clean must be distinguishable from never-checked"
1029        );
1030        assert_eq!(store.for_file(&file).len(), 0);
1031
1032        let entries = store.entries_for_file(&file);
1033        assert_eq!(entries.len(), 1);
1034        assert!(entries[0].1.epoch > 0);
1035    }
1036
1037    #[test]
1038    fn never_checked_returns_no_report() {
1039        let store = DiagnosticsStore::new();
1040        let file = PathBuf::from("/tmp/never.rs");
1041        assert!(!store.has_any_report_for_file(&file));
1042        assert!(store.for_file(&file).is_empty());
1043    }
1044
1045    #[test]
1046    fn stale_entries_are_hidden_but_preserved_for_refresh() {
1047        let file = PathBuf::from("/tmp/stale.rs");
1048        let mut store = DiagnosticsStore::new();
1049        let key = server_key(ServerKind::Rust);
1050        store.publish(
1051            key.clone(),
1052            file.clone(),
1053            vec![diag("/tmp/stale.rs", 1, "old", DiagnosticSeverity::Error)],
1054        );
1055
1056        let (had_entries, changed) = store.mark_stale_for_file(&file);
1057
1058        assert!(had_entries);
1059        assert!(changed);
1060        assert!(store.has_any_report_for_file(&file));
1061        assert!(!store.has_any_fresh_report_for_file(&file));
1062        assert!(store.for_file(&file).is_empty());
1063        assert!(store.all().is_empty());
1064        assert_eq!(store.error_warning_counts(), (0, 0));
1065        assert_eq!(store.entries_for_file(&file).len(), 1);
1066
1067        assert!(store.mark_fresh_for_server_file(&key, &file));
1068        assert!(store.has_any_fresh_report_for_file(&file));
1069        assert_eq!(store.for_file(&file).len(), 1);
1070        assert_eq!(store.error_warning_counts(), (1, 0));
1071    }
1072
1073    #[test]
1074    fn per_server_state_is_tracked_independently() {
1075        let file = PathBuf::from("/tmp/multi.py");
1076        let mut store = DiagnosticsStore::new();
1077        let pyright_key = server_key(ServerKind::Python);
1078        let ty_key = server_key(ServerKind::Ty);
1079
1080        store.publish(
1081            pyright_key,
1082            file.clone(),
1083            vec![diag(
1084                "/tmp/multi.py",
1085                1,
1086                "pyright says X",
1087                DiagnosticSeverity::Error,
1088            )],
1089        );
1090        store.publish(
1091            ty_key,
1092            file.clone(),
1093            vec![diag(
1094                "/tmp/multi.py",
1095                2,
1096                "ty says Y",
1097                DiagnosticSeverity::Warning,
1098            )],
1099        );
1100
1101        let messages: Vec<&str> = store
1102            .for_file(&file)
1103            .into_iter()
1104            .map(|d| d.message.as_str())
1105            .collect();
1106
1107        assert_eq!(messages.len(), 2, "both servers' reports preserved");
1108        assert!(messages.iter().any(|m| m == &"pyright says X"));
1109        assert!(messages.iter().any(|m| m == &"ty says Y"));
1110    }
1111
1112    #[test]
1113    fn clear_for_server_file_removes_only_exact_entry() {
1114        let file_a = PathBuf::from("/tmp/a.rs");
1115        let file_b = PathBuf::from("/tmp/b.rs");
1116        let mut store = DiagnosticsStore::new();
1117        let rust_key = server_key(ServerKind::Rust);
1118        let py_key = server_key(ServerKind::Python);
1119
1120        store.publish(
1121            rust_key.clone(),
1122            file_a.clone(),
1123            vec![diag("/tmp/a.rs", 1, "rust a", DiagnosticSeverity::Error)],
1124        );
1125        store.publish(
1126            rust_key.clone(),
1127            file_b.clone(),
1128            vec![diag("/tmp/b.rs", 1, "rust b", DiagnosticSeverity::Warning)],
1129        );
1130        store.publish(
1131            py_key.clone(),
1132            file_a.clone(),
1133            vec![diag("/tmp/a.rs", 2, "py a", DiagnosticSeverity::Warning)],
1134        );
1135
1136        store.clear_for_server_file(&rust_key, &file_a);
1137
1138        assert!(!store.has_report_for_server_file(&rust_key, &file_a));
1139        assert!(store.has_report_for_server_file(&rust_key, &file_b));
1140        assert!(store.has_report_for_server_file(&py_key, &file_a));
1141    }
1142
1143    #[test]
1144    fn lru_evicts_oldest_when_capacity_exceeded() {
1145        let mut store = DiagnosticsStore::with_capacity(2);
1146        let key = server_key(ServerKind::Rust);
1147
1148        store.publish(
1149            key.clone(),
1150            PathBuf::from("/a.rs"),
1151            vec![diag("/a.rs", 1, "a", DiagnosticSeverity::Warning)],
1152        );
1153        store.publish(
1154            key.clone(),
1155            PathBuf::from("/b.rs"),
1156            vec![diag("/b.rs", 1, "b", DiagnosticSeverity::Warning)],
1157        );
1158        assert_eq!(store.len(), 2);
1159
1160        // Inserting a third entry should evict /a.rs (oldest).
1161        store.publish(
1162            key.clone(),
1163            PathBuf::from("/c.rs"),
1164            vec![diag("/c.rs", 1, "c", DiagnosticSeverity::Warning)],
1165        );
1166        assert_eq!(store.len(), 2);
1167        assert!(!store.has_any_report_for_file(Path::new("/a.rs")));
1168        assert!(!store.by_file.contains_key(Path::new("/a.rs")));
1169        assert!(store.has_any_report_for_file(Path::new("/b.rs")));
1170        assert!(store.has_any_report_for_file(Path::new("/c.rs")));
1171        store.debug_assert_index_consistent();
1172    }
1173
1174    #[test]
1175    fn secondary_index_stays_consistent_through_seeded_mutation_storm() {
1176        fn next_random(seed: &mut u64) -> u64 {
1177            *seed = seed
1178                .wrapping_mul(6_364_136_223_846_793_005)
1179                .wrapping_add(1_442_695_040_888_963_407);
1180            *seed
1181        }
1182
1183        let servers = [
1184            server_key(ServerKind::Rust),
1185            server_key(ServerKind::TypeScript),
1186            server_key(ServerKind::Python),
1187            server_key(ServerKind::Biome),
1188        ];
1189        let files = (0..11)
1190            .map(|index| PathBuf::from(format!("/tmp/index-{index}.rs")))
1191            .collect::<Vec<_>>();
1192        let mut store = DiagnosticsStore::with_capacity(7);
1193        let mut seed = 0x05ee_dd1a_6005_71c5_u64;
1194        let mut operation_counts = [0usize; 7];
1195        let mut stale_hits = 0usize;
1196
1197        for step in 0..1_000 {
1198            let operation = (next_random(&mut seed) % operation_counts.len() as u64) as usize;
1199            operation_counts[operation] += 1;
1200            let server = servers[(next_random(&mut seed) % servers.len() as u64) as usize].clone();
1201            let file = files[(next_random(&mut seed) % files.len() as u64) as usize].clone();
1202
1203            match operation {
1204                0 | 1 => store.publish(
1205                    server,
1206                    file.clone(),
1207                    vec![diag(
1208                        file.to_str().unwrap(),
1209                        step + 1,
1210                        "seeded diagnostic",
1211                        DiagnosticSeverity::Warning,
1212                    )],
1213                ),
1214                2 => {
1215                    let expected = store.mark_stale_for_file_linear_reference(&file);
1216                    let actual = store.mark_stale_for_file(&file);
1217                    assert_eq!(actual, expected);
1218                    stale_hits += usize::from(actual.0);
1219                }
1220                3 => {
1221                    store.clear_for_server_file(&server, &file);
1222                }
1223                4 => {
1224                    store.clear_for_file(&file);
1225                }
1226                5 => {
1227                    store.clear_for_server(&server);
1228                }
1229                6 => {
1230                    store.clear_server(server.kind);
1231                }
1232                _ => unreachable!(),
1233            }
1234
1235            store.debug_assert_index_consistent();
1236            assert!(store.len() <= 7);
1237        }
1238
1239        assert!(operation_counts.into_iter().all(|count| count > 0));
1240        assert!(
1241            stale_hits > 0,
1242            "seeded sequence must stale existing entries"
1243        );
1244    }
1245
1246    #[test]
1247    fn touching_existing_entry_moves_it_to_end_of_lru() {
1248        let mut store = DiagnosticsStore::with_capacity(2);
1249        let key = server_key(ServerKind::Rust);
1250
1251        store.publish(
1252            key.clone(),
1253            PathBuf::from("/a.rs"),
1254            vec![diag("/a.rs", 1, "a", DiagnosticSeverity::Warning)],
1255        );
1256        store.publish(
1257            key.clone(),
1258            PathBuf::from("/b.rs"),
1259            vec![diag("/b.rs", 1, "b", DiagnosticSeverity::Warning)],
1260        );
1261
1262        // Re-publish /a.rs — this should refresh its LRU position so it's
1263        // newer than /b.rs. Inserting /c.rs should now evict /b.rs.
1264        store.publish(
1265            key.clone(),
1266            PathBuf::from("/a.rs"),
1267            vec![diag("/a.rs", 1, "a2", DiagnosticSeverity::Error)],
1268        );
1269        store.publish(
1270            key.clone(),
1271            PathBuf::from("/c.rs"),
1272            vec![diag("/c.rs", 1, "c", DiagnosticSeverity::Warning)],
1273        );
1274
1275        assert!(store.has_any_report_for_file(Path::new("/a.rs")));
1276        assert!(!store.has_any_report_for_file(Path::new("/b.rs")));
1277        assert!(store.has_any_report_for_file(Path::new("/c.rs")));
1278    }
1279
1280    #[test]
1281    fn capacity_zero_disables_eviction() {
1282        let mut store = DiagnosticsStore::with_capacity(0);
1283        let key = server_key(ServerKind::Rust);
1284
1285        for i in 0..50 {
1286            store.publish(
1287                key.clone(),
1288                PathBuf::from(format!("/f{i}.rs")),
1289                vec![diag(
1290                    &format!("/f{i}.rs"),
1291                    1,
1292                    "x",
1293                    DiagnosticSeverity::Warning,
1294                )],
1295            );
1296        }
1297        assert_eq!(store.len(), 50);
1298    }
1299
1300    #[test]
1301    fn set_capacity_evicts_on_shrink() {
1302        let mut store = DiagnosticsStore::with_capacity(0);
1303        let key = server_key(ServerKind::Rust);
1304        for i in 0..10 {
1305            store.publish(
1306                key.clone(),
1307                PathBuf::from(format!("/f{i}.rs")),
1308                vec![diag(
1309                    &format!("/f{i}.rs"),
1310                    1,
1311                    "x",
1312                    DiagnosticSeverity::Warning,
1313                )],
1314            );
1315        }
1316        assert_eq!(store.len(), 10);
1317
1318        store.set_capacity(3);
1319        assert_eq!(store.len(), 3);
1320        // Most recent 3 should remain (/f7.rs, /f8.rs, /f9.rs).
1321        assert!(store.has_any_report_for_file(Path::new("/f9.rs")));
1322        assert!(!store.has_any_report_for_file(Path::new("/f0.rs")));
1323    }
1324
1325    #[test]
1326    fn epoch_increments_monotonically() {
1327        let mut store = DiagnosticsStore::new();
1328        let key = server_key(ServerKind::Rust);
1329        let file = PathBuf::from("/e.rs");
1330
1331        store.publish(key.clone(), file.clone(), Vec::new());
1332        let e1 = store.entries_for_file(&file)[0].1.epoch;
1333
1334        store.publish(key.clone(), file.clone(), Vec::new());
1335        let e2 = store.entries_for_file(&file)[0].1.epoch;
1336
1337        assert!(e2 > e1, "epoch must increase on republish");
1338    }
1339
1340    #[test]
1341    fn result_id_is_round_tripped() {
1342        let mut store = DiagnosticsStore::new();
1343        let key = server_key(ServerKind::Rust);
1344        let file = PathBuf::from("/r.rs");
1345
1346        store.publish_with_result_id(
1347            key.clone(),
1348            file.clone(),
1349            Vec::new(),
1350            Some("rev-42".to_string()),
1351        );
1352
1353        let entries = store.entries_for_file(&file);
1354        assert_eq!(entries[0].1.result_id.as_deref(), Some("rev-42"));
1355    }
1356
1357    #[test]
1358    fn clear_server_drops_all_entries_for_kind() {
1359        let mut store = DiagnosticsStore::new();
1360        let py_key = server_key(ServerKind::Python);
1361        let rust_key = server_key(ServerKind::Rust);
1362
1363        store.publish(
1364            py_key.clone(),
1365            PathBuf::from("/a.py"),
1366            vec![diag("/a.py", 1, "x", DiagnosticSeverity::Error)],
1367        );
1368        store.publish(
1369            rust_key.clone(),
1370            PathBuf::from("/b.rs"),
1371            vec![diag("/b.rs", 1, "y", DiagnosticSeverity::Error)],
1372        );
1373
1374        store.clear_server(ServerKind::Python);
1375        assert!(!store.has_any_report_for_file(Path::new("/a.py")));
1376        assert!(store.has_any_report_for_file(Path::new("/b.rs")));
1377    }
1378
1379    #[test]
1380    fn clear_for_file_drops_every_server_entry_and_updates_counts() {
1381        let mut store = DiagnosticsStore::new();
1382        let py_key = server_key(ServerKind::Python);
1383        let biome_key = server_key(ServerKind::Biome);
1384
1385        // Two servers both report for the SAME deleted file, plus an unrelated
1386        // file that must survive.
1387        store.publish(
1388            py_key,
1389            PathBuf::from("/gone.ts"),
1390            vec![diag("/gone.ts", 4, "type error", DiagnosticSeverity::Error)],
1391        );
1392        store.publish(
1393            biome_key,
1394            PathBuf::from("/gone.ts"),
1395            vec![diag(
1396                "/gone.ts",
1397                7,
1398                "lint warning",
1399                DiagnosticSeverity::Warning,
1400            )],
1401        );
1402        store.publish(
1403            server_key(ServerKind::Rust),
1404            PathBuf::from("/keep.rs"),
1405            vec![diag("/keep.rs", 1, "live error", DiagnosticSeverity::Error)],
1406        );
1407
1408        assert_eq!(store.error_warning_counts(), (2, 1));
1409
1410        // Clearing the deleted file drops both server entries for it.
1411        let removed = store.clear_for_file(Path::new("/gone.ts"));
1412        assert!(removed);
1413        assert!(!store.has_any_report_for_file(Path::new("/gone.ts")));
1414        // The unrelated file's diagnostic is untouched.
1415        assert!(store.has_any_report_for_file(Path::new("/keep.rs")));
1416        assert_eq!(store.error_warning_counts(), (1, 0));
1417
1418        // Clearing again is a no-op (nothing left for that file).
1419        assert!(!store.clear_for_file(Path::new("/gone.ts")));
1420    }
1421
1422    #[test]
1423    fn filtered_counts_apply_keep_predicate() {
1424        let mut store = DiagnosticsStore::new();
1425        store.publish(
1426            server_key(ServerKind::TypeScript),
1427            PathBuf::from("/repo/src/app.ts"),
1428            vec![diag(
1429                "/repo/src/app.ts",
1430                1,
1431                "in build",
1432                DiagnosticSeverity::Error,
1433            )],
1434        );
1435        store.publish(
1436            server_key(ServerKind::TypeScript),
1437            PathBuf::from("/repo/src/app.test.ts"),
1438            vec![diag(
1439                "/repo/src/app.test.ts",
1440                1,
1441                "excluded",
1442                DiagnosticSeverity::Error,
1443            )],
1444        );
1445
1446        // Raw count sees both files.
1447        assert_eq!(store.error_warning_counts(), (2, 0));
1448        // Filtered count drops the build-excluded test file.
1449        let counts = store.filtered_error_warning_counts(|file| !file.ends_with("app.test.ts"));
1450        assert_eq!(counts, (1, 0));
1451    }
1452
1453    #[test]
1454    fn filtered_counts_dedup_across_servers() {
1455        let mut store = DiagnosticsStore::new();
1456        let file = "/repo/src/app.ts";
1457        // Two different servers report the SAME diagnostic (same file/range/
1458        // severity/message/source) for one file — e.g. tsserver + a linter that
1459        // both surface an identical issue. Raw counting double-counts; the
1460        // status-bar count must collapse to one (matching inspect sort_and_dedup).
1461        store.publish(
1462            server_key(ServerKind::TypeScript),
1463            PathBuf::from(file),
1464            vec![diag(file, 7, "dup", DiagnosticSeverity::Error)],
1465        );
1466        store.publish(
1467            server_key(ServerKind::Biome),
1468            PathBuf::from(file),
1469            vec![diag(file, 7, "dup", DiagnosticSeverity::Error)],
1470        );
1471
1472        assert_eq!(store.error_warning_counts(), (2, 0));
1473        assert_eq!(store.filtered_error_warning_counts(|_| true), (1, 0));
1474    }
1475
1476    #[test]
1477    fn filtered_counts_keep_distinct_diagnostics_same_file() {
1478        let mut store = DiagnosticsStore::new();
1479        let file = "/repo/src/app.ts";
1480        // Two servers, genuinely different diagnostics on the same file — both
1481        // must be counted (dedup keys on location+message+source, not file).
1482        store.publish(
1483            server_key(ServerKind::TypeScript),
1484            PathBuf::from(file),
1485            vec![diag(file, 7, "type error", DiagnosticSeverity::Error)],
1486        );
1487        store.publish(
1488            server_key(ServerKind::Biome),
1489            PathBuf::from(file),
1490            vec![diag(file, 12, "lint warn", DiagnosticSeverity::Warning)],
1491        );
1492        assert_eq!(store.filtered_error_warning_counts(|_| true), (1, 1));
1493    }
1494
1495    #[test]
1496    fn filtered_counts_exclude_environmental_diagnostics() {
1497        let mut store = DiagnosticsStore::new();
1498        let file = "/repo/src/app.ts";
1499        store.publish(
1500            server_key(ServerKind::TypeScript),
1501            PathBuf::from(file),
1502            vec![
1503                diag(
1504                    file,
1505                    1,
1506                    "Cannot find name 'foo'.",
1507                    DiagnosticSeverity::Error,
1508                ),
1509                diag(
1510                    file,
1511                    2,
1512                    "Failed to load schema from https://cdn.example/pkg/schema.json",
1513                    DiagnosticSeverity::Error,
1514                ),
1515            ],
1516        );
1517        assert_eq!(store.error_warning_counts(), (2, 0));
1518        assert_eq!(
1519            store.filtered_error_warning_counts(|_| true),
1520            (1, 0),
1521            "environmental schema-fetch must not inflate E count"
1522        );
1523    }
1524
1525    #[test]
1526    fn environmental_flap_does_not_change_filtered_counts() {
1527        let mut store = DiagnosticsStore::new();
1528        let file = "/repo/package.json";
1529        let key = server_key(ServerKind::TypeScript);
1530        let env_msg =
1531            "Failed to fetch schema from https://json.schemastore.org/package.json: network";
1532
1533        assert_eq!(store.filtered_error_warning_counts(|_| true), (0, 0));
1534
1535        store.publish(
1536            key.clone(),
1537            PathBuf::from(file),
1538            vec![diag(file, 1, env_msg, DiagnosticSeverity::Error)],
1539        );
1540        assert_eq!(
1541            store.filtered_error_warning_counts(|_| true),
1542            (0, 0),
1543            "publish environmental diagnostic must not change filtered E/W"
1544        );
1545
1546        store.publish(key, PathBuf::from(file), vec![]);
1547        assert_eq!(
1548            store.filtered_error_warning_counts(|_| true),
1549            (0, 0),
1550            "removing environmental diagnostic must not change filtered E/W"
1551        );
1552    }
1553
1554    #[test]
1555    fn mixed_syntax_and_schema_fetch_counts_one_error() {
1556        let mut store = DiagnosticsStore::new();
1557        let file = "/repo/src/mixed.ts";
1558        store.publish(
1559            server_key(ServerKind::TypeScript),
1560            PathBuf::from(file),
1561            vec![
1562                diag(
1563                    file,
1564                    3,
1565                    "Cannot find name 'bar'.",
1566                    DiagnosticSeverity::Error,
1567                ),
1568                diag(
1569                    file,
1570                    1,
1571                    "Failed to resolve schema https://example.com/x.json",
1572                    DiagnosticSeverity::Error,
1573                ),
1574            ],
1575        );
1576        assert_eq!(
1577            store.filtered_error_warning_counts(|_| true),
1578            (1, 0),
1579            "classifier is per-diagnostic: one real syntax error => E1"
1580        );
1581    }
1582}