Skip to main content

mcpls_core/
error.rs

1//! Error types for mcpls-core.
2//!
3//! This module defines the canonical error type for the library,
4//! following the Microsoft Rust Guidelines for error handling.
5
6use std::path::PathBuf;
7
8use crate::config::{ServerId, ToolKind};
9
10/// Substring rust-analyzer's raw error text carries when a position-based
11/// request's `line`/`character` falls outside the target document. Shared
12/// between [`sanitize_lsp_server_message`] (rewrites the message shown to the
13/// caller) and [`Error::mcp_error_kind`] (classifies this shape of
14/// [`Error::LspServerError`] as caller-fault) so the two stay in sync.
15const INVALID_OFFSET_MARKER: &str = "Invalid offset LineCol";
16
17/// Rewrites an LSP server's raw error message for display to the MCP caller,
18/// replacing rust-analyzer's "Invalid offset" internal error with a clean,
19/// client-appropriate message.
20///
21/// rust-analyzer returns this `Debug`-formatted internal error (embedding its
22/// `LineCol` struct and the line index's byte length, e.g. `"Invalid offset
23/// LineCol { line: 2291, col: 0 } (line index length: 100417)"`) when a
24/// position-based request's `line` or `character` falls outside the target
25/// document. Every other [`Error`] variant produces a clean message; this
26/// function keeps [`Error::LspServerError`]'s `Display` impl consistent with
27/// that convention instead of forwarding the upstream server's internals
28/// verbatim.
29///
30/// Matches via `contains` rather than `starts_with`: rust-analyzer's error
31/// travels through `anyhow`/`lsp_server` before reaching mcpls, so a future
32/// upstream `.context(...)` wrapper (or a truncation prefix added on the
33/// mcpls side) could prepend text ahead of `"Invalid offset LineCol"` without
34/// mcpls's control -- `contains` keeps the guard robust to that at no extra
35/// cost. Deliberately not also gated on the JSON-RPC error `code`: this error
36/// class has been observed under both `-32603` (internal error) and `-32803`
37/// (`RequestFailed`) across rust-analyzer versions, so a code condition would
38/// make the guard more fragile, not less.
39fn sanitize_lsp_server_message(message: &str) -> String {
40    if message.contains(INVALID_OFFSET_MARKER) {
41        "position out of range for this document".to_string()
42    } else {
43        message.to_string()
44    }
45}
46
47/// Details of a single server spawn failure.
48#[derive(Debug, Clone)]
49pub struct ServerSpawnFailure {
50    /// Routing identity of the failed server.
51    pub server_id: ServerId,
52    /// Language ID of the failed server.
53    pub language_id: String,
54    /// Command that was attempted.
55    pub command: String,
56    /// Error message describing the failure.
57    pub message: String,
58}
59
60impl std::fmt::Display for ServerSpawnFailure {
61    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
62        write!(
63            f,
64            "{} [{}] ({}): {}",
65            self.server_id, self.language_id, self.command, self.message
66        )
67    }
68}
69
70/// The main error type for mcpls-core operations.
71///
72/// This enum is `#[non_exhaustive]`: downstream crates that match on it must
73/// include a wildcard arm. New variants (such as [`Error::ServerInitializing`])
74/// can then be added without further breaking changes.
75#[derive(Debug, thiserror::Error)]
76#[non_exhaustive]
77pub enum Error {
78    /// LSP server failed to initialize.
79    #[error("LSP server initialization failed: {message}")]
80    LspInitFailed {
81        /// Description of the initialization failure.
82        message: String,
83    },
84
85    /// LSP server returned an error response.
86    #[error("LSP server error: {code} - {}", sanitize_lsp_server_message(message))]
87    LspServerError {
88        /// JSON-RPC error code.
89        code: i32,
90        /// Raw error message from the server, kept verbatim for diagnostics
91        /// (logging, `Debug`, pattern matching). The `Display` impl for this
92        /// variant rewrites known-internal upstream text before it reaches
93        /// an MCP caller, so this field is not always what the caller sees.
94        message: String,
95        /// Optional additional data from the JSON-RPC error object.
96        data: Option<serde_json::Value>,
97    },
98
99    /// MCP server error.
100    #[error("MCP server error: {0}")]
101    McpServer(String),
102
103    /// Document was not found or could not be opened.
104    #[error("document not found: {0}")]
105    DocumentNotFound(PathBuf),
106
107    /// No LSP server configured for the given language.
108    #[error("no LSP server configured for language: {0}")]
109    NoServerForLanguage(String),
110
111    /// A server is configured for the language, but no server claims this
112    /// specific tool (either no server lists it in `handles` and there is no
113    /// catch-all, or the server that claimed it failed to spawn with no live
114    /// catch-all to rebind to).
115    #[error("no server handles tool '{tool}' for language '{language_id}'")]
116    NoServerForTool {
117        /// Language ID the request was for.
118        language_id: String,
119        /// Tool that no server claims.
120        tool: ToolKind,
121    },
122
123    /// LSP server for the language is configured but still initializing.
124    #[error(
125        "LSP server '{server_id}' is still initializing (large project load in progress); wait and retry the request (this may take a few minutes on large projects)"
126    )]
127    ServerInitializing {
128        /// Routing identity of the server that has not yet registered.
129        server_id: ServerId,
130    },
131
132    /// A workspace-wide tool (one with no file to resolve a language from,
133    /// e.g. `workspace_symbol_search`) could not be routed because at least
134    /// one expected LSP server has not registered yet. Unlike
135    /// [`Error::ServerInitializing`], resolution never narrowed down to a
136    /// single candidate server, so no `server_id` is available.
137    #[error(
138        "LSP servers are still initializing (large project load in progress); wait and retry the request (this may take a few minutes on large projects)"
139    )]
140    WorkspaceServersInitializing,
141
142    /// No LSP server is currently configured.
143    #[error("no LSP server configured")]
144    NoServerConfigured,
145
146    /// At least one server is configured somewhere in the workspace, but
147    /// none of them claims a workspace-wide tool that has no file to
148    /// resolve a language from (e.g. `workspace_symbol_search`). The
149    /// language-less counterpart of [`Error::NoServerForTool`].
150    #[error("no server handles tool '{tool}' (no server's `handles` list or catch-all claims it)")]
151    NoServerForWorkspaceTool {
152        /// Tool that no server claims anywhere in the workspace.
153        tool: ToolKind,
154    },
155
156    /// Configuration file not found.
157    #[error("configuration file not found: {0}")]
158    ConfigNotFound(PathBuf),
159
160    /// Invalid configuration format.
161    #[error("invalid configuration: {0}")]
162    InvalidConfig(String),
163
164    /// I/O error.
165    #[error("I/O error: {0}")]
166    Io(#[from] std::io::Error),
167
168    /// JSON serialization/deserialization error.
169    #[error("JSON error: {0}")]
170    Json(#[from] serde_json::Error),
171
172    /// TOML deserialization error.
173    #[error("TOML parsing error: {0}")]
174    TomlDe(#[from] toml::de::Error),
175
176    /// TOML serialization error.
177    #[error("TOML serialization error: {0}")]
178    TomlSer(#[from] toml::ser::Error),
179
180    /// LSP client transport error.
181    #[error("transport error: {0}")]
182    Transport(String),
183
184    /// Request timeout.
185    #[error("request timed out after {0} seconds")]
186    Timeout(u64),
187
188    /// LSP server failed to spawn.
189    #[error("failed to spawn LSP server '{command}': {source}")]
190    ServerSpawnFailed {
191        /// Command that failed to spawn.
192        command: String,
193        /// Underlying IO error.
194        #[source]
195        source: std::io::Error,
196    },
197
198    /// LSP protocol error during message parsing.
199    #[error("LSP protocol error: {0}")]
200    LspProtocolError(String),
201
202    /// Invalid URI format.
203    #[error("invalid URI: {0}")]
204    InvalidUri(String),
205
206    /// Server process terminated unexpectedly.
207    #[error("LSP server process terminated unexpectedly")]
208    ServerTerminated,
209
210    /// A crashed server could not be automatically respawned.
211    ///
212    /// Distinct from [`Self::ServerTerminated`] so a caller (or a log
213    /// reader) can tell "the connection just died" apart from "mcpls tried
214    /// to bring it back and could not" -- e.g. no respawn config was ever
215    /// registered for it, or it is crash-looping and is being backed off.
216    #[error("LSP server '{server_id}' is unavailable: {reason}")]
217    ServerUnavailable {
218        /// Routing identity of the server that could not be respawned.
219        server_id: ServerId,
220        /// Human-readable reason the respawn did not proceed.
221        reason: String,
222    },
223
224    /// Invalid tool parameters provided.
225    #[error("invalid tool parameters: {0}")]
226    InvalidToolParams(String),
227
228    /// File I/O error occurred.
229    ///
230    /// See [`Error::mcp_error_kind`] for the JSON-RPC classification: a
231    /// `source.kind() == ErrorKind::NotFound` failure -- whether `path` was
232    /// freshly supplied in this request or was tracked from an earlier one
233    /// and has since been deleted/moved on disk -- is caller-fault; any
234    /// other IO failure is not.
235    #[error("file I/O error for {path:?}: {source}")]
236    FileIo {
237        /// Path to the file.
238        path: PathBuf,
239        /// Underlying I/O error.
240        #[source]
241        source: std::io::Error,
242    },
243
244    /// Path is outside allowed workspace boundaries.
245    #[error("path outside workspace: {0}")]
246    PathOutsideWorkspace(PathBuf),
247
248    /// No workspace roots are configured, so path-taking operations are
249    /// rejected outright rather than allowed unrestricted (fail closed).
250    #[error("no workspace roots configured: refusing access to {0}")]
251    NoWorkspaceRoots(PathBuf),
252
253    /// Document limit exceeded.
254    #[error(
255        "document limit exceeded: {current}/{max} (raise workspace.max_documents in config to increase this)"
256    )]
257    DocumentLimitExceeded {
258        /// Current number of documents.
259        current: usize,
260        /// Maximum allowed documents.
261        max: usize,
262    },
263
264    /// Resource-subscription limit exceeded for the session.
265    ///
266    /// See [`Error::mcp_error_kind`] for the JSON-RPC classification: same
267    /// shape as [`Self::DocumentLimitExceeded`] -- fires on aggregate
268    /// per-session tracker state, not this request's params -- so it is
269    /// classified the same way, not `InvalidParams`.
270    #[error("subscription limit of {max} reached")]
271    SubscriptionLimitReached {
272        /// Maximum number of subscriptions allowed per session.
273        max: usize,
274    },
275
276    /// File size limit exceeded.
277    #[error(
278        "file size limit exceeded: {size} bytes, max {max} bytes (raise workspace.max_file_size in config to increase this)"
279    )]
280    FileSizeLimitExceeded {
281        /// Actual file size.
282        size: u64,
283        /// Maximum allowed size.
284        max: u64,
285    },
286
287    /// Path exists but does not refer to a regular file (e.g. a FIFO or a
288    /// character/block device).
289    ///
290    /// mcpls refuses to read such paths: their reported size does not bound
291    /// how much data reading them could produce, and opening some of them
292    /// for reading can block indefinitely waiting for a peer. A Unix domain
293    /// socket special file is not covered by this variant -- `open(2)` on
294    /// one fails outright (`ENXIO`) before the file-type check that produces
295    /// this error ever runs, so it surfaces as [`Self::FileIo`] instead.
296    #[error("not a regular file: {0}")]
297    NotARegularFile(PathBuf),
298
299    /// All configured LSP servers failed to initialize.
300    #[error("all LSP servers failed to initialize ({count} configured)")]
301    AllServersFailedToInit {
302        /// Number of servers that were configured.
303        count: usize,
304        /// Details of each failure.
305        failures: Vec<ServerSpawnFailure>,
306    },
307
308    /// No LSP servers available (none configured or all failed).
309    #[error("{0}")]
310    NoServersAvailable(String),
311
312    /// The server routed for this request does not advertise support for the
313    /// requested LSP capability (e.g. no `renameProvider` in its
314    /// `ServerCapabilities`).
315    #[error("server '{server_id}' does not support capability '{capability}'")]
316    CapabilityNotSupported {
317        /// Routing identity of the server that lacks the capability.
318        server_id: ServerId,
319        /// The missing LSP capability's name (e.g. `"renameProvider"`), the
320        /// `ServerCapabilities` field mcpls checked.
321        capability: &'static str,
322    },
323
324    /// The routed server has an active signal indicating its initial
325    /// workspace-load/indexing phase is still in progress, and the bounded
326    /// wait for it to finish elapsed before it completed. Returned instead
327    /// of an unqualified empty/`null` result so a caller cannot mistake
328    /// "index not ready yet" for "this position/symbol genuinely has
329    /// nothing here".
330    #[error(
331        "LSP server '{server_id}' is still indexing the workspace after {elapsed_secs}s; wait and retry the request"
332    )]
333    WorkspaceIndexing {
334        /// Routing identity of the server still indexing.
335        server_id: ServerId,
336        /// How long mcpls waited for readiness before giving up.
337        elapsed_secs: u64,
338    },
339}
340
341/// Bespoke JSON-RPC code for [`Error::WorkspaceIndexing`].
342///
343/// Picked clear of rmcp's `-32002`/`-32020..-32022`; the range is convention, not a registry.
344///
345/// # Examples
346///
347/// ```
348/// use mcpls_core::error::WORKSPACE_INDEXING_ERROR_CODE;
349///
350/// assert_eq!(WORKSPACE_INDEXING_ERROR_CODE, -32050);
351/// ```
352pub const WORKSPACE_INDEXING_ERROR_CODE: i32 = -32050;
353
354/// Bespoke JSON-RPC code for [`Error::ServerInitializing`].
355///
356/// Distinct from [`WORKSPACE_INDEXING_ERROR_CODE`] so a client can tell "the
357/// server hasn't registered yet" apart from "the server registered but is
358/// still indexing" -- both retryable, but for different reasons.
359///
360/// # Examples
361///
362/// ```
363/// use mcpls_core::error::{SERVER_INITIALIZING_ERROR_CODE, WORKSPACE_INDEXING_ERROR_CODE};
364///
365/// assert_eq!(SERVER_INITIALIZING_ERROR_CODE, -32051);
366/// assert_ne!(SERVER_INITIALIZING_ERROR_CODE, WORKSPACE_INDEXING_ERROR_CODE);
367/// ```
368pub const SERVER_INITIALIZING_ERROR_CODE: i32 = -32051;
369
370/// Bespoke JSON-RPC code for a resource subscription request rejected
371/// because it was served over rmcp's stateless per-request HTTP path (#482).
372///
373/// Same convention range as [`WORKSPACE_INDEXING_ERROR_CODE`]/
374/// [`SERVER_INITIALIZING_ERROR_CODE`], next unused slot.
375///
376/// # Examples
377///
378/// ```
379/// use mcpls_core::error::{
380///     SERVER_INITIALIZING_ERROR_CODE, STATELESS_SUBSCRIPTION_ERROR_CODE,
381///     WORKSPACE_INDEXING_ERROR_CODE,
382/// };
383///
384/// assert_eq!(STATELESS_SUBSCRIPTION_ERROR_CODE, -32052);
385/// assert_ne!(STATELESS_SUBSCRIPTION_ERROR_CODE, WORKSPACE_INDEXING_ERROR_CODE);
386/// assert_ne!(STATELESS_SUBSCRIPTION_ERROR_CODE, SERVER_INITIALIZING_ERROR_CODE);
387/// ```
388pub const STATELESS_SUBSCRIPTION_ERROR_CODE: i32 = -32052;
389
390/// JSON-RPC error-code classification for an [`Error`], returned by
391/// [`Error::mcp_error_kind`].
392///
393/// mcpls-core has no dependency on the MCP transport crate, so this carries
394/// only plain data; `crate::mcp` is responsible for turning it into the
395/// actual wire-level error type.
396///
397/// # Examples
398///
399/// ```
400/// use mcpls_core::error::{Error, McpErrorKind};
401///
402/// let err = Error::InvalidToolParams("missing `file_path`".to_string());
403/// assert_eq!(err.mcp_error_kind(), McpErrorKind::InvalidParams);
404/// ```
405#[derive(Debug, Clone, PartialEq, Eq)]
406pub enum McpErrorKind {
407    /// Caller-fault: the request itself was invalid. Maps to JSON-RPC
408    /// `-32602` (`INVALID_PARAMS`).
409    InvalidParams,
410    /// A transient, retryable server-side condition, distinct from a crash.
411    /// Maps to a bespoke JSON-RPC `code` with a structured `data` payload a
412    /// caller can act on mechanically, rather than the generic
413    /// `INTERNAL_ERROR`.
414    Retryable {
415        /// Bespoke JSON-RPC error code.
416        code: i32,
417        /// Structured details about the retryable condition.
418        data: serde_json::Value,
419    },
420    /// An unexpected server-side failure. Maps to JSON-RPC `-32603`
421    /// (`INTERNAL_ERROR`).
422    Internal,
423}
424
425impl Error {
426    /// Classify this error for JSON-RPC error-code mapping.
427    ///
428    /// Matched exhaustively with no wildcard arm: a newly added [`Error`]
429    /// variant must be given an explicit classification here instead of
430    /// silently defaulting to [`McpErrorKind::Internal`].
431    ///
432    /// # Examples
433    ///
434    /// ```
435    /// use mcpls_core::config::ServerId;
436    /// use mcpls_core::error::{Error, McpErrorKind};
437    ///
438    /// let err = Error::WorkspaceIndexing {
439    ///     server_id: ServerId::from("rust"),
440    ///     elapsed_secs: 30,
441    /// };
442    /// let McpErrorKind::Retryable { code, data } = err.mcp_error_kind() else {
443    ///     panic!("expected a retryable classification");
444    /// };
445    /// assert_eq!(data["serverId"], "rust");
446    /// ```
447    #[must_use]
448    pub fn mcp_error_kind(&self) -> McpErrorKind {
449        match self {
450            Self::InvalidToolParams(_)
451            | Self::PathOutsideWorkspace(_)
452            | Self::NotARegularFile(_)
453            | Self::InvalidUri(_)
454            | Self::DocumentNotFound(_)
455            | Self::FileSizeLimitExceeded { .. } => McpErrorKind::InvalidParams,
456
457            // A path that doesn't exist -- whether freshly supplied in this
458            // request or tracked from an earlier request and then
459            // deleted/moved on disk since -- is caller-fault, same as
460            // `DocumentNotFound`, and matches the MCP spec's expectation
461            // that resource-not-found map to INVALID_PARAMS, not
462            // INTERNAL_ERROR (rmcp's `read_resource` handling, SEP-2164).
463            // Any other IO failure (permission denied, etc.) reaching here is
464            // a genuine server-side problem the caller cannot fix by
465            // changing their request.
466            Self::FileIo { source, .. } => {
467                if source.kind() == std::io::ErrorKind::NotFound {
468                    McpErrorKind::InvalidParams
469                } else {
470                    McpErrorKind::Internal
471                }
472            }
473
474            Self::WorkspaceIndexing {
475                server_id,
476                elapsed_secs,
477            } => McpErrorKind::Retryable {
478                code: WORKSPACE_INDEXING_ERROR_CODE,
479                data: serde_json::json!({
480                    "serverId": server_id.as_str(),
481                    "elapsedSecs": elapsed_secs,
482                }),
483            },
484            Self::ServerInitializing { server_id } => McpErrorKind::Retryable {
485                code: SERVER_INITIALIZING_ERROR_CODE,
486                data: serde_json::json!({
487                    "serverId": server_id.as_str(),
488                }),
489            },
490            // Same condition as `ServerInitializing` -- an expected LSP
491            // server hasn't registered yet, retry -- just without a single
492            // candidate server narrowed down (see the variant's doc), so
493            // there's no `serverId` to report.
494            Self::WorkspaceServersInitializing => McpErrorKind::Retryable {
495                code: SERVER_INITIALIZING_ERROR_CODE,
496                data: serde_json::json!({}),
497            },
498
499            // Same recognized shape `sanitize_lsp_server_message` rewrites
500            // for display: rust-analyzer reports this when a position-based
501            // request's line/character falls outside the target document --
502            // caller-fault. Every other `LspServerError` shape is a genuine
503            // server-side problem and stays `Internal`.
504            Self::LspServerError { message, .. } if message.contains(INVALID_OFFSET_MARKER) => {
505                McpErrorKind::InvalidParams
506            }
507
508            Self::LspInitFailed { .. }
509            | Self::LspServerError { .. }
510            | Self::McpServer(_)
511            | Self::NoServerForLanguage(_)
512            | Self::NoServerForTool { .. }
513            | Self::NoServerConfigured
514            | Self::NoServerForWorkspaceTool { .. }
515            | Self::ConfigNotFound(_)
516            | Self::InvalidConfig(_)
517            | Self::Io(_)
518            | Self::Json(_)
519            | Self::TomlDe(_)
520            | Self::TomlSer(_)
521            | Self::Transport(_)
522            | Self::Timeout(_)
523            | Self::ServerSpawnFailed { .. }
524            | Self::LspProtocolError(_)
525            | Self::ServerTerminated
526            | Self::ServerUnavailable { .. }
527            | Self::NoWorkspaceRoots(_)
528            // Unlike `FileSizeLimitExceeded`, this fires on aggregate tracker
529            // state, not this request's params -- it can succeed unchanged
530            // once other documents close, so `InvalidParams` is wrong; not
531            // `Retryable` either, since nothing evicts documents on a timer.
532            | Self::DocumentLimitExceeded { .. }
533            // Same shape as `DocumentLimitExceeded` above -- see this
534            // variant's doc comment.
535            | Self::SubscriptionLimitReached { .. }
536            | Self::AllServersFailedToInit { .. }
537            | Self::NoServersAvailable(_)
538            | Self::CapabilityNotSupported { .. } => McpErrorKind::Internal,
539        }
540    }
541}
542
543/// A specialized Result type for mcpls-core operations.
544pub type Result<T> = std::result::Result<T, Error>;
545
546#[cfg(test)]
547mod tests {
548    use super::*;
549
550    #[test]
551    fn test_error_display_lsp_init_failed() {
552        let err = Error::LspInitFailed {
553            message: "server not found".to_string(),
554        };
555        assert_eq!(
556            err.to_string(),
557            "LSP server initialization failed: server not found"
558        );
559    }
560
561    #[test]
562    fn test_error_display_lsp_server_error() {
563        let err = Error::LspServerError {
564            code: -32600,
565            message: "Invalid request".to_string(),
566            data: None,
567        };
568        assert_eq!(
569            err.to_string(),
570            "LSP server error: -32600 - Invalid request"
571        );
572    }
573
574    #[test]
575    fn test_error_display_lsp_server_error_sanitizes_invalid_offset() {
576        let err = Error::LspServerError {
577            code: -32603,
578            message: "Invalid offset LineCol { line: 2291, col: 0 } (line index length: 100417)"
579                .to_string(),
580            data: None,
581        };
582        assert_eq!(
583            err.to_string(),
584            "LSP server error: -32603 - position out of range for this document"
585        );
586    }
587
588    #[test]
589    fn test_error_display_lsp_server_error_sanitizes_wrapped_invalid_offset() {
590        // Guards the `contains` (not `starts_with`) match: an upstream
591        // wrapper (e.g. an `anyhow::Context`) or a future mcpls-side prefix
592        // could prepend text ahead of rust-analyzer's raw message.
593        let err = Error::LspServerError {
594            code: -32803,
595            message: "request handler panicked: Invalid offset LineCol { line: 5, col: 0 } \
596                      (line index length: 3)"
597                .to_string(),
598            data: None,
599        };
600        assert_eq!(
601            err.to_string(),
602            "LSP server error: -32803 - position out of range for this document"
603        );
604    }
605
606    #[test]
607    fn test_error_display_lsp_server_error_passes_through_unrelated_message() {
608        let err = Error::LspServerError {
609            code: -32602,
610            message: "Invalid params: expected object".to_string(),
611            data: None,
612        };
613        assert_eq!(
614            err.to_string(),
615            "LSP server error: -32602 - Invalid params: expected object"
616        );
617    }
618
619    #[test]
620    fn test_error_display_document_not_found() {
621        let err = Error::DocumentNotFound(PathBuf::from("/path/to/file.rs"));
622        assert!(err.to_string().contains("document not found"));
623        assert!(err.to_string().contains("file.rs"));
624    }
625
626    #[test]
627    fn test_error_display_no_server_for_language() {
628        let err = Error::NoServerForLanguage("rust".to_string());
629        assert_eq!(
630            err.to_string(),
631            "no LSP server configured for language: rust"
632        );
633    }
634
635    #[test]
636    fn test_error_display_workspace_servers_initializing() {
637        let err = Error::WorkspaceServersInitializing;
638        assert!(err.to_string().contains("still initializing"));
639    }
640
641    #[test]
642    fn test_error_display_no_server_for_workspace_tool() {
643        let err = Error::NoServerForWorkspaceTool {
644            tool: crate::config::ToolKind::WorkspaceSymbols,
645        };
646        assert!(err.to_string().contains("workspace_symbols"));
647        assert!(err.to_string().contains("no server's `handles` list"));
648    }
649
650    #[test]
651    fn test_error_display_timeout() {
652        let err = Error::Timeout(30);
653        assert_eq!(err.to_string(), "request timed out after 30 seconds");
654    }
655
656    #[test]
657    fn test_error_display_document_limit() {
658        let err = Error::DocumentLimitExceeded {
659            current: 150,
660            max: 100,
661        };
662        assert_eq!(
663            err.to_string(),
664            "document limit exceeded: 150/100 (raise workspace.max_documents in config to increase this)"
665        );
666    }
667
668    #[test]
669    fn test_error_display_file_size_limit() {
670        let err = Error::FileSizeLimitExceeded {
671            size: 20_000_000,
672            max: 10_000_000,
673        };
674        assert_eq!(
675            err.to_string(),
676            "file size limit exceeded: 20000000 bytes, max 10000000 bytes (raise workspace.max_file_size in config to increase this)"
677        );
678    }
679
680    #[test]
681    fn test_error_display_not_a_regular_file() {
682        let err = Error::NotARegularFile(PathBuf::from("/tmp/some.fifo"));
683        assert_eq!(err.to_string(), "not a regular file: /tmp/some.fifo");
684    }
685
686    #[test]
687    fn test_error_from_io() {
688        let io_err = std::io::Error::new(std::io::ErrorKind::NotFound, "file not found");
689        let err: Error = io_err.into();
690        assert!(matches!(err, Error::Io(_)));
691    }
692
693    #[test]
694    #[allow(clippy::unwrap_used)]
695    fn test_error_from_json() {
696        let json_str = "{invalid json}";
697        let json_err = serde_json::from_str::<serde_json::Value>(json_str).unwrap_err();
698        let err: Error = json_err.into();
699        assert!(matches!(err, Error::Json(_)));
700    }
701
702    #[test]
703    #[allow(clippy::unwrap_used)]
704    fn test_error_from_toml_de() {
705        let toml_str = "[invalid toml";
706        let toml_err = toml::from_str::<toml::Value>(toml_str).unwrap_err();
707        let err: Error = toml_err.into();
708        assert!(matches!(err, Error::TomlDe(_)));
709    }
710
711    #[test]
712    fn test_result_type_alias() {
713        fn _returns_error() -> Result<i32> {
714            Err(Error::InvalidConfig("test error".to_string()))
715        }
716
717        let result: Result<i32> = Ok(42);
718        assert!(result.is_ok());
719        if let Ok(value) = result {
720            assert_eq!(value, 42);
721        }
722    }
723
724    #[test]
725    fn test_error_source_chain() {
726        let io_err = std::io::Error::new(std::io::ErrorKind::NotFound, "file not found");
727        let err = Error::ServerSpawnFailed {
728            command: "rust-analyzer".to_string(),
729            source: io_err,
730        };
731
732        let source = std::error::Error::source(&err);
733        assert!(source.is_some());
734    }
735
736    #[test]
737    fn test_server_spawn_failure_display() {
738        let failure = ServerSpawnFailure {
739            server_id: ServerId::from("rust"),
740            language_id: "rust".to_string(),
741            command: "rust-analyzer".to_string(),
742            message: "No such file or directory".to_string(),
743        };
744        assert_eq!(
745            failure.to_string(),
746            "rust [rust] (rust-analyzer): No such file or directory"
747        );
748    }
749
750    #[test]
751    fn test_server_spawn_failure_debug() {
752        let failure = ServerSpawnFailure {
753            server_id: ServerId::from("python"),
754            language_id: "python".to_string(),
755            command: "pyright".to_string(),
756            message: "command not found".to_string(),
757        };
758        let debug_str = format!("{failure:?}");
759        assert!(debug_str.contains("python"));
760        assert!(debug_str.contains("pyright"));
761        assert!(debug_str.contains("command not found"));
762    }
763
764    #[test]
765    fn test_server_spawn_failure_clone() {
766        let failure = ServerSpawnFailure {
767            server_id: ServerId::from("typescript"),
768            language_id: "typescript".to_string(),
769            command: "tsserver".to_string(),
770            message: "failed to start".to_string(),
771        };
772        let cloned = failure.clone();
773        assert_eq!(failure.language_id, cloned.language_id);
774        assert_eq!(failure.command, cloned.command);
775        assert_eq!(failure.message, cloned.message);
776    }
777
778    #[test]
779    fn test_error_display_all_servers_failed_to_init() {
780        let err = Error::AllServersFailedToInit {
781            count: 2,
782            failures: vec![],
783        };
784        assert_eq!(
785            err.to_string(),
786            "all LSP servers failed to initialize (2 configured)"
787        );
788    }
789
790    #[test]
791    fn test_error_all_servers_failed_with_failures() {
792        let failures = vec![
793            ServerSpawnFailure {
794                server_id: ServerId::from("rust"),
795                language_id: "rust".to_string(),
796                command: "rust-analyzer".to_string(),
797                message: "not found".to_string(),
798            },
799            ServerSpawnFailure {
800                server_id: ServerId::from("python"),
801                language_id: "python".to_string(),
802                command: "pyright".to_string(),
803                message: "permission denied".to_string(),
804            },
805        ];
806
807        let err = Error::AllServersFailedToInit { count: 2, failures };
808
809        assert!(err.to_string().contains("all LSP servers failed"));
810        assert!(err.to_string().contains("2 configured"));
811    }
812
813    #[test]
814    fn test_error_display_no_servers_available() {
815        let err =
816            Error::NoServersAvailable("none configured or all failed to initialize".to_string());
817        assert_eq!(
818            err.to_string(),
819            "none configured or all failed to initialize"
820        );
821    }
822
823    #[test]
824    fn test_error_no_servers_available_with_custom_message() {
825        let custom_msg = "none configured or all failed to initialize";
826        let err = Error::NoServersAvailable(custom_msg.to_string());
827        assert_eq!(err.to_string(), custom_msg);
828    }
829
830    #[test]
831    fn test_error_display_capability_not_supported() {
832        let err = Error::CapabilityNotSupported {
833            server_id: ServerId::from("rust"),
834            capability: "renameProvider",
835        };
836        assert_eq!(
837            err.to_string(),
838            "server 'rust' does not support capability 'renameProvider'"
839        );
840    }
841
842    #[test]
843    fn test_error_display_workspace_indexing() {
844        let err = Error::WorkspaceIndexing {
845            server_id: ServerId::from("rust"),
846            elapsed_secs: 30,
847        };
848        assert_eq!(
849            err.to_string(),
850            "LSP server 'rust' is still indexing the workspace after 30s; wait and retry the request"
851        );
852    }
853
854    /// #479: caller-fault variants must classify as `InvalidParams`, not fall
855    /// through to the generic `Internal` bucket.
856    #[test]
857    fn test_mcp_error_kind_caller_fault_variants_are_invalid_params() {
858        let caller_fault_errors = vec![
859            Error::InvalidToolParams("bad params".to_string()),
860            Error::PathOutsideWorkspace(PathBuf::from("/etc/passwd")),
861            Error::NotARegularFile(PathBuf::from("/dev/null")),
862            Error::InvalidUri("not a uri".to_string()),
863            Error::DocumentNotFound(PathBuf::from("/missing.rs")),
864            Error::FileSizeLimitExceeded { size: 100, max: 10 },
865        ];
866
867        for err in caller_fault_errors {
868            assert_eq!(
869                err.mcp_error_kind(),
870                McpErrorKind::InvalidParams,
871                "expected {err:?} to classify as InvalidParams"
872            );
873        }
874    }
875
876    #[test]
877    fn test_mcp_error_kind_workspace_indexing_is_retryable_with_dedicated_code() {
878        let err = Error::WorkspaceIndexing {
879            server_id: ServerId::from("rust"),
880            elapsed_secs: 30,
881        };
882        let McpErrorKind::Retryable { code, data } = err.mcp_error_kind() else {
883            panic!("expected WorkspaceIndexing to classify as Retryable");
884        };
885        assert_eq!(code, WORKSPACE_INDEXING_ERROR_CODE);
886        assert_eq!(data["serverId"], "rust");
887        assert_eq!(data["elapsedSecs"], 30);
888    }
889
890    #[test]
891    fn test_mcp_error_kind_server_initializing_is_retryable_with_dedicated_code() {
892        let err = Error::ServerInitializing {
893            server_id: ServerId::from("python"),
894        };
895        let McpErrorKind::Retryable { code, data } = err.mcp_error_kind() else {
896            panic!("expected ServerInitializing to classify as Retryable");
897        };
898        assert_eq!(code, SERVER_INITIALIZING_ERROR_CODE);
899        assert_eq!(data["serverId"], "python");
900        assert_ne!(
901            code, WORKSPACE_INDEXING_ERROR_CODE,
902            "ServerInitializing must be distinguishable on the wire from WorkspaceIndexing"
903        );
904    }
905
906    /// `WorkspaceServersInitializing` is `ServerInitializing`'s counterpart
907    /// for a resolution that never narrowed down to a single server (see the
908    /// variant's doc comment), so it must be retryable too -- a client that
909    /// auto-retries on the bespoke retryable code must not treat this as a
910    /// hard failure just because no `server_id` was available.
911    #[test]
912    fn test_mcp_error_kind_workspace_servers_initializing_is_retryable() {
913        let err = Error::WorkspaceServersInitializing;
914        let McpErrorKind::Retryable { code, .. } = err.mcp_error_kind() else {
915            panic!("expected WorkspaceServersInitializing to classify as Retryable");
916        };
917        assert_eq!(code, SERVER_INITIALIZING_ERROR_CODE);
918    }
919
920    #[test]
921    fn test_mcp_error_kind_unretained_variants_stay_internal() {
922        let internal_errors = vec![
923            Error::NoServerForLanguage("python".to_string()),
924            Error::NoServerForTool {
925                language_id: "rust".to_string(),
926                tool: crate::config::ToolKind::Hover,
927            },
928            Error::CapabilityNotSupported {
929                server_id: ServerId::from("rust"),
930                capability: "renameProvider",
931            },
932            Error::NoWorkspaceRoots(PathBuf::from("/tmp")),
933            Error::DocumentLimitExceeded {
934                current: 150,
935                max: 100,
936            },
937            Error::SubscriptionLimitReached { max: 1000 },
938        ];
939
940        for err in internal_errors {
941            assert_eq!(
942                err.mcp_error_kind(),
943                McpErrorKind::Internal,
944                "expected {err:?} to classify as Internal"
945            );
946        }
947    }
948
949    /// #479 regression: a client-supplied path that doesn't exist (the
950    /// common case behind `validate_path_against_roots`'s `canonicalize()`
951    /// failure) must classify as caller-fault, matching `DocumentNotFound`.
952    #[test]
953    fn test_mcp_error_kind_file_io_not_found_is_invalid_params() {
954        let err = Error::FileIo {
955            path: PathBuf::from("/no/such/file.rs"),
956            source: std::io::Error::new(std::io::ErrorKind::NotFound, "no such file or directory"),
957        };
958        assert_eq!(err.mcp_error_kind(), McpErrorKind::InvalidParams);
959    }
960
961    /// Counterpart: a non-not-found IO failure (permission denied, etc.) is
962    /// a genuine server-side problem, not something the caller can fix by
963    /// changing their request.
964    #[test]
965    fn test_mcp_error_kind_file_io_other_kind_stays_internal() {
966        let err = Error::FileIo {
967            path: PathBuf::from("/root/secret.rs"),
968            source: std::io::Error::new(std::io::ErrorKind::PermissionDenied, "permission denied"),
969        };
970        assert_eq!(err.mcp_error_kind(), McpErrorKind::Internal);
971    }
972
973    /// #496: an `LspServerError` carrying the recognized "position out of
974    /// range" shape (same substring `sanitize_lsp_server_message` rewrites
975    /// for display) is caller-fault, not a generic internal failure.
976    #[test]
977    fn test_mcp_error_kind_lsp_server_error_invalid_offset_is_invalid_params() {
978        let err = Error::LspServerError {
979            code: -32603,
980            message: "Invalid offset LineCol { line: 2291, col: 0 } (line index length: 100417)"
981                .to_string(),
982            data: None,
983        };
984        assert_eq!(err.mcp_error_kind(), McpErrorKind::InvalidParams);
985    }
986
987    /// Counterpart: an `LspServerError` whose message doesn't match the
988    /// recognized position-out-of-range shape is a genuine server-side
989    /// problem and must stay `Internal`.
990    #[test]
991    fn test_mcp_error_kind_lsp_server_error_other_message_stays_internal() {
992        let err = Error::LspServerError {
993            code: -32603,
994            message: "internal error".to_string(),
995            data: None,
996        };
997        assert_eq!(err.mcp_error_kind(), McpErrorKind::Internal);
998    }
999
1000    /// #496: `SubscriptionLimitReached` fires on aggregate per-session
1001    /// tracker state, not this request's params, so it must classify the
1002    /// same way as `DocumentLimitExceeded` -- not `InvalidParams`.
1003    #[test]
1004    fn test_mcp_error_kind_subscription_limit_reached_stays_internal() {
1005        let err = Error::SubscriptionLimitReached { max: 1000 };
1006        assert_eq!(err.mcp_error_kind(), McpErrorKind::Internal);
1007    }
1008}