Skip to main content

mcpls_core/bridge/
notifications.rs

1//! LSP notification storage and management.
2//!
3//! Stores diagnostics, log messages, and server messages received from LSP servers.
4
5use std::collections::{BTreeMap, HashMap, HashSet, VecDeque};
6
7use chrono::{DateTime, Utc};
8use lsp_types::{Diagnostic as LspDiagnostic, Uri};
9use serde::{Deserialize, Serialize};
10use tracing::warn;
11
12use crate::bridge::indexing::{IndexingPolicy, IndexingState, IndexingTracker};
13use crate::config::ServerId;
14use crate::util::{truncate_str, truncate_string};
15
16/// Maximum number of log entries to store.
17const MAX_LOG_ENTRIES: usize = 100;
18
19/// Maximum size, in bytes, of a single cached log message, server message,
20/// or a single diagnostic's free-form `message` text.
21///
22/// `MAX_LOG_ENTRIES`/`MAX_SERVER_MESSAGES`/`MAX_DIAGNOSTIC_ENTRIES` bound
23/// the *number* of cached entries, but not the size of any one entry -- a
24/// spawned LSP server could publish a single pathologically large message
25/// and still fit under those caps while consuming unbounded memory (#311).
26/// This is independent of the transport-level `MAX_CONTENT_LENGTH` cap in
27/// `lsp::transport`, which bounds a whole JSON-RPC frame, not one field
28/// within it. 256 KiB comfortably fits any realistic diagnostic or log
29/// message while still capping the worst case.
30///
31/// This alone does not bound a whole diagnostics *entry* (a
32/// `Vec<LspDiagnostic>`), only one diagnostic's `message` field -- see
33/// `MAX_DIAGNOSTICS_ENTRY_BYTES` for the entry-level cap.
34const MAX_ENTRY_TEXT_BYTES: usize = 256 * 1024;
35
36/// Maximum serialized size, in bytes, of a single document's *whole*
37/// diagnostics list (`Vec<LspDiagnostic>`), enforced by
38/// [`cap_diagnostics_entry_size`].
39///
40/// `MAX_ENTRY_TEXT_BYTES` alone does not bound this: it only truncates one
41/// diagnostic's `message` field, but the list's *length* is uncapped, and
42/// `LspDiagnostic` carries several more free-form or arbitrary-JSON fields
43/// besides `message` (`source`, `code`, `code_description`,
44/// `related_information`, `data`). A hostile server can stay under
45/// `MAX_ENTRY_TEXT_BYTES` on every individual message while still
46/// publishing e.g. 100k diagnostics for one URI, or a single diagnostic
47/// with a multi-MiB `data` blob -- both still fit under the transport-level
48/// `lsp::transport::MAX_CONTENT_LENGTH` (10 MiB) per notification, and
49/// `MAX_DIAGNOSTIC_ENTRIES` bounds only the *number* of distinct cached
50/// URIs, not their individual size, so up to 1000 such entries could
51/// otherwise accumulate to gigabytes. 1 MiB is far larger than any
52/// realistic diagnostics list for one file, and combined with
53/// `MAX_DIAGNOSTIC_ENTRIES` bounds the cache's total diagnostics footprint
54/// to roughly 1 GiB in the worst case.
55const MAX_DIAGNOSTICS_ENTRY_BYTES: usize = 1024 * 1024;
56
57/// Global budget for distinct-URI diagnostic entries, shared work-conservingly
58/// across every registered diagnostics-route server rather than claimed by
59/// one server alone.
60///
61/// Guards against unbounded growth when a spawned LSP server publishes
62/// diagnostics for an unbounded number of distinct URIs over a long-running
63/// session, matching the bounding already applied to `logs`/`messages`.
64/// Eviction only triggers once this global total is reached; it then targets
65/// whichever server most exceeds its fair share of
66/// `MAX_DIAGNOSTIC_ENTRIES / diagnostics_route_count` (see
67/// [`NotificationCache::set_diagnostics_route_count`]). If no server exceeds
68/// its share, eviction falls back to the writer's own oldest entry instead
69/// -- even if the writer is itself within its share -- since it is the one
70/// whose new entry needs room; a narrower fallback further evicts from the
71/// largest other in-share server only if the writer itself has no entries
72/// yet (its very first write) and every existing server is already within
73/// its own share, since otherwise there would be nothing to evict and the
74/// aggregate cap could be exceeded (see the private `server_to_evict_from`
75/// for both fallbacks). A quieter, non-writer server that is within its fair
76/// share is otherwise never touched (#266). A single active server can
77/// still use the full budget when other registered servers are idle (#276)
78/// instead of being capped at a static equal split regardless of how much
79/// of it they actually use. Which single entry within the chosen server (or
80/// another over-share one) is actually removed is further refined by
81/// emptiness -- see the private `entry_to_evict` (#284).
82const MAX_DIAGNOSTIC_ENTRIES: usize = 1000;
83
84/// Normalize a URI string to a stable cache key.
85///
86/// On Windows, URI comparisons must be case-insensitive: the filesystem is
87/// case-insensitive and different tools (e.g. rust-analyzer vs std) may
88/// produce drive letters in different cases (`C:` vs `c:`).
89/// Lowercasing the entire URI is safe for `file://` URIs because they have
90/// no case-sensitive query or fragment components.
91fn uri_cache_key(uri: &str) -> std::borrow::Cow<'_, str> {
92    if cfg!(windows) {
93        std::borrow::Cow::Owned(uri.to_ascii_lowercase())
94    } else {
95        std::borrow::Cow::Borrowed(uri)
96    }
97}
98
99/// Maximum number of server messages to store.
100const MAX_SERVER_MESSAGES: usize = 50;
101
102/// Conservative fixed-field/JSON-structure overhead assumed per diagnostic
103/// (`range`, `severity`, and object/field-name punctuation) by
104/// [`cap_diagnostics_entry_size`]'s cheap size estimate. Deliberately
105/// generous relative to the true overhead (`range` alone serializes to
106/// roughly 70 bytes) so the estimate can only ever *overcount*, never
107/// undercount, actual serialized size.
108const DIAGNOSTIC_ESTIMATE_OVERHEAD_BYTES: usize = 256;
109
110/// Worst-case JSON string-escaping expansion factor, applied to each raw
111/// string field's byte length in [`cap_diagnostics_entry_size`]'s cheap
112/// size estimate.
113///
114/// A raw byte's serialized JSON form is at most 6 bytes: `"` and `\` and
115/// the five control characters with a short escape (`\b \f \n \r \t`) cost
116/// 2 bytes, but every other control character (`U+0000`..=`U+001F`, e.g.
117/// NUL) has no short escape and is emitted as `\u00XX` -- 6 bytes for 1 raw
118/// byte. The original estimate summed raw string lengths directly and
119/// could *undercount* an escape-heavy string (e.g. all-NUL) by up to this
120/// factor, letting an oversized entry skip the real `fits` check
121/// entirely -- multiplying by it keeps the estimate a true upper bound on
122/// serialized size rather than merely a typical-case guess.
123const JSON_ESCAPE_WORST_CASE_FACTOR: usize = 6;
124
125/// Last-resort message length used by [`cap_diagnostics_entry_size`]'s
126/// terminal-enforcement fallback -- small enough that a single diagnostic
127/// (fixed-size `range`/`severity` plus this one short string, every other
128/// field cleared) can never approach [`MAX_DIAGNOSTICS_ENTRY_BYTES`]
129/// regardless of JSON encoding overhead.
130const DIAGNOSTIC_TERMINAL_FALLBACK_MESSAGE_BYTES: usize = 1024;
131
132/// Ordinal rank used to sort diagnostics by severity before
133/// [`cap_diagnostics_entry_size`] truncates an oversized list -- lower rank
134/// sorts first, so it is kept preferentially (#311 S6).
135///
136/// `DiagnosticSeverity`'s inner value is private, so its natural numeric
137/// ordering (`ERROR` < `WARNING` < `INFORMATION` < `HINT`) can't be read
138/// directly; `Option<DiagnosticSeverity>`'s *derived* `Ord` would also rank
139/// `None` before every `Some` value, the opposite of what's wanted here
140/// (no reported severity is treated as least important, same as `HINT`).
141/// This maps explicitly instead of relying on either.
142const fn diagnostic_severity_rank(diagnostic: &LspDiagnostic) -> u8 {
143    match diagnostic.severity {
144        Some(lsp_types::DiagnosticSeverity::Error) => 0,
145        Some(lsp_types::DiagnosticSeverity::Warning) => 1,
146        Some(lsp_types::DiagnosticSeverity::Information) => 2,
147        // An unrecognized (future) severity value is treated the same as
148        // no severity at all: least important, not most.
149        Some(_) | None => 3,
150    }
151}
152
153/// Largest `k` such that `fits(&diagnostics[..k])`, found via binary search
154/// rather than a linear scan or a flat halve (#311 S6).
155///
156/// Correct because a JSON array's serialized length is monotonically
157/// non-decreasing in its element count -- appending a diagnostic can only
158/// add bytes, never remove them -- so `fits(&diagnostics[..k])` is `true`
159/// for a contiguous run of small `k` and `false` for every larger `k`,
160/// exactly the shape a boundary binary search requires. `fits(&[])` is
161/// always `true`, so the search is well-defined even if no diagnostic at
162/// all fits individually.
163fn largest_fitting_prefix(
164    diagnostics: &[LspDiagnostic],
165    fits: impl Fn(&[LspDiagnostic]) -> bool,
166) -> usize {
167    let (mut lo, mut hi) = (0usize, diagnostics.len());
168    while lo < hi {
169        let mid = lo + (hi - lo).div_ceil(2);
170        if fits(&diagnostics[..mid]) {
171            lo = mid;
172        } else {
173            hi = mid - 1;
174        }
175    }
176    lo
177}
178
179/// Borrows a diagnostic's free-form `message` as plain text, regardless of
180/// whether the server sent it as a plain string or (per LSP 3.18)
181/// `MarkupContent`.
182pub fn message_as_str(message: &lsp_types::Message) -> &str {
183    match message {
184        lsp_types::Message::String(s) => s,
185        lsp_types::Message::MarkupContent(m) => &m.value,
186    }
187}
188
189/// Truncates a diagnostic's free-form `message` to at most `max_bytes`,
190/// regardless of whether it is a plain string or `MarkupContent`.
191fn truncate_message(message: lsp_types::Message, max_bytes: usize) -> lsp_types::Message {
192    match message {
193        lsp_types::Message::String(s) => lsp_types::Message::String(truncate_string(s, max_bytes)),
194        lsp_types::Message::MarkupContent(mut m) => {
195            m.value = truncate_string(m.value, max_bytes);
196            lsp_types::Message::MarkupContent(m)
197        }
198    }
199}
200
201/// Bounds `diagnostics`' serialized size to at most
202/// `MAX_DIAGNOSTICS_ENTRY_BYTES` (#311 C1 fix).
203///
204/// Measures the list's *actual* serialized size via `serde_json::to_vec`
205/// rather than bounding each field individually -- that covers every
206/// field on `LspDiagnostic` (`source`, `code`, `code_description`,
207/// `related_information`, `data`, `tags`) at once, not just `message`.
208///
209/// # Guarantee
210///
211/// The postcondition -- the returned list's serialized size is at most
212/// `MAX_DIAGNOSTICS_ENTRY_BYTES` -- is enforced directly by a final,
213/// unconditional check at the end of this function, not merely assumed to
214/// follow from the field-specific mitigations below it. Those mitigations
215/// are best-effort (preserve as much real content as fits) and only cover
216/// the fields known today; the terminal step is what actually guarantees
217/// the bound holds even if a mitigation is incomplete or `LspDiagnostic`
218/// gains a new unbounded field in a future `lsp-types` upgrade.
219///
220/// # Cost (#311 S5)
221///
222/// `publishDiagnostics` is a hot path (rust-analyzer republishes
223/// whole-workspace diagnostics on every save), so this avoids a full
224/// `serde_json` serialization pass whenever every diagnostic's size is
225/// cheaply accountable from `message`/`source`/`code` alone (i.e. none
226/// carry `data`, `code_description`, `related_information`, or `tags`,
227/// each of which needs real serialization to size safely) and a
228/// conservative *upper bound* on their sum already fits. The estimate is
229/// not their raw byte length: JSON string escaping can expand a byte up to
230/// [`JSON_ESCAPE_WORST_CASE_FACTOR`]-fold (a NUL-heavy string previously
231/// let this fast path undercount actual serialized size by that much and
232/// skip the real `fits` check below entirely), so raw lengths are
233/// multiplied by that factor before comparing against the cap.
234///
235/// # Visibility (#311 S7)
236///
237/// Every mitigation that drops or truncates real content -- discarding
238/// diagnostics entirely, or clearing a survivor's `data` (which the LSP
239/// spec says is preserved through to a later `textDocument/codeAction`
240/// request, so losing it can silently break that diagnostic's quick fix)
241/// -- logs a `tracing::warn!` so the degradation is visible rather than a
242/// silent, hard-to-diagnose gap in what a caller sees.
243fn cap_diagnostics_entry_size(uri: &Uri, diagnostics: &mut Vec<LspDiagnostic>) {
244    let fits = |ds: &[LspDiagnostic]| {
245        // A serialization error is conservatively treated as "does not
246        // fit" (triggers the mitigations below) rather than as success.
247        // `LspDiagnostic`'s fields can't actually produce one in practice
248        // (no floats, no non-string map keys anywhere in `Diagnostic` or
249        // `serde_json::Value`'s own object representation), but failing
250        // safe costs nothing here.
251        serde_json::to_vec(ds).is_ok_and(|bytes| bytes.len() <= MAX_DIAGNOSTICS_ENTRY_BYTES)
252    };
253
254    let cheaply_estimable = diagnostics.iter().all(|d| {
255        d.data.is_none()
256            && d.code_description.is_none()
257            && d.related_information.is_none()
258            && d.tags.is_none()
259    });
260    if cheaply_estimable {
261        let estimated: usize = diagnostics
262            .iter()
263            .map(|d| {
264                let raw_string_bytes = message_as_str(&d.message).len()
265                    + d.source.as_deref().map_or(0, str::len)
266                    + match &d.code {
267                        Some(lsp_types::Code::String(s)) => s.len(),
268                        _ => 0,
269                    };
270                raw_string_bytes * JSON_ESCAPE_WORST_CASE_FACTOR
271                    + DIAGNOSTIC_ESTIMATE_OVERHEAD_BYTES
272            })
273            .sum();
274        if estimated <= MAX_DIAGNOSTICS_ENTRY_BYTES {
275            return;
276        }
277    }
278
279    if fits(diagnostics) {
280        return;
281    }
282
283    let original_count = diagnostics.len();
284
285    // Prefer dropping lower-severity diagnostics first (a stable sort, so
286    // same-severity diagnostics keep their original -- typically
287    // file-position -- relative order), then keep the largest prefix that
288    // actually fits rather than a flat halve, which both overshoots (a
289    // list one byte over the cap would otherwise lose half its
290    // diagnostics) and was severity-blind (would keep hundreds of leading
291    // HINT-level noise over a later ERROR). At least one diagnostic is
292    // always kept here so the mitigations below have a survivor to act on.
293    diagnostics.sort_by_key(diagnostic_severity_rank);
294    let keep = largest_fitting_prefix(diagnostics, fits).max(1);
295    diagnostics.truncate(keep);
296    if diagnostics.len() < original_count {
297        warn!(
298            "diagnostics for {} exceeded the {MAX_DIAGNOSTICS_ENTRY_BYTES}-byte cache cap; kept \
299             the {} highest-severity of {original_count} diagnostics",
300            uri.as_ref(),
301            diagnostics.len(),
302        );
303    }
304
305    // Drop opaque/structured fields first -- cheap, and often enough on
306    // its own (e.g. the single-huge-`data`-blob shape).
307    if diagnostics.len() == 1 && !fits(diagnostics) {
308        let diagnostic = &mut diagnostics[0];
309        let had_data = diagnostic.data.is_some();
310        diagnostic.data = None;
311        diagnostic.code_description = None;
312        diagnostic.related_information = None;
313        diagnostic.tags = None;
314        warn!(
315            "diagnostic for {} exceeded the cache cap; dropped its data/code_description/\
316             related_information/tags fields{}",
317            uri.as_ref(),
318            if had_data {
319                " (a later code-action request for this diagnostic may not resolve its quick fix)"
320            } else {
321                ""
322            },
323        );
324    }
325
326    // Still oversized: `source`/`code` (plain strings, unlike the opaque
327    // fields above) are truncated rather than dropped, to preserve some
328    // content.
329    if diagnostics.len() == 1 && !fits(diagnostics) {
330        let diagnostic = &mut diagnostics[0];
331        if let Some(source) = &diagnostic.source {
332            diagnostic.source = Some(truncate_str(source, MAX_ENTRY_TEXT_BYTES));
333        }
334        if let Some(lsp_types::Code::String(code)) = &diagnostic.code {
335            diagnostic.code = Some(lsp_types::Code::String(truncate_str(
336                code,
337                MAX_ENTRY_TEXT_BYTES,
338            )));
339        }
340    }
341
342    // Terminal enforcement: guarantee the postcondition directly rather
343    // than trusting the mitigations above to have covered every case --
344    // see this function's doc.
345    if !fits(diagnostics) {
346        diagnostics.truncate(1);
347        if let Some(diagnostic) = diagnostics.first_mut() {
348            let placeholder = lsp_types::Message::String(String::new());
349            diagnostic.message = truncate_message(
350                std::mem::replace(&mut diagnostic.message, placeholder),
351                DIAGNOSTIC_TERMINAL_FALLBACK_MESSAGE_BYTES,
352            );
353            diagnostic.source = None;
354            diagnostic.code = None;
355            diagnostic.code_description = None;
356            diagnostic.related_information = None;
357            diagnostic.tags = None;
358            diagnostic.data = None;
359        }
360        warn!(
361            "diagnostic for {} still exceeded the cache cap after every other mitigation; \
362             truncated its message to {DIAGNOSTIC_TERMINAL_FALLBACK_MESSAGE_BYTES} bytes and \
363             cleared all other fields",
364            uri.as_ref(),
365        );
366    }
367}
368
369/// Information about diagnostics for a document.
370#[derive(Debug, Clone, Serialize, Deserialize)]
371pub struct DiagnosticInfo {
372    /// URI of the document.
373    pub uri: Uri,
374    /// Document version when diagnostics were received.
375    pub version: Option<i32>,
376    /// List of diagnostics.
377    pub diagnostics: Vec<LspDiagnostic>,
378}
379
380/// A log entry from the LSP server.
381#[derive(Debug, Clone, Serialize, Deserialize)]
382pub struct LogEntry {
383    /// Log level.
384    pub level: LogLevel,
385    /// Log message.
386    pub message: String,
387    /// Timestamp when the log was received.
388    pub timestamp: DateTime<Utc>,
389}
390
391/// Log severity level.
392#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
393#[serde(rename_all = "lowercase")]
394pub enum LogLevel {
395    /// Error log level.
396    Error,
397    /// Warning log level.
398    Warning,
399    /// Info log level.
400    Info,
401    /// Debug log level.
402    Debug,
403}
404
405impl From<lsp_types::MessageType> for LogLevel {
406    fn from(msg_type: lsp_types::MessageType) -> Self {
407        match msg_type {
408            lsp_types::MessageType::Error => Self::Error,
409            lsp_types::MessageType::Warning => Self::Warning,
410            lsp_types::MessageType::Info => Self::Info,
411            // LOG and unknown message types default to Debug
412            _ => Self::Debug,
413        }
414    }
415}
416
417/// A message from the LSP server.
418#[derive(Debug, Clone, Serialize, Deserialize)]
419pub struct ServerMessage {
420    /// Message type.
421    pub message_type: MessageType,
422    /// Message content.
423    pub message: String,
424    /// Timestamp when the message was received.
425    pub timestamp: DateTime<Utc>,
426}
427
428/// Server message type.
429#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
430#[serde(rename_all = "lowercase")]
431pub enum MessageType {
432    /// Error message.
433    Error,
434    /// Warning message.
435    Warning,
436    /// Info message.
437    Info,
438    /// Log message.
439    Log,
440}
441
442impl From<lsp_types::MessageType> for MessageType {
443    fn from(msg_type: lsp_types::MessageType) -> Self {
444        match msg_type {
445            lsp_types::MessageType::Error => Self::Error,
446            lsp_types::MessageType::Warning => Self::Warning,
447            lsp_types::MessageType::Info => Self::Info,
448            // LOG and unknown message types default to Log
449            _ => Self::Log,
450        }
451    }
452}
453
454/// Cache for LSP server notifications.
455#[derive(Debug)]
456pub struct NotificationCache {
457    /// Diagnostics indexed by document URI.
458    diagnostics: HashMap<String, DiagnosticInfo>,
459    /// Server that currently owns each cached URI, so an entry's order map
460    /// can be found without scanning every server's.
461    diagnostics_owners: HashMap<String, ServerId>,
462    /// Per-server `diagnostics` keys ordered oldest-write-first, keyed by a
463    /// monotonic sequence number rather than position: a re-publish removes
464    /// its old entry by key in `O(log n)` (via `diagnostic_seq`) instead of
465    /// scanning for it, which a plain `VecDeque` would require. Not
466    /// independently capped per server -- only the aggregate across all
467    /// servers is bounded, by `MAX_DIAGNOSTIC_ENTRIES` -- but each server's
468    /// own map length is what eviction compares against its fair share (see
469    /// [`NotificationCache::server_to_evict_from`]) to decide which server
470    /// loses an entry once the aggregate is full, so one server's write
471    /// volume can never evict another's entries while it still has room
472    /// left in the global budget (#266, #276). Kept in sync with
473    /// `diagnostics` by every method that adds or removes an entry.
474    diagnostic_order: HashMap<ServerId, BTreeMap<u64, String>>,
475    /// Maps each cached URI to its current sequence number in its owner's
476    /// `diagnostic_order` map, so a re-publish or clear can find and remove
477    /// its old order entry without scanning.
478    diagnostic_seq: HashMap<String, u64>,
479    /// Next sequence number to assign in `diagnostic_order`. Shared across
480    /// every server's order map and monotonically increasing for the
481    /// cache's lifetime; never reused, so it never collides with an older
482    /// entry still pending eviction.
483    next_diagnostic_seq: u64,
484    /// Number of registered diagnostics-route servers currently sharing the
485    /// `MAX_DIAGNOSTIC_ENTRIES` budget, explicitly configured via
486    /// [`NotificationCache::set_diagnostics_route_count`].
487    ///
488    /// `None` until that setter is called -- `per_server_budget` then falls
489    /// back to the number of servers whose `diagnostic_order` entry is
490    /// non-empty (i.e. currently holds at least one entry) rather than
491    /// treating an unset count as `1`, which used to hand a single early
492    /// publisher the entire budget with no fair-share partitioning at all
493    /// (#283). The explicit setter remains the preferred path when the
494    /// caller knows it up front: it pre-accounts for servers that are
495    /// registered but have not published anything yet, avoiding a window
496    /// where an early publisher is temporarily over-allocated before a
497    /// slower server's first write grows `diagnostic_order`.
498    diagnostics_route_count: Option<usize>,
499
500    /// Count of entries in `diagnostics` whose diagnostics list is currently
501    /// empty (`[]`), i.e. an LSP server reporting a previously-tracked file
502    /// as now clean. A plain counter, not a duplicated key set, so `0` is an
503    /// `O(1)` signal that lets `entry_to_evict` skip its empty-entry search
504    /// entirely in the common steady state of a codebase full of real
505    /// diagnostics (#284) -- which entry is empty is still answered by
506    /// looking the key up in `diagnostics` itself (see the private
507    /// `is_empty_entry`), not by mirroring membership here. Kept in sync by
508    /// every method that adds or removes a `diagnostics` entry.
509    empty_diagnostics_count: usize,
510    /// Recent log entries (FIFO queue with max size).
511    logs: VecDeque<LogEntry>,
512    /// Recent server messages (FIFO queue with max size).
513    messages: VecDeque<ServerMessage>,
514    /// Server ids whose `textDocument/publishDiagnostics` push notifications
515    /// are known to be dark: `Translator::respawn_if_dead` replaced a crashed
516    /// process for this id, and the replacement's notification receiver is
517    /// drained and discarded rather than wired into a running
518    /// `diagnostics_pump` (#249's documented trade-off -- the pump's
519    /// remaining dependencies live in `serve_with`'s scope, not
520    /// `Translator`'s). Once marked, an id is never unmarked here: only a
521    /// full mcpls process restart actually restores push diagnostics for
522    /// that server, so clearing this on a later respawn attempt would
523    /// misreport the cache as fresh again.
524    push_degraded: HashSet<ServerId>,
525    /// Workspace-indexing readiness per server, driven by recognized
526    /// out-of-band signals (rust-analyzer's `experimental/serverStatus`, and
527    /// a generic `$/progress` `begin`/`end` sequence) rather than the
528    /// `initialize`/`initialized` handshake. See
529    /// [`Self::observe_indexing_signal`] and [`Self::observe_progress`].
530    indexing: IndexingTracker,
531}
532
533impl Default for NotificationCache {
534    fn default() -> Self {
535        Self::new()
536    }
537}
538
539impl NotificationCache {
540    /// Create a new notification cache.
541    #[must_use]
542    pub fn new() -> Self {
543        Self {
544            diagnostics: HashMap::with_capacity(32),
545            diagnostics_owners: HashMap::with_capacity(32),
546            diagnostic_order: HashMap::new(),
547            diagnostic_seq: HashMap::with_capacity(32),
548            next_diagnostic_seq: 0,
549            diagnostics_route_count: None,
550            empty_diagnostics_count: 0,
551            logs: VecDeque::with_capacity(MAX_LOG_ENTRIES),
552            messages: VecDeque::with_capacity(MAX_SERVER_MESSAGES),
553            push_degraded: HashSet::new(),
554            indexing: IndexingTracker::new(),
555        }
556    }
557
558    /// Configure how many diagnostics-route servers share the global
559    /// `MAX_DIAGNOSTIC_ENTRIES` budget.
560    ///
561    /// Each server's fair share becomes `MAX_DIAGNOSTIC_ENTRIES / count`
562    /// (minimum 1). This does not cap any server's entries by itself -- the
563    /// aggregate cache is only ever trimmed once it reaches
564    /// `MAX_DIAGNOSTIC_ENTRIES` total -- it only decides, at that point,
565    /// which server's oldest entry is the one that gets evicted. Call once
566    /// after server registration completes and before diagnostics start
567    /// flowing, to pre-account for servers that are registered but have not
568    /// published anything yet. If never called, `per_server_budget` derives
569    /// the count from the number of servers currently holding at least one
570    /// entry instead (#283) -- a consumer that forgets to call this still
571    /// gets fair-share partitioning once more than one server has written an
572    /// entry, rather than silently handing the whole budget to a single
573    /// early publisher.
574    pub fn set_diagnostics_route_count(&mut self, count: usize) {
575        self.diagnostics_route_count = Some(count.max(1));
576    }
577
578    /// Current per-server fair share of `MAX_DIAGNOSTIC_ENTRIES`, divided
579    /// evenly across the configured server count and floored at 1 so a
580    /// large server count can never reduce a server's share to zero.
581    ///
582    /// Uses the explicit count from [`Self::set_diagnostics_route_count`]
583    /// when set; otherwise falls back to the number of servers whose
584    /// `diagnostic_order` entry is non-empty (#283) -- matching
585    /// `server_to_evict_from`'s own `!order.is_empty()` filter, so a server
586    /// that has been fully evicted or reassigned away from (an empty but
587    /// still-present order map) is not double-counted in the denominator.
588    /// Both are floored at 1 so a fresh cache with no entries and no
589    /// explicit count yet still yields a usable budget instead of dividing
590    /// by zero.
591    ///
592    /// This is a tie-breaker for eviction, not a hard per-server cap: a
593    /// server may hold more than its fair share of entries at any time, as
594    /// long as the aggregate across all servers stays within
595    /// `MAX_DIAGNOSTIC_ENTRIES` (#276).
596    fn per_server_budget(&self) -> usize {
597        let count = self
598            .diagnostics_route_count
599            .unwrap_or_else(|| {
600                self.diagnostic_order
601                    .values()
602                    .filter(|order| !order.is_empty())
603                    .count()
604            })
605            .max(1);
606        (MAX_DIAGNOSTIC_ENTRIES / count).max(1)
607    }
608
609    /// Picks which server's oldest entry to evict once the aggregate cache
610    /// is full: whichever registered server holds the most entries, if that
611    /// exceeds its fair share ([`Self::per_server_budget`]) -- so a noisy
612    /// server can only ever evict its own entries, never a quiet server's
613    /// that is still within its share (#266). If every server (including
614    /// `writer`) is within its share, falls back to `writer`'s own oldest
615    /// entry, since it is the one currently growing. Falls back further, to
616    /// whichever server holds the most entries regardless of share, only in
617    /// the edge case where `writer` has no entries of its own yet (its very
618    /// first write) while the aggregate is already full purely from other
619    /// servers each individually within their share -- otherwise there
620    /// would be nothing to evict from and the aggregate cap could be
621    /// exceeded despite every server behaving fairly.
622    ///
623    /// Ties in entry count are broken by `ServerId`, not left to
624    /// `HashMap`'s iteration order: `Iterator::max_by_key` returns the
625    /// *last* equally-maximal element it sees, and a `HashMap`'s iteration
626    /// order is randomized per process, so an `order.len()`-only key would
627    /// make the eviction target for a genuine tie vary from run to run.
628    /// Every candidate here is a distinct `diagnostic_order` key, so pairing
629    /// the count with `id.as_str()` makes the sort key unique per server --
630    /// no two entries can ever tie on the full key, which eliminates the
631    /// non-determinism outright rather than just picking a fixed side of it.
632    fn server_to_evict_from(&self, writer: &ServerId) -> Option<ServerId> {
633        let largest = self
634            .diagnostic_order
635            .iter()
636            .filter(|(_, order)| !order.is_empty())
637            .max_by_key(|(id, order)| (order.len(), id.as_str()));
638
639        let budget = self.per_server_budget();
640        if let Some((id, order)) = largest
641            && order.len() > budget
642        {
643            return Some(id.clone());
644        }
645
646        if self
647            .diagnostic_order
648            .get(writer)
649            .is_some_and(|order| !order.is_empty())
650        {
651            return Some(writer.clone());
652        }
653
654        largest.map(|(id, _)| id.clone())
655    }
656
657    /// Whether the cached entry for `key` currently has an empty (`[]`)
658    /// diagnostics list, i.e. an LSP server reporting a previously-tracked
659    /// file as now clean. Derived directly from `diagnostics` rather than
660    /// from a separately maintained key set, so there is nothing else to
661    /// keep in sync (#284).
662    fn is_empty_entry(&self, key: &str) -> bool {
663        self.diagnostics
664            .get(key)
665            .is_some_and(|info| info.diagnostics.is_empty())
666    }
667
668    /// Oldest entry in `server`'s own order map whose diagnostics list is
669    /// empty, if it has one.
670    fn oldest_empty_entry_in(&self, server: &ServerId) -> Option<(u64, String)> {
671        let order = self.diagnostic_order.get(server)?;
672        order
673            .iter()
674            .find(|(_, key)| self.is_empty_entry(key))
675            .map(|(&seq, key)| (seq, key.clone()))
676    }
677
678    /// Which single entry to remove next when the aggregate cache is full
679    /// and a genuinely new URI needs room, returned as `(owner, seq, key)`
680    /// so the caller can remove it from every index it appears in.
681    ///
682    /// [`Self::server_to_evict_from`] decides which server is fairness's
683    /// primary target; this picks *which of that server's entries* to
684    /// actually remove, preferring an empty (`[]`) one over its
685    /// strictly-oldest entry wherever one can be found without disturbing a
686    /// server that is within its own fair share (#284):
687    ///
688    /// 1. If the chosen victim itself holds an empty entry, evict its oldest
689    ///    one -- a `[]` publish carries no diagnostic content to lose, so
690    ///    this lets an older, still-meaningful entry from the same server
691    ///    survive in its place.
692    /// 2. Otherwise, if some *other* server that also exceeds
693    ///    [`Self::per_server_budget`] holds an empty entry, evict that one
694    ///    instead of destroying the chosen victim's real diagnostics (S1):
695    ///    fairness only protects a server that is within its share, so an
696    ///    over-share server's own clean entry is fair game regardless of
697    ///    which over-share server `server_to_evict_from` happened to name.
698    ///    Ties use the same `(count, id)` key as `server_to_evict_from`, for
699    ///    the same determinism reason.
700    /// 3. Otherwise -- no empty entry exists anywhere over-budget -- falls
701    ///    back to the chosen victim's strictly-oldest entry, exactly as
702    ///    before #284.
703    ///
704    /// Step 1/2's search is skipped entirely when `empty_diagnostics_count`
705    /// is `0`, so the common steady state (a codebase full of real
706    /// diagnostics, no clean-file churn) pays no extra cost over a plain
707    /// oldest-first lookup (#284).
708    fn entry_to_evict(&self, writer: &ServerId) -> Option<(ServerId, u64, String)> {
709        let evict_from = self.server_to_evict_from(writer)?;
710
711        if self.empty_diagnostics_count > 0 {
712            if let Some((seq, key)) = self.oldest_empty_entry_in(&evict_from) {
713                return Some((evict_from, seq, key));
714            }
715
716            let budget = self.per_server_budget();
717            let cross_server_pick = self
718                .diagnostic_order
719                .iter()
720                .filter(|(id, order)| order.len() > budget && *id != &evict_from)
721                .filter_map(|(id, order)| {
722                    self.oldest_empty_entry_in(id)
723                        .map(|(seq, key)| (id, order.len(), seq, key))
724                })
725                .max_by_key(|(id, len, ..)| (*len, id.as_str()));
726
727            if let Some((id, _, seq, key)) = cross_server_pick {
728                return Some((id.clone(), seq, key));
729            }
730        }
731
732        let order = self.diagnostic_order.get(&evict_from)?;
733        let (&seq, key) = order.iter().next()?;
734        Some((evict_from, seq, key.clone()))
735    }
736
737    /// Store diagnostics for a document published by `server_id`.
738    ///
739    /// Each diagnostic's `message` is truncated to `MAX_ENTRY_TEXT_BYTES`,
740    /// and the whole list is bounded to `MAX_DIAGNOSTICS_ENTRY_BYTES`
741    /// serialized bytes, before storing (#311). When that bound requires
742    /// dropping diagnostics, the *survivors* come back sorted by severity
743    /// (`diagnostic_severity_rank`: `ERROR` first), not in the original
744    /// publish/file-position order -- see [`Self::diagnostics`].
745    ///
746    /// If diagnostics already exist for the URI, they are replaced and the
747    /// entry is repositioned to the back of its owner's eviction order, so
748    /// a URI republished on every edit is tracked as most-recently-written
749    /// and evicted last, not first -- and, since it is not a new distinct
750    /// URI, never triggers eviction on its own.
751    ///
752    /// Eviction is work-conserving (#276): storing diagnostics for a
753    /// genuinely new URI only evicts an existing entry once the *aggregate*
754    /// across every server reaches `MAX_DIAGNOSTIC_ENTRIES`, and then only
755    /// the least-recently-written entry of whichever server most exceeds its
756    /// fair share, or -- per the fallbacks documented on
757    /// `server_to_evict_from` -- the writer's own oldest entry when no
758    /// server exceeds its share. A quieter, non-writer server that is within
759    /// its fair share is never touched, outside the narrow edge case also
760    /// documented there. This lets a single active server use the full
761    /// aggregate budget while other registered servers are idle, instead of
762    /// being capped at a static equal split regardless of how much of it
763    /// they actually use. Which exact entry is removed is further refined by
764    /// emptiness -- see the private `entry_to_evict` (#284).
765    ///
766    /// # Examples
767    ///
768    /// ```
769    /// use mcpls_core::bridge::NotificationCache;
770    /// use mcpls_core::config::ServerId;
771    /// use lsp_types::Uri;
772    ///
773    /// let mut cache = NotificationCache::new();
774    /// let server: ServerId = "rust-analyzer".into();
775    /// let uri: Uri = Uri::from("file:///main.rs");
776    /// cache.store_diagnostics(&server, &uri, Some(1), vec![]);
777    /// assert!(cache.diagnostics(uri.as_ref()).is_some());
778    /// ```
779    pub fn store_diagnostics(
780        &mut self,
781        server_id: &ServerId,
782        uri: &Uri,
783        version: Option<i32>,
784        mut diagnostics: Vec<LspDiagnostic>,
785    ) {
786        // Bound each diagnostic's free-form message text (#311); see
787        // `MAX_ENTRY_TEXT_BYTES`. `mem::take` + `truncate_string` avoids an
788        // extra clone on the common (already-under-limit) path, since
789        // `message` is already an owned `String` here.
790        for diagnostic in &mut diagnostics {
791            let placeholder = lsp_types::Message::String(String::new());
792            diagnostic.message = truncate_message(
793                std::mem::replace(&mut diagnostic.message, placeholder),
794                MAX_ENTRY_TEXT_BYTES,
795            );
796        }
797        // Bound the whole list's serialized size (#311 C1); see
798        // `MAX_DIAGNOSTICS_ENTRY_BYTES`.
799        cap_diagnostics_entry_size(uri, &mut diagnostics);
800
801        let key = uri_cache_key(uri.as_ref()).into_owned();
802        let info = DiagnosticInfo {
803            uri: uri.clone(),
804            version,
805            diagnostics,
806        };
807
808        // Remove the URI's existing order entry, if any -- from its
809        // previous owner's order map, whether that's this same server (a
810        // republish, repositioned to the back below) or a different one
811        // (the diagnostics route changed, e.g. on respawn). Also tells us
812        // whether this store adds a new entry to the aggregate (and so may
813        // need to evict to stay within budget) or merely replaces one.
814        let mut is_new_entry = true;
815        if let Some(old_seq) = self.diagnostic_seq.remove(&key) {
816            is_new_entry = false;
817            if let Some(previous_owner) = self.diagnostics_owners.get(&key)
818                && let Some(order) = self.diagnostic_order.get_mut(previous_owner)
819            {
820                order.remove(&old_seq);
821            }
822        }
823
824        if is_new_entry {
825            while self.diagnostics.len() >= MAX_DIAGNOSTIC_ENTRIES
826                && let Some((owner, seq, evict_key)) = self.entry_to_evict(server_id)
827            {
828                if let Some(order) = self.diagnostic_order.get_mut(&owner) {
829                    order.remove(&seq);
830                }
831                self.diagnostic_seq.remove(&evict_key);
832                self.diagnostics_owners.remove(&evict_key);
833                if let Some(removed) = self.diagnostics.remove(&evict_key)
834                    && removed.diagnostics.is_empty()
835                {
836                    self.empty_diagnostics_count -= 1;
837                }
838            }
839        }
840
841        self.diagnostics_owners
842            .insert(key.clone(), server_id.clone());
843        let seq = self.next_diagnostic_seq;
844        self.next_diagnostic_seq += 1;
845        self.diagnostic_order
846            .entry(server_id.clone())
847            .or_default()
848            .insert(seq, key.clone());
849        self.diagnostic_seq.insert(key.clone(), seq);
850
851        // Track the emptiness transition, if any, of the entry this store
852        // replaces (or creates) -- `self.diagnostics` still holds the old
853        // value at this point, since the `insert` below hasn't run yet
854        // (#284: derived from `diagnostics` itself, not a duplicated key
855        // set).
856        let was_empty = self.is_empty_entry(&key);
857        let is_empty_now = info.diagnostics.is_empty();
858        match (was_empty, is_empty_now) {
859            (false, true) => self.empty_diagnostics_count += 1,
860            (true, false) => self.empty_diagnostics_count -= 1,
861            _ => {}
862        }
863        self.diagnostics.insert(key, info);
864    }
865
866    /// Store a log entry.
867    ///
868    /// Maintains a maximum of `MAX_LOG_ENTRIES` entries, removing oldest when full.
869    /// `message` is truncated to `MAX_ENTRY_TEXT_BYTES` before storing.
870    pub fn store_log(&mut self, level: LogLevel, message: String) {
871        let entry = LogEntry {
872            level,
873            message: truncate_string(message, MAX_ENTRY_TEXT_BYTES),
874            timestamp: Utc::now(),
875        };
876
877        if self.logs.len() >= MAX_LOG_ENTRIES {
878            self.logs.pop_front();
879        }
880        self.logs.push_back(entry);
881    }
882
883    /// Store a server message.
884    ///
885    /// Maintains a maximum of `MAX_SERVER_MESSAGES` entries, removing oldest when full.
886    /// `message` is truncated to `MAX_ENTRY_TEXT_BYTES` before storing.
887    pub fn store_message(&mut self, message_type: MessageType, message: String) {
888        let msg = ServerMessage {
889            message_type,
890            message: truncate_string(message, MAX_ENTRY_TEXT_BYTES),
891            timestamp: Utc::now(),
892        };
893
894        if self.messages.len() >= MAX_SERVER_MESSAGES {
895            self.messages.pop_front();
896        }
897        self.messages.push_back(msg);
898    }
899
900    /// Record a workspace-readiness signal from an unrecognized/custom LSP
901    /// notification (`LspNotification::Other`), updating `server_id`'s
902    /// tracked [`IndexingState`] if the notification is one this cache
903    /// understands.
904    ///
905    /// Currently recognizes rust-analyzer's `experimental/serverStatus`
906    /// notification: a `quiescent` boolean of `false` marks the
907    /// server [`IndexingState::Loading`], `true` marks it
908    /// [`IndexingState::Ready`]. Any other method, or a `serverStatus`
909    /// payload missing/malformed the field, leaves the current state
910    /// untouched rather than erroring -- an unrecognized signal is
911    /// equivalent to no signal.
912    ///
913    /// Once a server reaches [`IndexingState::Ready`] it never regresses on
914    /// its own: this only tracks the *initial* workspace load, not later
915    /// re-indexing triggered by large-scale file changes. See
916    /// [`Self::reset_indexing_state`] for the one case that does move a
917    /// server back out of `Ready`/`Loading`.
918    ///
919    /// # Examples
920    ///
921    /// ```
922    /// use mcpls_core::bridge::{IndexingState, NotificationCache};
923    /// use mcpls_core::config::ServerId;
924    /// use serde_json::json;
925    ///
926    /// let mut cache = NotificationCache::new();
927    /// let id: ServerId = "rust-analyzer".into();
928    /// assert_eq!(cache.indexing_state(&id), IndexingState::Unknown);
929    ///
930    /// let loading = json!({"quiescent": false});
931    /// cache.observe_indexing_signal(&id, "experimental/serverStatus", Some(&loading));
932    /// assert_eq!(cache.indexing_state(&id), IndexingState::Loading);
933    ///
934    /// let ready = json!({"quiescent": true});
935    /// cache.observe_indexing_signal(&id, "experimental/serverStatus", Some(&ready));
936    /// assert_eq!(cache.indexing_state(&id), IndexingState::Ready);
937    /// ```
938    pub fn observe_indexing_signal(
939        &mut self,
940        server_id: &ServerId,
941        method: &str,
942        params: Option<&serde_json::Value>,
943    ) {
944        self.indexing
945            .observe_server_status(server_id, method, params);
946    }
947
948    /// Record a `$/progress` notification toward `server_id`'s tracked
949    /// [`IndexingState`] -- the generic LSP counterpart to
950    /// [`Self::observe_indexing_signal`]'s rust-analyzer-specific
951    /// `experimental/serverStatus`. See
952    /// [`crate::bridge::indexing::IndexingTracker::observe_progress`] for
953    /// the full begin/end/settle/latch transition rules.
954    pub(crate) fn observe_progress(
955        &mut self,
956        server_id: &ServerId,
957        params: &lsp_types::ProgressParams,
958    ) {
959        self.indexing.observe_progress(server_id, params);
960    }
961
962    /// Configure `server_id`'s [`IndexingPolicy`] -- see
963    /// [`crate::bridge::indexing::IndexingTracker::set_policy`].
964    pub(crate) fn set_indexing_policy(&mut self, server_id: ServerId, policy: IndexingPolicy) {
965        self.indexing.set_policy(server_id, policy);
966    }
967
968    /// Current tracked workspace-indexing readiness for `server_id`.
969    ///
970    /// Returns [`IndexingState::Unknown`] for a server no readiness signal
971    /// has ever been observed for -- see [`Self::observe_indexing_signal`].
972    ///
973    /// A `Loading` entry older than `INDEXING_STALENESS_BOUND` is read
974    /// back as `Unknown` rather than `Loading`: this is the self-heal for a
975    /// `quiescent: true` notification dropped by a full channel, or a
976    /// server that stalled mid-index, applied at *read* time based on the
977    /// signal's own age so it can never be triggered by (and can never
978    /// affect) any individual caller's own wait -- see
979    /// `Translator::wait_for_indexing_ready`.
980    #[must_use]
981    pub fn indexing_state(&self, server_id: &ServerId) -> IndexingState {
982        self.indexing.state(server_id)
983    }
984
985    /// Forget `server_id`'s tracked [`IndexingState`], reverting it to
986    /// [`IndexingState::Unknown`].
987    ///
988    /// `Translator::respawn_if_dead` calls this after replacing a crashed
989    /// server's process, since the new process starts indexing from
990    /// scratch and has sent no signal of its own yet -- a stale
991    /// `Ready`/`Loading` carried over from the crashed connection must not
992    /// leak into requests routed to its replacement. This is the only
993    /// production caller: a timed-out [`Self::indexing_state`] read does
994    /// *not* call this, so one caller's wait can never affect another's.
995    ///
996    /// # Examples
997    ///
998    /// ```
999    /// use mcpls_core::bridge::{IndexingState, NotificationCache};
1000    /// use mcpls_core::config::ServerId;
1001    /// use serde_json::json;
1002    ///
1003    /// let mut cache = NotificationCache::new();
1004    /// let id: ServerId = "rust-analyzer".into();
1005    /// cache.observe_indexing_signal(&id, "experimental/serverStatus", Some(&json!({"quiescent": false})));
1006    /// assert_eq!(cache.indexing_state(&id), IndexingState::Loading);
1007    ///
1008    /// cache.reset_indexing_state(&id);
1009    /// assert_eq!(cache.indexing_state(&id), IndexingState::Unknown);
1010    /// ```
1011    pub fn reset_indexing_state(&mut self, server_id: &ServerId) {
1012        self.indexing.reset(server_id);
1013    }
1014
1015    /// Get diagnostics for a document URI.
1016    ///
1017    /// If the stored list was ever truncated by `store_diagnostics`'s
1018    /// `MAX_DIAGNOSTICS_ENTRY_BYTES` cap (#311), the diagnostics here are in
1019    /// severity order (`ERROR` first), not the original publish/file-position
1020    /// order -- callers that assume file-position order should not rely on
1021    /// it after a cap-triggered truncation.
1022    #[inline]
1023    #[must_use]
1024    pub fn diagnostics(&self, uri: &str) -> Option<&DiagnosticInfo> {
1025        self.diagnostics.get(uri_cache_key(uri).as_ref())
1026    }
1027
1028    /// Server that published the currently cached diagnostics for `uri`, if
1029    /// any. Used to look up that server's negotiated position encoding for a
1030    /// cache-only read that has no live LSP round trip of its own to resolve
1031    /// one from.
1032    #[inline]
1033    #[must_use]
1034    pub fn diagnostics_owner(&self, uri: &str) -> Option<&ServerId> {
1035        self.diagnostics_owners.get(uri_cache_key(uri).as_ref())
1036    }
1037
1038    /// All stored log entries.
1039    #[inline]
1040    #[must_use]
1041    pub const fn logs(&self) -> &VecDeque<LogEntry> {
1042        &self.logs
1043    }
1044
1045    /// All stored server messages.
1046    #[inline]
1047    #[must_use]
1048    pub const fn messages(&self) -> &VecDeque<ServerMessage> {
1049        &self.messages
1050    }
1051
1052    /// Clear diagnostics for a specific document URI.
1053    ///
1054    /// Returns the cleared diagnostics if they existed.
1055    pub fn clear_diagnostics(&mut self, uri: &str) -> Option<DiagnosticInfo> {
1056        let key = uri_cache_key(uri).into_owned();
1057        if let Some(owner) = self.diagnostics_owners.remove(&key)
1058            && let Some(seq) = self.diagnostic_seq.remove(&key)
1059            && let Some(order) = self.diagnostic_order.get_mut(&owner)
1060        {
1061            order.remove(&seq);
1062        }
1063        let removed = self.diagnostics.remove(&key);
1064        if removed
1065            .as_ref()
1066            .is_some_and(|info| info.diagnostics.is_empty())
1067        {
1068            self.empty_diagnostics_count -= 1;
1069        }
1070        removed
1071    }
1072
1073    /// Clear all diagnostics owned by a single server.
1074    ///
1075    /// Used when a server crashes and respawns: its own stale entries must
1076    /// be invalidated without disturbing any other server's cache entries
1077    /// (#266), unlike [`Self::clear_all_diagnostics`].
1078    ///
1079    /// # Examples
1080    ///
1081    /// ```
1082    /// use mcpls_core::bridge::NotificationCache;
1083    /// use mcpls_core::config::ServerId;
1084    /// use lsp_types::Uri;
1085    ///
1086    /// let mut cache = NotificationCache::new();
1087    /// let crashed: ServerId = "pyright".into();
1088    /// let healthy: ServerId = "rust-analyzer".into();
1089    /// let crashed_uri: Uri = Uri::from("file:///main.py");
1090    /// let healthy_uri: Uri = Uri::from("file:///main.rs");
1091    /// cache.store_diagnostics(&crashed, &crashed_uri, Some(1), vec![]);
1092    /// cache.store_diagnostics(&healthy, &healthy_uri, Some(1), vec![]);
1093    ///
1094    /// cache.clear_server_diagnostics(&crashed);
1095    ///
1096    /// assert!(cache.diagnostics(crashed_uri.as_ref()).is_none());
1097    /// assert!(cache.diagnostics(healthy_uri.as_ref()).is_some());
1098    /// ```
1099    pub fn clear_server_diagnostics(&mut self, server_id: &ServerId) {
1100        let Some(order) = self.diagnostic_order.remove(server_id) else {
1101            return;
1102        };
1103        for (_, key) in order {
1104            if self
1105                .diagnostics
1106                .remove(&key)
1107                .is_some_and(|info| info.diagnostics.is_empty())
1108            {
1109                self.empty_diagnostics_count -= 1;
1110            }
1111            self.diagnostics_owners.remove(&key);
1112            self.diagnostic_seq.remove(&key);
1113        }
1114    }
1115
1116    /// Marks `server_id`'s push-based diagnostics as no longer live -- see
1117    /// the `push_degraded` field doc for why this is permanent for the life
1118    /// of the cache.
1119    ///
1120    /// # Examples
1121    ///
1122    /// ```
1123    /// use mcpls_core::bridge::NotificationCache;
1124    /// use mcpls_core::config::ServerId;
1125    ///
1126    /// let mut cache = NotificationCache::new();
1127    /// let id: ServerId = "rust-analyzer".into();
1128    /// assert!(!cache.is_push_degraded(&id));
1129    /// cache.mark_push_degraded(&id);
1130    /// assert!(cache.is_push_degraded(&id));
1131    /// ```
1132    pub fn mark_push_degraded(&mut self, server_id: &ServerId) {
1133        self.push_degraded.insert(server_id.clone());
1134    }
1135
1136    /// Whether `server_id`'s push-based diagnostics are known to be
1137    /// degraded (see [`Self::mark_push_degraded`]) -- callers such as
1138    /// `get_cached_diagnostics` and `read_resource` use this to flag a
1139    /// cache-only result as potentially stale rather than presenting it as
1140    /// current.
1141    #[inline]
1142    #[must_use]
1143    pub fn is_push_degraded(&self, server_id: &ServerId) -> bool {
1144        self.push_degraded.contains(server_id)
1145    }
1146
1147    /// Clear all diagnostics, for every server.
1148    pub fn clear_all_diagnostics(&mut self) {
1149        self.diagnostics.clear();
1150        self.diagnostics_owners.clear();
1151        self.diagnostic_order.clear();
1152        self.diagnostic_seq.clear();
1153        self.empty_diagnostics_count = 0;
1154    }
1155
1156    /// Clear all logs.
1157    pub fn clear_logs(&mut self) {
1158        self.logs.clear();
1159    }
1160
1161    /// Clear all messages.
1162    pub fn clear_messages(&mut self) {
1163        self.messages.clear();
1164    }
1165
1166    /// Get the number of documents with stored diagnostics.
1167    #[inline]
1168    #[must_use]
1169    pub fn diagnostics_count(&self) -> usize {
1170        self.diagnostics.len()
1171    }
1172
1173    /// Get the number of stored log entries.
1174    #[inline]
1175    #[must_use]
1176    pub fn logs_count(&self) -> usize {
1177        self.logs.len()
1178    }
1179
1180    /// Get the number of stored server messages.
1181    #[inline]
1182    #[must_use]
1183    pub fn messages_count(&self) -> usize {
1184        self.messages.len()
1185    }
1186}
1187
1188/// Applies one lifecycle-lane notification (`$/progress` or the generic
1189/// `Other` variant, which carries e.g. rust-analyzer's
1190/// `experimental/serverStatus`) to `cache` -- the only two variants that
1191/// lane ever carries, since the notification lane handles
1192/// diagnostics/log/showMessage instead (see
1193/// `LspClient::message_loop_inner`'s routing).
1194///
1195/// Shared by `diagnostics_pump`'s lifecycle-lane arm (wiring a freshly
1196/// spawned server's own pump) and `Translator::respawn_if_dead`'s
1197/// lifecycle-lane forwarding task (wiring a respawned server's replacement
1198/// process), so the two can't silently drift apart on which notification
1199/// variants feed which `NotificationCache` method.
1200pub fn apply_lifecycle_notification(
1201    cache: &mut NotificationCache,
1202    server_id: &ServerId,
1203    notif: crate::lsp::LspNotification,
1204) {
1205    match notif {
1206        crate::lsp::LspNotification::Progress(params) => {
1207            cache.observe_progress(server_id, &params);
1208        }
1209        crate::lsp::LspNotification::Other { method, params } => {
1210            cache.observe_indexing_signal(server_id, &method, params.as_ref());
1211        }
1212        crate::lsp::LspNotification::PublishDiagnostics(_)
1213        | crate::lsp::LspNotification::LogMessage(_)
1214        | crate::lsp::LspNotification::ShowMessage(_) => {}
1215    }
1216}
1217
1218#[cfg(test)]
1219#[allow(clippy::unwrap_used)]
1220mod tests {
1221    use lsp_types::{Position, Range};
1222
1223    use super::*;
1224    use crate::test_lsp::CapturedLogs;
1225
1226    /// Every test in this module that doesn't exercise multi-server
1227    /// fairness routes through one implicit server, so `set_diagnostics_route_count`
1228    /// is left at its default of `1` (full `MAX_DIAGNOSTIC_ENTRIES` budget).
1229    fn test_server() -> ServerId {
1230        ServerId::from("test-server")
1231    }
1232
1233    #[test]
1234    fn test_notification_cache_new() {
1235        let cache = NotificationCache::new();
1236        assert_eq!(cache.diagnostics_count(), 0);
1237        assert_eq!(cache.logs_count(), 0);
1238        assert_eq!(cache.messages_count(), 0);
1239    }
1240
1241    #[test]
1242    fn test_store_and_diagnostics() {
1243        let mut cache = NotificationCache::new();
1244        let uri: Uri = Uri::from("file:///test.rs");
1245
1246        let diagnostic = LspDiagnostic {
1247            range: Range {
1248                start: Position {
1249                    line: 0,
1250                    character: 0,
1251                },
1252                end: Position {
1253                    line: 0,
1254                    character: 5,
1255                },
1256            },
1257            severity: Some(lsp_types::DiagnosticSeverity::Error),
1258            message: "test error".to_string().into(),
1259            code: None,
1260            source: None,
1261            code_description: None,
1262            related_information: None,
1263            tags: None,
1264            data: None,
1265        };
1266
1267        cache.store_diagnostics(&test_server(), &uri, Some(1), vec![diagnostic]);
1268
1269        let stored = cache.diagnostics(uri.as_ref()).unwrap();
1270        assert_eq!(stored.uri, uri);
1271        assert_eq!(stored.version, Some(1));
1272        assert_eq!(stored.diagnostics.len(), 1);
1273        assert_eq!(
1274            stored.diagnostics[0].message,
1275            lsp_types::Message::String("test error".to_string())
1276        );
1277    }
1278
1279    /// #311: a single diagnostic's `message` must be bounded independently
1280    /// of `MAX_DIAGNOSTIC_ENTRIES`, which only caps the number of entries.
1281    #[test]
1282    fn test_store_diagnostics_truncates_oversized_message() {
1283        let mut cache = NotificationCache::new();
1284        let uri: Uri = Uri::from("file:///test.rs");
1285        let oversized = "a".repeat(MAX_ENTRY_TEXT_BYTES + 100);
1286
1287        let diagnostic = LspDiagnostic {
1288            range: Range {
1289                start: Position {
1290                    line: 0,
1291                    character: 0,
1292                },
1293                end: Position {
1294                    line: 0,
1295                    character: 5,
1296                },
1297            },
1298            severity: Some(lsp_types::DiagnosticSeverity::Error),
1299            message: oversized.clone().into(),
1300            code: None,
1301            source: None,
1302            code_description: None,
1303            related_information: None,
1304            tags: None,
1305            data: None,
1306        };
1307
1308        cache.store_diagnostics(&test_server(), &uri, Some(1), vec![diagnostic]);
1309
1310        let stored = cache.diagnostics(uri.as_ref()).unwrap();
1311        let stored = message_as_str(&stored.diagnostics[0].message);
1312        assert!(stored.len() < oversized.len());
1313        assert!(stored.ends_with("... (truncated)"));
1314    }
1315
1316    /// Minimal diagnostic with an arbitrary `message`, for tests that only
1317    /// care about size/count bounds rather than range/severity details.
1318    fn minimal_diagnostic(message: String) -> LspDiagnostic {
1319        LspDiagnostic {
1320            range: Range {
1321                start: Position {
1322                    line: 0,
1323                    character: 0,
1324                },
1325                end: Position {
1326                    line: 0,
1327                    character: 5,
1328                },
1329            },
1330            severity: Some(lsp_types::DiagnosticSeverity::Error),
1331            message: message.into(),
1332            code: None,
1333            source: None,
1334            code_description: None,
1335            related_information: None,
1336            tags: None,
1337            data: None,
1338        }
1339    }
1340
1341    /// #311 C1: `MAX_ENTRY_TEXT_BYTES` alone bounds one `message` field, not
1342    /// the whole entry -- many diagnostics, each individually small, must
1343    /// still be capped in aggregate.
1344    #[test]
1345    fn test_store_diagnostics_caps_aggregate_size_for_many_small_diagnostics() {
1346        let mut cache = NotificationCache::new();
1347        let uri: Uri = Uri::from("file:///test.rs");
1348
1349        // Each diagnostic is far under MAX_ENTRY_TEXT_BYTES individually,
1350        // but 5000 of them comfortably exceeds MAX_DIAGNOSTICS_ENTRY_BYTES
1351        // in aggregate.
1352        let diagnostics: Vec<LspDiagnostic> = (0..5000)
1353            .map(|i| {
1354                minimal_diagnostic(format!(
1355                    "diagnostic number {i}, padded: {}",
1356                    "x".repeat(200)
1357                ))
1358            })
1359            .collect();
1360        let original_count = diagnostics.len();
1361
1362        cache.store_diagnostics(&test_server(), &uri, Some(1), diagnostics);
1363
1364        let stored = &cache.diagnostics(uri.as_ref()).unwrap().diagnostics;
1365        assert!(
1366            stored.len() < original_count,
1367            "aggregate cap must trim the list, kept {} of {original_count}",
1368            stored.len()
1369        );
1370        assert!(!stored.is_empty(), "must keep at least one diagnostic");
1371        let serialized_len = serde_json::to_vec(stored).unwrap().len();
1372        assert!(
1373            serialized_len <= MAX_DIAGNOSTICS_ENTRY_BYTES,
1374            "stored entry must fit the aggregate cap, got {serialized_len} bytes"
1375        );
1376    }
1377
1378    /// #311 S6: a naive flat halve would keep only the first N/2
1379    /// diagnostics even when far more than that would actually fit --
1380    /// truncation must find the largest prefix that fits instead.
1381    #[test]
1382    fn test_store_diagnostics_truncation_keeps_largest_fitting_prefix() {
1383        let mut cache = NotificationCache::new();
1384        let uri: Uri = Uri::from("file:///test.rs");
1385
1386        // Each diagnostic serializes to roughly 300 bytes; ~3800 of them
1387        // fit under the 1 MiB cap, well over half of the 5000 published --
1388        // a flat halve would incorrectly stop at 2500.
1389        let diagnostics: Vec<LspDiagnostic> = (0..5000)
1390            .map(|i| minimal_diagnostic(format!("diagnostic {i}: {}", "x".repeat(250))))
1391            .collect();
1392
1393        cache.store_diagnostics(&test_server(), &uri, Some(1), diagnostics);
1394
1395        let stored = &cache.diagnostics(uri.as_ref()).unwrap().diagnostics;
1396        assert!(
1397            stored.len() > 2600,
1398            "largest-fitting-prefix search must keep far more than half, kept {}",
1399            stored.len()
1400        );
1401        let serialized_len = serde_json::to_vec(stored).unwrap().len();
1402        assert!(serialized_len <= MAX_DIAGNOSTICS_ENTRY_BYTES);
1403        // The search must find the *largest* fitting prefix, not just *a*
1404        // fitting one: one more diagnostic than what was kept must no
1405        // longer fit (otherwise it should have been kept too).
1406        let mut with_one_more = stored.clone();
1407        with_one_more.push(minimal_diagnostic(format!(
1408            "diagnostic overflow: {}",
1409            "x".repeat(250)
1410        )));
1411        assert!(
1412            serde_json::to_vec(&with_one_more).unwrap().len() > MAX_DIAGNOSTICS_ENTRY_BYTES,
1413            "kept count must be the largest that fits, not merely a fitting count"
1414        );
1415    }
1416
1417    /// #311 S6: truncation must prefer keeping higher-severity diagnostics,
1418    /// not just whichever the server happened to publish first -- a late
1419    /// `ERROR` must survive over leading `HINT`-level noise.
1420    #[test]
1421    fn test_store_diagnostics_truncation_prefers_higher_severity() {
1422        let mut cache = NotificationCache::new();
1423        let uri: Uri = Uri::from("file:///test.rs");
1424
1425        let mut diagnostics: Vec<LspDiagnostic> = (0..5000)
1426            .map(|i| {
1427                let mut d = minimal_diagnostic(format!("hint {i}: {}", "x".repeat(200)));
1428                d.severity = Some(lsp_types::DiagnosticSeverity::Hint);
1429                d
1430            })
1431            .collect();
1432        let mut trailing_error = minimal_diagnostic("the one real error".to_string());
1433        trailing_error.severity = Some(lsp_types::DiagnosticSeverity::Error);
1434        diagnostics.push(trailing_error);
1435
1436        cache.store_diagnostics(&test_server(), &uri, Some(1), diagnostics);
1437
1438        let stored = &cache.diagnostics(uri.as_ref()).unwrap().diagnostics;
1439        assert!(
1440            stored
1441                .iter()
1442                .any(|d| message_as_str(&d.message) == "the one real error"),
1443            "the trailing ERROR diagnostic must survive truncation over leading HINT noise"
1444        );
1445    }
1446
1447    /// #311 S7 / M7: truncating the diagnostics list must not be silent --
1448    /// a caller with no visibility into this cache would otherwise have no
1449    /// way to know a `get_cached_diagnostics` result is incomplete.
1450    #[test]
1451    fn test_store_diagnostics_warns_when_truncating_list() {
1452        use tracing_subscriber::layer::SubscriberExt as _;
1453
1454        let mut cache = NotificationCache::new();
1455        let uri: Uri = Uri::from("file:///test.rs");
1456        let diagnostics: Vec<LspDiagnostic> = (0..5000)
1457            .map(|i| minimal_diagnostic(format!("diagnostic {i}: {}", "x".repeat(250))))
1458            .collect();
1459
1460        let captured = CapturedLogs::default();
1461        let subscriber = tracing_subscriber::registry().with(captured.clone());
1462        let guard = tracing::subscriber::set_default(subscriber);
1463        cache.store_diagnostics(&test_server(), &uri, Some(1), diagnostics);
1464        drop(guard);
1465
1466        let messages = captured.messages();
1467        assert!(
1468            messages
1469                .iter()
1470                .any(|m| m.contains("highest-severity") && m.contains("file:///test.rs")),
1471            "expected a truncation warning naming the URI, got: {messages:?}"
1472        );
1473    }
1474
1475    /// #311 S7: dropping a diagnostic's `data` breaks the LSP contract that
1476    /// it round-trips to a later `textDocument/codeAction` request -- this
1477    /// must be logged, not silent.
1478    #[test]
1479    fn test_store_diagnostics_warns_when_dropping_data_blob() {
1480        use tracing_subscriber::layer::SubscriberExt as _;
1481
1482        let mut cache = NotificationCache::new();
1483        let uri: Uri = Uri::from("file:///test.rs");
1484        let mut diagnostic = minimal_diagnostic("small message".to_string());
1485        diagnostic.data = Some(serde_json::json!({
1486            "blob": "x".repeat(MAX_DIAGNOSTICS_ENTRY_BYTES + 1000),
1487        }));
1488
1489        let captured = CapturedLogs::default();
1490        let subscriber = tracing_subscriber::registry().with(captured.clone());
1491        let guard = tracing::subscriber::set_default(subscriber);
1492        cache.store_diagnostics(&test_server(), &uri, Some(1), vec![diagnostic]);
1493        drop(guard);
1494
1495        let messages = captured.messages();
1496        assert!(
1497            messages.iter().any(|m| m.contains("code-action")),
1498            "expected a warning noting the code-action quick-fix impact, got: {messages:?}"
1499        );
1500    }
1501
1502    /// #311 C1: a single diagnostic dominated by an oversized `data` blob
1503    /// must be capped even though `message` alone is small -- the aggregate
1504    /// list-halving path can't shrink a one-element list, so the opaque
1505    /// fields on that single diagnostic must be dropped instead.
1506    #[test]
1507    fn test_store_diagnostics_drops_oversized_data_blob_on_single_diagnostic() {
1508        let mut cache = NotificationCache::new();
1509        let uri: Uri = Uri::from("file:///test.rs");
1510
1511        let mut diagnostic = minimal_diagnostic("small message".to_string());
1512        diagnostic.data = Some(serde_json::json!({
1513            "blob": "x".repeat(MAX_DIAGNOSTICS_ENTRY_BYTES + 1000),
1514        }));
1515
1516        cache.store_diagnostics(&test_server(), &uri, Some(1), vec![diagnostic]);
1517
1518        let stored = &cache.diagnostics(uri.as_ref()).unwrap().diagnostics;
1519        assert_eq!(stored.len(), 1);
1520        assert_eq!(
1521            stored[0].message,
1522            lsp_types::Message::String("small message".to_string())
1523        );
1524        assert!(
1525            stored[0].data.is_none(),
1526            "oversized data blob must be dropped"
1527        );
1528        let serialized_len = serde_json::to_vec(stored).unwrap().len();
1529        assert!(
1530            serialized_len <= MAX_DIAGNOSTICS_ENTRY_BYTES,
1531            "stored entry must fit the aggregate cap after dropping data, got {serialized_len} bytes"
1532        );
1533    }
1534
1535    /// #311 C1 follow-up: an oversized `source` (not `data`) on a single
1536    /// diagnostic must also be brought back under the cap -- the
1537    /// opaque-field-drop mitigation alone does not touch `source`, which is
1538    /// a plain string and must be truncated instead.
1539    #[test]
1540    fn test_store_diagnostics_truncates_oversized_source_on_single_diagnostic() {
1541        let mut cache = NotificationCache::new();
1542        let uri: Uri = Uri::from("file:///test.rs");
1543
1544        let mut diagnostic = minimal_diagnostic("small message".to_string());
1545        diagnostic.source = Some("x".repeat(MAX_DIAGNOSTICS_ENTRY_BYTES + 1000));
1546
1547        cache.store_diagnostics(&test_server(), &uri, Some(1), vec![diagnostic]);
1548
1549        let stored = &cache.diagnostics(uri.as_ref()).unwrap().diagnostics;
1550        assert_eq!(stored.len(), 1);
1551        assert_eq!(
1552            stored[0].message,
1553            lsp_types::Message::String("small message".to_string())
1554        );
1555        let serialized_len = serde_json::to_vec(stored).unwrap().len();
1556        assert!(
1557            serialized_len <= MAX_DIAGNOSTICS_ENTRY_BYTES,
1558            "stored entry must fit the aggregate cap after truncating source, got {serialized_len} bytes"
1559        );
1560    }
1561
1562    /// #311 C1 follow-up: `cap_diagnostics_entry_size`'s postcondition --
1563    /// the result always fits `MAX_DIAGNOSTICS_ENTRY_BYTES` -- must hold
1564    /// even when every uncapped field is maxed out simultaneously, not just
1565    /// one at a time. This is the terminal-enforcement guarantee itself,
1566    /// exercised end to end through `store_diagnostics` rather than by
1567    /// calling the private function directly.
1568    #[test]
1569    fn test_store_diagnostics_caps_single_diagnostic_with_every_field_maxed_out() {
1570        let mut cache = NotificationCache::new();
1571        let uri: Uri = Uri::from("file:///test.rs");
1572
1573        // Each field individually exceeds MAX_ENTRY_TEXT_BYTES (so
1574        // source/code truncation is exercised) and the combination exceeds
1575        // MAX_DIAGNOSTICS_ENTRY_BYTES, without needing to allocate multiple
1576        // megabytes per field just to prove the same point.
1577        let mut diagnostic = minimal_diagnostic("x".repeat(MAX_ENTRY_TEXT_BYTES + 1000));
1578        diagnostic.source = Some("x".repeat(MAX_ENTRY_TEXT_BYTES + 1000));
1579        diagnostic.code = Some(lsp_types::Code::String(
1580            "x".repeat(MAX_ENTRY_TEXT_BYTES + 1000),
1581        ));
1582        diagnostic.data = Some(serde_json::json!({ "blob": "x".repeat(MAX_ENTRY_TEXT_BYTES) }));
1583        diagnostic.tags = Some(vec![lsp_types::DiagnosticTag::Unnecessary; 50]);
1584        diagnostic.related_information = Some(vec![
1585            lsp_types::DiagnosticRelatedInformation {
1586                location: lsp_types::Location {
1587                    uri: uri.clone(),
1588                    range: Range::default(),
1589                },
1590                message: "x".repeat(1000),
1591            };
1592            5
1593        ]);
1594
1595        cache.store_diagnostics(&test_server(), &uri, Some(1), vec![diagnostic]);
1596
1597        let stored = &cache.diagnostics(uri.as_ref()).unwrap().diagnostics;
1598        assert_eq!(stored.len(), 1);
1599        let serialized_len = serde_json::to_vec(stored).unwrap().len();
1600        assert!(
1601            serialized_len <= MAX_DIAGNOSTICS_ENTRY_BYTES,
1602            "postcondition must hold even with every field maxed out, got {serialized_len} bytes"
1603        );
1604    }
1605
1606    /// #311 C1 follow-up: exercises `cap_diagnostics_entry_size`'s terminal
1607    /// fallback directly. `message` is the one field the field-specific
1608    /// mitigations never touch (they only cover
1609    /// `source`/`code`/`data`/`code_description`/`related_information`/
1610    /// `tags`), so an oversized, *untruncated* message -- as it would be if
1611    /// this private function were ever called without `store_diagnostics`'s
1612    /// own prior message truncation -- must still be brought under budget
1613    /// by the terminal step, not left to slip through.
1614    #[test]
1615    fn test_cap_diagnostics_entry_size_terminal_fallback_bounds_untruncated_message() {
1616        let uri: Uri = Uri::from("file:///test.rs");
1617        let mut diagnostics = vec![minimal_diagnostic(
1618            "x".repeat(MAX_DIAGNOSTICS_ENTRY_BYTES + 1000),
1619        )];
1620
1621        cap_diagnostics_entry_size(&uri, &mut diagnostics);
1622
1623        assert_eq!(diagnostics.len(), 1);
1624        assert!(
1625            message_as_str(&diagnostics[0].message).len()
1626                <= DIAGNOSTIC_TERMINAL_FALLBACK_MESSAGE_BYTES + 20,
1627            "terminal fallback must truncate the message itself, got {} bytes",
1628            message_as_str(&diagnostics[0].message).len()
1629        );
1630        let serialized_len = serde_json::to_vec(&diagnostics).unwrap().len();
1631        assert!(
1632            serialized_len <= MAX_DIAGNOSTICS_ENTRY_BYTES,
1633            "postcondition must hold via the terminal fallback, got {serialized_len} bytes"
1634        );
1635    }
1636
1637    /// #311 S5: when no diagnostic carries `data`/`code_description`/
1638    /// `related_information`/`tags` and the cheap size estimate is already
1639    /// under budget, nothing should be modified -- the fast path must not
1640    /// alter content it didn't need to touch.
1641    #[test]
1642    fn test_store_diagnostics_cheap_path_leaves_small_diagnostics_untouched() {
1643        let mut cache = NotificationCache::new();
1644        let uri: Uri = Uri::from("file:///test.rs");
1645
1646        let mut diagnostic = minimal_diagnostic("a small, ordinary diagnostic message".to_string());
1647        diagnostic.source = Some("rustc".to_string());
1648
1649        cache.store_diagnostics(&test_server(), &uri, Some(1), vec![diagnostic]);
1650
1651        let stored = &cache.diagnostics(uri.as_ref()).unwrap().diagnostics;
1652        assert_eq!(stored.len(), 1);
1653        assert_eq!(
1654            stored[0].message,
1655            lsp_types::Message::String("a small, ordinary diagnostic message".to_string())
1656        );
1657        assert_eq!(stored[0].source.as_deref(), Some("rustc"));
1658    }
1659
1660    /// #311 S5 follow-up: the critic's exact counterexample. A NUL-heavy
1661    /// message's *raw* byte length looks small enough for the cheap
1662    /// estimate to skip the real check, but its *serialized* (JSON-escaped)
1663    /// size is up to `JSON_ESCAPE_WORST_CASE_FACTOR`x larger -- each NUL
1664    /// byte costs 6 bytes as `\u0000` once JSON-encoded. Three diagnostics
1665    /// at exactly `MAX_ENTRY_TEXT_BYTES` of NULs each previously passed the
1666    /// old raw-length estimate (787,200 bytes, under the 1 MiB cap) while
1667    /// actually serializing to roughly 4.5 MiB -- letting an entry ~4.5x
1668    /// over budget skip `fits`/truncation/terminal-fallback entirely.
1669    #[test]
1670    fn test_store_diagnostics_cheap_path_escape_safe_for_control_character_heavy_message() {
1671        let mut cache = NotificationCache::new();
1672        let uri: Uri = Uri::from("file:///test.rs");
1673
1674        let nul_heavy_message = "\0".repeat(MAX_ENTRY_TEXT_BYTES);
1675        let diagnostics: Vec<LspDiagnostic> = (0..3)
1676            .map(|_| minimal_diagnostic(nul_heavy_message.clone()))
1677            .collect();
1678
1679        cache.store_diagnostics(&test_server(), &uri, Some(1), diagnostics);
1680
1681        let stored = &cache.diagnostics(uri.as_ref()).unwrap().diagnostics;
1682        let serialized_len = serde_json::to_vec(stored).unwrap().len();
1683        assert!(
1684            serialized_len <= MAX_DIAGNOSTICS_ENTRY_BYTES,
1685            "escape-heavy content must not let the cheap-estimate fast path skip the real cap, \
1686             got {serialized_len} bytes"
1687        );
1688    }
1689
1690    #[test]
1691    fn test_store_diagnostics_replaces_existing() {
1692        let mut cache = NotificationCache::new();
1693        let uri: Uri = Uri::from("file:///test.rs");
1694
1695        cache.store_diagnostics(&test_server(), &uri, Some(1), vec![]);
1696        assert_eq!(cache.diagnostics_count(), 1);
1697
1698        cache.store_diagnostics(&test_server(), &uri, Some(2), vec![]);
1699        assert_eq!(cache.diagnostics_count(), 1);
1700
1701        let stored = cache.diagnostics(uri.as_ref()).unwrap();
1702        assert_eq!(stored.version, Some(2));
1703    }
1704
1705    #[test]
1706    fn test_clear_diagnostics() {
1707        let mut cache = NotificationCache::new();
1708        let uri: Uri = Uri::from("file:///test.rs");
1709
1710        cache.store_diagnostics(&test_server(), &uri, Some(1), vec![]);
1711        assert_eq!(cache.diagnostics_count(), 1);
1712
1713        let cleared = cache.clear_diagnostics(uri.as_ref());
1714        assert!(cleared.is_some());
1715        assert_eq!(cache.diagnostics_count(), 0);
1716    }
1717
1718    #[test]
1719    fn test_clear_all_diagnostics() {
1720        let mut cache = NotificationCache::new();
1721        let uri1: Uri = Uri::from("file:///test1.rs");
1722        let uri2: Uri = Uri::from("file:///test2.rs");
1723
1724        cache.store_diagnostics(&test_server(), &uri1, Some(1), vec![]);
1725        cache.store_diagnostics(&test_server(), &uri2, Some(1), vec![]);
1726        assert_eq!(cache.diagnostics_count(), 2);
1727
1728        cache.clear_all_diagnostics();
1729        assert_eq!(cache.diagnostics_count(), 0);
1730    }
1731
1732    #[test]
1733    fn test_store_and_get_logs() {
1734        let mut cache = NotificationCache::new();
1735
1736        cache.store_log(LogLevel::Error, "error message".to_string());
1737        cache.store_log(LogLevel::Info, "info message".to_string());
1738
1739        let logs = cache.logs();
1740        assert_eq!(logs.len(), 2);
1741        assert_eq!(logs[0].level, LogLevel::Error);
1742        assert_eq!(logs[0].message, "error message");
1743        assert_eq!(logs[1].level, LogLevel::Info);
1744        assert_eq!(logs[1].message, "info message");
1745    }
1746
1747    #[test]
1748    fn test_logs_max_capacity() {
1749        let mut cache = NotificationCache::new();
1750
1751        // Add more than MAX_LOG_ENTRIES
1752        for i in 0..MAX_LOG_ENTRIES + 10 {
1753            cache.store_log(LogLevel::Info, format!("message {i}"));
1754        }
1755
1756        assert_eq!(cache.logs_count(), MAX_LOG_ENTRIES);
1757
1758        // Oldest entries should be removed (FIFO)
1759        let logs = cache.logs();
1760        assert_eq!(logs.front().unwrap().message, "message 10");
1761        assert_eq!(
1762            logs.back().unwrap().message,
1763            format!("message {}", MAX_LOG_ENTRIES + 9)
1764        );
1765    }
1766
1767    /// #311: `MAX_LOG_ENTRIES` bounds the number of log entries, but not the
1768    /// size of any one entry -- an oversized message must be truncated
1769    /// rather than stored verbatim.
1770    #[test]
1771    fn test_store_log_truncates_oversized_message() {
1772        let mut cache = NotificationCache::new();
1773        let oversized = "a".repeat(MAX_ENTRY_TEXT_BYTES + 100);
1774
1775        cache.store_log(LogLevel::Info, oversized.clone());
1776
1777        let stored = &cache.logs()[0].message;
1778        assert!(stored.len() < oversized.len());
1779        assert!(stored.ends_with("... (truncated)"));
1780    }
1781
1782    #[test]
1783    fn test_store_log_does_not_truncate_message_at_or_below_limit() {
1784        let mut cache = NotificationCache::new();
1785        let message = "a".repeat(MAX_ENTRY_TEXT_BYTES);
1786
1787        cache.store_log(LogLevel::Info, message.clone());
1788
1789        assert_eq!(cache.logs()[0].message, message);
1790    }
1791
1792    #[test]
1793    fn test_clear_logs() {
1794        let mut cache = NotificationCache::new();
1795        cache.store_log(LogLevel::Info, "test".to_string());
1796        assert_eq!(cache.logs_count(), 1);
1797
1798        cache.clear_logs();
1799        assert_eq!(cache.logs_count(), 0);
1800    }
1801
1802    #[test]
1803    fn test_store_and_get_messages() {
1804        let mut cache = NotificationCache::new();
1805
1806        cache.store_message(MessageType::Error, "error msg".to_string());
1807        cache.store_message(MessageType::Warning, "warning msg".to_string());
1808
1809        let messages = cache.messages();
1810        assert_eq!(messages.len(), 2);
1811        assert_eq!(messages[0].message_type, MessageType::Error);
1812        assert_eq!(messages[0].message, "error msg");
1813        assert_eq!(messages[1].message_type, MessageType::Warning);
1814        assert_eq!(messages[1].message, "warning msg");
1815    }
1816
1817    #[test]
1818    fn test_messages_max_capacity() {
1819        let mut cache = NotificationCache::new();
1820
1821        // Add more than MAX_SERVER_MESSAGES
1822        for i in 0..MAX_SERVER_MESSAGES + 10 {
1823            cache.store_message(MessageType::Info, format!("message {i}"));
1824        }
1825
1826        assert_eq!(cache.messages_count(), MAX_SERVER_MESSAGES);
1827
1828        // Oldest entries should be removed (FIFO)
1829        let messages = cache.messages();
1830        assert_eq!(messages.front().unwrap().message, "message 10");
1831        assert_eq!(
1832            messages.back().unwrap().message,
1833            format!("message {}", MAX_SERVER_MESSAGES + 9)
1834        );
1835    }
1836
1837    #[test]
1838    fn test_clear_messages() {
1839        let mut cache = NotificationCache::new();
1840        cache.store_message(MessageType::Info, "test".to_string());
1841        assert_eq!(cache.messages_count(), 1);
1842
1843        cache.clear_messages();
1844        assert_eq!(cache.messages_count(), 0);
1845    }
1846
1847    /// #311: same per-entry byte cap as `store_log`, applied to server messages.
1848    #[test]
1849    fn test_store_message_truncates_oversized_message() {
1850        let mut cache = NotificationCache::new();
1851        let oversized = "a".repeat(MAX_ENTRY_TEXT_BYTES + 100);
1852
1853        cache.store_message(MessageType::Info, oversized.clone());
1854
1855        let stored = &cache.messages()[0].message;
1856        assert!(stored.len() < oversized.len());
1857        assert!(stored.ends_with("... (truncated)"));
1858    }
1859
1860    #[test]
1861    fn test_log_levels() {
1862        let mut cache = NotificationCache::new();
1863
1864        cache.store_log(LogLevel::Error, "error".to_string());
1865        cache.store_log(LogLevel::Warning, "warning".to_string());
1866        cache.store_log(LogLevel::Info, "info".to_string());
1867        cache.store_log(LogLevel::Debug, "debug".to_string());
1868
1869        let logs = cache.logs();
1870        assert_eq!(logs[0].level, LogLevel::Error);
1871        assert_eq!(logs[1].level, LogLevel::Warning);
1872        assert_eq!(logs[2].level, LogLevel::Info);
1873        assert_eq!(logs[3].level, LogLevel::Debug);
1874    }
1875
1876    #[test]
1877    fn test_message_types() {
1878        let mut cache = NotificationCache::new();
1879
1880        cache.store_message(MessageType::Error, "error".to_string());
1881        cache.store_message(MessageType::Warning, "warning".to_string());
1882        cache.store_message(MessageType::Info, "info".to_string());
1883        cache.store_message(MessageType::Log, "log".to_string());
1884
1885        let messages = cache.messages();
1886        assert_eq!(messages[0].message_type, MessageType::Error);
1887        assert_eq!(messages[1].message_type, MessageType::Warning);
1888        assert_eq!(messages[2].message_type, MessageType::Info);
1889        assert_eq!(messages[3].message_type, MessageType::Log);
1890    }
1891
1892    #[test]
1893    fn test_timestamp_ordering() {
1894        let mut cache = NotificationCache::new();
1895
1896        cache.store_log(LogLevel::Info, "first".to_string());
1897        std::thread::sleep(std::time::Duration::from_millis(10));
1898        cache.store_log(LogLevel::Info, "second".to_string());
1899
1900        let logs = cache.logs();
1901        assert!(logs[0].timestamp < logs[1].timestamp);
1902    }
1903
1904    #[test]
1905    fn test_store_diagnostics_empty_list() {
1906        let mut cache = NotificationCache::new();
1907        let uri: Uri = Uri::from("file:///test.rs");
1908
1909        let diagnostic = LspDiagnostic {
1910            range: Range {
1911                start: Position {
1912                    line: 0,
1913                    character: 0,
1914                },
1915                end: Position {
1916                    line: 0,
1917                    character: 5,
1918                },
1919            },
1920            severity: Some(lsp_types::DiagnosticSeverity::Error),
1921            message: "test error".to_string().into(),
1922            code: None,
1923            source: None,
1924            code_description: None,
1925            related_information: None,
1926            tags: None,
1927            data: None,
1928        };
1929
1930        cache.store_diagnostics(&test_server(), &uri, Some(1), vec![diagnostic]);
1931        assert_eq!(
1932            cache.diagnostics(uri.as_ref()).unwrap().diagnostics.len(),
1933            1
1934        );
1935
1936        cache.store_diagnostics(&test_server(), &uri, Some(2), vec![]);
1937        let stored = cache.diagnostics(uri.as_ref()).unwrap();
1938        assert_eq!(stored.diagnostics.len(), 0);
1939        assert_eq!(stored.version, Some(2));
1940    }
1941
1942    #[test]
1943    fn test_store_many_diagnostics_single_file() {
1944        let mut cache = NotificationCache::new();
1945        let uri: Uri = Uri::from("file:///test.rs");
1946
1947        let diagnostics: Vec<LspDiagnostic> = (0..100)
1948            .map(|i| LspDiagnostic {
1949                range: Range {
1950                    start: Position {
1951                        line: i,
1952                        character: 0,
1953                    },
1954                    end: Position {
1955                        line: i,
1956                        character: 10,
1957                    },
1958                },
1959                message: format!("Error {i}").into(),
1960                severity: Some(lsp_types::DiagnosticSeverity::Error),
1961                code: None,
1962                source: None,
1963                code_description: None,
1964                related_information: None,
1965                tags: None,
1966                data: None,
1967            })
1968            .collect();
1969
1970        cache.store_diagnostics(&test_server(), &uri, Some(1), diagnostics);
1971
1972        let stored = cache.diagnostics(uri.as_ref()).unwrap();
1973        assert_eq!(stored.diagnostics.len(), 100);
1974    }
1975
1976    #[test]
1977    fn test_logs_exact_capacity_boundary() {
1978        let mut cache = NotificationCache::new();
1979
1980        for i in 0..MAX_LOG_ENTRIES {
1981            cache.store_log(LogLevel::Info, format!("message {i}"));
1982        }
1983        assert_eq!(cache.logs_count(), MAX_LOG_ENTRIES);
1984
1985        cache.store_log(LogLevel::Info, "overflow".to_string());
1986        assert_eq!(cache.logs_count(), MAX_LOG_ENTRIES);
1987        assert_eq!(cache.logs().front().unwrap().message, "message 1");
1988    }
1989
1990    #[test]
1991    fn test_messages_exact_capacity_boundary() {
1992        let mut cache = NotificationCache::new();
1993
1994        for i in 0..MAX_SERVER_MESSAGES {
1995            cache.store_message(MessageType::Info, format!("message {i}"));
1996        }
1997        assert_eq!(cache.messages_count(), MAX_SERVER_MESSAGES);
1998
1999        cache.store_message(MessageType::Info, "overflow".to_string());
2000        assert_eq!(cache.messages_count(), MAX_SERVER_MESSAGES);
2001        assert_eq!(cache.messages().front().unwrap().message, "message 1");
2002    }
2003
2004    #[test]
2005    fn test_diagnostics_max_capacity() {
2006        let mut cache = NotificationCache::new();
2007
2008        for i in 0..MAX_DIAGNOSTIC_ENTRIES + 10 {
2009            let uri: Uri = Uri::from(format!("file:///test{i}.rs"));
2010            cache.store_diagnostics(&test_server(), &uri, Some(1), vec![]);
2011        }
2012
2013        assert_eq!(cache.diagnostics_count(), MAX_DIAGNOSTIC_ENTRIES);
2014
2015        // Oldest entries should be evicted (FIFO).
2016        let evicted: Uri = Uri::from("file:///test0.rs");
2017        assert!(cache.diagnostics(evicted.as_ref()).is_none());
2018        let newest: Uri = Uri::from(format!("file:///test{}.rs", MAX_DIAGNOSTIC_ENTRIES + 9));
2019        assert!(cache.diagnostics(newest.as_ref()).is_some());
2020    }
2021
2022    #[test]
2023    fn test_diagnostics_replacing_existing_uri_does_not_trigger_eviction() {
2024        let mut cache = NotificationCache::new();
2025        let uri: Uri = Uri::from("file:///stable.rs");
2026
2027        for i in 0..MAX_DIAGNOSTIC_ENTRIES {
2028            cache.store_diagnostics(
2029                &test_server(),
2030                &uri,
2031                Some(i32::try_from(i).unwrap()),
2032                vec![],
2033            );
2034        }
2035        assert_eq!(cache.diagnostics_count(), 1);
2036        assert!(cache.diagnostics(uri.as_ref()).is_some());
2037    }
2038
2039    #[test]
2040    fn test_diagnostics_republish_refreshes_eviction_order() {
2041        // #234 S2 / #266 S3 regression: an actively-edited file, republished
2042        // on every keystroke, must not be evicted ahead of a file that was
2043        // merely opened once and never touched again.
2044        let mut cache = NotificationCache::new();
2045        let actively_edited: Uri = Uri::from("file:///keep.rs");
2046        cache.store_diagnostics(&test_server(), &actively_edited, Some(1), vec![]);
2047
2048        // Fill the rest of the cache with untouched entries.
2049        for i in 0..MAX_DIAGNOSTIC_ENTRIES - 1 {
2050            let uri: Uri = Uri::from(format!("file:///untouched{i}.rs"));
2051            cache.store_diagnostics(&test_server(), &uri, Some(1), vec![]);
2052        }
2053        assert_eq!(cache.diagnostics_count(), MAX_DIAGNOSTIC_ENTRIES);
2054
2055        // Republish the actively-edited file -- this must move it to the
2056        // back of the eviction order, not leave it at its original (oldest)
2057        // position.
2058        cache.store_diagnostics(&test_server(), &actively_edited, Some(2), vec![]);
2059
2060        // One more new URI arrives, exceeding the cap by one: the oldest
2061        // *untouched* entry must be evicted, not the republished one.
2062        let overflow: Uri = Uri::from("file:///overflow.rs");
2063        cache.store_diagnostics(&test_server(), &overflow, Some(1), vec![]);
2064
2065        assert!(
2066            cache.diagnostics(actively_edited.as_ref()).is_some(),
2067            "republished entry must survive eviction after being refreshed"
2068        );
2069        let oldest_untouched: Uri = Uri::from("file:///untouched0.rs");
2070        assert!(
2071            cache.diagnostics(oldest_untouched.as_ref()).is_none(),
2072            "the oldest never-republished entry must be evicted instead"
2073        );
2074        assert!(cache.diagnostics(overflow.as_ref()).is_some());
2075    }
2076
2077    #[test]
2078    fn test_clear_diagnostics_then_refill_does_not_evict_early() {
2079        let mut cache = NotificationCache::new();
2080        let first: Uri = Uri::from("file:///first.rs");
2081        cache.store_diagnostics(&test_server(), &first, Some(1), vec![]);
2082        cache.clear_diagnostics(first.as_ref());
2083        assert_eq!(cache.diagnostics_count(), 0);
2084
2085        for i in 0..MAX_DIAGNOSTIC_ENTRIES {
2086            let uri: Uri = Uri::from(format!("file:///test{i}.rs"));
2087            cache.store_diagnostics(&test_server(), &uri, Some(1), vec![]);
2088        }
2089        assert_eq!(cache.diagnostics_count(), MAX_DIAGNOSTIC_ENTRIES);
2090        // Every entry from this batch must still be present -- the earlier
2091        // clear must not have left a stale `diagnostic_order` entry that
2092        // causes a premature eviction here.
2093        let first_of_batch: Uri = Uri::from("file:///test0.rs");
2094        assert!(cache.diagnostics(first_of_batch.as_ref()).is_some());
2095    }
2096
2097    #[test]
2098    fn test_clear_diagnostics_nonexistent() {
2099        let mut cache = NotificationCache::new();
2100        let result = cache.clear_diagnostics("file:///nonexistent.rs");
2101        assert!(result.is_none());
2102    }
2103
2104    #[test]
2105    fn test_store_diagnostics_no_version() {
2106        let mut cache = NotificationCache::new();
2107        let uri: Uri = Uri::from("file:///test.rs");
2108
2109        cache.store_diagnostics(&test_server(), &uri, None, vec![]);
2110        let stored = cache.diagnostics(uri.as_ref()).unwrap();
2111        assert_eq!(stored.version, None);
2112    }
2113
2114    /// #266/#276: once the *aggregate* cache is full, a noisy server that has
2115    /// grown far past its fair share must have its own oldest entries
2116    /// evicted, never a quiet server's, even though both share one
2117    /// `NotificationCache` and the noisy server was allowed to keep growing
2118    /// past its static equal share while the aggregate still had room.
2119    #[test]
2120    fn test_noisy_server_does_not_evict_quiet_server_entries() {
2121        let mut cache = NotificationCache::new();
2122        cache.set_diagnostics_route_count(2);
2123        let noisy = ServerId::from("noisy");
2124        let quiet = ServerId::from("quiet");
2125
2126        let quiet_uri: Uri = Uri::from("file:///quiet/only_file.rs");
2127        cache.store_diagnostics(&quiet, &quiet_uri, Some(1), vec![]);
2128
2129        // Drive the noisy server well past the aggregate cap -- it must be
2130        // allowed to consume nearly all of it since the quiet server leaves
2131        // the rest unused (#276), and once the aggregate is full it must
2132        // only evict its own oldest entries.
2133        for i in 0..MAX_DIAGNOSTIC_ENTRIES + 50 {
2134            let uri: Uri = Uri::from(format!("file:///noisy/file{i}.rs"));
2135            cache.store_diagnostics(&noisy, &uri, Some(1), vec![]);
2136        }
2137
2138        assert_eq!(cache.diagnostics_count(), MAX_DIAGNOSTIC_ENTRIES);
2139        assert!(
2140            cache.diagnostics(quiet_uri.as_ref()).is_some(),
2141            "quiet server's only entry must survive the noisy server's overflow"
2142        );
2143
2144        let noisy_first: Uri = Uri::from("file:///noisy/file0.rs");
2145        assert!(
2146            cache.diagnostics(noisy_first.as_ref()).is_none(),
2147            "noisy server's own oldest entries must be evicted once the aggregate cache is full"
2148        );
2149    }
2150
2151    /// #276: a dominant server must be able to exceed its static equal share
2152    /// of the budget while other registered diagnostics-route servers are
2153    /// idle -- eviction is work-conserving and only triggers once the
2154    /// *aggregate* cache reaches `MAX_DIAGNOSTIC_ENTRIES`, not once a single
2155    /// server passes `MAX_DIAGNOSTIC_ENTRIES / diagnostics_route_count`.
2156    #[test]
2157    fn test_dominant_server_exceeds_equal_share_while_others_idle() {
2158        let mut cache = NotificationCache::new();
2159        cache.set_diagnostics_route_count(4);
2160        let dominant = ServerId::from("dominant");
2161
2162        let equal_share = MAX_DIAGNOSTIC_ENTRIES / 4;
2163        let more_than_share = equal_share + 100;
2164        for i in 0..more_than_share {
2165            let uri: Uri = Uri::from(format!("file:///file{i}.rs"));
2166            cache.store_diagnostics(&dominant, &uri, Some(1), vec![]);
2167        }
2168        assert_eq!(
2169            cache.diagnostics_count(),
2170            more_than_share,
2171            "a dominant server must be able to exceed its static equal share while the aggregate has room"
2172        );
2173
2174        // The other three registered servers never write anything, so the
2175        // dominant server can keep growing all the way to the full budget.
2176        for i in more_than_share..MAX_DIAGNOSTIC_ENTRIES {
2177            let uri: Uri = Uri::from(format!("file:///file{i}.rs"));
2178            cache.store_diagnostics(&dominant, &uri, Some(1), vec![]);
2179        }
2180        assert_eq!(cache.diagnostics_count(), MAX_DIAGNOSTIC_ENTRIES);
2181    }
2182
2183    /// M1: eviction-target ties (multiple servers holding the same entry
2184    /// count) must resolve deterministically, not depend on `HashMap`'s
2185    /// per-process randomized iteration order. This pins the exact winner
2186    /// rather than only checking repeat-call stability -- stability across
2187    /// calls would hold trivially even without the fix, since a single
2188    /// `HashMap` instance's iteration order does not change between calls
2189    /// within one process; the real risk is a *different* winner on a
2190    /// *different* process run, which this test can't observe directly, but
2191    /// the pinned assertion below only passes because the tie-break key
2192    /// (`(order.len(), id.as_str())`) is unique per server -- no two
2193    /// distinct `ServerId`s can ever share it, so `max_by_key` never
2194    /// actually has a tie left to resolve by iteration order.
2195    #[test]
2196    fn test_eviction_target_tie_break_is_deterministic() {
2197        let mut cache = NotificationCache::new();
2198        cache.set_diagnostics_route_count(1000); // fair share floors at 1
2199
2200        let a = ServerId::from("a");
2201        let b = ServerId::from("b");
2202        for i in 0..2 {
2203            let uri: Uri = Uri::from(format!("file:///a/file{i}.rs"));
2204            cache.store_diagnostics(&a, &uri, Some(1), vec![]);
2205        }
2206        for i in 0..2 {
2207            let uri: Uri = Uri::from(format!("file:///b/file{i}.rs"));
2208            cache.store_diagnostics(&b, &uri, Some(1), vec![]);
2209        }
2210
2211        // `a` and `b` are tied at 2 entries each, both over the floor-1
2212        // share -- `"b"` sorts after `"a"` lexicographically, so it is the
2213        // one always picked.
2214        let writer = ServerId::from("writer");
2215        assert_eq!(cache.server_to_evict_from(&writer), Some(b));
2216    }
2217
2218    /// M2: `server_to_evict_from`'s "largest in-share server" fallback is
2219    /// reachable and correct through the public `store_diagnostics` API,
2220    /// not just in isolation -- a brand-new server's first write must still
2221    /// evict something when the aggregate cache is already full purely from
2222    /// other servers that are each individually within their fair share.
2223    /// Without this fallback there would be nothing to evict from (the
2224    /// writer has no entries yet, and no one else exceeds their share) and
2225    /// the aggregate could grow past `MAX_DIAGNOSTIC_ENTRIES`.
2226    #[test]
2227    fn test_new_writer_still_evicts_when_every_existing_server_is_in_share() {
2228        let mut cache = NotificationCache::new();
2229        cache.set_diagnostics_route_count(2); // fair share = 500 each
2230
2231        let a = ServerId::from("a");
2232        let b = ServerId::from("b");
2233        for i in 0..500 {
2234            let uri: Uri = Uri::from(format!("file:///a/file{i}.rs"));
2235            cache.store_diagnostics(&a, &uri, Some(1), vec![]);
2236        }
2237        for i in 0..500 {
2238            let uri: Uri = Uri::from(format!("file:///b/file{i}.rs"));
2239            cache.store_diagnostics(&b, &uri, Some(1), vec![]);
2240        }
2241        assert_eq!(cache.diagnostics_count(), MAX_DIAGNOSTIC_ENTRIES);
2242
2243        // `c` has never written before -- its very first write hits a full,
2244        // entirely-in-share aggregate.
2245        let c = ServerId::from("c");
2246        let new_uri: Uri = Uri::from("file:///c/first.rs");
2247        cache.store_diagnostics(&c, &new_uri, Some(1), vec![]);
2248
2249        assert_eq!(
2250            cache.diagnostics_count(),
2251            MAX_DIAGNOSTIC_ENTRIES,
2252            "the aggregate cap must still be enforced even when every existing server is within share"
2253        );
2254        assert!(cache.diagnostics(new_uri.as_ref()).is_some());
2255
2256        // `a` and `b` are tied at 500 entries each; the deterministic
2257        // tie-break in `server_to_evict_from` picks `b`, so `b`'s oldest
2258        // entry is the one evicted, not `a`'s.
2259        let b_oldest: Uri = Uri::from("file:///b/file0.rs");
2260        assert!(
2261            cache.diagnostics(b_oldest.as_ref()).is_none(),
2262            "the largest in-share server (tie-broken to b) must lose its oldest entry"
2263        );
2264        assert!(
2265            cache.diagnostics("file:///a/file0.rs").is_some(),
2266            "the other in-share server must be untouched"
2267        );
2268    }
2269
2270    /// Re-publishing diagnostics for a URI under its existing owner must not
2271    /// count as a new entry against that server's budget.
2272    #[test]
2273    fn test_repeated_writes_same_owner_do_not_grow_order_map() {
2274        let mut cache = NotificationCache::new();
2275        let server = ServerId::from("server");
2276        let uri: Uri = Uri::from("file:///test.rs");
2277
2278        let max_version = i32::try_from(MAX_DIAGNOSTIC_ENTRIES).unwrap() + 10;
2279        for version in 0..max_version {
2280            cache.store_diagnostics(&server, &uri, Some(version), vec![]);
2281        }
2282
2283        assert_eq!(cache.diagnostics_count(), 1);
2284        let stored = cache.diagnostics(uri.as_ref()).unwrap();
2285        assert_eq!(stored.version, Some(max_version - 1));
2286    }
2287
2288    /// If a URI's diagnostics route changes to a different server (e.g.
2289    /// after a respawn rebind), the entry must move to the new owner's
2290    /// order map rather than staying attributed to the old one.
2291    #[test]
2292    fn test_store_diagnostics_reassigns_ownership() {
2293        let mut cache = NotificationCache::new();
2294        let old_owner = ServerId::from("old");
2295        let new_owner = ServerId::from("new");
2296        let uri: Uri = Uri::from("file:///test.rs");
2297
2298        cache.store_diagnostics(&old_owner, &uri, Some(1), vec![]);
2299        cache.store_diagnostics(&new_owner, &uri, Some(2), vec![]);
2300
2301        assert_eq!(cache.diagnostics_count(), 1);
2302        let stored = cache.diagnostics(uri.as_ref()).unwrap();
2303        assert_eq!(stored.version, Some(2));
2304
2305        // The old owner's order map must no longer reference this URI:
2306        // filling the old owner's budget with fresh entries must not evict
2307        // this URI a second time (it's not there to evict) nor corrupt state.
2308        for i in 0..MAX_DIAGNOSTIC_ENTRIES + 5 {
2309            let other: Uri = Uri::from(format!("file:///old/file{i}.rs"));
2310            cache.store_diagnostics(&old_owner, &other, Some(1), vec![]);
2311        }
2312        assert!(cache.diagnostics(uri.as_ref()).is_some());
2313    }
2314
2315    /// #290: `diagnostics_owner` is what a cache-only read (e.g.
2316    /// `get_cached_diagnostics`) uses to resolve the publishing server's
2317    /// negotiated position encoding, so both branches -- an owner on record
2318    /// and none -- must behave correctly.
2319    #[test]
2320    fn test_diagnostics_owner_returns_publisher_after_store() {
2321        let mut cache = NotificationCache::new();
2322        let server = ServerId::from("rust");
2323        let uri: Uri = Uri::from("file:///main.rs");
2324
2325        cache.store_diagnostics(&server, &uri, Some(1), vec![]);
2326
2327        assert_eq!(cache.diagnostics_owner(uri.as_ref()), Some(&server));
2328    }
2329
2330    #[test]
2331    fn test_diagnostics_owner_none_for_untracked_uri() {
2332        let cache = NotificationCache::new();
2333        let uri: Uri = Uri::from("file:///never-seen.rs");
2334
2335        assert_eq!(cache.diagnostics_owner(uri.as_ref()), None);
2336    }
2337
2338    /// Reassigning ownership (see `test_store_diagnostics_reassigns_ownership`
2339    /// above) must also update `diagnostics_owner`, not just the cached
2340    /// content -- otherwise a stale owner's encoding would be used to
2341    /// convert a different server's diagnostics.
2342    #[test]
2343    fn test_diagnostics_owner_reflects_reassigned_ownership() {
2344        let mut cache = NotificationCache::new();
2345        let old_owner = ServerId::from("old");
2346        let new_owner = ServerId::from("new");
2347        let uri: Uri = Uri::from("file:///test.rs");
2348
2349        cache.store_diagnostics(&old_owner, &uri, Some(1), vec![]);
2350        assert_eq!(cache.diagnostics_owner(uri.as_ref()), Some(&old_owner));
2351
2352        cache.store_diagnostics(&new_owner, &uri, Some(2), vec![]);
2353        assert_eq!(cache.diagnostics_owner(uri.as_ref()), Some(&new_owner));
2354    }
2355
2356    /// #266 S2: clearing one server's diagnostics must not disturb another
2357    /// server's cached entries, unlike `clear_all_diagnostics`.
2358    #[test]
2359    fn test_clear_server_diagnostics_scopes_to_one_server() {
2360        let mut cache = NotificationCache::new();
2361        let crashed = ServerId::from("crashed");
2362        let healthy = ServerId::from("healthy");
2363
2364        let crashed_uri: Uri = Uri::from("file:///crashed/main.py");
2365        let healthy_uri: Uri = Uri::from("file:///healthy/main.rs");
2366        cache.store_diagnostics(&crashed, &crashed_uri, Some(1), vec![]);
2367        cache.store_diagnostics(&healthy, &healthy_uri, Some(1), vec![]);
2368
2369        cache.clear_server_diagnostics(&crashed);
2370
2371        assert!(cache.diagnostics(crashed_uri.as_ref()).is_none());
2372        assert!(cache.diagnostics(healthy_uri.as_ref()).is_some());
2373        assert_eq!(cache.diagnostics_count(), 1);
2374
2375        // Idempotent / no-op for a server with no (or no longer any) entries.
2376        cache.clear_server_diagnostics(&crashed);
2377        assert_eq!(cache.diagnostics_count(), 1);
2378    }
2379
2380    /// #359: `mark_push_degraded` must be scoped per server and, once set,
2381    /// stay set -- there is no "unmark" operation, since only a full mcpls
2382    /// process restart actually restores push diagnostics for a respawned
2383    /// server.
2384    #[test]
2385    fn test_push_degraded_is_scoped_per_server_and_permanent() {
2386        let mut cache = NotificationCache::new();
2387        let degraded = ServerId::from("degraded");
2388        let healthy = ServerId::from("healthy");
2389
2390        assert!(!cache.is_push_degraded(&degraded));
2391        assert!(!cache.is_push_degraded(&healthy));
2392
2393        cache.mark_push_degraded(&degraded);
2394
2395        assert!(cache.is_push_degraded(&degraded));
2396        assert!(!cache.is_push_degraded(&healthy));
2397
2398        // Marking again is idempotent.
2399        cache.mark_push_degraded(&degraded);
2400        assert!(cache.is_push_degraded(&degraded));
2401    }
2402
2403    /// #276: `set_diagnostics_route_count` shrinking a server's fair share
2404    /// must not retroactively evict any of its already-cached entries --
2405    /// eviction is work-conserving and only fires once the *aggregate* cache
2406    /// is full. Once full, though, the shrunk share is what makes that
2407    /// server the eviction target for a *different* server's write, rather
2408    /// than the write that actually needed room being rejected or evicting
2409    /// its own (nonexistent) entries.
2410    #[test]
2411    fn test_shrinking_budget_affects_eviction_target_not_existing_entries() {
2412        let mut cache = NotificationCache::new();
2413        let server = ServerId::from("server");
2414
2415        for i in 0..MAX_DIAGNOSTIC_ENTRIES {
2416            let uri: Uri = Uri::from(format!("file:///file{i}.rs"));
2417            cache.store_diagnostics(&server, &uri, Some(1), vec![]);
2418        }
2419        assert_eq!(
2420            cache.diagnostics_count(),
2421            MAX_DIAGNOSTIC_ENTRIES,
2422            "filling to the aggregate cap must not evict anything early"
2423        );
2424
2425        // A drastic shrink relative to the entries `server` already holds --
2426        // must not evict anything by itself.
2427        cache.set_diagnostics_route_count(4);
2428        assert_eq!(cache.diagnostics_count(), MAX_DIAGNOSTIC_ENTRIES);
2429
2430        // A different server's first write, once the aggregate is full,
2431        // evicts from `server` (now far over its shrunk share) instead.
2432        let other = ServerId::from("other");
2433        let new_uri: Uri = Uri::from("file:///other/new.rs");
2434        cache.store_diagnostics(&other, &new_uri, Some(1), vec![]);
2435
2436        assert_eq!(cache.diagnostics_count(), MAX_DIAGNOSTIC_ENTRIES);
2437        assert!(cache.diagnostics(new_uri.as_ref()).is_some());
2438        let server_oldest: Uri = Uri::from("file:///file0.rs");
2439        assert!(
2440            cache.diagnostics(server_oldest.as_ref()).is_none(),
2441            "the pre-existing server's oldest entry, now far over its shrunk share, must be evicted"
2442        );
2443    }
2444
2445    /// #283: an external `NotificationCache` consumer that never calls
2446    /// `set_diagnostics_route_count` must still get fair-share partitioning
2447    /// once more than one server has written an entry -- the pre-#266
2448    /// regression this guards against is an unset count silently giving one
2449    /// server the entire aggregate budget, letting it starve a quiet server.
2450    #[test]
2451    fn test_fair_share_applies_by_default_without_explicit_route_count() {
2452        let mut cache = NotificationCache::new();
2453        let noisy = ServerId::from("noisy");
2454        let quiet = ServerId::from("quiet");
2455
2456        let quiet_uri: Uri = Uri::from("file:///quiet/only_file.rs");
2457        cache.store_diagnostics(&quiet, &quiet_uri, Some(1), vec![]);
2458
2459        // `set_diagnostics_route_count` is deliberately never called here.
2460        for i in 0..MAX_DIAGNOSTIC_ENTRIES + 50 {
2461            let uri: Uri = Uri::from(format!("file:///noisy/file{i}.rs"));
2462            cache.store_diagnostics(&noisy, &uri, Some(1), vec![]);
2463        }
2464
2465        assert_eq!(cache.diagnostics_count(), MAX_DIAGNOSTIC_ENTRIES);
2466        assert!(
2467            cache.diagnostics(quiet_uri.as_ref()).is_some(),
2468            "quiet server's only entry must survive even without ever calling \
2469             set_diagnostics_route_count"
2470        );
2471        let noisy_first: Uri = Uri::from("file:///noisy/file0.rs");
2472        assert!(
2473            cache.diagnostics(noisy_first.as_ref()).is_none(),
2474            "the noisy server, now auto-derived as one of two servers sharing the budget, \
2475             must still lose its own oldest entries once over its fair share"
2476        );
2477    }
2478
2479    /// #283: with only one server ever writing, the auto-derived fair-share
2480    /// count (from `diagnostic_order.len()`) must stay `1`, letting that
2481    /// server use the whole aggregate budget -- the same as the old default
2482    /// of `1` when the setter went uncalled, not a regression for the
2483    /// common single-server case.
2484    #[test]
2485    fn test_single_server_gets_full_budget_without_explicit_route_count() {
2486        let mut cache = NotificationCache::new();
2487
2488        for i in 0..MAX_DIAGNOSTIC_ENTRIES {
2489            let uri: Uri = Uri::from(format!("file:///file{i}.rs"));
2490            cache.store_diagnostics(&test_server(), &uri, Some(1), vec![]);
2491        }
2492
2493        assert_eq!(cache.diagnostics_count(), MAX_DIAGNOSTIC_ENTRIES);
2494    }
2495
2496    /// #284: when the cache is full and a server's own entry must be
2497    /// evicted, an entry with an empty (`[]`) diagnostics list -- a file
2498    /// reported as now clean -- must be evicted ahead of an older entry that
2499    /// still carries real diagnostics, even though the empty entry is not
2500    /// that server's strictly-oldest entry.
2501    #[test]
2502    fn test_empty_diagnostics_entries_evicted_before_non_empty_ones() {
2503        let mut cache = NotificationCache::new();
2504        let server = test_server();
2505
2506        let important: Uri = Uri::from("file:///important.rs");
2507        cache.store_diagnostics(
2508            &server,
2509            &important,
2510            Some(1),
2511            vec![minimal_diagnostic("real error".to_string())],
2512        );
2513
2514        // Fill the rest of the budget with empty ("file is clean") entries,
2515        // all published after `important` and so all newer in eviction
2516        // order.
2517        for i in 0..MAX_DIAGNOSTIC_ENTRIES - 1 {
2518            let uri: Uri = Uri::from(format!("file:///clean{i}.rs"));
2519            cache.store_diagnostics(&server, &uri, Some(1), vec![]);
2520        }
2521        assert_eq!(cache.diagnostics_count(), MAX_DIAGNOSTIC_ENTRIES);
2522
2523        // One more new URI exceeds the cap: `important` is the strictly
2524        // oldest entry, but it must survive in favor of the oldest *empty*
2525        // entry instead.
2526        let overflow: Uri = Uri::from("file:///overflow.rs");
2527        cache.store_diagnostics(&server, &overflow, Some(1), vec![]);
2528
2529        assert!(
2530            cache.diagnostics(important.as_ref()).is_some(),
2531            "a non-empty entry must survive eviction over empty entries, even though it is older"
2532        );
2533        let oldest_clean: Uri = Uri::from("file:///clean0.rs");
2534        assert!(
2535            cache.diagnostics(oldest_clean.as_ref()).is_none(),
2536            "the oldest empty entry must be evicted instead of the older non-empty one"
2537        );
2538        assert!(cache.diagnostics(overflow.as_ref()).is_some());
2539    }
2540
2541    /// #284: storing an empty diagnostics list must still create a fully
2542    /// tracked, cacheable entry -- eviction priority changes which entry is
2543    /// removed once the cache is full, but does not change what gets stored
2544    /// in the first place. See the `tracked` field semantics in
2545    /// `mcp::server::ResourceDiagnosticsResponse` for why callers rely on
2546    /// this: a `[]` publish for a previously-tracked URI must read back as
2547    /// "tracked, zero diagnostics", not as untracked.
2548    #[test]
2549    fn test_empty_diagnostics_entry_is_still_tracked_until_evicted() {
2550        let mut cache = NotificationCache::new();
2551        let uri: Uri = Uri::from("file:///clean.rs");
2552
2553        cache.store_diagnostics(&test_server(), &uri, Some(1), vec![]);
2554
2555        let stored = cache.diagnostics(uri.as_ref());
2556        assert!(
2557            stored.is_some(),
2558            "an empty-diagnostics entry must still be tracked"
2559        );
2560        assert_eq!(stored.unwrap().diagnostics.len(), 0);
2561    }
2562
2563    /// #284 S1: an over-share server's own empty ("clean") entry must be
2564    /// evicted before a *different* over-share server's real diagnostics are
2565    /// destroyed, even when the fairness-selected victim (the largest
2566    /// over-share server) itself holds no empty entry of its own.
2567    #[test]
2568    fn test_over_share_servers_empty_entry_evicted_before_a_different_servers_real_diagnostic() {
2569        let mut cache = NotificationCache::new();
2570        cache.set_diagnostics_route_count(3); // fair share = 333
2571
2572        let a = ServerId::from("a"); // over share, all real diagnostics
2573        let b = ServerId::from("b"); // over share, all empty/clean
2574        let c = ServerId::from("c"); // within share
2575
2576        for i in 0..500 {
2577            let uri: Uri = Uri::from(format!("file:///a/file{i}.rs"));
2578            cache.store_diagnostics(
2579                &a,
2580                &uri,
2581                Some(1),
2582                vec![minimal_diagnostic(format!("error {i}"))],
2583            );
2584        }
2585        for i in 0..400 {
2586            let uri: Uri = Uri::from(format!("file:///b/file{i}.rs"));
2587            cache.store_diagnostics(&b, &uri, Some(1), vec![]);
2588        }
2589        for i in 0..100 {
2590            let uri: Uri = Uri::from(format!("file:///c/file{i}.rs"));
2591            cache.store_diagnostics(&c, &uri, Some(1), vec![]);
2592        }
2593        assert_eq!(cache.diagnostics_count(), MAX_DIAGNOSTIC_ENTRIES);
2594
2595        // One more write from `a` (the fairness-selected victim, being the
2596        // largest over-share server) must not destroy any of `a`'s real
2597        // diagnostics: `b`, also over its share, has an empty entry to give
2598        // up instead.
2599        let overflow: Uri = Uri::from("file:///a/overflow.rs");
2600        cache.store_diagnostics(
2601            &a,
2602            &overflow,
2603            Some(1),
2604            vec![minimal_diagnostic("overflow error".to_string())],
2605        );
2606
2607        for i in 0..500 {
2608            let uri: Uri = Uri::from(format!("file:///a/file{i}.rs"));
2609            assert!(
2610                cache.diagnostics(uri.as_ref()).is_some(),
2611                "server a's real diagnostics must all survive; b has an empty entry to lose \
2612                 instead"
2613            );
2614        }
2615        let b_oldest: Uri = Uri::from("file:///b/file0.rs");
2616        assert!(
2617            cache.diagnostics(b_oldest.as_ref()).is_none(),
2618            "b's oldest empty entry must be evicted instead of a's real diagnostics"
2619        );
2620        assert!(cache.diagnostics(overflow.as_ref()).is_some());
2621    }
2622
2623    /// #284: a URI that transitions non-empty -> empty -> non-empty must not
2624    /// be treated as still-empty for eviction priority after the second
2625    /// transition -- emptiness tracking must reflect the *current* state,
2626    /// not the URI's history.
2627    #[test]
2628    fn test_dirty_then_clean_then_dirty_again_updates_emptiness_tracking() {
2629        let mut cache = NotificationCache::new();
2630        let server = test_server();
2631        let uri: Uri = Uri::from("file:///flapping.rs");
2632
2633        cache.store_diagnostics(
2634            &server,
2635            &uri,
2636            Some(1),
2637            vec![minimal_diagnostic("first error".to_string())],
2638        );
2639        cache.store_diagnostics(&server, &uri, Some(2), vec![]); // now clean
2640        cache.store_diagnostics(
2641            &server,
2642            &uri,
2643            Some(3),
2644            vec![minimal_diagnostic("second error".to_string())],
2645        ); // dirty again
2646
2647        // Fill the rest of the budget with genuinely empty entries -- if
2648        // `uri` were still (wrongly) tracked as empty, one of these would be
2649        // evicted in its place instead of `uri` being left alone.
2650        for i in 0..MAX_DIAGNOSTIC_ENTRIES - 1 {
2651            let other: Uri = Uri::from(format!("file:///clean{i}.rs"));
2652            cache.store_diagnostics(&server, &other, Some(1), vec![]);
2653        }
2654        assert_eq!(cache.diagnostics_count(), MAX_DIAGNOSTIC_ENTRIES);
2655
2656        let overflow: Uri = Uri::from("file:///overflow.rs");
2657        cache.store_diagnostics(&server, &overflow, Some(1), vec![]);
2658
2659        let stored = cache.diagnostics(uri.as_ref());
2660        assert!(
2661            stored.is_some_and(|info| info.diagnostics.len() == 1),
2662            "the re-dirtied entry must survive and keep its real diagnostic, not be mistaken \
2663             for an empty entry"
2664        );
2665    }
2666
2667    /// `set_diagnostics_route_count(0)` must clamp to `1`, not panic via
2668    /// division by zero in `per_server_budget`.
2669    #[test]
2670    fn test_set_diagnostics_route_count_zero_clamps_to_one() {
2671        let mut cache = NotificationCache::new();
2672        cache.set_diagnostics_route_count(0);
2673
2674        for i in 0..MAX_DIAGNOSTIC_ENTRIES + 5 {
2675            let uri: Uri = Uri::from(format!("file:///file{i}.rs"));
2676            cache.store_diagnostics(&test_server(), &uri, Some(1), vec![]);
2677        }
2678
2679        assert_eq!(cache.diagnostics_count(), MAX_DIAGNOSTIC_ENTRIES);
2680    }
2681
2682    // Transition logic is unit-tested in `bridge::indexing`; these tests only cover delegation wiring.
2683
2684    #[test]
2685    fn test_indexing_state_defaults_unknown() {
2686        let cache = NotificationCache::new();
2687        assert_eq!(cache.indexing_state(&test_server()), IndexingState::Unknown);
2688    }
2689
2690    #[test]
2691    fn test_observe_indexing_signal_delegates_to_tracker() {
2692        let mut cache = NotificationCache::new();
2693        let server = test_server();
2694        cache.observe_indexing_signal(
2695            &server,
2696            "experimental/serverStatus",
2697            Some(&serde_json::json!({"quiescent": false})),
2698        );
2699        assert_eq!(cache.indexing_state(&server), IndexingState::Loading);
2700    }
2701
2702    #[test]
2703    fn test_reset_indexing_state_delegates_to_tracker() {
2704        let mut cache = NotificationCache::new();
2705        let server = test_server();
2706        cache.observe_indexing_signal(
2707            &server,
2708            "experimental/serverStatus",
2709            Some(&serde_json::json!({"quiescent": false})),
2710        );
2711        cache.reset_indexing_state(&server);
2712        assert_eq!(cache.indexing_state(&server), IndexingState::Unknown);
2713    }
2714}