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/// Details of a single server spawn failure.
11#[derive(Debug, Clone)]
12pub struct ServerSpawnFailure {
13    /// Routing identity of the failed server.
14    pub server_id: ServerId,
15    /// Language ID of the failed server.
16    pub language_id: String,
17    /// Command that was attempted.
18    pub command: String,
19    /// Error message describing the failure.
20    pub message: String,
21}
22
23impl std::fmt::Display for ServerSpawnFailure {
24    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
25        write!(
26            f,
27            "{} [{}] ({}): {}",
28            self.server_id, self.language_id, self.command, self.message
29        )
30    }
31}
32
33/// The main error type for mcpls-core operations.
34///
35/// This enum is `#[non_exhaustive]`: downstream crates that match on it must
36/// include a wildcard arm. New variants (such as [`Error::ServerInitializing`])
37/// can then be added without further breaking changes.
38#[derive(Debug, thiserror::Error)]
39#[non_exhaustive]
40pub enum Error {
41    /// LSP server failed to initialize.
42    #[error("LSP server initialization failed: {message}")]
43    LspInitFailed {
44        /// Description of the initialization failure.
45        message: String,
46    },
47
48    /// LSP server returned an error response.
49    #[error("LSP server error: {code} - {message}")]
50    LspServerError {
51        /// JSON-RPC error code.
52        code: i32,
53        /// Error message from the server.
54        message: String,
55        /// Optional additional data from the JSON-RPC error object.
56        data: Option<serde_json::Value>,
57    },
58
59    /// MCP server error.
60    #[error("MCP server error: {0}")]
61    McpServer(String),
62
63    /// Document was not found or could not be opened.
64    #[error("document not found: {0}")]
65    DocumentNotFound(PathBuf),
66
67    /// No LSP server configured for the given language.
68    #[error("no LSP server configured for language: {0}")]
69    NoServerForLanguage(String),
70
71    /// A server is configured for the language, but no server claims this
72    /// specific tool (either no server lists it in `handles` and there is no
73    /// catch-all, or the server that claimed it failed to spawn with no live
74    /// catch-all to rebind to).
75    #[error("no server handles tool '{tool}' for language '{language_id}'")]
76    NoServerForTool {
77        /// Language ID the request was for.
78        language_id: String,
79        /// Tool that no server claims.
80        tool: ToolKind,
81    },
82
83    /// LSP server for the language is configured but still initializing.
84    #[error(
85        "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)"
86    )]
87    ServerInitializing {
88        /// Routing identity of the server that has not yet registered.
89        server_id: ServerId,
90    },
91
92    /// A workspace-wide tool (one with no file to resolve a language from,
93    /// e.g. `workspace_symbol_search`) could not be routed because at least
94    /// one expected LSP server has not registered yet. Unlike
95    /// [`Error::ServerInitializing`], resolution never narrowed down to a
96    /// single candidate server, so no `server_id` is available.
97    #[error(
98        "LSP servers are still initializing (large project load in progress); wait and retry the request (this may take a few minutes on large projects)"
99    )]
100    WorkspaceServersInitializing,
101
102    /// No LSP server is currently configured.
103    #[error("no LSP server configured")]
104    NoServerConfigured,
105
106    /// At least one server is configured somewhere in the workspace, but
107    /// none of them claims a workspace-wide tool that has no file to
108    /// resolve a language from (e.g. `workspace_symbol_search`). The
109    /// language-less counterpart of [`Error::NoServerForTool`].
110    #[error("no server handles tool '{tool}' (no server's `handles` list or catch-all claims it)")]
111    NoServerForWorkspaceTool {
112        /// Tool that no server claims anywhere in the workspace.
113        tool: ToolKind,
114    },
115
116    /// Configuration error.
117    #[error("configuration error: {0}")]
118    Config(String),
119
120    /// Configuration file not found.
121    #[error("configuration file not found: {0}")]
122    ConfigNotFound(PathBuf),
123
124    /// Invalid configuration format.
125    #[error("invalid configuration: {0}")]
126    InvalidConfig(String),
127
128    /// I/O error.
129    #[error("I/O error: {0}")]
130    Io(#[from] std::io::Error),
131
132    /// JSON serialization/deserialization error.
133    #[error("JSON error: {0}")]
134    Json(#[from] serde_json::Error),
135
136    /// TOML deserialization error.
137    #[error("TOML parsing error: {0}")]
138    TomlDe(#[from] toml::de::Error),
139
140    /// TOML serialization error.
141    #[error("TOML serialization error: {0}")]
142    TomlSer(#[from] toml::ser::Error),
143
144    /// LSP client transport error.
145    #[error("transport error: {0}")]
146    Transport(String),
147
148    /// Request timeout.
149    #[error("request timed out after {0} seconds")]
150    Timeout(u64),
151
152    /// Server shutdown requested.
153    #[error("server shutdown requested")]
154    Shutdown,
155
156    /// LSP server failed to spawn.
157    #[error("failed to spawn LSP server '{command}': {source}")]
158    ServerSpawnFailed {
159        /// Command that failed to spawn.
160        command: String,
161        /// Underlying IO error.
162        #[source]
163        source: std::io::Error,
164    },
165
166    /// LSP protocol error during message parsing.
167    #[error("LSP protocol error: {0}")]
168    LspProtocolError(String),
169
170    /// Invalid URI format.
171    #[error("invalid URI: {0}")]
172    InvalidUri(String),
173
174    /// Position encoding error.
175    #[error("position encoding error: {0}")]
176    EncodingError(String),
177
178    /// Server process terminated unexpectedly.
179    #[error("LSP server process terminated unexpectedly")]
180    ServerTerminated,
181
182    /// A crashed server could not be automatically respawned.
183    ///
184    /// Distinct from [`Self::ServerTerminated`] so a caller (or a log
185    /// reader) can tell "the connection just died" apart from "mcpls tried
186    /// to bring it back and could not" -- e.g. no respawn config was ever
187    /// registered for it, or it is crash-looping and is being backed off.
188    #[error("LSP server '{server_id}' is unavailable: {reason}")]
189    ServerUnavailable {
190        /// Routing identity of the server that could not be respawned.
191        server_id: ServerId,
192        /// Human-readable reason the respawn did not proceed.
193        reason: String,
194    },
195
196    /// Invalid tool parameters provided.
197    #[error("invalid tool parameters: {0}")]
198    InvalidToolParams(String),
199
200    /// File I/O error occurred.
201    #[error("file I/O error for {path:?}: {source}")]
202    FileIo {
203        /// Path to the file.
204        path: PathBuf,
205        /// Underlying I/O error.
206        #[source]
207        source: std::io::Error,
208    },
209
210    /// Path is outside allowed workspace boundaries.
211    #[error("path outside workspace: {0}")]
212    PathOutsideWorkspace(PathBuf),
213
214    /// Document limit exceeded.
215    #[error(
216        "document limit exceeded: {current}/{max} (raise workspace.max_documents in config to increase this)"
217    )]
218    DocumentLimitExceeded {
219        /// Current number of documents.
220        current: usize,
221        /// Maximum allowed documents.
222        max: usize,
223    },
224
225    /// File size limit exceeded.
226    #[error(
227        "file size limit exceeded: {size} bytes, max {max} bytes (raise workspace.max_file_size in config to increase this)"
228    )]
229    FileSizeLimitExceeded {
230        /// Actual file size.
231        size: u64,
232        /// Maximum allowed size.
233        max: u64,
234    },
235
236    /// Partial server initialization - some servers failed but at least one succeeded.
237    #[error("some LSP servers failed to initialize: {failed_count}/{total_count} servers")]
238    PartialServerInit {
239        /// Number of servers that failed.
240        failed_count: usize,
241        /// Total number of configured servers.
242        total_count: usize,
243        /// Details of each failure.
244        failures: Vec<ServerSpawnFailure>,
245    },
246
247    /// All configured LSP servers failed to initialize.
248    #[error("all LSP servers failed to initialize ({count} configured)")]
249    AllServersFailedToInit {
250        /// Number of servers that were configured.
251        count: usize,
252        /// Details of each failure.
253        failures: Vec<ServerSpawnFailure>,
254    },
255
256    /// No LSP servers available (none configured or all failed).
257    #[error("{0}")]
258    NoServersAvailable(String),
259
260    /// The server routed for this request does not advertise support for the
261    /// requested LSP capability (e.g. no `renameProvider` in its
262    /// `ServerCapabilities`).
263    #[error("server '{server_id}' does not support capability '{capability}'")]
264    CapabilityNotSupported {
265        /// Routing identity of the server that lacks the capability.
266        server_id: ServerId,
267        /// Name of the missing LSP capability field (e.g. `"renameProvider"`).
268        capability: &'static str,
269    },
270}
271
272/// A specialized Result type for mcpls-core operations.
273pub type Result<T> = std::result::Result<T, Error>;
274
275#[cfg(test)]
276mod tests {
277    use super::*;
278
279    #[test]
280    fn test_error_display_lsp_init_failed() {
281        let err = Error::LspInitFailed {
282            message: "server not found".to_string(),
283        };
284        assert_eq!(
285            err.to_string(),
286            "LSP server initialization failed: server not found"
287        );
288    }
289
290    #[test]
291    fn test_error_display_lsp_server_error() {
292        let err = Error::LspServerError {
293            code: -32600,
294            message: "Invalid request".to_string(),
295            data: None,
296        };
297        assert_eq!(
298            err.to_string(),
299            "LSP server error: -32600 - Invalid request"
300        );
301    }
302
303    #[test]
304    fn test_error_display_document_not_found() {
305        let err = Error::DocumentNotFound(PathBuf::from("/path/to/file.rs"));
306        assert!(err.to_string().contains("document not found"));
307        assert!(err.to_string().contains("file.rs"));
308    }
309
310    #[test]
311    fn test_error_display_no_server_for_language() {
312        let err = Error::NoServerForLanguage("rust".to_string());
313        assert_eq!(
314            err.to_string(),
315            "no LSP server configured for language: rust"
316        );
317    }
318
319    #[test]
320    fn test_error_display_workspace_servers_initializing() {
321        let err = Error::WorkspaceServersInitializing;
322        assert!(err.to_string().contains("still initializing"));
323    }
324
325    #[test]
326    fn test_error_display_no_server_for_workspace_tool() {
327        let err = Error::NoServerForWorkspaceTool {
328            tool: crate::config::ToolKind::WorkspaceSymbols,
329        };
330        assert!(err.to_string().contains("workspace_symbols"));
331        assert!(err.to_string().contains("no server's `handles` list"));
332    }
333
334    #[test]
335    fn test_error_display_timeout() {
336        let err = Error::Timeout(30);
337        assert_eq!(err.to_string(), "request timed out after 30 seconds");
338    }
339
340    #[test]
341    fn test_error_display_document_limit() {
342        let err = Error::DocumentLimitExceeded {
343            current: 150,
344            max: 100,
345        };
346        assert_eq!(
347            err.to_string(),
348            "document limit exceeded: 150/100 (raise workspace.max_documents in config to increase this)"
349        );
350    }
351
352    #[test]
353    fn test_error_display_file_size_limit() {
354        let err = Error::FileSizeLimitExceeded {
355            size: 20_000_000,
356            max: 10_000_000,
357        };
358        assert_eq!(
359            err.to_string(),
360            "file size limit exceeded: 20000000 bytes, max 10000000 bytes (raise workspace.max_file_size in config to increase this)"
361        );
362    }
363
364    #[test]
365    fn test_error_from_io() {
366        let io_err = std::io::Error::new(std::io::ErrorKind::NotFound, "file not found");
367        let err: Error = io_err.into();
368        assert!(matches!(err, Error::Io(_)));
369    }
370
371    #[test]
372    #[allow(clippy::unwrap_used)]
373    fn test_error_from_json() {
374        let json_str = "{invalid json}";
375        let json_err = serde_json::from_str::<serde_json::Value>(json_str).unwrap_err();
376        let err: Error = json_err.into();
377        assert!(matches!(err, Error::Json(_)));
378    }
379
380    #[test]
381    #[allow(clippy::unwrap_used)]
382    fn test_error_from_toml_de() {
383        let toml_str = "[invalid toml";
384        let toml_err = toml::from_str::<toml::Value>(toml_str).unwrap_err();
385        let err: Error = toml_err.into();
386        assert!(matches!(err, Error::TomlDe(_)));
387    }
388
389    #[test]
390    fn test_result_type_alias() {
391        fn _returns_error() -> Result<i32> {
392            Err(Error::Config("test error".to_string()))
393        }
394
395        let result: Result<i32> = Ok(42);
396        assert!(result.is_ok());
397        if let Ok(value) = result {
398            assert_eq!(value, 42);
399        }
400    }
401
402    #[test]
403    fn test_error_source_chain() {
404        let io_err = std::io::Error::new(std::io::ErrorKind::NotFound, "file not found");
405        let err = Error::ServerSpawnFailed {
406            command: "rust-analyzer".to_string(),
407            source: io_err,
408        };
409
410        let source = std::error::Error::source(&err);
411        assert!(source.is_some());
412    }
413
414    #[test]
415    fn test_server_spawn_failure_display() {
416        let failure = ServerSpawnFailure {
417            server_id: ServerId::from("rust"),
418            language_id: "rust".to_string(),
419            command: "rust-analyzer".to_string(),
420            message: "No such file or directory".to_string(),
421        };
422        assert_eq!(
423            failure.to_string(),
424            "rust [rust] (rust-analyzer): No such file or directory"
425        );
426    }
427
428    #[test]
429    fn test_server_spawn_failure_debug() {
430        let failure = ServerSpawnFailure {
431            server_id: ServerId::from("python"),
432            language_id: "python".to_string(),
433            command: "pyright".to_string(),
434            message: "command not found".to_string(),
435        };
436        let debug_str = format!("{failure:?}");
437        assert!(debug_str.contains("python"));
438        assert!(debug_str.contains("pyright"));
439        assert!(debug_str.contains("command not found"));
440    }
441
442    #[test]
443    fn test_server_spawn_failure_clone() {
444        let failure = ServerSpawnFailure {
445            server_id: ServerId::from("typescript"),
446            language_id: "typescript".to_string(),
447            command: "tsserver".to_string(),
448            message: "failed to start".to_string(),
449        };
450        let cloned = failure.clone();
451        assert_eq!(failure.language_id, cloned.language_id);
452        assert_eq!(failure.command, cloned.command);
453        assert_eq!(failure.message, cloned.message);
454    }
455
456    #[test]
457    fn test_error_display_partial_server_init() {
458        let err = Error::PartialServerInit {
459            failed_count: 2,
460            total_count: 3,
461            failures: vec![],
462        };
463        assert_eq!(
464            err.to_string(),
465            "some LSP servers failed to initialize: 2/3 servers"
466        );
467    }
468
469    #[test]
470    fn test_error_display_all_servers_failed_to_init() {
471        let err = Error::AllServersFailedToInit {
472            count: 2,
473            failures: vec![],
474        };
475        assert_eq!(
476            err.to_string(),
477            "all LSP servers failed to initialize (2 configured)"
478        );
479    }
480
481    #[test]
482    fn test_error_all_servers_failed_with_failures() {
483        let failures = vec![
484            ServerSpawnFailure {
485                server_id: ServerId::from("rust"),
486                language_id: "rust".to_string(),
487                command: "rust-analyzer".to_string(),
488                message: "not found".to_string(),
489            },
490            ServerSpawnFailure {
491                server_id: ServerId::from("python"),
492                language_id: "python".to_string(),
493                command: "pyright".to_string(),
494                message: "permission denied".to_string(),
495            },
496        ];
497
498        let err = Error::AllServersFailedToInit { count: 2, failures };
499
500        assert!(err.to_string().contains("all LSP servers failed"));
501        assert!(err.to_string().contains("2 configured"));
502    }
503
504    #[test]
505    fn test_error_partial_server_init_with_failures() {
506        let failures = vec![ServerSpawnFailure {
507            server_id: ServerId::from("python"),
508            language_id: "python".to_string(),
509            command: "pyright".to_string(),
510            message: "not found".to_string(),
511        }];
512
513        let err = Error::PartialServerInit {
514            failed_count: 1,
515            total_count: 2,
516            failures,
517        };
518
519        assert!(err.to_string().contains("some LSP servers failed"));
520        assert!(err.to_string().contains("1/2"));
521    }
522
523    #[test]
524    fn test_error_display_no_servers_available() {
525        let err =
526            Error::NoServersAvailable("none configured or all failed to initialize".to_string());
527        assert_eq!(
528            err.to_string(),
529            "none configured or all failed to initialize"
530        );
531    }
532
533    #[test]
534    fn test_error_no_servers_available_with_custom_message() {
535        let custom_msg = "none configured or all failed to initialize";
536        let err = Error::NoServersAvailable(custom_msg.to_string());
537        assert_eq!(err.to_string(), custom_msg);
538    }
539
540    #[test]
541    fn test_error_display_capability_not_supported() {
542        let err = Error::CapabilityNotSupported {
543            server_id: ServerId::from("rust"),
544            capability: "renameProvider",
545        };
546        assert_eq!(
547            err.to_string(),
548            "server 'rust' does not support capability 'renameProvider'"
549        );
550    }
551}