Skip to main content

pathfinder_common/
error.rs

1//! Pathfinder error taxonomy.
2//!
3//! All tools return errors in a standardized format:
4//! `{ "error": "ERROR_CODE", "message": "...", "details": {} }`
5//!
6//! See PRD §5 for the full error taxonomy.
7
8use serde::{Deserialize, Serialize};
9use std::path::PathBuf;
10
11/// A standardized error type for Pathfinder operations.
12#[derive(Debug, thiserror::Error)]
13pub enum PathfinderError {
14    /// File path doesn't exist.
15    #[error("file not found: {path}")]
16    FileNotFound {
17        /// Path to the missing file.
18        path: PathBuf,
19    },
20
21    /// Semantic path doesn't resolve.
22    #[error("symbol not found: {semantic_path}")]
23    SymbolNotFound {
24        /// The semantic path that wasn't found.
25        semantic_path: String,
26        /// Similar symbol names suggested by the system (Levenshtein distance).
27        did_you_mean: Vec<String>,
28    },
29
30    /// Semantic path is malformed or missing required '::' separator.
31    #[error("invalid semantic path: {input}")]
32    InvalidSemanticPath {
33        /// The invalid input string.
34        input: String,
35        /// Description of what makes it invalid.
36        issue: String,
37    },
38
39    /// Multiple matches for a semantic path.
40    #[error("ambiguous symbol: {semantic_path}")]
41    AmbiguousSymbol {
42        /// The ambiguous semantic path.
43        semantic_path: String,
44        /// All matching symbol paths.
45        matches: Vec<String>,
46    },
47
48    /// No language server available for this file type.
49    #[error("no LSP available for language: {language}")]
50    NoLspAvailable { language: String },
51
52    /// Language server crashed or returned an error.
53    #[error("LSP error: {message}")]
54    LspError { message: String },
55
56    /// A generic I/O error occurred.
57    #[error("I/O error: {message}")]
58    IoError { message: String },
59
60    /// LSP didn't respond within timeout.
61    #[error("LSP timeout after {timeout_ms}ms")]
62    LspTimeout { timeout_ms: u64 },
63
64    /// File is in the sandbox deny-list.
65    #[error("access denied: {path}")]
66    AccessDenied { path: PathBuf, tier: SandboxTier },
67
68    /// Tree-sitter couldn't parse the file.
69    #[error("parse error in {path}: {reason}")]
70    ParseError { path: PathBuf, reason: String },
71
72    /// The language of the semantic path's file is not supported.
73    #[error("unsupported language for target file: {path}")]
74    UnsupportedLanguage { path: PathBuf },
75
76    /// Response would exceed `max_tokens`.
77    #[error("token budget exceeded: {used} / {budget}")]
78    TokenBudgetExceeded { used: usize, budget: usize },
79
80    /// Path traversal detected in `resolve_strict`.
81    #[error("path traversal rejected: {path} escapes workspace root {workspace_root}")]
82    PathTraversal {
83        path: PathBuf,
84        workspace_root: PathBuf,
85    },
86}
87
88impl PathfinderError {
89    /// Returns the MCP-facing error code string.
90    #[must_use]
91    pub const fn error_code(&self) -> &'static str {
92        match self {
93            Self::FileNotFound { .. } => "FILE_NOT_FOUND",
94            Self::SymbolNotFound { .. } => "SYMBOL_NOT_FOUND",
95            Self::AmbiguousSymbol { .. } => "AMBIGUOUS_SYMBOL",
96            Self::NoLspAvailable { .. } => "NO_LSP_AVAILABLE",
97            Self::LspError { .. } => "LSP_ERROR",
98            Self::LspTimeout { .. } => "LSP_TIMEOUT",
99            Self::AccessDenied { .. } => "ACCESS_DENIED",
100            Self::IoError { .. } => "INTERNAL_ERROR",
101            Self::ParseError { .. } => "PARSE_ERROR",
102            Self::UnsupportedLanguage { .. } => "UNSUPPORTED_LANGUAGE",
103            Self::TokenBudgetExceeded { .. } => "TOKEN_BUDGET_EXCEEDED",
104            Self::InvalidSemanticPath { .. } => "INVALID_SEMANTIC_PATH",
105            Self::PathTraversal { .. } => "PATH_TRAVERSAL",
106        }
107    }
108
109    /// Returns an actionable hint for the agent to self-correct without additional round-trips.
110    ///
111    /// `SYMBOL_NOT_FOUND` hints are dynamic and built from the `did_you_mean` suggestions.
112    /// All other hints are static strings referencing specific Pathfinder tools.
113    #[must_use]
114    pub fn hint(&self) -> Option<String> {
115        match self {
116            Self::SymbolNotFound {
117                semantic_path,
118                did_you_mean,
119            } => {
120                // Detect path separator confusion: agent may have used `.` instead of `::`
121                // (e.g., `src/lib.rs.MyStruct.method` instead of `src/lib.rs::MyStruct.method`)
122                // or used `::` between nested symbols where `.` is expected.
123                let separator_hint = if !semantic_path.contains("::") {
124                    Some(
125                        " Note: semantic paths require '::' between the file and symbol \
126                         (e.g., 'src/lib.rs::MyStruct.method'). \
127                         Nested symbols within the same file use '.' (e.g., 'MyStruct.method')."
128                    )
129                } else if semantic_path.matches("::").count() > 1 {
130                    Some(
131                        " Note: only one '::' is allowed — between the file path and the symbol. \
132                         Nested symbols within the file use '.' (e.g., 'src/lib.rs::Outer.Inner.method')."
133                    )
134                } else {
135                    None
136                };
137
138                if did_you_mean.is_empty() {
139                    // No suggestions — the symbol might be in a different file than what the agent guessed.
140                    // Suggest search_codebase to find which file actually defines this symbol.
141                    let symbol_name = semantic_path.split("::").last().unwrap_or(semantic_path);
142                    Some(format!(
143                        "Symbol not found in the specified file. Use search_codebase(query=\"{symbol_name}\") to find which file defines this symbol, then use the correct file path in the semantic path.{}",
144                        separator_hint.unwrap_or("")
145                    ))
146                } else {
147                    Some(format!(
148                        "Did you mean: {}? Use search_codebase if the symbol is in a different file, or read_source_file to see available symbols in this file.{}",
149                        did_you_mean.join(", "),
150                        separator_hint.unwrap_or("")
151                    ))
152                }
153            }
154            Self::AccessDenied { .. } => {
155                Some("File is outside workspace sandbox. Check .pathfinderignore rules.".to_owned())
156            }
157            Self::UnsupportedLanguage { .. } => Some(
158                "No tree-sitter grammar for this file type. Use read_file for raw content."
159                    .to_owned(),
160            ),
161            Self::FileNotFound { .. } => Some(
162                "Verify the file path is relative to the workspace root and the file exists."
163                    .to_owned(),
164            ),
165            Self::InvalidSemanticPath { input, .. } => Some(format!(
166                "'{input}' is missing the file path — did you mean 'crates/.../file.rs::{input}'? \
167                 Semantic paths must include the file path and '::' separator (e.g., 'src/auth.ts::AuthService.login')."
168            )),
169            Self::PathTraversal { .. } => Some(
170                "Path traversal is not allowed. Use a relative path without '..' components or absolute paths."
171                    .to_owned(),
172            ),
173            Self::LspError { message } => {
174                let hint = if message.contains("timed out") || message.contains("timeout") {
175                    format!(
176                        "LSP timed out. The language server may still be indexing, under memory pressure, or deadlocked. \
177                         Workaround: use search_codebase + read_symbol_scope (tree-sitter) instead of \
178                         LSP-dependent tools (get_definition, analyze_impact, read_with_deep_context). \
179                         Original error: {message}"
180                    )
181                } else if message.contains("connection lost") || message.contains("crashed") {
182                    format!(
183                        "LSP process crashed or disconnected. Pathfinder will attempt to restart it. \
184                         Workaround: use tree-sitter-based tools (search_codebase, read_symbol_scope, read_source_file). \
185                         Original error: {message}"
186                    )
187                } else {
188                    format!(
189                        "LSP error: {message}. Workaround: use search_codebase for text-based navigation \
190                         or check lsp_health for current status."
191                    )
192                };
193                Some(hint)
194            }
195            Self::LspTimeout { timeout_ms } => Some(format!(
196                "LSP timed out after {timeout_ms}ms. The language server may still be indexing, under memory pressure, or deadlocked. \
197                 Workaround: use search_codebase + read_symbol_scope (tree-sitter) instead of \
198                 LSP-dependent tools (get_definition, analyze_impact, read_with_deep_context). \
199                 Check lsp_health for current status."
200            )),
201            Self::NoLspAvailable { language } => Some(format!(
202                "No LSP available for {language}. Install a language server to enable LSP-dependent features. \
203                 Tree-sitter tools (read_symbol_scope, search_codebase, read_source_file) still work without LSP."
204            )),
205            _ => None,
206        }
207    }
208
209    /// Serialize to the standard MCP error JSON format.
210    #[must_use]
211    pub fn to_error_response(&self) -> ErrorResponse {
212        ErrorResponse {
213            error: self.error_code().to_owned(),
214            message: self.to_string(),
215            details: self.to_details(),
216            hint: self.hint(),
217        }
218    }
219
220    fn to_details(&self) -> serde_json::Value {
221        match self {
222            Self::SymbolNotFound { did_you_mean, .. } => {
223                serde_json::json!({ "did_you_mean": did_you_mean })
224            }
225            Self::AmbiguousSymbol { matches, .. } => {
226                serde_json::json!({ "matches": matches })
227            }
228            Self::AccessDenied { tier, .. } => {
229                serde_json::json!({ "tier": tier })
230            }
231            Self::TokenBudgetExceeded { used, budget } => {
232                serde_json::json!({ "used": used, "budget": budget })
233            }
234            Self::InvalidSemanticPath { issue, .. } => {
235                serde_json::json!({ "issue": issue })
236            }
237            Self::PathTraversal {
238                path,
239                workspace_root,
240            } => {
241                serde_json::json!({ "path": path, "workspace_root": workspace_root })
242            }
243            _ => serde_json::Value::Object(serde_json::Map::new()),
244        }
245    }
246}
247
248/// Standard MCP error response format.
249#[derive(Debug, Clone, Serialize, Deserialize)]
250pub struct ErrorResponse {
251    /// Error code identifying the type of error.
252    pub error: String,
253    /// Human-readable message describing the error.
254    pub message: String,
255    /// Additional details about the error in JSON format.
256    pub details: serde_json::Value,
257    /// Actionable recovery hint for the agent. Present on most error variants.
258    #[serde(skip_serializing_if = "Option::is_none")]
259    pub hint: Option<String>,
260}
261
262/// Sandbox tier that denied access.
263#[derive(Debug, Clone, Copy, Serialize, Deserialize)]
264pub enum SandboxTier {
265    /// Always excluded, not configurable.
266    HardcodedDeny,
267    /// Excluded by default, overridable in config.
268    DefaultDeny,
269    /// User-defined via `.pathfinderignore`.
270    UserDefined,
271}
272
273#[cfg(test)]
274#[allow(clippy::expect_used)]
275mod tests {
276    use super::*;
277
278    #[test]
279    fn test_error_code_mapping() {
280        let err = PathfinderError::FileNotFound {
281            path: "src/main.rs".into(),
282        };
283        assert_eq!(err.error_code(), "FILE_NOT_FOUND");
284
285        let err = PathfinderError::SymbolNotFound {
286            semantic_path: "src/auth.ts::AuthService.login".into(),
287            did_you_mean: vec!["AuthService.logout".into()],
288        };
289        assert_eq!(err.error_code(), "SYMBOL_NOT_FOUND");
290    }
291
292    #[test]
293    fn test_hint_file_not_found() {
294        let err = PathfinderError::FileNotFound { path: "a".into() };
295        let hint = err.hint().expect("should have hint");
296        assert!(hint.contains("relative"), "hint: {hint}");
297    }
298
299    #[test]
300    fn test_hint_invalid_semantic_path() {
301        let err = PathfinderError::InvalidSemanticPath {
302            input: "x".into(),
303            issue: "y".into(),
304        };
305        let hint = err.hint().expect("should have hint");
306        assert!(hint.contains("'x' is missing"), "hint: {hint}");
307    }
308
309    // ── GAP-008: LSP error hints ────────────────────────────────────
310
311    #[test]
312    fn test_lsp_error_hint_timeout_includes_workaround() {
313        let err = PathfinderError::LspError {
314            message: "LSP timed out on 'textDocument/definition' after 10000ms".to_owned(),
315        };
316        let hint = err.hint().expect("LspError should have a hint");
317        assert!(
318            hint.contains("search_codebase"),
319            "hint should mention search_codebase: {hint}"
320        );
321        assert!(
322            hint.contains("tree-sitter"),
323            "hint should mention tree-sitter: {hint}"
324        );
325    }
326
327    #[test]
328    fn test_lsp_error_hint_connection_lost() {
329        let err = PathfinderError::LspError {
330            message: "connection lost to language server".to_owned(),
331        };
332        let hint = err.hint().expect("LspError should have a hint");
333        assert!(
334            hint.contains("crashed or disconnected"),
335            "hint should mention crash: {hint}"
336        );
337        assert!(
338            hint.contains("read_source_file"),
339            "hint should mention tree-sitter tools: {hint}"
340        );
341    }
342
343    #[test]
344    fn test_lsp_error_hint_generic() {
345        let err = PathfinderError::LspError {
346            message: "unexpected internal error".to_owned(),
347        };
348        let hint = err.hint().expect("LspError should have a hint");
349        assert!(
350            hint.contains("search_codebase"),
351            "hint should mention search_codebase: {hint}"
352        );
353        assert!(
354            hint.contains("lsp_health"),
355            "hint should mention lsp_health: {hint}"
356        );
357    }
358
359    #[test]
360    fn test_lsp_timeout_hint_includes_workaround() {
361        let err = PathfinderError::LspTimeout { timeout_ms: 10000 };
362        let hint = err.hint().expect("LspTimeout should have a hint");
363        assert!(
364            hint.contains("10000ms"),
365            "hint should include timeout duration: {hint}"
366        );
367        assert!(
368            hint.contains("search_codebase"),
369            "hint should mention search_codebase: {hint}"
370        );
371        assert!(
372            hint.contains("tree-sitter"),
373            "hint should mention tree-sitter: {hint}"
374        );
375        assert!(
376            hint.contains("lsp_health"),
377            "hint should mention lsp_health: {hint}"
378        );
379    }
380
381    #[test]
382    fn test_no_lsp_hint_mentions_tree_sitter() {
383        let err = PathfinderError::NoLspAvailable {
384            language: "go".to_owned(),
385        };
386        let hint = err.hint().expect("NoLspAvailable should have a hint");
387        assert!(hint.contains("go"), "hint should mention language: {hint}");
388        assert!(
389            hint.to_lowercase().contains("tree-sitter"),
390            "hint should mention tree-sitter: {hint}"
391        );
392        assert!(
393            hint.contains("read_symbol_scope"),
394            "hint should mention read_symbol_scope: {hint}"
395        );
396    }
397
398    #[test]
399    fn test_details_serialization_extra() {
400        let err = PathfinderError::AmbiguousSymbol {
401            semantic_path: "a".into(),
402            matches: vec!["b".into()],
403        };
404        assert_eq!(err.to_details()["matches"][0], "b");
405
406        let err = PathfinderError::AccessDenied {
407            path: "a".into(),
408            tier: SandboxTier::UserDefined,
409        };
410        assert_eq!(err.to_details()["tier"], "UserDefined");
411
412        let err = PathfinderError::TokenBudgetExceeded {
413            used: 10,
414            budget: 5,
415        };
416        assert_eq!(err.to_details()["used"], 10);
417        assert_eq!(err.to_details()["budget"], 5);
418
419        let err = PathfinderError::InvalidSemanticPath {
420            input: "a".into(),
421            issue: "b".into(),
422        };
423        assert_eq!(err.to_details()["issue"], "b");
424
425        let err = PathfinderError::FileNotFound { path: "a".into() };
426        assert!(err
427            .to_details()
428            .as_object()
429            .expect("should be an object")
430            .is_empty());
431    }
432
433    #[test]
434    fn test_all_error_codes_are_screaming_snake_case() {
435        let errors: Vec<PathfinderError> = vec![
436            PathfinderError::FileNotFound { path: "a".into() },
437            PathfinderError::SymbolNotFound {
438                semantic_path: "a".into(),
439                did_you_mean: vec![],
440            },
441            PathfinderError::AmbiguousSymbol {
442                semantic_path: "a".into(),
443                matches: vec![],
444            },
445            PathfinderError::NoLspAvailable {
446                language: "a".into(),
447            },
448            PathfinderError::LspError {
449                message: "a".into(),
450            },
451            PathfinderError::LspTimeout { timeout_ms: 0 },
452            PathfinderError::AccessDenied {
453                path: "a".into(),
454                tier: SandboxTier::HardcodedDeny,
455            },
456            PathfinderError::ParseError {
457                path: "a".into(),
458                reason: "a".into(),
459            },
460            PathfinderError::UnsupportedLanguage { path: "a".into() },
461            PathfinderError::TokenBudgetExceeded { used: 0, budget: 0 },
462            PathfinderError::IoError {
463                message: "disk full".into(),
464            },
465            PathfinderError::InvalidSemanticPath {
466                input: "send".into(),
467                issue: "missing ::".into(),
468            },
469        ];
470
471        for err in &errors {
472            let code = err.error_code();
473            assert!(
474                code.chars().all(|c| c.is_ascii_uppercase() || c == '_'),
475                "Error code '{code}' is not SCREAMING_SNAKE_CASE"
476            );
477        }
478    }
479
480    #[test]
481    fn test_symbol_not_found_details_include_did_you_mean() {
482        let err = PathfinderError::SymbolNotFound {
483            semantic_path: "src/auth.ts::startServer".into(),
484            did_you_mean: vec!["stopServer".into(), "startService".into()],
485        };
486        let response = err.to_error_response();
487        let suggestions = response.details["did_you_mean"]
488            .as_array()
489            .expect("did_you_mean should be an array");
490        assert_eq!(suggestions.len(), 2);
491    }
492
493    // ── E7.3: hint() method ─────────────────────────────────────────
494
495    #[test]
496    fn test_symbol_not_found_hint_with_suggestions() {
497        let err = PathfinderError::SymbolNotFound {
498            semantic_path: "src/auth.ts::login".into(),
499            did_you_mean: vec!["logout".into(), "logIn".into()],
500        };
501        let hint = err.hint().expect("should have hint");
502        assert!(
503            hint.contains("logout"),
504            "hint should include suggestions: {hint}"
505        );
506        assert!(
507            hint.contains("logIn"),
508            "hint should include all suggestions: {hint}"
509        );
510    }
511
512    #[test]
513    fn test_symbol_not_found_hint_without_suggestions() {
514        let err = PathfinderError::SymbolNotFound {
515            semantic_path: "src/auth.ts::unknown".into(),
516            did_you_mean: vec![],
517        };
518        let hint = err
519            .hint()
520            .expect("should have hint even without suggestions");
521        // When no suggestions, the symbol is likely in a different file.
522        // Hint should suggest search_codebase to find the correct file.
523        assert!(
524            hint.contains("search_codebase"),
525            "hint should suggest search_codebase to find the correct file: {hint}"
526        );
527    }
528
529    #[test]
530    fn test_access_denied_hint_mentions_sandbox() {
531        let err = PathfinderError::AccessDenied {
532            path: ".env".into(),
533            tier: SandboxTier::HardcodedDeny,
534        };
535        let hint = err.hint().expect("ACCESS_DENIED should have a hint");
536        assert!(
537            hint.contains("sandbox"),
538            "hint should mention sandbox: {hint}"
539        );
540    }
541
542    #[test]
543    fn test_unsupported_language_hint_mentions_read_file() {
544        let err = PathfinderError::UnsupportedLanguage {
545            path: "data.xyz".into(),
546        };
547        let hint = err.hint().expect("UNSUPPORTED_LANGUAGE should have a hint");
548        assert!(
549            hint.contains("read_file"),
550            "hint should mention read_file: {hint}"
551        );
552    }
553
554    #[test]
555    fn test_hint_serialized_in_error_response() {
556        let err = PathfinderError::AccessDenied {
557            path: ".env".into(),
558            tier: SandboxTier::HardcodedDeny,
559        };
560        let resp = err.to_error_response();
561        assert!(
562            resp.hint.is_some(),
563            "hint must be serialized in ErrorResponse"
564        );
565        let json = serde_json::to_value(&resp).expect("serialize");
566        assert!(
567            json.get("hint").is_some(),
568            "hint must appear in JSON output"
569        );
570    }
571
572    #[test]
573    fn test_path_traversal_error() {
574        let err = PathfinderError::PathTraversal {
575            path: "../../etc/passwd".into(),
576            workspace_root: "/workspace".into(),
577        };
578
579        assert_eq!(err.error_code(), "PATH_TRAVERSAL");
580        let hint = err.hint().expect("PATH_TRAVERSAL should have a hint");
581        assert!(
582            hint.contains("not allowed"),
583            "hint should explain traversal is not allowed: {hint}"
584        );
585
586        let response = err.to_error_response();
587        assert_eq!(response.error, "PATH_TRAVERSAL");
588        assert_eq!(response.details["path"], "../../etc/passwd");
589        assert_eq!(response.details["workspace_root"], "/workspace");
590    }
591}