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