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