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