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