Skip to main content

mcpls_core/bridge/translator/
diagnostics.rs

1//! Diagnostics pull/push merging, cache-derived diagnostics, and server
2//! log/message retrieval.
3
4use std::path::PathBuf;
5use std::sync::{Arc, LazyLock};
6
7use lsp_types::{
8    DocumentDiagnosticParams, PartialResultParams, TextDocumentIdentifier, WorkDoneProgressParams,
9};
10use tokio::sync::Mutex;
11
12use super::Translator;
13use super::dto::{
14    Diagnostic, DiagnosticSeverity, DiagnosticsResult, Position2D, Range, ServerLogsResult,
15    ServerMessagesResult,
16};
17use super::encoding_ctx::EncodingCtx;
18use super::routing::validate_path_against_roots;
19use crate::bridge::encoding::PositionEncoding;
20use crate::bridge::notifications::message_as_str;
21use crate::bridge::{DiagnosticInfo, DocumentTracker, NotificationCache, path_to_uri};
22use crate::config::ToolKind;
23use crate::error::{Error, Result};
24
25/// Hand-rolled union of `textDocument/diagnostic`'s two possible result
26/// shapes.
27///
28/// `gen-lsp-types` types `DocumentDiagnosticRequest::Result` as the
29/// non-nullable `DocumentDiagnosticReport` alone, splitting the streaming
30/// `Partial` shape off into `RequestWithPartialResults::PartialResult`
31/// (`DocumentDiagnosticReportProgress`) -- binding this call to that typed
32/// result via `LspClient::request_typed` would turn a partial response into
33/// a deserialization error where today it degrades to an empty diagnostics
34/// list. This preserves the union gluon's `DocumentDiagnosticReportResult`
35/// used to provide, via the untyped `LspClient::request`.
36#[derive(serde::Deserialize)]
37#[serde(untagged)]
38enum DocumentDiagnosticReportResult {
39    Report(lsp_types::DocumentDiagnosticReport),
40    // The partial shape's content is never read -- matching this variant at
41    // all (rather than failing to deserialize) is the only thing that
42    // matters, so today's behavior of degrading to an empty diagnostics list
43    // is preserved.
44    Partial(#[allow(dead_code)] lsp_types::DocumentDiagnosticReportPartialResult),
45}
46
47/// Shared, never-mutated empty `workspace_roots` for an `EncodingCtx` built
48/// where it's documented as never read -- avoids a per-poll `Arc` allocation.
49static EMPTY_WORKSPACE_ROOTS: LazyLock<Arc<Vec<PathBuf>>> = LazyLock::new(|| Arc::new(Vec::new()));
50
51/// Convert an LSP diagnostic into the MCP-facing `Diagnostic` shape.
52///
53/// Shared by both the pull-model (`handle_diagnostics`) and cache-derived
54/// (`diagnostics_from_cache_entry`) diagnostic paths, so their output never
55/// diverges in formatting — `merge_diagnostics`'s dedup logic depends on
56/// both sides mapping severity/code identically.
57pub(super) async fn diagnostic_to_mcp(
58    diag: &lsp_types::Diagnostic,
59    ctx: &EncodingCtx,
60    uri: &lsp_types::Uri,
61) -> Diagnostic {
62    Diagnostic {
63        range: ctx.normalize_range(uri, diag.range).await,
64        severity: match diag.severity {
65            Some(lsp_types::DiagnosticSeverity::Error) => DiagnosticSeverity::Error,
66            Some(lsp_types::DiagnosticSeverity::Warning) => DiagnosticSeverity::Warning,
67            Some(lsp_types::DiagnosticSeverity::Hint) => DiagnosticSeverity::Hint,
68            // INFORMATION and None (no severity reported) both fall here.
69            _ => DiagnosticSeverity::Information,
70        },
71        message: message_as_str(&diag.message).to_string(),
72        code: diag.code.as_ref().map(|c| match c {
73            lsp_types::Code::Int(n) => n.to_string(),
74            lsp_types::Code::String(s) => s.clone(),
75        }),
76    }
77}
78
79impl Translator {
80    /// Resolve the LSP-side cache key (URI string) for a cached-diagnostics lookup.
81    ///
82    /// Split out from the cache read itself so callers (e.g. the
83    /// `get_cached_diagnostics` MCP tool) can do the path `canonicalize()` and
84    /// workspace-boundary check *before* taking the `NotificationCache` lock —
85    /// that lock is also needed by `diagnostics_pump` to store incoming
86    /// notifications, so nothing that isn't a plain map lookup should run
87    /// while it's held.
88    ///
89    /// # Errors
90    ///
91    /// Returns an error if the path is invalid or outside workspace boundaries.
92    pub fn cached_diagnostics_uri(workspace_roots: &[PathBuf], file_path: &str) -> Result<String> {
93        Self::cached_diagnostics_path_and_uri(workspace_roots, file_path).map(|(_, uri)| uri)
94    }
95
96    /// As [`Self::cached_diagnostics_uri`], but also returns the validated,
97    /// canonicalized path -- for a caller (e.g. `get_cached_diagnostics`,
98    /// `read_resource`) that needs it too, such as to resolve a
99    /// diagnostics-route server via [`Self::diagnostics_route_id_for_path`]
100    /// from the same canonical path the URI itself is derived from, rather
101    /// than re-canonicalizing or (worse) using an unvalidated raw path.
102    ///
103    /// # Errors
104    ///
105    /// Returns an error if the path is invalid or outside workspace boundaries.
106    pub(crate) fn cached_diagnostics_path_and_uri(
107        workspace_roots: &[PathBuf],
108        file_path: &str,
109    ) -> Result<(PathBuf, String)> {
110        let path = PathBuf::from(file_path);
111        let validated_path = validate_path_against_roots(&path, workspace_roots)?;
112
113        // Use path_to_uri (strips \\?\ on Windows) so the key matches what
114        // rust-analyzer stores in publishDiagnostics notifications.
115        let uri = path_to_uri(&validated_path)?.to_string();
116        Ok((validated_path, uri))
117    }
118
119    /// Handle diagnostics request.
120    ///
121    /// Merges the LSP pull-model response (`textDocument/diagnostic`) with
122    /// whatever is already cached from `textDocument/publishDiagnostics` push
123    /// notifications for the same file, so this returns the same diagnostics
124    /// `get_cached_diagnostics` would for the file at the same point in time
125    /// (see #244 — rust-analyzer's pull endpoint omits flycheck/clippy-sourced
126    /// diagnostics, and empirically also some native ones, that are only ever
127    /// delivered via the push path). If the pull request itself fails (e.g. a
128    /// push-only server answering `-32601`, or a timeout), a non-empty cache
129    /// entry is returned as a cache-only result instead of propagating the
130    /// error, since the cache is not required to be fresher than the pull
131    /// response to be useful here.
132    ///
133    /// The cache is read only after the pull request settles (success or
134    /// failure) and held only for the lookup itself — never across the LSP
135    /// round-trip — matching the lock-ordering discipline documented on
136    /// `cached_diagnostics_uri`. Like `get_cached_diagnostics`, the cache is
137    /// treated as eventually consistent: a cached entry may reflect a
138    /// slightly older document version than the fresh pull result if an edit
139    /// landed inside the server's flycheck debounce window.
140    ///
141    /// Deliberately not gated on workspace-indexing readiness the way
142    /// `IndexingGate::Required` whole-workspace queries are (#445, see
143    /// `routing::IndexingGate`'s doc): this is a poll-based read, not a live
144    /// whole-workspace LSP request, so blocking it on
145    /// `Translator::wait_for_indexing_ready` would be the wrong fix shape.
146    /// The `get_diagnostics` MCP tool instead surfaces the routed server's
147    /// indexing state as an explicit `indexingInProgress` flag alongside this
148    /// method's result.
149    ///
150    /// # Errors
151    ///
152    /// Returns an error if the LSP pull request fails and the cache holds no
153    /// diagnostics for the file either, or if the file cannot be opened.
154    pub async fn handle_diagnostics(
155        &self,
156        file_path: String,
157        notification_cache: &Mutex<NotificationCache>,
158    ) -> Result<DiagnosticsResult> {
159        let (server_id, client, uri) = self
160            .prepare_document(&file_path, ToolKind::Diagnostics)
161            .await?;
162        let ctx = self.encoding_ctx(&server_id);
163
164        let params = DocumentDiagnosticParams {
165            text_document: TextDocumentIdentifier { uri: uri.clone() },
166            identifier: None,
167            previous_result_id: None,
168            work_done_progress_params: WorkDoneProgressParams::default(),
169            partial_result_params: PartialResultParams::default(),
170        };
171
172        let pull_response: Result<DocumentDiagnosticReportResult> = client
173            .request("textDocument/diagnostic", params, client.request_timeout())
174            .await;
175
176        let diag_info = {
177            let cache = notification_cache.lock().await;
178            cache.diagnostics(uri.as_ref()).cloned()
179        };
180
181        match pull_response {
182            Ok(response) => {
183                let items = match response {
184                    DocumentDiagnosticReportResult::Report(report) => match report {
185                        lsp_types::DocumentDiagnosticReport::RelatedFullDocumentDiagnosticReport(
186                            full,
187                        ) => full.full_document_diagnostic_report.items,
188                        lsp_types::DocumentDiagnosticReport::RelatedUnchangedDocumentDiagnosticReport(
189                            _,
190                        ) => vec![],
191                    },
192                    DocumentDiagnosticReportResult::Partial(_) => vec![],
193                };
194                let mut diagnostics = Vec::with_capacity(items.len());
195                for d in &items {
196                    diagnostics.push(diagnostic_to_mcp(d, &ctx, &uri).await);
197                }
198                let pull = DiagnosticsResult {
199                    diagnostics,
200                    positions_degraded: ctx.positions_degraded(),
201                };
202                Ok(Self::merge_diagnostics(
203                    pull,
204                    diag_info.as_ref(),
205                    ctx.encoding,
206                    &self.document_tracker,
207                )
208                .await)
209            }
210            Err(e) => {
211                let cache_only = Self::diagnostics_from_cache_entry(
212                    diag_info.as_ref(),
213                    ctx.encoding,
214                    &self.document_tracker,
215                )
216                .await;
217                if cache_only.diagnostics.is_empty() {
218                    Err(e)
219                } else {
220                    Ok(cache_only)
221                }
222            }
223        }
224    }
225
226    /// Convert a cached diagnostics entry into the MCP-facing result shape.
227    ///
228    /// Takes an already-cloned `Option<&DiagnosticInfo>` (out of the
229    /// `NotificationCache` lock) rather than the cache itself, so this
230    /// mapping — which is not a bounded operation for a large diagnostics set
231    /// — never runs while the cache is locked.
232    ///
233    /// `encoding` is the negotiated encoding of the server that published
234    /// these diagnostics; pass `PositionEncoding::Utf16` when no live server
235    /// context is available (e.g. a cache-only read with no resolved owner).
236    #[must_use]
237    pub async fn diagnostics_from_cache_entry(
238        diag_info: Option<&DiagnosticInfo>,
239        encoding: PositionEncoding,
240        tracker: &Arc<DocumentTracker>,
241    ) -> DiagnosticsResult {
242        match diag_info {
243            Some(diag_info) => {
244                let ctx = EncodingCtx {
245                    encoding,
246                    tracker: tracker.clone(),
247                    // Never read here -- see `EMPTY_WORKSPACE_ROOTS`'s doc.
248                    workspace_roots: EMPTY_WORKSPACE_ROOTS.clone(),
249                    line_cache: super::encoding_ctx::new_line_cache(),
250                };
251                let mut result = Vec::with_capacity(diag_info.diagnostics.len());
252                for d in &diag_info.diagnostics {
253                    result.push(diagnostic_to_mcp(d, &ctx, &diag_info.uri).await);
254                }
255                DiagnosticsResult {
256                    diagnostics: result,
257                    positions_degraded: ctx.positions_degraded(),
258                }
259            }
260            None => DiagnosticsResult {
261                diagnostics: Vec::new(),
262                positions_degraded: false,
263            },
264        }
265    }
266
267    /// Merge push-model diagnostics from the notification cache into a
268    /// pull-model (`textDocument/diagnostic`) result.
269    ///
270    /// rust-analyzer's pull endpoint omits diagnostics that are only ever
271    /// delivered via `textDocument/publishDiagnostics` push notifications —
272    /// not just flycheck/clippy lints, but empirically (verified against a
273    /// live rust-analyzer 1.97.1 session, see #244) some native diagnostics
274    /// too. Those are cached separately in `NotificationCache`.
275    ///
276    /// Where the *same* logical problem is reported through both paths, the
277    /// two representations were observed to differ in both `range` and
278    /// rendered `message`. Captured example, a "not all trait items
279    /// implemented" (E0046) error for one `impl` block: pull reported range
280    /// `(96,7)-(96,12)` (the trait name) with message "not all trait items
281    /// implemented, missing: `fn hello`"; the push notification for the same
282    /// error reported range `(95,1)-(95,32)` (the impl block) with message
283    /// "not all trait items implemented, missing: `hello`\nmissing `hello`
284    /// in implementation" — same `code`/`severity`, adjacent but distinct
285    /// ranges, different message text. Exact field equality never dedups
286    /// cases like that.
287    ///
288    /// Given that, a cache entry is treated as a duplicate of a pull entry
289    /// when both carry a `code`, the `(severity, code)` pair matches, *and*
290    /// the two ranges are either overlapping or start within
291    /// `DUPLICATE_RANGE_PROXIMITY_LINES` lines of each other — close
292    /// enough to be the same underlying model divergence, not two distinct
293    /// occurrences of the same error class (e.g. two unrelated `E0308`
294    /// mismatches at different call sites in one file, one caught only
295    /// natively and one only by flycheck). Diagnostics with no `code` fall
296    /// back to full-field equality, since there is no cheaper stable
297    /// identity available for them.
298    ///
299    /// Output is sorted by `(start.line, start.character)` so merged
300    /// cache-only entries don't land out of document order after the
301    /// pull-model ones.
302    #[must_use]
303    pub async fn merge_diagnostics(
304        mut pull: DiagnosticsResult,
305        diag_info: Option<&DiagnosticInfo>,
306        encoding: PositionEncoding,
307        tracker: &Arc<DocumentTracker>,
308    ) -> DiagnosticsResult {
309        /// Start-line distance within which same-code, same-severity
310        /// diagnostics from the two models are still considered the same
311        /// underlying problem. Derived from the captured E0046 case above
312        /// (1 line apart); wide enough to absorb span drift between
313        /// rust-analyzer's own spans and rustc's, narrow enough that two
314        /// genuinely distinct same-code errors elsewhere in a file are not
315        /// collapsed into one.
316        const DUPLICATE_RANGE_PROXIMITY_LINES: u32 = 3;
317
318        fn position_le(a: &Position2D, b: &Position2D) -> bool {
319            (a.line, a.character) <= (b.line, b.character)
320        }
321
322        fn ranges_close(a: &Range, b: &Range) -> bool {
323            let overlaps = position_le(&a.start, &b.end) && position_le(&b.start, &a.end);
324            overlaps || a.start.line.abs_diff(b.start.line) <= DUPLICATE_RANGE_PROXIMITY_LINES
325        }
326
327        fn is_duplicate(pull: &[Diagnostic], candidate: &Diagnostic) -> bool {
328            pull.iter().any(|p| match (&candidate.code, &p.code) {
329                (Some(c), Some(pc)) if c == pc && p.severity == candidate.severity => {
330                    ranges_close(&p.range, &candidate.range)
331                }
332                _ => p == candidate,
333            })
334        }
335
336        let cached = Self::diagnostics_from_cache_entry(diag_info, encoding, tracker).await;
337        pull.positions_degraded |= cached.positions_degraded;
338        let new_diagnostics: Vec<_> = cached
339            .diagnostics
340            .into_iter()
341            .filter(|c| !is_duplicate(&pull.diagnostics, c))
342            .collect();
343        pull.diagnostics.extend(new_diagnostics);
344        pull.diagnostics
345            .sort_by_key(|d| (d.range.start.line, d.range.start.character));
346        pull
347    }
348
349    /// Handle server logs request.
350    ///
351    /// # Errors
352    ///
353    /// Returns an error if the `min_level` parameter is invalid.
354    pub fn handle_server_logs(
355        cache: &NotificationCache,
356        limit: usize,
357        min_level: Option<String>,
358    ) -> Result<ServerLogsResult> {
359        use crate::bridge::notifications::LogLevel;
360
361        let min_level_filter = if let Some(level_str) = min_level {
362            let level = match level_str.to_lowercase().as_str() {
363                "error" => LogLevel::Error,
364                "warning" => LogLevel::Warning,
365                "info" => LogLevel::Info,
366                "debug" => LogLevel::Debug,
367                _ => {
368                    return Err(Error::InvalidToolParams(format!(
369                        "Invalid min_level: '{level_str}'. Valid values: error, warning, info, debug"
370                    )));
371                }
372            };
373            Some(level)
374        } else {
375            None
376        };
377
378        let all_logs = cache.logs();
379
380        let logs: Vec<_> = all_logs
381            .iter()
382            .filter(|log| {
383                min_level_filter.is_none_or(|min| match min {
384                    LogLevel::Error => matches!(log.level, LogLevel::Error),
385                    LogLevel::Warning => matches!(log.level, LogLevel::Error | LogLevel::Warning),
386                    LogLevel::Info => !matches!(log.level, LogLevel::Debug),
387                    LogLevel::Debug => true,
388                })
389            })
390            .take(limit)
391            .cloned()
392            .collect();
393
394        Ok(ServerLogsResult { logs })
395    }
396
397    /// Handle server messages request.
398    ///
399    /// # Errors
400    ///
401    /// This method does not return errors.
402    pub fn handle_server_messages(
403        cache: &NotificationCache,
404        limit: usize,
405    ) -> Result<ServerMessagesResult> {
406        let all_messages = cache.messages();
407        let messages: Vec<_> = all_messages.iter().take(limit).cloned().collect();
408        Ok(ServerMessagesResult { messages })
409    }
410}
411
412#[cfg(test)]
413#[allow(clippy::unwrap_used, clippy::expect_used)]
414mod tests {
415    use std::collections::HashMap;
416    use std::fs;
417    use std::sync::Arc;
418    use std::time::Duration;
419
420    use tempfile::TempDir;
421    use tokio::io::BufReader;
422    use tokio::time::timeout;
423    use url::Url;
424
425    use super::*;
426    use crate::bridge::translator::testing::*;
427    use crate::config::{ServerId, ToolRouter};
428
429    /// Pins the upstream `lsp_types::DocumentDiagnosticParams` serde
430    /// attributes (`skip_serializing_if` on both optionals) across future
431    /// `gen-lsp-types` version bumps -- this behavior was previously
432    /// guaranteed by a hand-rolled `DiagnosticRequestParams` (see #166),
433    /// dropped in favor of direct construction once verified byte-identical.
434    #[test]
435    fn test_document_diagnostic_params_omit_optional_null_fields() {
436        let uri = lsp_types::Uri::from("file:///test.ts");
437        let params = DocumentDiagnosticParams {
438            text_document: TextDocumentIdentifier { uri },
439            identifier: None,
440            previous_result_id: None,
441            work_done_progress_params: WorkDoneProgressParams::default(),
442            partial_result_params: PartialResultParams::default(),
443        };
444        let value = serde_json::to_value(params).unwrap();
445
446        assert_eq!(value["textDocument"]["uri"], "file:///test.ts");
447        assert!(value.get("identifier").is_none());
448        assert!(value.get("previousResultId").is_none());
449    }
450
451    #[tokio::test]
452    async fn test_handle_cached_diagnostics_empty() {
453        let cache = NotificationCache::new();
454        let temp_dir = TempDir::new().unwrap();
455        let test_file = temp_dir.path().join("test.rs");
456        fs::write(&test_file, "fn main() {}").unwrap();
457
458        let cache_key = Translator::cached_diagnostics_uri(
459            &[temp_dir.path().to_path_buf()],
460            test_file.to_str().unwrap(),
461        )
462        .unwrap();
463        let diag_info = cache.diagnostics(&cache_key).cloned();
464        let diags = Translator::diagnostics_from_cache_entry(
465            diag_info.as_ref(),
466            PositionEncoding::Utf16,
467            &test_tracker(),
468        )
469        .await;
470        assert_eq!(diags.diagnostics.len(), 0);
471    }
472
473    #[test]
474    fn test_handle_server_logs_with_filter() {
475        use crate::bridge::notifications::LogLevel;
476
477        let mut cache = NotificationCache::new();
478
479        // Add some logs
480        cache.store_log(LogLevel::Error, "error msg".to_string());
481        cache.store_log(LogLevel::Warning, "warning msg".to_string());
482        cache.store_log(LogLevel::Info, "info msg".to_string());
483        cache.store_log(LogLevel::Debug, "debug msg".to_string());
484
485        // Test with error filter
486        let result = Translator::handle_server_logs(&cache, 10, Some("error".to_string()));
487        assert!(result.is_ok());
488        let logs = result.unwrap();
489        assert_eq!(logs.logs.len(), 1);
490        assert_eq!(logs.logs[0].message, "error msg");
491
492        // Test with warning filter (includes error and warning)
493        let result = Translator::handle_server_logs(&cache, 10, Some("warning".to_string()));
494        assert!(result.is_ok());
495        let logs = result.unwrap();
496        assert_eq!(logs.logs.len(), 2);
497
498        // Test with info filter (excludes debug)
499        let result = Translator::handle_server_logs(&cache, 10, Some("info".to_string()));
500        assert!(result.is_ok());
501        let logs = result.unwrap();
502        assert_eq!(logs.logs.len(), 3);
503
504        // Test with debug filter (includes all)
505        let result = Translator::handle_server_logs(&cache, 10, Some("debug".to_string()));
506        assert!(result.is_ok());
507        let logs = result.unwrap();
508        assert_eq!(logs.logs.len(), 4);
509
510        // Test with invalid filter
511        let result = Translator::handle_server_logs(&cache, 10, Some("invalid".to_string()));
512        assert!(matches!(result, Err(Error::InvalidToolParams(_))));
513    }
514
515    #[test]
516    fn test_handle_server_messages_limit() {
517        use crate::bridge::notifications::MessageType;
518
519        let mut cache = NotificationCache::new();
520
521        // Add some messages
522        for i in 0..10 {
523            cache.store_message(MessageType::Info, format!("message {i}"));
524        }
525
526        // Test limit
527        let result = Translator::handle_server_messages(&cache, 5);
528        assert!(result.is_ok());
529        let messages = result.unwrap();
530        assert_eq!(messages.messages.len(), 5);
531        assert_eq!(messages.messages[0].message, "message 0");
532        assert_eq!(messages.messages[4].message, "message 4");
533
534        // Test limit larger than available
535        let result = Translator::handle_server_messages(&cache, 100);
536        assert!(result.is_ok());
537        let messages = result.unwrap();
538        assert_eq!(messages.messages.len(), 10);
539    }
540
541    #[tokio::test]
542    async fn test_handle_cached_diagnostics_with_data() {
543        let mut cache = NotificationCache::new();
544        let temp_dir = TempDir::new().unwrap();
545        let test_file = temp_dir.path().join("test.rs");
546        fs::write(&test_file, "fn main() {}").unwrap();
547
548        let canonical_path = test_file.canonicalize().unwrap();
549        let uri: lsp_types::Uri =
550            lsp_types::Uri::from(Url::from_file_path(&canonical_path).unwrap().as_str());
551        let diagnostic = lsp_types::Diagnostic {
552            range: lsp_types::Range {
553                start: lsp_types::Position {
554                    line: 0,
555                    character: 0,
556                },
557                end: lsp_types::Position {
558                    line: 0,
559                    character: 5,
560                },
561            },
562            severity: Some(lsp_types::DiagnosticSeverity::Error),
563            message: "test error".to_string().into(),
564            code: Some(lsp_types::Code::String("E001".to_string())),
565            source: None,
566            code_description: None,
567            related_information: None,
568            tags: None,
569            data: None,
570        };
571
572        cache.store_diagnostics(&ServerId::from("rust"), &uri, Some(1), vec![diagnostic]);
573
574        let cache_key = Translator::cached_diagnostics_uri(
575            &[temp_dir.path().to_path_buf()],
576            test_file.to_str().unwrap(),
577        )
578        .unwrap();
579        let diag_info = cache.diagnostics(&cache_key).cloned();
580        let diags = Translator::diagnostics_from_cache_entry(
581            diag_info.as_ref(),
582            PositionEncoding::Utf16,
583            &test_tracker(),
584        )
585        .await;
586        assert_eq!(diags.diagnostics.len(), 1);
587        assert_eq!(diags.diagnostics[0].message, "test error");
588        assert_eq!(diags.diagnostics[0].code, Some("E001".to_string()));
589        assert!(matches!(
590            diags.diagnostics[0].severity,
591            DiagnosticSeverity::Error
592        ));
593        assert_eq!(diags.diagnostics[0].range.start.line, 1);
594        assert_eq!(diags.diagnostics[0].range.start.character, 1);
595    }
596
597    #[tokio::test]
598    #[allow(clippy::too_many_lines)]
599    async fn test_handle_cached_diagnostics_multiple_severities() {
600        let mut cache = NotificationCache::new();
601        let temp_dir = TempDir::new().unwrap();
602        let test_file = temp_dir.path().join("test.rs");
603        fs::write(&test_file, "fn main() {}").unwrap();
604
605        let canonical_path = test_file.canonicalize().unwrap();
606        let uri: lsp_types::Uri =
607            lsp_types::Uri::from(Url::from_file_path(&canonical_path).unwrap().as_str());
608        let diagnostics = vec![
609            lsp_types::Diagnostic {
610                range: lsp_types::Range {
611                    start: lsp_types::Position {
612                        line: 0,
613                        character: 0,
614                    },
615                    end: lsp_types::Position {
616                        line: 0,
617                        character: 5,
618                    },
619                },
620                severity: Some(lsp_types::DiagnosticSeverity::Error),
621                message: "error".to_string().into(),
622                code: None,
623                source: None,
624                code_description: None,
625                related_information: None,
626                tags: None,
627                data: None,
628            },
629            lsp_types::Diagnostic {
630                range: lsp_types::Range {
631                    start: lsp_types::Position {
632                        line: 1,
633                        character: 0,
634                    },
635                    end: lsp_types::Position {
636                        line: 1,
637                        character: 5,
638                    },
639                },
640                severity: Some(lsp_types::DiagnosticSeverity::Warning),
641                message: "warning".to_string().into(),
642                code: None,
643                source: None,
644                code_description: None,
645                related_information: None,
646                tags: None,
647                data: None,
648            },
649            lsp_types::Diagnostic {
650                range: lsp_types::Range {
651                    start: lsp_types::Position {
652                        line: 2,
653                        character: 0,
654                    },
655                    end: lsp_types::Position {
656                        line: 2,
657                        character: 5,
658                    },
659                },
660                severity: Some(lsp_types::DiagnosticSeverity::Information),
661                message: "info".to_string().into(),
662                code: None,
663                source: None,
664                code_description: None,
665                related_information: None,
666                tags: None,
667                data: None,
668            },
669            lsp_types::Diagnostic {
670                range: lsp_types::Range {
671                    start: lsp_types::Position {
672                        line: 3,
673                        character: 0,
674                    },
675                    end: lsp_types::Position {
676                        line: 3,
677                        character: 5,
678                    },
679                },
680                severity: Some(lsp_types::DiagnosticSeverity::Hint),
681                message: "hint".to_string().into(),
682                code: None,
683                source: None,
684                code_description: None,
685                related_information: None,
686                tags: None,
687                data: None,
688            },
689        ];
690
691        cache.store_diagnostics(&ServerId::from("rust"), &uri, Some(1), diagnostics);
692
693        let cache_key = Translator::cached_diagnostics_uri(
694            &[temp_dir.path().to_path_buf()],
695            test_file.to_str().unwrap(),
696        )
697        .unwrap();
698        let diag_info = cache.diagnostics(&cache_key).cloned();
699        let diags = Translator::diagnostics_from_cache_entry(
700            diag_info.as_ref(),
701            PositionEncoding::Utf16,
702            &test_tracker(),
703        )
704        .await;
705        assert_eq!(diags.diagnostics.len(), 4);
706        assert!(matches!(
707            diags.diagnostics[0].severity,
708            DiagnosticSeverity::Error
709        ));
710        assert!(matches!(
711            diags.diagnostics[1].severity,
712            DiagnosticSeverity::Warning
713        ));
714        assert!(matches!(
715            diags.diagnostics[2].severity,
716            DiagnosticSeverity::Information
717        ));
718        assert!(matches!(
719            diags.diagnostics[3].severity,
720            DiagnosticSeverity::Hint
721        ));
722    }
723
724    #[tokio::test]
725    async fn test_handle_cached_diagnostics_with_numeric_code() {
726        let mut cache = NotificationCache::new();
727        let temp_dir = TempDir::new().unwrap();
728        let test_file = temp_dir.path().join("test.rs");
729        fs::write(&test_file, "fn main() {}").unwrap();
730
731        let canonical_path = test_file.canonicalize().unwrap();
732        let uri: lsp_types::Uri =
733            lsp_types::Uri::from(Url::from_file_path(&canonical_path).unwrap().as_str());
734        let diagnostic = lsp_types::Diagnostic {
735            range: lsp_types::Range {
736                start: lsp_types::Position {
737                    line: 0,
738                    character: 0,
739                },
740                end: lsp_types::Position {
741                    line: 0,
742                    character: 5,
743                },
744            },
745            severity: Some(lsp_types::DiagnosticSeverity::Error),
746            message: "test error".to_string().into(),
747            code: Some(lsp_types::Code::Int(42)),
748            source: None,
749            code_description: None,
750            related_information: None,
751            tags: None,
752            data: None,
753        };
754
755        cache.store_diagnostics(&ServerId::from("rust"), &uri, Some(1), vec![diagnostic]);
756
757        let cache_key = Translator::cached_diagnostics_uri(
758            &[temp_dir.path().to_path_buf()],
759            test_file.to_str().unwrap(),
760        )
761        .unwrap();
762        let diag_info = cache.diagnostics(&cache_key).cloned();
763        let diags = Translator::diagnostics_from_cache_entry(
764            diag_info.as_ref(),
765            PositionEncoding::Utf16,
766            &test_tracker(),
767        )
768        .await;
769        assert_eq!(diags.diagnostics.len(), 1);
770        assert_eq!(diags.diagnostics[0].code, Some("42".to_string()));
771    }
772
773    #[test]
774    fn test_handle_cached_diagnostics_invalid_path() {
775        #[cfg(windows)]
776        let root = PathBuf::from(r"C:\");
777        #[cfg(not(windows))]
778        let root = PathBuf::from("/");
779        let result = Translator::cached_diagnostics_uri(&[root], "/nonexistent/path/file.rs");
780        assert!(matches!(result, Err(Error::FileIo { .. })));
781    }
782
783    #[tokio::test]
784    async fn test_merge_diagnostics_cache_only_appends_to_empty_pull() {
785        let pull = DiagnosticsResult {
786            diagnostics: vec![],
787            positions_degraded: false,
788        };
789        let cache = diag_info(vec![lsp_diag(
790            0,
791            10,
792            lsp_types::DiagnosticSeverity::Warning,
793            "unused import: `std::fmt`",
794            None,
795        )]);
796
797        let merged = Translator::merge_diagnostics(
798            pull,
799            Some(&cache),
800            PositionEncoding::Utf16,
801            &test_tracker(),
802        )
803        .await;
804
805        assert_eq!(merged.diagnostics.len(), 1);
806        assert_eq!(merged.diagnostics[0].message, "unused import: `std::fmt`");
807        assert!(matches!(
808            merged.diagnostics[0].severity,
809            DiagnosticSeverity::Warning
810        ));
811    }
812
813    #[tokio::test]
814    async fn test_merge_diagnostics_exact_duplicate_not_repeated() {
815        // Same range/severity/message/code as the cache entry below, expressed
816        // in the 1-based MCP shape `diagnostics_from_cache_entry` would produce.
817        let pull_diag = Diagnostic {
818            range: Range {
819                start: Position2D {
820                    line: 1,
821                    character: 1,
822                },
823                end: Position2D {
824                    line: 1,
825                    character: 11,
826                },
827            },
828            severity: DiagnosticSeverity::Error,
829            message: "mismatched types".to_string(),
830            code: Some("E0308".to_string()),
831        };
832        let pull = DiagnosticsResult {
833            diagnostics: vec![pull_diag.clone()],
834            positions_degraded: false,
835        };
836        let cache = diag_info(vec![lsp_diag(
837            0,
838            10,
839            lsp_types::DiagnosticSeverity::Error,
840            "mismatched types",
841            Some("E0308"),
842        )]);
843
844        let merged = Translator::merge_diagnostics(
845            pull,
846            Some(&cache),
847            PositionEncoding::Utf16,
848            &test_tracker(),
849        )
850        .await;
851
852        assert_eq!(merged.diagnostics.len(), 1);
853        assert_eq!(merged.diagnostics[0], pull_diag);
854    }
855
856    /// #497 test gap: `merge_diagnostics`'s `pull.positions_degraded |=
857    /// cached.positions_degraded` must actually OR the two sides, not just
858    /// pass one through -- exercised here with the pull side degraded and
859    /// the cache side (UTF-16, never degradable) not.
860    #[tokio::test]
861    async fn test_merge_diagnostics_positions_degraded_true_when_pull_side_is_degraded() {
862        let pull = DiagnosticsResult {
863            diagnostics: vec![],
864            positions_degraded: true,
865        };
866        let cache = diag_info(vec![lsp_diag(
867            0,
868            10,
869            lsp_types::DiagnosticSeverity::Warning,
870            "unused import: `std::fmt`",
871            None,
872        )]);
873
874        let merged = Translator::merge_diagnostics(
875            pull,
876            Some(&cache),
877            PositionEncoding::Utf16,
878            &test_tracker(),
879        )
880        .await;
881
882        assert!(
883            merged.positions_degraded,
884            "the pull side's degraded flag must survive the merge even when the cache side \
885             isn't degraded"
886        );
887    }
888
889    /// #497 test gap companion: the same OR-merge, but with the degradation
890    /// coming from the *cache* side instead -- `diagnostics_from_cache_entry`
891    /// under a non-UTF-16 encoding, resolving `diag_info`'s uri
892    /// (`file:///test.rs`, which does not exist on disk), degrades on its
893    /// own. Proves the merge isn't only ever driven by the pull side.
894    #[tokio::test]
895    async fn test_merge_diagnostics_positions_degraded_true_when_cache_side_is_degraded() {
896        let pull = DiagnosticsResult {
897            diagnostics: vec![],
898            positions_degraded: false,
899        };
900        let cache = diag_info(vec![lsp_diag(
901            0,
902            10,
903            lsp_types::DiagnosticSeverity::Warning,
904            "unused import: `std::fmt`",
905            None,
906        )]);
907
908        let merged = Translator::merge_diagnostics(
909            pull,
910            Some(&cache),
911            PositionEncoding::Utf8,
912            &test_tracker(),
913        )
914        .await;
915
916        assert!(
917            merged.positions_degraded,
918            "the cache side's degraded flag must be OR-ed into the merged result even when the \
919             pull side isn't degraded"
920        );
921    }
922
923    #[tokio::test]
924    async fn test_merge_diagnostics_no_cache_entry_returns_pull_unchanged() {
925        let pull_diag = Diagnostic {
926            range: Range {
927                start: Position2D {
928                    line: 1,
929                    character: 1,
930                },
931                end: Position2D {
932                    line: 1,
933                    character: 5,
934                },
935            },
936            severity: DiagnosticSeverity::Error,
937            message: "syntax error".to_string(),
938            code: None,
939        };
940        let pull = DiagnosticsResult {
941            diagnostics: vec![pull_diag.clone()],
942            positions_degraded: false,
943        };
944
945        let merged =
946            Translator::merge_diagnostics(pull, None, PositionEncoding::Utf16, &test_tracker())
947                .await;
948
949        assert_eq!(merged.diagnostics, vec![pull_diag]);
950    }
951
952    #[tokio::test]
953    async fn test_merge_diagnostics_multiple_distinct_cache_entries_all_appear() {
954        let pull = DiagnosticsResult {
955            diagnostics: vec![],
956            positions_degraded: false,
957        };
958        let cache = diag_info(vec![
959            lsp_diag(
960                0,
961                10,
962                lsp_types::DiagnosticSeverity::Warning,
963                "unused import: `std::fmt`",
964                None,
965            ),
966            lsp_diag(
967                5,
968                8,
969                lsp_types::DiagnosticSeverity::Warning,
970                "function `helper` is never used",
971                None,
972            ),
973        ]);
974
975        let merged = Translator::merge_diagnostics(
976            pull,
977            Some(&cache),
978            PositionEncoding::Utf16,
979            &test_tracker(),
980        )
981        .await;
982
983        assert_eq!(merged.diagnostics.len(), 2);
984        assert!(
985            merged
986                .diagnostics
987                .iter()
988                .any(|d| d.message == "unused import: `std::fmt`")
989        );
990        assert!(
991            merged
992                .diagnostics
993                .iter()
994                .any(|d| d.message == "function `helper` is never used")
995        );
996    }
997
998    #[tokio::test]
999    async fn test_merge_diagnostics_same_range_different_message_not_deduped() {
1000        let pull_diag = Diagnostic {
1001            range: Range {
1002                start: Position2D {
1003                    line: 1,
1004                    character: 1,
1005                },
1006                end: Position2D {
1007                    line: 1,
1008                    character: 11,
1009                },
1010            },
1011            severity: DiagnosticSeverity::Error,
1012            message: "mismatched types".to_string(),
1013            code: None,
1014        };
1015        let pull = DiagnosticsResult {
1016            diagnostics: vec![pull_diag],
1017            positions_degraded: false,
1018        };
1019        // Same range and severity as the pull diagnostic, but a different
1020        // message — must be treated as a distinct diagnostic, not a duplicate.
1021        let cache = diag_info(vec![lsp_diag(
1022            0,
1023            10,
1024            lsp_types::DiagnosticSeverity::Error,
1025            "expected `i32`, found `&str`",
1026            None,
1027        )]);
1028
1029        let merged = Translator::merge_diagnostics(
1030            pull,
1031            Some(&cache),
1032            PositionEncoding::Utf16,
1033            &test_tracker(),
1034        )
1035        .await;
1036
1037        assert_eq!(merged.diagnostics.len(), 2);
1038    }
1039
1040    /// Pins a cross-model duplicate shape verified empirically against a live
1041    /// rust-analyzer 1.97.1 session (#244): the pull and push diagnostics for
1042    /// the *same* "not all trait items implemented" (E0046) error had
1043    /// different ranges (trait name vs. impl block) and different messages
1044    /// (terse vs. rustc's full rendering), but shared `code` and `severity`.
1045    /// Exact-field dedup would report this twice; the `(severity, code)`
1046    /// fingerprint must collapse it to one entry.
1047    #[tokio::test]
1048    async fn test_merge_diagnostics_same_code_different_range_and_message_deduped() {
1049        let pull_diag = Diagnostic {
1050            range: Range {
1051                start: Position2D {
1052                    line: 96,
1053                    character: 7,
1054                },
1055                end: Position2D {
1056                    line: 96,
1057                    character: 12,
1058                },
1059            },
1060            severity: DiagnosticSeverity::Error,
1061            message: "not all trait items implemented, missing: `fn hello`".to_string(),
1062            code: Some("E0046".to_string()),
1063        };
1064        let pull = DiagnosticsResult {
1065            diagnostics: vec![pull_diag.clone()],
1066            positions_degraded: false,
1067        };
1068        // Same code and severity, but a different range and a longer,
1069        // differently-worded message -- the rustc-rendered push side of the
1070        // same underlying error.
1071        let cache = diag_info(vec![lsp_diag(
1072            94,
1073            31,
1074            lsp_types::DiagnosticSeverity::Error,
1075            "not all trait items implemented, missing: `hello`\nmissing `hello` in implementation",
1076            Some("E0046"),
1077        )]);
1078
1079        let merged = Translator::merge_diagnostics(
1080            pull,
1081            Some(&cache),
1082            PositionEncoding::Utf16,
1083            &test_tracker(),
1084        )
1085        .await;
1086
1087        assert_eq!(merged.diagnostics.len(), 1);
1088        assert_eq!(merged.diagnostics[0], pull_diag);
1089    }
1090
1091    /// Regression: `merge_diagnostics`'s `(severity, code)` fingerprint alone
1092    /// is coarser than full-field equality and cannot tell apart two
1093    /// genuinely distinct diagnostics that happen to share `code` and
1094    /// `severity` -- e.g. two separate `E0308` mismatched-type errors at
1095    /// different locations in the same file, one caught only by native
1096    /// (pull) analysis and a second, unrelated one caught only by
1097    /// flycheck/cargo check (cache), such as an error inside macro-expanded
1098    /// code the native pass did not evaluate. This previously caused the
1099    /// cache-only entry to be silently dropped -- reproducing #244's exact
1100    /// failure mode, just relocated from "no merge" to "over-eager dedup".
1101    ///
1102    /// The range-proximity check on `is_duplicate` (see `merge_diagnostics`)
1103    /// closes this: these two diagnostics are 45 lines apart, far outside
1104    /// `DUPLICATE_RANGE_PROXIMITY_LINES`, so both must survive the merge.
1105    #[tokio::test]
1106    async fn test_merge_diagnostics_same_code_distinct_diagnostics_at_different_locations_both_kept()
1107     {
1108        let pull_diag = Diagnostic {
1109            range: Range {
1110                start: Position2D {
1111                    line: 5,
1112                    character: 9,
1113                },
1114                end: Position2D {
1115                    line: 5,
1116                    character: 20,
1117                },
1118            },
1119            severity: DiagnosticSeverity::Error,
1120            message: "mismatched types: expected `i32`, found `&str`".to_string(),
1121            code: Some("E0308".to_string()),
1122        };
1123        let pull = DiagnosticsResult {
1124            diagnostics: vec![pull_diag.clone()],
1125            positions_degraded: false,
1126        };
1127        // A second, unrelated E0308 at a completely different location with
1128        // a completely different message -- a real, distinct diagnostic,
1129        // not a duplicate of pull_diag.
1130        let cache = diag_info(vec![lsp_diag(
1131            49,
1132            22,
1133            lsp_types::DiagnosticSeverity::Error,
1134            "mismatched types: expected `String`, found `Vec<u8>`",
1135            Some("E0308"),
1136        )]);
1137
1138        let merged = Translator::merge_diagnostics(
1139            pull,
1140            Some(&cache),
1141            PositionEncoding::Utf16,
1142            &test_tracker(),
1143        )
1144        .await;
1145
1146        assert_eq!(merged.diagnostics.len(), 2);
1147        assert_eq!(merged.diagnostics[0], pull_diag);
1148        assert_eq!(
1149            merged.diagnostics[1].message,
1150            "mismatched types: expected `String`, found `Vec<u8>`"
1151        );
1152    }
1153
1154    #[test]
1155    fn test_handle_server_logs_no_filter() {
1156        use crate::bridge::notifications::LogLevel;
1157
1158        let mut cache = NotificationCache::new();
1159
1160        cache.store_log(LogLevel::Error, "error msg".to_string());
1161        cache.store_log(LogLevel::Warning, "warning msg".to_string());
1162        cache.store_log(LogLevel::Info, "info msg".to_string());
1163        cache.store_log(LogLevel::Debug, "debug msg".to_string());
1164
1165        let result = Translator::handle_server_logs(&cache, 10, None);
1166        assert!(result.is_ok());
1167        let logs = result.unwrap();
1168        assert_eq!(logs.logs.len(), 4);
1169    }
1170
1171    #[test]
1172    fn test_handle_server_logs_error_filter_strict() {
1173        use crate::bridge::notifications::LogLevel;
1174
1175        let mut cache = NotificationCache::new();
1176
1177        cache.store_log(LogLevel::Error, "error msg".to_string());
1178        cache.store_log(LogLevel::Warning, "warning msg".to_string());
1179        cache.store_log(LogLevel::Info, "info msg".to_string());
1180
1181        let result = Translator::handle_server_logs(&cache, 10, Some("error".to_string()));
1182        assert!(result.is_ok());
1183        let logs = result.unwrap();
1184        assert_eq!(logs.logs.len(), 1);
1185        assert_eq!(logs.logs[0].message, "error msg");
1186    }
1187
1188    #[test]
1189    fn test_handle_server_logs_warning_filter_includes_errors() {
1190        use crate::bridge::notifications::LogLevel;
1191
1192        let mut cache = NotificationCache::new();
1193
1194        cache.store_log(LogLevel::Error, "error msg".to_string());
1195        cache.store_log(LogLevel::Warning, "warning msg".to_string());
1196        cache.store_log(LogLevel::Info, "info msg".to_string());
1197
1198        let result = Translator::handle_server_logs(&cache, 10, Some("warning".to_string()));
1199        assert!(result.is_ok());
1200        let logs = result.unwrap();
1201        assert_eq!(logs.logs.len(), 2);
1202    }
1203
1204    #[test]
1205    fn test_handle_server_logs_info_filter_excludes_debug() {
1206        use crate::bridge::notifications::LogLevel;
1207
1208        let mut cache = NotificationCache::new();
1209
1210        cache.store_log(LogLevel::Error, "error msg".to_string());
1211        cache.store_log(LogLevel::Info, "info msg".to_string());
1212        cache.store_log(LogLevel::Debug, "debug msg".to_string());
1213
1214        let result = Translator::handle_server_logs(&cache, 10, Some("info".to_string()));
1215        assert!(result.is_ok());
1216        let logs = result.unwrap();
1217        assert_eq!(logs.logs.len(), 2);
1218    }
1219
1220    #[test]
1221    fn test_handle_server_logs_debug_filter_includes_all() {
1222        use crate::bridge::notifications::LogLevel;
1223
1224        let mut cache = NotificationCache::new();
1225
1226        cache.store_log(LogLevel::Error, "error msg".to_string());
1227        cache.store_log(LogLevel::Warning, "warning msg".to_string());
1228        cache.store_log(LogLevel::Info, "info msg".to_string());
1229        cache.store_log(LogLevel::Debug, "debug msg".to_string());
1230
1231        let result = Translator::handle_server_logs(&cache, 10, Some("debug".to_string()));
1232        assert!(result.is_ok());
1233        let logs = result.unwrap();
1234        assert_eq!(logs.logs.len(), 4);
1235    }
1236
1237    #[test]
1238    fn test_handle_server_logs_limit_applies_after_filter() {
1239        use crate::bridge::notifications::LogLevel;
1240
1241        let mut cache = NotificationCache::new();
1242
1243        for i in 0..10 {
1244            cache.store_log(LogLevel::Error, format!("error {i}"));
1245        }
1246
1247        let result = Translator::handle_server_logs(&cache, 5, Some("error".to_string()));
1248        assert!(result.is_ok());
1249        let logs = result.unwrap();
1250        assert_eq!(logs.logs.len(), 5);
1251        assert_eq!(logs.logs[0].message, "error 0");
1252        assert_eq!(logs.logs[4].message, "error 4");
1253    }
1254
1255    #[test]
1256    fn test_handle_server_logs_case_insensitive_level() {
1257        use crate::bridge::notifications::LogLevel;
1258
1259        let mut cache = NotificationCache::new();
1260
1261        cache.store_log(LogLevel::Error, "error msg".to_string());
1262
1263        let result = Translator::handle_server_logs(&cache, 10, Some("ERROR".to_string()));
1264        assert!(result.is_ok());
1265
1266        let result = Translator::handle_server_logs(&cache, 10, Some("Error".to_string()));
1267        assert!(result.is_ok());
1268
1269        let result = Translator::handle_server_logs(&cache, 10, Some("eRrOr".to_string()));
1270        assert!(result.is_ok());
1271    }
1272
1273    #[test]
1274    fn test_handle_server_messages_empty() {
1275        let cache = NotificationCache::new();
1276
1277        let result = Translator::handle_server_messages(&cache, 10);
1278        assert!(result.is_ok());
1279        let messages = result.unwrap();
1280        assert_eq!(messages.messages.len(), 0);
1281    }
1282
1283    #[test]
1284    fn test_handle_server_messages_with_different_types() {
1285        use crate::bridge::notifications::MessageType;
1286
1287        let mut cache = NotificationCache::new();
1288
1289        cache.store_message(MessageType::Error, "error".to_string());
1290        cache.store_message(MessageType::Warning, "warning".to_string());
1291        cache.store_message(MessageType::Info, "info".to_string());
1292        cache.store_message(MessageType::Log, "log".to_string());
1293
1294        let result = Translator::handle_server_messages(&cache, 10);
1295        assert!(result.is_ok());
1296        let messages = result.unwrap();
1297        assert_eq!(messages.messages.len(), 4);
1298        assert_eq!(messages.messages[0].message, "error");
1299        assert_eq!(messages.messages[1].message, "warning");
1300        assert_eq!(messages.messages[2].message, "info");
1301        assert_eq!(messages.messages[3].message, "log");
1302    }
1303
1304    #[test]
1305    fn test_handle_server_messages_zero_limit() {
1306        use crate::bridge::notifications::MessageType;
1307
1308        let mut cache = NotificationCache::new();
1309
1310        cache.store_message(MessageType::Info, "test".to_string());
1311
1312        let result = Translator::handle_server_messages(&cache, 0);
1313        assert!(result.is_ok());
1314        let messages = result.unwrap();
1315        assert_eq!(messages.messages.len(), 0);
1316    }
1317
1318    #[test]
1319    fn test_handle_cached_diagnostics_path_outside_workspace() {
1320        let temp_dir1 = TempDir::new().unwrap();
1321        let temp_dir2 = TempDir::new().unwrap();
1322
1323        let workspace_roots = vec![temp_dir1.path().to_path_buf()];
1324
1325        let test_file = temp_dir2.path().join("test.rs");
1326        fs::write(&test_file, "fn main() {}").unwrap();
1327
1328        let result =
1329            Translator::cached_diagnostics_uri(&workspace_roots, test_file.to_str().unwrap());
1330        assert!(matches!(result, Err(Error::PathOutsideWorkspace(_))));
1331    }
1332
1333    /// S1 regression (#244): a push-only server (or one that times out)
1334    /// answering `textDocument/diagnostic` with an LSP error must not
1335    /// discard diagnostics `handle_diagnostics` already knows about from the
1336    /// cache -- it should return the cache-only result instead of `Err`.
1337    #[tokio::test]
1338    async fn test_handle_diagnostics_pull_error_falls_back_to_nonempty_cache() {
1339        let dir = TempDir::new().unwrap();
1340        let mut extensions = HashMap::new();
1341        extensions.insert("rs".to_string(), "rust".to_string());
1342
1343        let mut translator =
1344            Translator::new()
1345                .with_extensions(extensions)
1346                .with_router(ToolRouter::catch_all([(
1347                    ServerId::from("rust"),
1348                    "rust".to_string(),
1349                )]));
1350        translator.set_workspace_roots(vec![dir.path().to_path_buf()]);
1351
1352        let (client, mut server) = fake_lsp_client();
1353        translator.register_client("rust".to_string(), client);
1354
1355        let path = dir.path().join("lib.rs");
1356        fs::write(&path, "fn main() {}").unwrap();
1357        let path_str = path.to_string_lossy().to_string();
1358
1359        // Prime the cache under the exact URI handle_diagnostics will look
1360        // up (path_to_uri over the canonicalized path, same as
1361        // document_tracker uses to open the document).
1362        let canonical = path.canonicalize().unwrap();
1363        let uri = path_to_uri(&canonical).unwrap();
1364        let notification_cache = Mutex::new(NotificationCache::new());
1365        {
1366            let mut cache = notification_cache.lock().await;
1367            cache.store_diagnostics(
1368                &ServerId::from("rust"),
1369                &uri,
1370                Some(1),
1371                vec![lsp_diag(
1372                    0,
1373                    4,
1374                    lsp_types::DiagnosticSeverity::Warning,
1375                    "unused import: `std::fmt`",
1376                    None,
1377                )],
1378            );
1379        }
1380
1381        let translator = Arc::new(translator);
1382        let handle = {
1383            let translator = Arc::clone(&translator);
1384            tokio::spawn(async move {
1385                translator
1386                    .handle_diagnostics(path_str, &notification_cache)
1387                    .await
1388            })
1389        };
1390
1391        let mut wire = BufReader::new(&mut server.write_stdout);
1392        let opened = read_framed_message(&mut wire).await;
1393        assert_eq!(opened["method"], "textDocument/didOpen");
1394        let diag_request = read_framed_message(&mut wire).await;
1395        assert_eq!(diag_request["method"], "textDocument/diagnostic");
1396        write_error_response(
1397            &mut server.read_half_stdin,
1398            &diag_request["id"],
1399            -32601,
1400            "method not found",
1401        )
1402        .await;
1403
1404        let result = timeout(Duration::from_secs(2), handle)
1405            .await
1406            .expect("handler call should not hang")
1407            .unwrap();
1408
1409        let diagnostics = result.expect("cache-only fallback should succeed despite pull error");
1410        assert_eq!(diagnostics.diagnostics.len(), 1);
1411        assert_eq!(
1412            diagnostics.diagnostics[0].message,
1413            "unused import: `std::fmt`"
1414        );
1415    }
1416
1417    /// S4 lock-in for critic finding N1: `textDocument/diagnostic` keeps an
1418    /// untyped `LspClient::request` bound to the hand-rolled
1419    /// `DocumentDiagnosticReportResult` union specifically so that a
1420    /// streaming `Partial` response (the shape
1421    /// `DocumentDiagnosticRequest`'s typed `Result` cannot represent)
1422    /// degrades to an empty diagnostics list instead of a deserialization
1423    /// error. This test drives that exact response shape through the real
1424    /// `handle_diagnostics` handler.
1425    #[tokio::test]
1426    async fn test_handle_diagnostics_partial_response_degrades_to_empty_result() {
1427        let dir = TempDir::new().unwrap();
1428        let mut extensions = HashMap::new();
1429        extensions.insert("rs".to_string(), "rust".to_string());
1430
1431        let mut translator =
1432            Translator::new()
1433                .with_extensions(extensions)
1434                .with_router(ToolRouter::catch_all([(
1435                    ServerId::from("rust"),
1436                    "rust".to_string(),
1437                )]));
1438        translator.set_workspace_roots(vec![dir.path().to_path_buf()]);
1439
1440        let (client, mut server) = fake_lsp_client();
1441        translator.register_client("rust".to_string(), client);
1442
1443        let path = dir.path().join("lib.rs");
1444        fs::write(&path, "fn main() {}").unwrap();
1445        let path_str = path.to_string_lossy().to_string();
1446
1447        let notification_cache = Mutex::new(NotificationCache::new());
1448
1449        let translator = Arc::new(translator);
1450        let handle = {
1451            let translator = Arc::clone(&translator);
1452            tokio::spawn(async move {
1453                translator
1454                    .handle_diagnostics(path_str, &notification_cache)
1455                    .await
1456            })
1457        };
1458
1459        let mut wire = BufReader::new(&mut server.write_stdout);
1460        let opened = read_framed_message(&mut wire).await;
1461        assert_eq!(opened["method"], "textDocument/didOpen");
1462        let diag_request = read_framed_message(&mut wire).await;
1463        assert_eq!(diag_request["method"], "textDocument/diagnostic");
1464
1465        // A `DocumentDiagnosticReportPartialResult`: no `kind` field (so it
1466        // cannot be a `Report`), only `relatedDocuments`.
1467        write_response(
1468            &mut server.read_half_stdin,
1469            &diag_request["id"],
1470            serde_json::json!({ "relatedDocuments": {} }),
1471        )
1472        .await;
1473
1474        let result = timeout(Duration::from_secs(2), handle)
1475            .await
1476            .expect("handler call should not hang")
1477            .unwrap();
1478
1479        let diagnostics =
1480            result.expect("a Partial response must degrade to Ok(empty), not an error");
1481        assert!(
1482            diagnostics.diagnostics.is_empty(),
1483            "expected no diagnostics from a Partial response, got {diagnostics:?}"
1484        );
1485    }
1486
1487    /// S1 counterpart: when the cache is also empty, the pull error must
1488    /// still propagate -- there is nothing to fall back to.
1489    #[tokio::test]
1490    async fn test_handle_diagnostics_pull_error_and_empty_cache_propagates_error() {
1491        let dir = TempDir::new().unwrap();
1492        let mut extensions = HashMap::new();
1493        extensions.insert("rs".to_string(), "rust".to_string());
1494
1495        let mut translator =
1496            Translator::new()
1497                .with_extensions(extensions)
1498                .with_router(ToolRouter::catch_all([(
1499                    ServerId::from("rust"),
1500                    "rust".to_string(),
1501                )]));
1502        translator.set_workspace_roots(vec![dir.path().to_path_buf()]);
1503
1504        let (client, mut server) = fake_lsp_client();
1505        translator.register_client("rust".to_string(), client);
1506
1507        let path = dir.path().join("lib.rs");
1508        fs::write(&path, "fn main() {}").unwrap();
1509        let path_str = path.to_string_lossy().to_string();
1510
1511        let notification_cache = Mutex::new(NotificationCache::new());
1512
1513        let translator = Arc::new(translator);
1514        let handle = {
1515            let translator = Arc::clone(&translator);
1516            tokio::spawn(async move {
1517                translator
1518                    .handle_diagnostics(path_str, &notification_cache)
1519                    .await
1520            })
1521        };
1522
1523        let mut wire = BufReader::new(&mut server.write_stdout);
1524        let opened = read_framed_message(&mut wire).await;
1525        assert_eq!(opened["method"], "textDocument/didOpen");
1526        let diag_request = read_framed_message(&mut wire).await;
1527        assert_eq!(diag_request["method"], "textDocument/diagnostic");
1528        write_error_response(
1529            &mut server.read_half_stdin,
1530            &diag_request["id"],
1531            -32601,
1532            "method not found",
1533        )
1534        .await;
1535
1536        let result = timeout(Duration::from_secs(2), handle)
1537            .await
1538            .expect("handler call should not hang")
1539            .unwrap();
1540
1541        assert!(
1542            result.is_err(),
1543            "pull error with no cache data must propagate, got {result:?}"
1544        );
1545    }
1546}