1use serde::{Deserialize, Serialize};
9use std::path::PathBuf;
10
11#[derive(Debug, thiserror::Error)]
13pub enum PathfinderError {
14 #[error("file not found: {path}")]
16 FileNotFound {
17 path: PathBuf,
19 },
20
21 #[error("symbol not found: {semantic_path}")]
23 SymbolNotFound {
24 semantic_path: String,
26 did_you_mean: Vec<String>,
28 },
29
30 #[error("invalid semantic path: {input}")]
32 InvalidSemanticPath {
33 input: String,
35 issue: String,
37 },
38
39 #[error("ambiguous symbol: {semantic_path}")]
41 AmbiguousSymbol {
42 semantic_path: String,
44 matches: Vec<String>,
46 },
47
48 #[error("no LSP available for language: {language}")]
50 NoLspAvailable { language: String },
51
52 #[error("LSP error: {message}")]
54 LspError { message: String },
55
56 #[error("I/O error: {message}")]
58 IoError { message: String },
59
60 #[error("LSP timeout after {timeout_ms}ms")]
62 LspTimeout { timeout_ms: u64 },
63
64 #[error("access denied: {path}")]
66 AccessDenied { path: PathBuf, tier: SandboxTier },
67
68 #[error("parse error in {path}: {reason}")]
70 ParseError { path: PathBuf, reason: String },
71
72 #[error("unsupported language for target file: {path}")]
74 UnsupportedLanguage { path: PathBuf },
75
76 #[error("token budget exceeded: {used} / {budget}")]
78 TokenBudgetExceeded { used: usize, budget: usize },
79
80 #[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 #[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 #[must_use]
114 pub fn hint(&self) -> Option<String> {
115 match self {
116 Self::SymbolNotFound {
117 semantic_path,
118 did_you_mean,
119 } => {
120 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 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 #[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#[derive(Debug, Clone, Serialize, Deserialize)]
250pub struct ErrorResponse {
251 pub error: String,
253 pub message: String,
255 pub details: serde_json::Value,
257 #[serde(skip_serializing_if = "Option::is_none")]
259 pub hint: Option<String>,
260}
261
262#[derive(Debug, Clone, Copy, Serialize, Deserialize)]
264pub enum SandboxTier {
265 HardcodedDeny,
267 DefaultDeny,
269 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 #[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 #[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 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}