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 any server has an authoritative report for this file: an entry
434    /// that is neither watcher-stale nor warming-provisional. Empty
435    /// checked-clean reports count — they still prove a producer analyzed the
436    /// file. Callers must not treat "no authoritative report" as "clean"; it
437    /// only ever means the analysis evidence is missing or not yet settled.
438    pub fn has_authoritative_report_for_file(&self, file: &Path) -> bool {
439        let Some(servers) = self.by_file.get(file) else {
440            return false;
441        };
442        let file = file.to_path_buf();
443        servers.iter().any(|server| {
444            self.entries
445                .get(&(server.clone(), file.clone()))
446                .is_some_and(|entry| !entry.stale && !entry.provisional)
447        })
448    }
449
450    /// True if this server holds any authoritative report: an entry that is
451    /// neither watcher-stale nor warming-provisional. Empty checked-clean
452    /// reports count — they still prove the producer analyzed a document.
453    pub fn has_authoritative_report_for_server(&self, server: &ServerKey) -> bool {
454        self.entries
455            .iter()
456            .any(|((key, _), entry)| key == server && !entry.stale && !entry.provisional)
457    }
458
459    /// True if this exact server instance has an entry (fresh or stale) for
460    /// this exact file. Pull diagnostics use stale entries as the previous
461    /// resultId cache when asking the server whether diagnostics are unchanged.
462    pub fn has_report_for_server_file(&self, server: &ServerKey, file: &Path) -> bool {
463        self.entries
464            .contains_key(&(server.clone(), file.to_path_buf()))
465    }
466
467    /// True if this exact server instance has a non-stale report for this file.
468    pub fn has_fresh_report_for_server_file(&self, server: &ServerKey, file: &Path) -> bool {
469        self.entries
470            .get(&(server.clone(), file.to_path_buf()))
471            .is_some_and(|entry| !entry.stale)
472    }
473
474    /// True if this exact server instance published/replaced diagnostics for
475    /// this exact file after `since`. This is intentionally per `(kind, root,
476    /// file)`; a publish for another file must not prove freshness here.
477    pub fn has_publish_for_file_after(
478        &self,
479        server: &ServerKey,
480        file: &Path,
481        since: Instant,
482    ) -> bool {
483        self.last_publish_at_for_file
484            .get(&(server.clone(), file.to_path_buf()))
485            .is_some_and(|published_at| {
486                *published_at >= since && self.has_fresh_report_for_server_file(server, file)
487            })
488    }
489
490    /// Get current diagnostics for files under a directory.
491    pub fn for_directory(&self, dir: &Path) -> Vec<&StoredDiagnostic> {
492        self.entries
493            .iter()
494            .filter(|((_, stored_file), entry)| stored_file.starts_with(dir) && !entry.stale)
495            .flat_map(|(_, entry)| entry.diagnostics.iter())
496            .collect()
497    }
498
499    /// Current diagnostics under a directory with the entry-level readiness
500    /// marker preserved for inspect's provisional framing.
501    pub fn for_directory_with_provisional(&self, dir: &Path) -> Vec<(&StoredDiagnostic, bool)> {
502        self.entries
503            .iter()
504            .filter(|((_, stored_file), entry)| stored_file.starts_with(dir) && !entry.stale)
505            .flat_map(|(_, entry)| {
506                entry
507                    .diagnostics
508                    .iter()
509                    .map(|diagnostic| (diagnostic, entry.provisional))
510            })
511            .collect()
512    }
513
514    /// All current diagnostics, flattened. Watcher-stale entries are hidden.
515    pub fn all(&self) -> Vec<&StoredDiagnostic> {
516        self.entries
517            .values()
518            .filter(|entry| !entry.stale)
519            .flat_map(|entry| entry.diagnostics.iter())
520            .collect()
521    }
522
523    /// All current diagnostics with the entry-level readiness marker.
524    pub fn all_with_provisional(&self) -> Vec<(&StoredDiagnostic, bool)> {
525        self.entries
526            .values()
527            .filter(|entry| !entry.stale)
528            .flat_map(|entry| {
529                entry
530                    .diagnostics
531                    .iter()
532                    .map(|diagnostic| (diagnostic, entry.provisional))
533            })
534            .collect()
535    }
536
537    /// Count of errors and warnings across the entire warm set (every file any
538    /// server has published for). Allocation-free — the raw, unfiltered union.
539    /// Callers that want the agent-status-bar semantics (project-root scoped,
540    /// tsconfig-membership filtered, cross-server deduped) should use
541    /// [`filtered_error_warning_counts`](Self::filtered_error_warning_counts).
542    pub fn error_warning_counts(&self) -> (usize, usize) {
543        self.error_warning_counts_with_provisional().0
544    }
545
546    /// Raw warm-set counts plus whether any current entry is provisional.
547    pub fn error_warning_counts_with_provisional(&self) -> ((usize, usize), bool) {
548        let mut errors = 0usize;
549        let mut warnings = 0usize;
550        let mut has_provisional = false;
551        for entry in self.entries.values() {
552            if entry.provisional {
553                has_provisional = true;
554            }
555            if entry.stale || entry.provisional {
556                continue;
557            }
558            for diagnostic in &entry.diagnostics {
559                match diagnostic.severity {
560                    DiagnosticSeverity::Error => errors += 1,
561                    DiagnosticSeverity::Warning => warnings += 1,
562                    _ => {}
563                }
564            }
565        }
566        ((errors, warnings), has_provisional)
567    }
568
569    /// Error/warning counts after applying a per-file `keep` predicate,
570    /// excluding environmental/setup diagnostics (see `environmental.rs`),
571    /// and de-duplicating diagnostics that multiple servers reported for the
572    /// same location. This matches `aft_inspect`'s warm semantics
573    /// (`inspect/diagnostics_category.rs`: project-root filter +
574    /// tsconfig-membership skip + environmental filter + `sort_and_dedup`) so
575    /// the agent status bar's E/W agree with `aft_inspect`/`tsc` instead of
576    /// counting build-excluded files and double-counting multi-server overlaps.
577    ///
578    /// The store itself holds no tsconfig/project policy — the caller encodes
579    /// it in `keep` (see `LspManager::filtered_error_warning_counts`). `keep`
580    /// is `FnMut` because the membership cache resolves lazily.
581    pub fn filtered_error_warning_counts(&self, keep: impl FnMut(&Path) -> bool) -> (usize, usize) {
582        self.filtered_error_warning_counts_with_provisional(keep).0
583    }
584
585    /// Return status-bar counts plus whether a kept entry is still provisional.
586    /// The boolean lets the context retain the previous authoritative E/W values
587    /// instead of replacing them with zero while an analyzer warms up.
588    pub fn filtered_error_warning_counts_with_provisional(
589        &self,
590        mut keep: impl FnMut(&Path) -> bool,
591    ) -> ((usize, usize), bool) {
592        // Dedup key mirrors `sort_and_dedup` in inspect/diagnostics_category.rs
593        // exactly (file, range, severity, message, source) so the bar and
594        // inspect collapse the same multi-server overlaps.
595        let mut seen: std::collections::HashSet<(
596            &Path,
597            u32,
598            u32,
599            u32,
600            u32,
601            &str,
602            &str,
603            Option<&str>,
604        )> = std::collections::HashSet::new();
605        let mut errors = 0usize;
606        let mut warnings = 0usize;
607        let mut has_provisional = false;
608        for ((_, file), entry) in &self.entries {
609            if entry.provisional && keep(file) {
610                has_provisional = true;
611            }
612            if entry.stale || entry.provisional {
613                continue;
614            }
615            // All diagnostics in an entry share the entry's file, so the keep
616            // predicate (the cost center: tsconfig resolution) runs once per
617            // (server, file) entry, not once per diagnostic.
618            if !keep(file) {
619                continue;
620            }
621            for diagnostic in &entry.diagnostics {
622                if crate::lsp::environmental::is_environmental_diagnostic(diagnostic) {
623                    continue;
624                }
625                let dedup_key = (
626                    diagnostic.file.as_path(),
627                    diagnostic.line,
628                    diagnostic.column,
629                    diagnostic.end_line,
630                    diagnostic.end_column,
631                    diagnostic.severity.as_str(),
632                    diagnostic.message.as_str(),
633                    diagnostic.source.as_deref(),
634                );
635                if !seen.insert(dedup_key) {
636                    continue;
637                }
638                match diagnostic.severity {
639                    DiagnosticSeverity::Error => errors += 1,
640                    DiagnosticSeverity::Warning => warnings += 1,
641                    _ => {}
642                }
643            }
644        }
645        ((errors, warnings), has_provisional)
646    }
647
648    /// Drop all entries for a server kind (e.g., on server crash/restart).
649    /// Prefer `clear_for_server` for real manager cleanup so peer roots of the
650    /// same kind are not wiped.
651    pub fn clear_server(&mut self, server: ServerKind) {
652        let before = self.entries.len();
653        self.entries
654            .retain(|(stored_key, _), _| stored_key.kind != server);
655        self.order
656            .retain(|(stored_key, _)| stored_key.kind != server);
657        self.last_publish_at_for_file
658            .retain(|(stored_key, _), _| stored_key.kind != server);
659        self.by_file.retain(|_, servers| {
660            servers.retain(|stored_key| stored_key.kind != server);
661            !servers.is_empty()
662        });
663        if self.entries.len() != before {
664            self.generation = self.generation.wrapping_add(1);
665        }
666        self.debug_assert_index_consistent();
667    }
668
669    /// Drop one cached report for a specific server/file pair.
670    pub fn clear_for_server_file(&mut self, key: &ServerKey, file: &Path) {
671        let cache_key = (key.clone(), file.to_path_buf());
672        if self.entries.remove(&cache_key).is_some() {
673            self.unindex_entry(&cache_key);
674            self.generation = self.generation.wrapping_add(1);
675        }
676        self.order.retain(|entry_key| entry_key != &cache_key);
677        self.last_publish_at_for_file.remove(&cache_key);
678        self.debug_assert_index_consistent();
679    }
680
681    /// Drop every cached report for a file across all servers. Used when a file
682    /// is deleted/renamed away — its diagnostics would otherwise linger in the
683    /// warm set forever (no server republishes for a path that no longer
684    /// exists), inflating the error/warning counts surfaced in the status bar
685    /// and `aft_inspect`. Returns true if any entry was removed.
686    pub fn clear_for_file(&mut self, file: &Path) -> bool {
687        let Some(servers) = self.by_file.remove(file) else {
688            self.debug_assert_index_consistent();
689            return false;
690        };
691        let mut removed = false;
692        for server in servers {
693            let cache_key = (server, file.to_path_buf());
694            removed |= self.entries.remove(&cache_key).is_some();
695            self.last_publish_at_for_file.remove(&cache_key);
696        }
697        if removed {
698            self.generation = self.generation.wrapping_add(1);
699            self.order.retain(|(_, stored_file)| stored_file != file);
700        }
701        self.debug_assert_index_consistent();
702        removed
703    }
704
705    /// Mark every cached report for a file stale without evicting it.
706    ///
707    /// This is used for watcher-observed external edits: the previous
708    /// diagnostics may still be useful as a pull `previousResultId`, but warm
709    /// readers must stop counting them until a server publish or pull response
710    /// proves freshness. Returns `(had_entries, changed)` where `changed` is true
711    /// only if at least one previously-fresh entry became stale.
712    pub fn mark_stale_for_file(&mut self, file: &Path) -> (bool, bool) {
713        let Some(servers) = self.by_file.get(file) else {
714            return (false, false);
715        };
716        let had_entries = !servers.is_empty();
717        let mut changed = false;
718        for server in servers {
719            let cache_key = (server.clone(), file.to_path_buf());
720            if let Some(entry) = self
721                .entries
722                .get_mut(&cache_key)
723                .filter(|entry| !entry.stale)
724            {
725                entry.stale = true;
726                changed = true;
727            }
728        }
729        if changed {
730            self.generation = self.generation.wrapping_add(1);
731        }
732        self.debug_assert_index_consistent();
733        (had_entries, changed)
734    }
735
736    /// Mark one cached report fresh after a server response proves it still
737    /// describes the current document (for example a pull `kind: unchanged`).
738    pub fn mark_fresh_for_server_file(&mut self, key: &ServerKey, file: &Path) -> bool {
739        let cache_key = (key.clone(), file.to_path_buf());
740        let Some(entry) = self.entries.get_mut(&cache_key) else {
741            return false;
742        };
743        let changed = entry.stale;
744        entry.stale = false;
745        if changed {
746            self.generation = self.generation.wrapping_add(1);
747        }
748        self.touch_existing(&cache_key);
749        changed
750    }
751
752    /// Promote the latest provisional report for each file when its server reaches
753    /// quiescence. Each store entry is already the server's latest replacement
754    /// publish, so the settle boundary makes it authoritative without requiring a
755    /// later publish. Independent watcher staleness is preserved.
756    pub fn promote_provisional_for_server(&mut self, key: &ServerKey) -> bool {
757        let mut changed = false;
758        for ((stored_key, _), entry) in &mut self.entries {
759            if stored_key == key && entry.provisional {
760                entry.provisional = false;
761                changed = true;
762            }
763        }
764        if changed {
765            self.generation = self.generation.wrapping_add(1);
766        }
767        self.debug_assert_index_consistent();
768        changed
769    }
770
771    /// Clear the readiness marker after a pull response is received from a
772    /// quiescent server. This is separate from `mark_fresh` because a pull
773    /// response received while warming must remain provisional.
774    pub fn clear_provisional_for_server_file(&mut self, key: &ServerKey, file: &Path) -> bool {
775        let cache_key = (key.clone(), file.to_path_buf());
776        let Some(entry) = self.entries.get_mut(&cache_key) else {
777            return false;
778        };
779        if !entry.provisional {
780            return false;
781        }
782        entry.provisional = false;
783        self.generation = self.generation.wrapping_add(1);
784        true
785    }
786
787    /// Drop all entries for a specific server instance.
788    pub fn clear_for_server(&mut self, key: &ServerKey) {
789        let before = self.entries.len();
790        self.entries.retain(|(k, _), _| k != key);
791        self.order.retain(|(k, _)| k != key);
792        self.last_publish_at_for_file.retain(|(k, _), _| k != key);
793        self.by_file.retain(|_, servers| {
794            servers.remove(key);
795            !servers.is_empty()
796        });
797        if self.entries.len() != before {
798            self.generation = self.generation.wrapping_add(1);
799        }
800        self.debug_assert_index_consistent();
801    }
802
803    /// Backward-compatible alias for tests/callers that already used the
804    /// instance-scoped name.
805    pub fn clear_server_instance(&mut self, key: &ServerKey) {
806        self.clear_for_server(key);
807    }
808
809    /// Remove the least-recently-used entry, returning its key for telemetry.
810    fn evict_lru(&mut self) -> Option<(ServerKey, PathBuf)> {
811        if self.order.is_empty() {
812            return None;
813        }
814        let evicted = self.order.remove(0);
815        self.entries.remove(&evicted);
816        self.unindex_entry(&evicted);
817        self.last_publish_at_for_file.remove(&evicted);
818        self.debug_assert_index_consistent();
819        Some(evicted)
820    }
821
822    fn touch_existing(&mut self, key: &(ServerKey, PathBuf)) {
823        if let Some(idx) = self.order.iter().position(|k| k == key) {
824            let removed = self.order.remove(idx);
825            self.order.push(removed);
826        }
827    }
828
829    fn index_entry(&mut self, (server, file): &(ServerKey, PathBuf)) {
830        self.by_file
831            .entry(file.clone())
832            .or_default()
833            .insert(server.clone());
834    }
835
836    fn unindex_entry(&mut self, (server, file): &(ServerKey, PathBuf)) {
837        let remove_file = self.by_file.get_mut(file).is_some_and(|servers| {
838            servers.remove(server);
839            servers.is_empty()
840        });
841        if remove_file {
842            self.by_file.remove(file);
843        }
844    }
845
846    fn debug_assert_index_consistent(&self) {
847        #[cfg(debug_assertions)]
848        {
849            let indexed_entries = self.by_file.values().map(HashSet::len).sum::<usize>();
850            debug_assert_eq!(indexed_entries, self.entries.len());
851            for (server, file) in self.entries.keys() {
852                debug_assert!(self
853                    .by_file
854                    .get(file)
855                    .is_some_and(|servers| servers.contains(server)));
856            }
857            for (file, servers) in &self.by_file {
858                debug_assert!(!servers.is_empty());
859                for server in servers {
860                    debug_assert!(self.entries.contains_key(&(server.clone(), file.clone())));
861                }
862            }
863        }
864    }
865
866    #[cfg(test)]
867    fn mark_stale_for_file_linear_reference(&self, file: &Path) -> (bool, bool) {
868        let mut had_entries = false;
869        let mut changed = false;
870        for ((_, stored_file), entry) in &self.entries {
871            if stored_file == file {
872                had_entries = true;
873                changed |= !entry.stale;
874            }
875        }
876        (had_entries, changed)
877    }
878}
879
880impl Default for DiagnosticsStore {
881    fn default() -> Self {
882        Self::new()
883    }
884}
885
886/// Convert LSP diagnostics to our stored format.
887/// LSP uses 0-based line/character; we convert to 1-based.
888pub fn from_lsp_diagnostics(
889    file: PathBuf,
890    lsp_diagnostics: Vec<lsp_types::Diagnostic>,
891) -> Vec<StoredDiagnostic> {
892    lsp_diagnostics
893        .into_iter()
894        .map(|diagnostic| StoredDiagnostic {
895            file: file.clone(),
896            line: diagnostic.range.start.line + 1,
897            column: diagnostic.range.start.character + 1,
898            end_line: diagnostic.range.end.line + 1,
899            end_column: diagnostic.range.end.character + 1,
900            severity: match diagnostic.severity {
901                Some(lsp_types::DiagnosticSeverity::ERROR) => DiagnosticSeverity::Error,
902                Some(lsp_types::DiagnosticSeverity::WARNING) => DiagnosticSeverity::Warning,
903                Some(lsp_types::DiagnosticSeverity::INFORMATION) => DiagnosticSeverity::Information,
904                Some(lsp_types::DiagnosticSeverity::HINT) => DiagnosticSeverity::Hint,
905                _ => DiagnosticSeverity::Warning,
906            },
907            message: diagnostic.message,
908            code: diagnostic.code.map(|code| match code {
909                lsp_types::NumberOrString::Number(value) => value.to_string(),
910                lsp_types::NumberOrString::String(value) => value,
911            }),
912            source: diagnostic.source,
913        })
914        .collect()
915}
916
917#[cfg(test)]
918mod tests {
919    use std::path::{Path, PathBuf};
920
921    use lsp_types::{
922        Diagnostic, DiagnosticSeverity as LspDiagnosticSeverity, NumberOrString, Position, Range,
923    };
924
925    use super::{from_lsp_diagnostics, DiagnosticSeverity, DiagnosticsStore, StoredDiagnostic};
926    use crate::lsp::registry::ServerKind;
927    use crate::lsp::roots::ServerKey;
928
929    fn server_key(kind: ServerKind) -> ServerKey {
930        ServerKey {
931            kind,
932            root: PathBuf::from("/tmp/repo"),
933        }
934    }
935
936    fn diag(file: &str, line: u32, msg: &str, sev: DiagnosticSeverity) -> StoredDiagnostic {
937        StoredDiagnostic {
938            file: PathBuf::from(file),
939            line,
940            column: 1,
941            end_line: line,
942            end_column: 2,
943            severity: sev,
944            message: msg.into(),
945            code: None,
946            source: None,
947        }
948    }
949
950    #[test]
951    fn diagnostics_memory_estimate_is_zero_when_empty_and_nonzero_when_populated() {
952        let mut store = DiagnosticsStore::new();
953        assert_eq!(store.estimated_memory().estimated_bytes, Some(0));
954        let file = PathBuf::from("/tmp/memory.rs");
955        store.publish(
956            server_key(ServerKind::Rust),
957            file.clone(),
958            vec![diag(
959                file.to_str().unwrap(),
960                1,
961                "resident diagnostic message",
962                DiagnosticSeverity::Warning,
963            )],
964        );
965        let estimate = store.estimated_memory();
966        assert!(estimate.estimated_bytes.unwrap() > 0);
967        assert_eq!(estimate.counts["diagnostic_entries"], 1);
968        assert_eq!(estimate.counts["diagnostics"], 1);
969    }
970
971    #[test]
972    fn converts_lsp_positions_to_one_based() {
973        let file = PathBuf::from("/tmp/demo.rs");
974        let diagnostics = from_lsp_diagnostics(
975            file.clone(),
976            vec![Diagnostic {
977                range: Range::new(Position::new(0, 0), Position::new(1, 4)),
978                severity: Some(LspDiagnosticSeverity::ERROR),
979                code: Some(NumberOrString::String("E1".into())),
980                code_description: None,
981                source: Some("fake".into()),
982                message: "boom".into(),
983                related_information: None,
984                tags: None,
985                data: None,
986            }],
987        );
988
989        assert_eq!(diagnostics.len(), 1);
990        assert_eq!(diagnostics[0].file, file);
991        assert_eq!(diagnostics[0].line, 1);
992        assert_eq!(diagnostics[0].column, 1);
993        assert_eq!(diagnostics[0].end_line, 2);
994        assert_eq!(diagnostics[0].end_column, 5);
995        assert_eq!(diagnostics[0].severity, DiagnosticSeverity::Error);
996        assert_eq!(diagnostics[0].code.as_deref(), Some("E1"));
997    }
998
999    #[test]
1000    fn publish_replaces_existing_file_diagnostics() {
1001        let file = PathBuf::from("/tmp/demo.rs");
1002        let mut store = DiagnosticsStore::new();
1003        let key = server_key(ServerKind::Rust);
1004
1005        store.publish(
1006            key.clone(),
1007            file.clone(),
1008            vec![diag(
1009                "/tmp/demo.rs",
1010                1,
1011                "first",
1012                DiagnosticSeverity::Warning,
1013            )],
1014        );
1015        store.publish(
1016            key.clone(),
1017            file.clone(),
1018            vec![diag("/tmp/demo.rs", 2, "second", DiagnosticSeverity::Error)],
1019        );
1020
1021        let stored = store.for_file(&file);
1022        assert_eq!(stored.len(), 1);
1023        assert_eq!(stored[0].message, "second");
1024    }
1025
1026    #[test]
1027    fn empty_publish_is_preserved_as_checked_clean() {
1028        // The whole point of the v0.16 audit fix: empty publish ≠ deletion.
1029        // Agents need to be able to ask "has this file been checked yet?"
1030        // and get a truthful answer.
1031        let file = PathBuf::from("/tmp/clean.rs");
1032        let mut store = DiagnosticsStore::new();
1033        let key = server_key(ServerKind::Rust);
1034
1035        // First publish has an issue.
1036        store.publish(
1037            key.clone(),
1038            file.clone(),
1039            vec![diag(
1040                "/tmp/clean.rs",
1041                5,
1042                "fix me",
1043                DiagnosticSeverity::Warning,
1044            )],
1045        );
1046        assert!(store.has_any_report_for_file(&file));
1047        assert_eq!(store.for_file(&file).len(), 1);
1048
1049        // Second publish is empty (the fix worked). Entry is preserved as
1050        // "checked clean" rather than deleted.
1051        store.publish(key.clone(), file.clone(), Vec::new());
1052        assert!(
1053            store.has_any_report_for_file(&file),
1054            "checked-clean must be distinguishable from never-checked"
1055        );
1056        assert_eq!(store.for_file(&file).len(), 0);
1057
1058        let entries = store.entries_for_file(&file);
1059        assert_eq!(entries.len(), 1);
1060        assert!(entries[0].1.epoch > 0);
1061    }
1062
1063    #[test]
1064    fn never_checked_returns_no_report() {
1065        let store = DiagnosticsStore::new();
1066        let file = PathBuf::from("/tmp/never.rs");
1067        assert!(!store.has_any_report_for_file(&file));
1068        assert!(store.for_file(&file).is_empty());
1069    }
1070
1071    #[test]
1072    fn stale_entries_are_hidden_but_preserved_for_refresh() {
1073        let file = PathBuf::from("/tmp/stale.rs");
1074        let mut store = DiagnosticsStore::new();
1075        let key = server_key(ServerKind::Rust);
1076        store.publish(
1077            key.clone(),
1078            file.clone(),
1079            vec![diag("/tmp/stale.rs", 1, "old", DiagnosticSeverity::Error)],
1080        );
1081
1082        let (had_entries, changed) = store.mark_stale_for_file(&file);
1083
1084        assert!(had_entries);
1085        assert!(changed);
1086        assert!(store.has_any_report_for_file(&file));
1087        assert!(!store.has_any_fresh_report_for_file(&file));
1088        assert!(store.for_file(&file).is_empty());
1089        assert!(store.all().is_empty());
1090        assert_eq!(store.error_warning_counts(), (0, 0));
1091        assert_eq!(store.entries_for_file(&file).len(), 1);
1092
1093        assert!(store.mark_fresh_for_server_file(&key, &file));
1094        assert!(store.has_any_fresh_report_for_file(&file));
1095        assert_eq!(store.for_file(&file).len(), 1);
1096        assert_eq!(store.error_warning_counts(), (1, 0));
1097    }
1098
1099    #[test]
1100    fn per_server_state_is_tracked_independently() {
1101        let file = PathBuf::from("/tmp/multi.py");
1102        let mut store = DiagnosticsStore::new();
1103        let pyright_key = server_key(ServerKind::Python);
1104        let ty_key = server_key(ServerKind::Ty);
1105
1106        store.publish(
1107            pyright_key,
1108            file.clone(),
1109            vec![diag(
1110                "/tmp/multi.py",
1111                1,
1112                "pyright says X",
1113                DiagnosticSeverity::Error,
1114            )],
1115        );
1116        store.publish(
1117            ty_key,
1118            file.clone(),
1119            vec![diag(
1120                "/tmp/multi.py",
1121                2,
1122                "ty says Y",
1123                DiagnosticSeverity::Warning,
1124            )],
1125        );
1126
1127        let messages: Vec<&str> = store
1128            .for_file(&file)
1129            .into_iter()
1130            .map(|d| d.message.as_str())
1131            .collect();
1132
1133        assert_eq!(messages.len(), 2, "both servers' reports preserved");
1134        assert!(messages.iter().any(|m| m == &"pyright says X"));
1135        assert!(messages.iter().any(|m| m == &"ty says Y"));
1136    }
1137
1138    #[test]
1139    fn clear_for_server_file_removes_only_exact_entry() {
1140        let file_a = PathBuf::from("/tmp/a.rs");
1141        let file_b = PathBuf::from("/tmp/b.rs");
1142        let mut store = DiagnosticsStore::new();
1143        let rust_key = server_key(ServerKind::Rust);
1144        let py_key = server_key(ServerKind::Python);
1145
1146        store.publish(
1147            rust_key.clone(),
1148            file_a.clone(),
1149            vec![diag("/tmp/a.rs", 1, "rust a", DiagnosticSeverity::Error)],
1150        );
1151        store.publish(
1152            rust_key.clone(),
1153            file_b.clone(),
1154            vec![diag("/tmp/b.rs", 1, "rust b", DiagnosticSeverity::Warning)],
1155        );
1156        store.publish(
1157            py_key.clone(),
1158            file_a.clone(),
1159            vec![diag("/tmp/a.rs", 2, "py a", DiagnosticSeverity::Warning)],
1160        );
1161
1162        store.clear_for_server_file(&rust_key, &file_a);
1163
1164        assert!(!store.has_report_for_server_file(&rust_key, &file_a));
1165        assert!(store.has_report_for_server_file(&rust_key, &file_b));
1166        assert!(store.has_report_for_server_file(&py_key, &file_a));
1167    }
1168
1169    #[test]
1170    fn lru_evicts_oldest_when_capacity_exceeded() {
1171        let mut store = DiagnosticsStore::with_capacity(2);
1172        let key = server_key(ServerKind::Rust);
1173
1174        store.publish(
1175            key.clone(),
1176            PathBuf::from("/a.rs"),
1177            vec![diag("/a.rs", 1, "a", DiagnosticSeverity::Warning)],
1178        );
1179        store.publish(
1180            key.clone(),
1181            PathBuf::from("/b.rs"),
1182            vec![diag("/b.rs", 1, "b", DiagnosticSeverity::Warning)],
1183        );
1184        assert_eq!(store.len(), 2);
1185
1186        // Inserting a third entry should evict /a.rs (oldest).
1187        store.publish(
1188            key.clone(),
1189            PathBuf::from("/c.rs"),
1190            vec![diag("/c.rs", 1, "c", DiagnosticSeverity::Warning)],
1191        );
1192        assert_eq!(store.len(), 2);
1193        assert!(!store.has_any_report_for_file(Path::new("/a.rs")));
1194        assert!(!store.by_file.contains_key(Path::new("/a.rs")));
1195        assert!(store.has_any_report_for_file(Path::new("/b.rs")));
1196        assert!(store.has_any_report_for_file(Path::new("/c.rs")));
1197        store.debug_assert_index_consistent();
1198    }
1199
1200    #[test]
1201    fn secondary_index_stays_consistent_through_seeded_mutation_storm() {
1202        fn next_random(seed: &mut u64) -> u64 {
1203            *seed = seed
1204                .wrapping_mul(6_364_136_223_846_793_005)
1205                .wrapping_add(1_442_695_040_888_963_407);
1206            *seed
1207        }
1208
1209        let servers = [
1210            server_key(ServerKind::Rust),
1211            server_key(ServerKind::TypeScript),
1212            server_key(ServerKind::Python),
1213            server_key(ServerKind::Biome),
1214        ];
1215        let files = (0..11)
1216            .map(|index| PathBuf::from(format!("/tmp/index-{index}.rs")))
1217            .collect::<Vec<_>>();
1218        let mut store = DiagnosticsStore::with_capacity(7);
1219        let mut seed = 0x05ee_dd1a_6005_71c5_u64;
1220        let mut operation_counts = [0usize; 7];
1221        let mut stale_hits = 0usize;
1222
1223        for step in 0..1_000 {
1224            let operation = (next_random(&mut seed) % operation_counts.len() as u64) as usize;
1225            operation_counts[operation] += 1;
1226            let server = servers[(next_random(&mut seed) % servers.len() as u64) as usize].clone();
1227            let file = files[(next_random(&mut seed) % files.len() as u64) as usize].clone();
1228
1229            match operation {
1230                0 | 1 => store.publish(
1231                    server,
1232                    file.clone(),
1233                    vec![diag(
1234                        file.to_str().unwrap(),
1235                        step + 1,
1236                        "seeded diagnostic",
1237                        DiagnosticSeverity::Warning,
1238                    )],
1239                ),
1240                2 => {
1241                    let expected = store.mark_stale_for_file_linear_reference(&file);
1242                    let actual = store.mark_stale_for_file(&file);
1243                    assert_eq!(actual, expected);
1244                    stale_hits += usize::from(actual.0);
1245                }
1246                3 => {
1247                    store.clear_for_server_file(&server, &file);
1248                }
1249                4 => {
1250                    store.clear_for_file(&file);
1251                }
1252                5 => {
1253                    store.clear_for_server(&server);
1254                }
1255                6 => {
1256                    store.clear_server(server.kind);
1257                }
1258                _ => unreachable!(),
1259            }
1260
1261            store.debug_assert_index_consistent();
1262            assert!(store.len() <= 7);
1263        }
1264
1265        assert!(operation_counts.into_iter().all(|count| count > 0));
1266        assert!(
1267            stale_hits > 0,
1268            "seeded sequence must stale existing entries"
1269        );
1270    }
1271
1272    #[test]
1273    fn touching_existing_entry_moves_it_to_end_of_lru() {
1274        let mut store = DiagnosticsStore::with_capacity(2);
1275        let key = server_key(ServerKind::Rust);
1276
1277        store.publish(
1278            key.clone(),
1279            PathBuf::from("/a.rs"),
1280            vec![diag("/a.rs", 1, "a", DiagnosticSeverity::Warning)],
1281        );
1282        store.publish(
1283            key.clone(),
1284            PathBuf::from("/b.rs"),
1285            vec![diag("/b.rs", 1, "b", DiagnosticSeverity::Warning)],
1286        );
1287
1288        // Re-publish /a.rs — this should refresh its LRU position so it's
1289        // newer than /b.rs. Inserting /c.rs should now evict /b.rs.
1290        store.publish(
1291            key.clone(),
1292            PathBuf::from("/a.rs"),
1293            vec![diag("/a.rs", 1, "a2", DiagnosticSeverity::Error)],
1294        );
1295        store.publish(
1296            key.clone(),
1297            PathBuf::from("/c.rs"),
1298            vec![diag("/c.rs", 1, "c", DiagnosticSeverity::Warning)],
1299        );
1300
1301        assert!(store.has_any_report_for_file(Path::new("/a.rs")));
1302        assert!(!store.has_any_report_for_file(Path::new("/b.rs")));
1303        assert!(store.has_any_report_for_file(Path::new("/c.rs")));
1304    }
1305
1306    #[test]
1307    fn capacity_zero_disables_eviction() {
1308        let mut store = DiagnosticsStore::with_capacity(0);
1309        let key = server_key(ServerKind::Rust);
1310
1311        for i in 0..50 {
1312            store.publish(
1313                key.clone(),
1314                PathBuf::from(format!("/f{i}.rs")),
1315                vec![diag(
1316                    &format!("/f{i}.rs"),
1317                    1,
1318                    "x",
1319                    DiagnosticSeverity::Warning,
1320                )],
1321            );
1322        }
1323        assert_eq!(store.len(), 50);
1324    }
1325
1326    #[test]
1327    fn set_capacity_evicts_on_shrink() {
1328        let mut store = DiagnosticsStore::with_capacity(0);
1329        let key = server_key(ServerKind::Rust);
1330        for i in 0..10 {
1331            store.publish(
1332                key.clone(),
1333                PathBuf::from(format!("/f{i}.rs")),
1334                vec![diag(
1335                    &format!("/f{i}.rs"),
1336                    1,
1337                    "x",
1338                    DiagnosticSeverity::Warning,
1339                )],
1340            );
1341        }
1342        assert_eq!(store.len(), 10);
1343
1344        store.set_capacity(3);
1345        assert_eq!(store.len(), 3);
1346        // Most recent 3 should remain (/f7.rs, /f8.rs, /f9.rs).
1347        assert!(store.has_any_report_for_file(Path::new("/f9.rs")));
1348        assert!(!store.has_any_report_for_file(Path::new("/f0.rs")));
1349    }
1350
1351    #[test]
1352    fn epoch_increments_monotonically() {
1353        let mut store = DiagnosticsStore::new();
1354        let key = server_key(ServerKind::Rust);
1355        let file = PathBuf::from("/e.rs");
1356
1357        store.publish(key.clone(), file.clone(), Vec::new());
1358        let e1 = store.entries_for_file(&file)[0].1.epoch;
1359
1360        store.publish(key.clone(), file.clone(), Vec::new());
1361        let e2 = store.entries_for_file(&file)[0].1.epoch;
1362
1363        assert!(e2 > e1, "epoch must increase on republish");
1364    }
1365
1366    #[test]
1367    fn result_id_is_round_tripped() {
1368        let mut store = DiagnosticsStore::new();
1369        let key = server_key(ServerKind::Rust);
1370        let file = PathBuf::from("/r.rs");
1371
1372        store.publish_with_result_id(
1373            key.clone(),
1374            file.clone(),
1375            Vec::new(),
1376            Some("rev-42".to_string()),
1377        );
1378
1379        let entries = store.entries_for_file(&file);
1380        assert_eq!(entries[0].1.result_id.as_deref(), Some("rev-42"));
1381    }
1382
1383    #[test]
1384    fn clear_server_drops_all_entries_for_kind() {
1385        let mut store = DiagnosticsStore::new();
1386        let py_key = server_key(ServerKind::Python);
1387        let rust_key = server_key(ServerKind::Rust);
1388
1389        store.publish(
1390            py_key.clone(),
1391            PathBuf::from("/a.py"),
1392            vec![diag("/a.py", 1, "x", DiagnosticSeverity::Error)],
1393        );
1394        store.publish(
1395            rust_key.clone(),
1396            PathBuf::from("/b.rs"),
1397            vec![diag("/b.rs", 1, "y", DiagnosticSeverity::Error)],
1398        );
1399
1400        store.clear_server(ServerKind::Python);
1401        assert!(!store.has_any_report_for_file(Path::new("/a.py")));
1402        assert!(store.has_any_report_for_file(Path::new("/b.rs")));
1403    }
1404
1405    #[test]
1406    fn clear_for_file_drops_every_server_entry_and_updates_counts() {
1407        let mut store = DiagnosticsStore::new();
1408        let py_key = server_key(ServerKind::Python);
1409        let biome_key = server_key(ServerKind::Biome);
1410
1411        // Two servers both report for the SAME deleted file, plus an unrelated
1412        // file that must survive.
1413        store.publish(
1414            py_key,
1415            PathBuf::from("/gone.ts"),
1416            vec![diag("/gone.ts", 4, "type error", DiagnosticSeverity::Error)],
1417        );
1418        store.publish(
1419            biome_key,
1420            PathBuf::from("/gone.ts"),
1421            vec![diag(
1422                "/gone.ts",
1423                7,
1424                "lint warning",
1425                DiagnosticSeverity::Warning,
1426            )],
1427        );
1428        store.publish(
1429            server_key(ServerKind::Rust),
1430            PathBuf::from("/keep.rs"),
1431            vec![diag("/keep.rs", 1, "live error", DiagnosticSeverity::Error)],
1432        );
1433
1434        assert_eq!(store.error_warning_counts(), (2, 1));
1435
1436        // Clearing the deleted file drops both server entries for it.
1437        let removed = store.clear_for_file(Path::new("/gone.ts"));
1438        assert!(removed);
1439        assert!(!store.has_any_report_for_file(Path::new("/gone.ts")));
1440        // The unrelated file's diagnostic is untouched.
1441        assert!(store.has_any_report_for_file(Path::new("/keep.rs")));
1442        assert_eq!(store.error_warning_counts(), (1, 0));
1443
1444        // Clearing again is a no-op (nothing left for that file).
1445        assert!(!store.clear_for_file(Path::new("/gone.ts")));
1446    }
1447
1448    #[test]
1449    fn filtered_counts_apply_keep_predicate() {
1450        let mut store = DiagnosticsStore::new();
1451        store.publish(
1452            server_key(ServerKind::TypeScript),
1453            PathBuf::from("/repo/src/app.ts"),
1454            vec![diag(
1455                "/repo/src/app.ts",
1456                1,
1457                "in build",
1458                DiagnosticSeverity::Error,
1459            )],
1460        );
1461        store.publish(
1462            server_key(ServerKind::TypeScript),
1463            PathBuf::from("/repo/src/app.test.ts"),
1464            vec![diag(
1465                "/repo/src/app.test.ts",
1466                1,
1467                "excluded",
1468                DiagnosticSeverity::Error,
1469            )],
1470        );
1471
1472        // Raw count sees both files.
1473        assert_eq!(store.error_warning_counts(), (2, 0));
1474        // Filtered count drops the build-excluded test file.
1475        let counts = store.filtered_error_warning_counts(|file| !file.ends_with("app.test.ts"));
1476        assert_eq!(counts, (1, 0));
1477    }
1478
1479    #[test]
1480    fn filtered_counts_dedup_across_servers() {
1481        let mut store = DiagnosticsStore::new();
1482        let file = "/repo/src/app.ts";
1483        // Two different servers report the SAME diagnostic (same file/range/
1484        // severity/message/source) for one file — e.g. tsserver + a linter that
1485        // both surface an identical issue. Raw counting double-counts; the
1486        // status-bar count must collapse to one (matching inspect sort_and_dedup).
1487        store.publish(
1488            server_key(ServerKind::TypeScript),
1489            PathBuf::from(file),
1490            vec![diag(file, 7, "dup", DiagnosticSeverity::Error)],
1491        );
1492        store.publish(
1493            server_key(ServerKind::Biome),
1494            PathBuf::from(file),
1495            vec![diag(file, 7, "dup", DiagnosticSeverity::Error)],
1496        );
1497
1498        assert_eq!(store.error_warning_counts(), (2, 0));
1499        assert_eq!(store.filtered_error_warning_counts(|_| true), (1, 0));
1500    }
1501
1502    #[test]
1503    fn filtered_counts_keep_distinct_diagnostics_same_file() {
1504        let mut store = DiagnosticsStore::new();
1505        let file = "/repo/src/app.ts";
1506        // Two servers, genuinely different diagnostics on the same file — both
1507        // must be counted (dedup keys on location+message+source, not file).
1508        store.publish(
1509            server_key(ServerKind::TypeScript),
1510            PathBuf::from(file),
1511            vec![diag(file, 7, "type error", DiagnosticSeverity::Error)],
1512        );
1513        store.publish(
1514            server_key(ServerKind::Biome),
1515            PathBuf::from(file),
1516            vec![diag(file, 12, "lint warn", DiagnosticSeverity::Warning)],
1517        );
1518        assert_eq!(store.filtered_error_warning_counts(|_| true), (1, 1));
1519    }
1520
1521    #[test]
1522    fn filtered_counts_exclude_environmental_diagnostics() {
1523        let mut store = DiagnosticsStore::new();
1524        let file = "/repo/src/app.ts";
1525        store.publish(
1526            server_key(ServerKind::TypeScript),
1527            PathBuf::from(file),
1528            vec![
1529                diag(
1530                    file,
1531                    1,
1532                    "Cannot find name 'foo'.",
1533                    DiagnosticSeverity::Error,
1534                ),
1535                diag(
1536                    file,
1537                    2,
1538                    "Failed to load schema from https://cdn.example/pkg/schema.json",
1539                    DiagnosticSeverity::Error,
1540                ),
1541            ],
1542        );
1543        assert_eq!(store.error_warning_counts(), (2, 0));
1544        assert_eq!(
1545            store.filtered_error_warning_counts(|_| true),
1546            (1, 0),
1547            "environmental schema-fetch must not inflate E count"
1548        );
1549    }
1550
1551    #[test]
1552    fn environmental_flap_does_not_change_filtered_counts() {
1553        let mut store = DiagnosticsStore::new();
1554        let file = "/repo/package.json";
1555        let key = server_key(ServerKind::TypeScript);
1556        let env_msg =
1557            "Failed to fetch schema from https://json.schemastore.org/package.json: network";
1558
1559        assert_eq!(store.filtered_error_warning_counts(|_| true), (0, 0));
1560
1561        store.publish(
1562            key.clone(),
1563            PathBuf::from(file),
1564            vec![diag(file, 1, env_msg, DiagnosticSeverity::Error)],
1565        );
1566        assert_eq!(
1567            store.filtered_error_warning_counts(|_| true),
1568            (0, 0),
1569            "publish environmental diagnostic must not change filtered E/W"
1570        );
1571
1572        store.publish(key, PathBuf::from(file), vec![]);
1573        assert_eq!(
1574            store.filtered_error_warning_counts(|_| true),
1575            (0, 0),
1576            "removing environmental diagnostic must not change filtered E/W"
1577        );
1578    }
1579
1580    #[test]
1581    fn mixed_syntax_and_schema_fetch_counts_one_error() {
1582        let mut store = DiagnosticsStore::new();
1583        let file = "/repo/src/mixed.ts";
1584        store.publish(
1585            server_key(ServerKind::TypeScript),
1586            PathBuf::from(file),
1587            vec![
1588                diag(
1589                    file,
1590                    3,
1591                    "Cannot find name 'bar'.",
1592                    DiagnosticSeverity::Error,
1593                ),
1594                diag(
1595                    file,
1596                    1,
1597                    "Failed to resolve schema https://example.com/x.json",
1598                    DiagnosticSeverity::Error,
1599                ),
1600            ],
1601        );
1602        assert_eq!(
1603            store.filtered_error_warning_counts(|_| true),
1604            (1, 0),
1605            "classifier is per-diagnostic: one real syntax error => E1"
1606        );
1607    }
1608
1609    #[test]
1610    fn authoritative_report_requires_settled_non_stale_entry() {
1611        let mut store = DiagnosticsStore::new();
1612        let key = server_key(ServerKind::Rust);
1613        let file = PathBuf::from("/tmp/auth.rs");
1614
1615        // Nothing published yet: no authority.
1616        assert!(!store.has_authoritative_report_for_file(&file));
1617
1618        // A warming (provisional) report is evidence, but not authority.
1619        store.publish_full_with_provisional(
1620            key.clone(),
1621            file.clone(),
1622            vec![diag(
1623                "/tmp/auth.rs",
1624                1,
1625                "warming",
1626                DiagnosticSeverity::Error,
1627            )],
1628            None,
1629            None,
1630            true,
1631        );
1632        assert!(store.has_any_fresh_report_for_file(&file));
1633        assert!(!store.has_authoritative_report_for_file(&file));
1634
1635        // Settling the same server promotes its latest report to authority.
1636        store.promote_provisional_for_server(&key);
1637        assert!(store.has_authoritative_report_for_file(&file));
1638
1639        // A watcher-observed edit revokes authority until the next report.
1640        assert!(store.mark_stale_for_file(&file).0);
1641        assert!(!store.has_authoritative_report_for_file(&file));
1642    }
1643
1644    #[test]
1645    fn authoritative_report_includes_empty_checked_clean() {
1646        let mut store = DiagnosticsStore::new();
1647        let file = PathBuf::from("/tmp/clean.rs");
1648        store.publish(server_key(ServerKind::Rust), file.clone(), Vec::new());
1649        assert!(
1650            store.has_authoritative_report_for_file(&file),
1651            "an empty checked-clean report still proves the file was analyzed"
1652        );
1653    }
1654}