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