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    /// No LSP server is currently configured.
93    #[error("no LSP server configured")]
94    NoServerConfigured,
95
96    /// Configuration error.
97    #[error("configuration error: {0}")]
98    Config(String),
99
100    /// Configuration file not found.
101    #[error("configuration file not found: {0}")]
102    ConfigNotFound(PathBuf),
103
104    /// Invalid configuration format.
105    #[error("invalid configuration: {0}")]
106    InvalidConfig(String),
107
108    /// I/O error.
109    #[error("I/O error: {0}")]
110    Io(#[from] std::io::Error),
111
112    /// JSON serialization/deserialization error.
113    #[error("JSON error: {0}")]
114    Json(#[from] serde_json::Error),
115
116    /// TOML deserialization error.
117    #[error("TOML parsing error: {0}")]
118    TomlDe(#[from] toml::de::Error),
119
120    /// TOML serialization error.
121    #[error("TOML serialization error: {0}")]
122    TomlSer(#[from] toml::ser::Error),
123
124    /// LSP client transport error.
125    #[error("transport error: {0}")]
126    Transport(String),
127
128    /// Request timeout.
129    #[error("request timed out after {0} seconds")]
130    Timeout(u64),
131
132    /// Server shutdown requested.
133    #[error("server shutdown requested")]
134    Shutdown,
135
136    /// LSP server failed to spawn.
137    #[error("failed to spawn LSP server '{command}': {source}")]
138    ServerSpawnFailed {
139        /// Command that failed to spawn.
140        command: String,
141        /// Underlying IO error.
142        #[source]
143        source: std::io::Error,
144    },
145
146    /// LSP protocol error during message parsing.
147    #[error("LSP protocol error: {0}")]
148    LspProtocolError(String),
149
150    /// Invalid URI format.
151    #[error("invalid URI: {0}")]
152    InvalidUri(String),
153
154    /// Position encoding error.
155    #[error("position encoding error: {0}")]
156    EncodingError(String),
157
158    /// Server process terminated unexpectedly.
159    #[error("LSP server process terminated unexpectedly")]
160    ServerTerminated,
161
162    /// Invalid tool parameters provided.
163    #[error("invalid tool parameters: {0}")]
164    InvalidToolParams(String),
165
166    /// File I/O error occurred.
167    #[error("file I/O error for {path:?}: {source}")]
168    FileIo {
169        /// Path to the file.
170        path: PathBuf,
171        /// Underlying I/O error.
172        #[source]
173        source: std::io::Error,
174    },
175
176    /// Path is outside allowed workspace boundaries.
177    #[error("path outside workspace: {0}")]
178    PathOutsideWorkspace(PathBuf),
179
180    /// Document limit exceeded.
181    #[error("document limit exceeded: {current}/{max}")]
182    DocumentLimitExceeded {
183        /// Current number of documents.
184        current: usize,
185        /// Maximum allowed documents.
186        max: usize,
187    },
188
189    /// File size limit exceeded.
190    #[error("file size limit exceeded: {size} bytes (max: {max} bytes)")]
191    FileSizeLimitExceeded {
192        /// Actual file size.
193        size: u64,
194        /// Maximum allowed size.
195        max: u64,
196    },
197
198    /// Partial server initialization - some servers failed but at least one succeeded.
199    #[error("some LSP servers failed to initialize: {failed_count}/{total_count} servers")]
200    PartialServerInit {
201        /// Number of servers that failed.
202        failed_count: usize,
203        /// Total number of configured servers.
204        total_count: usize,
205        /// Details of each failure.
206        failures: Vec<ServerSpawnFailure>,
207    },
208
209    /// All configured LSP servers failed to initialize.
210    #[error("all LSP servers failed to initialize ({count} configured)")]
211    AllServersFailedToInit {
212        /// Number of servers that were configured.
213        count: usize,
214        /// Details of each failure.
215        failures: Vec<ServerSpawnFailure>,
216    },
217
218    /// No LSP servers available (none configured or all failed).
219    #[error("{0}")]
220    NoServersAvailable(String),
221}
222
223/// A specialized Result type for mcpls-core operations.
224pub type Result<T> = std::result::Result<T, Error>;
225
226#[cfg(test)]
227mod tests {
228    use super::*;
229
230    #[test]
231    fn test_error_display_lsp_init_failed() {
232        let err = Error::LspInitFailed {
233            message: "server not found".to_string(),
234        };
235        assert_eq!(
236            err.to_string(),
237            "LSP server initialization failed: server not found"
238        );
239    }
240
241    #[test]
242    fn test_error_display_lsp_server_error() {
243        let err = Error::LspServerError {
244            code: -32600,
245            message: "Invalid request".to_string(),
246            data: None,
247        };
248        assert_eq!(
249            err.to_string(),
250            "LSP server error: -32600 - Invalid request"
251        );
252    }
253
254    #[test]
255    fn test_error_display_document_not_found() {
256        let err = Error::DocumentNotFound(PathBuf::from("/path/to/file.rs"));
257        assert!(err.to_string().contains("document not found"));
258        assert!(err.to_string().contains("file.rs"));
259    }
260
261    #[test]
262    fn test_error_display_no_server_for_language() {
263        let err = Error::NoServerForLanguage("rust".to_string());
264        assert_eq!(
265            err.to_string(),
266            "no LSP server configured for language: rust"
267        );
268    }
269
270    #[test]
271    fn test_error_display_timeout() {
272        let err = Error::Timeout(30);
273        assert_eq!(err.to_string(), "request timed out after 30 seconds");
274    }
275
276    #[test]
277    fn test_error_display_document_limit() {
278        let err = Error::DocumentLimitExceeded {
279            current: 150,
280            max: 100,
281        };
282        assert_eq!(err.to_string(), "document limit exceeded: 150/100");
283    }
284
285    #[test]
286    fn test_error_display_file_size_limit() {
287        let err = Error::FileSizeLimitExceeded {
288            size: 20_000_000,
289            max: 10_000_000,
290        };
291        assert!(err.to_string().contains("file size limit exceeded"));
292    }
293
294    #[test]
295    fn test_error_from_io() {
296        let io_err = std::io::Error::new(std::io::ErrorKind::NotFound, "file not found");
297        let err: Error = io_err.into();
298        assert!(matches!(err, Error::Io(_)));
299    }
300
301    #[test]
302    #[allow(clippy::unwrap_used)]
303    fn test_error_from_json() {
304        let json_str = "{invalid json}";
305        let json_err = serde_json::from_str::<serde_json::Value>(json_str).unwrap_err();
306        let err: Error = json_err.into();
307        assert!(matches!(err, Error::Json(_)));
308    }
309
310    #[test]
311    #[allow(clippy::unwrap_used)]
312    fn test_error_from_toml_de() {
313        let toml_str = "[invalid toml";
314        let toml_err = toml::from_str::<toml::Value>(toml_str).unwrap_err();
315        let err: Error = toml_err.into();
316        assert!(matches!(err, Error::TomlDe(_)));
317    }
318
319    #[test]
320    fn test_result_type_alias() {
321        fn _returns_error() -> Result<i32> {
322            Err(Error::Config("test error".to_string()))
323        }
324
325        let result: Result<i32> = Ok(42);
326        assert!(result.is_ok());
327        if let Ok(value) = result {
328            assert_eq!(value, 42);
329        }
330    }
331
332    #[test]
333    fn test_error_source_chain() {
334        let io_err = std::io::Error::new(std::io::ErrorKind::NotFound, "file not found");
335        let err = Error::ServerSpawnFailed {
336            command: "rust-analyzer".to_string(),
337            source: io_err,
338        };
339
340        let source = std::error::Error::source(&err);
341        assert!(source.is_some());
342    }
343
344    #[test]
345    fn test_server_spawn_failure_display() {
346        let failure = ServerSpawnFailure {
347            server_id: ServerId::from("rust"),
348            language_id: "rust".to_string(),
349            command: "rust-analyzer".to_string(),
350            message: "No such file or directory".to_string(),
351        };
352        assert_eq!(
353            failure.to_string(),
354            "rust [rust] (rust-analyzer): No such file or directory"
355        );
356    }
357
358    #[test]
359    fn test_server_spawn_failure_debug() {
360        let failure = ServerSpawnFailure {
361            server_id: ServerId::from("python"),
362            language_id: "python".to_string(),
363            command: "pyright".to_string(),
364            message: "command not found".to_string(),
365        };
366        let debug_str = format!("{failure:?}");
367        assert!(debug_str.contains("python"));
368        assert!(debug_str.contains("pyright"));
369        assert!(debug_str.contains("command not found"));
370    }
371
372    #[test]
373    fn test_server_spawn_failure_clone() {
374        let failure = ServerSpawnFailure {
375            server_id: ServerId::from("typescript"),
376            language_id: "typescript".to_string(),
377            command: "tsserver".to_string(),
378            message: "failed to start".to_string(),
379        };
380        let cloned = failure.clone();
381        assert_eq!(failure.language_id, cloned.language_id);
382        assert_eq!(failure.command, cloned.command);
383        assert_eq!(failure.message, cloned.message);
384    }
385
386    #[test]
387    fn test_error_display_partial_server_init() {
388        let err = Error::PartialServerInit {
389            failed_count: 2,
390            total_count: 3,
391            failures: vec![],
392        };
393        assert_eq!(
394            err.to_string(),
395            "some LSP servers failed to initialize: 2/3 servers"
396        );
397    }
398
399    #[test]
400    fn test_error_display_all_servers_failed_to_init() {
401        let err = Error::AllServersFailedToInit {
402            count: 2,
403            failures: vec![],
404        };
405        assert_eq!(
406            err.to_string(),
407            "all LSP servers failed to initialize (2 configured)"
408        );
409    }
410
411    #[test]
412    fn test_error_all_servers_failed_with_failures() {
413        let failures = vec![
414            ServerSpawnFailure {
415                server_id: ServerId::from("rust"),
416                language_id: "rust".to_string(),
417                command: "rust-analyzer".to_string(),
418                message: "not found".to_string(),
419            },
420            ServerSpawnFailure {
421                server_id: ServerId::from("python"),
422                language_id: "python".to_string(),
423                command: "pyright".to_string(),
424                message: "permission denied".to_string(),
425            },
426        ];
427
428        let err = Error::AllServersFailedToInit { count: 2, failures };
429
430        assert!(err.to_string().contains("all LSP servers failed"));
431        assert!(err.to_string().contains("2 configured"));
432    }
433
434    #[test]
435    fn test_error_partial_server_init_with_failures() {
436        let failures = vec![ServerSpawnFailure {
437            server_id: ServerId::from("python"),
438            language_id: "python".to_string(),
439            command: "pyright".to_string(),
440            message: "not found".to_string(),
441        }];
442
443        let err = Error::PartialServerInit {
444            failed_count: 1,
445            total_count: 2,
446            failures,
447        };
448
449        assert!(err.to_string().contains("some LSP servers failed"));
450        assert!(err.to_string().contains("1/2"));
451    }
452
453    #[test]
454    fn test_error_display_no_servers_available() {
455        let err =
456            Error::NoServersAvailable("none configured or all failed to initialize".to_string());
457        assert_eq!(
458            err.to_string(),
459            "none configured or all failed to initialize"
460        );
461    }
462
463    #[test]
464    fn test_error_no_servers_available_with_custom_message() {
465        let custom_msg = "none configured or all failed to initialize";
466        let err = Error::NoServersAvailable(custom_msg.to_string());
467        assert_eq!(err.to_string(), custom_msg);
468    }
469}