Skip to main content

tldr_core/
error.rs

1//! Error types for TLDR operations
2//!
3//! This module defines the error taxonomy for all TLDR operations.
4//! Error messages are designed to match Python output format (M17: Error Message Parity).
5
6use std::path::PathBuf;
7use thiserror::Error;
8
9/// All possible errors from TLDR operations.
10///
11/// Error messages follow a consistent format to maintain parity with
12/// the Python implementation and enable reliable error handling in
13/// downstream tooling.
14#[derive(Debug, Error)]
15pub enum TldrError {
16    // =========================================================================
17    // File System Errors
18    // =========================================================================
19    /// Path does not exist
20    #[error("Path not found: {0}")]
21    PathNotFound(PathBuf),
22
23    /// Directory traversal attack detected (path contains ..)
24    #[error("Path traversal detected: {0}")]
25    PathTraversal(PathBuf),
26
27    /// Symlink creates a cycle
28    #[error("Symlink cycle detected: {0}")]
29    SymlinkCycle(PathBuf),
30
31    /// Insufficient permissions to access path
32    #[error("Permission denied: {0}")]
33    PermissionDenied(PathBuf),
34
35    // =========================================================================
36    // Metrics Errors (Session 15)
37    // =========================================================================
38    /// File exceeds maximum allowed size
39    #[error("File too large: {path} is {size_mb}MB (max {max_mb}MB)")]
40    FileTooLarge {
41        /// Path of the oversized file.
42        path: PathBuf,
43        /// Observed size in megabytes.
44        size_mb: usize,
45        /// Configured maximum size in megabytes.
46        max_mb: usize,
47    },
48
49    /// Encoding error when reading file
50    #[error("Encoding error in {path}: {detail}")]
51    EncodingError {
52        /// File path that could not be decoded.
53        path: PathBuf,
54        /// Decoder failure details.
55        detail: String,
56    },
57
58    /// Coverage report parsing error
59    #[error("Coverage parse error ({format}): {detail}")]
60    CoverageParseError {
61        /// Coverage format being parsed (for example, `lcov`).
62        format: String,
63        /// Parser failure details.
64        detail: String,
65    },
66
67    /// Not a git repository (for hotspots command)
68    #[error("Not a git repository: {0}")]
69    NotGitRepository(PathBuf),
70
71    /// Git operation in progress (rebase, merge, etc.)
72    #[error("Git operation in progress: {0}. Complete or abort the operation first.")]
73    GitOperationInProgress(String),
74
75    /// Generic git command failure
76    #[error("Git error: {0}")]
77    GitError(String),
78
79    // =========================================================================
80    // Parse Errors
81    // =========================================================================
82    /// Syntax or parsing error in source file
83    ///
84    /// Includes optional line number for better error messages (M17, M20).
85    #[error("Parse error in {file}{}: {message}",
86        line.map(|l| format!(" at line {}", l)).unwrap_or_default())]
87    ParseError {
88        /// Source file where parsing failed.
89        file: PathBuf,
90        /// Optional one-based source line where parsing failed.
91        line: Option<u32>,
92        /// Human-readable parse error message.
93        message: String,
94    },
95
96    /// File extension doesn't match any supported language
97    #[error("Unsupported language: {0}")]
98    UnsupportedLanguage(String),
99
100    // =========================================================================
101    // Analysis Errors
102    // =========================================================================
103    /// Function could not be found in the codebase
104    ///
105    /// Includes suggestions for similar names when available (M20: Error Messages Unusable).
106    #[error("Function not found: {name}{}{}",
107        file.as_ref().map(|f| format!(" in {}", f.display())).unwrap_or_default(),
108        if suggestions.is_empty() { String::new() }
109        else { format!("\n\nDid you mean:\n{}", suggestions.iter().map(|s| format!("  - {}", s)).collect::<Vec<_>>().join("\n")) }
110    )]
111    FunctionNotFound {
112        /// Requested function name.
113        name: String,
114        /// Optional file scope used during lookup.
115        file: Option<PathBuf>,
116        /// Suggested nearby function names.
117        suggestions: Vec<String>,
118    },
119
120    /// Invalid slice direction (must be "backward" or "forward")
121    #[error("Invalid direction: {0}. Expected 'backward' or 'forward'")]
122    InvalidDirection(String),
123
124    /// Line number is not within the specified function
125    #[error("Line {0} is not within the specified function")]
126    LineNotInFunction(u32),
127
128    /// No supported files found in directory (T32 mitigation)
129    #[error("No supported files found in {0}")]
130    NoSupportedFiles(PathBuf),
131
132    /// Generic entity not found error (class, module, etc.)
133    #[error("{entity} not found: {name}{}",
134        suggestion.as_ref().map(|s| format!("\n\n{}", s)).unwrap_or_default())]
135    NotFound {
136        /// Kind of entity that was requested (class, module, etc.).
137        entity: String,
138        /// Name of the missing entity.
139        name: String,
140        /// Optional hint to resolve the issue.
141        suggestion: Option<String>,
142    },
143
144    /// Invalid argument value
145    #[error("Invalid argument {arg}: {message}{}",
146        suggestion.as_ref().map(|s| format!("\n\nHint: {}", s)).unwrap_or_default())]
147    InvalidArgs {
148        /// Argument name that failed validation.
149        arg: String,
150        /// Validation failure details.
151        message: String,
152        /// Optional corrective hint.
153        suggestion: Option<String>,
154    },
155
156    // =========================================================================
157    // Daemon Errors
158    // =========================================================================
159    /// Error communicating with the daemon
160    #[error("Daemon error: {0}")]
161    DaemonError(String),
162
163    /// Connection to daemon failed
164    #[error("Connection failed: {0}")]
165    ConnectionFailed(String),
166
167    /// Operation timed out
168    #[error("Timeout: {0}")]
169    Timeout(String),
170
171    // =========================================================================
172    // MCP Errors
173    // =========================================================================
174    /// Error in MCP protocol handling
175    #[error("MCP error: {0}")]
176    McpError(String),
177
178    // =========================================================================
179    // Serialization Errors
180    // =========================================================================
181    /// JSON or other serialization error
182    #[error("Serialization error: {0}")]
183    SerializationError(String),
184
185    // =========================================================================
186    // Semantic Search Errors (Session 16)
187    // =========================================================================
188    /// Embedding model or generation error
189    #[error("Embedding error: {0}")]
190    Embedding(String),
191
192    /// Model loading/initialization failed
193    #[error("Failed to load embedding model '{model}': {detail}")]
194    ModelLoadError {
195        /// Model identifier that failed to initialize.
196        model: String,
197        /// Loader error details.
198        detail: String,
199    },
200
201    /// Index exceeds maximum allowed size (P0 mitigation)
202    #[error("Index too large: {count} chunks exceeds maximum of {max}. Filter by language or directory.")]
203    IndexTooLarge {
204        /// Number of chunks requested for indexing.
205        count: usize,
206        /// Configured maximum chunk count.
207        max: usize,
208    },
209
210    /// Estimated memory usage exceeds limit (P0 mitigation)
211    #[error("Memory limit exceeded: estimated {estimated_mb}MB exceeds maximum of {max_mb}MB")]
212    MemoryLimitExceeded {
213        /// Estimated memory requirement in megabytes.
214        estimated_mb: usize,
215        /// Configured maximum memory limit in megabytes.
216        max_mb: usize,
217    },
218
219    /// Code chunk not found in index
220    #[error("Chunk not found: {file}{}", function.as_ref().map(|f| format!("::{}", f)).unwrap_or_default())]
221    ChunkNotFound {
222        /// File path key used during chunk lookup.
223        file: String,
224        /// Optional function key within the file.
225        function: Option<String>,
226    },
227
228    // =========================================================================
229    // IO Errors
230    // =========================================================================
231    /// General IO error
232    #[error(transparent)]
233    IoError(#[from] std::io::Error),
234}
235
236impl TldrError {
237    /// Create a FunctionNotFound error with just the name
238    pub fn function_not_found(name: impl Into<String>) -> Self {
239        TldrError::FunctionNotFound {
240            name: name.into(),
241            file: None,
242            suggestions: Vec::new(),
243        }
244    }
245
246    /// Create a FunctionNotFound error with file context
247    pub fn function_not_found_in_file(name: impl Into<String>, file: PathBuf) -> Self {
248        TldrError::FunctionNotFound {
249            name: name.into(),
250            file: Some(file),
251            suggestions: Vec::new(),
252        }
253    }
254
255    /// Create a FunctionNotFound error with suggestions (M20)
256    pub fn function_not_found_with_suggestions(
257        name: impl Into<String>,
258        file: Option<PathBuf>,
259        suggestions: Vec<String>,
260    ) -> Self {
261        TldrError::FunctionNotFound {
262            name: name.into(),
263            file,
264            suggestions,
265        }
266    }
267
268    /// Create a ParseError with line information
269    pub fn parse_error(file: PathBuf, line: Option<u32>, message: impl Into<String>) -> Self {
270        TldrError::ParseError {
271            file,
272            line,
273            message: message.into(),
274        }
275    }
276
277    /// Check if this is a recoverable error that allows partial results
278    pub fn is_recoverable(&self) -> bool {
279        matches!(
280            self,
281            TldrError::ParseError { .. }
282                | TldrError::PermissionDenied(_)
283                | TldrError::FunctionNotFound { .. }
284        )
285    }
286
287    /// Get error code for CLI exit status
288    ///
289    /// Exit codes are consistent with Unix conventions:
290    /// - 1: General IO error
291    /// - 2-9: File system errors
292    /// - 10-19: Parse errors
293    /// - 20-29: Analysis errors
294    /// - 30-39: Network/daemon errors
295    /// - 40-49: Serialization errors
296    pub fn exit_code(&self) -> i32 {
297        match self {
298            // File system errors (2-9)
299            TldrError::PathNotFound(_) => 2,
300            TldrError::PathTraversal(_) => 3,
301            TldrError::SymlinkCycle(_) => 4,
302            TldrError::PermissionDenied(_) => 5,
303            TldrError::FileTooLarge { .. } => 6,
304            TldrError::EncodingError { .. } => 7,
305            TldrError::NotGitRepository(_) => 8,
306            TldrError::GitOperationInProgress(_) => 9,
307            TldrError::GitError(_) => 9,
308
309            // Parse errors (10-19)
310            TldrError::ParseError { .. } => 10,
311            TldrError::UnsupportedLanguage(_) => 11,
312            TldrError::CoverageParseError { .. } => 12,
313
314            // Analysis errors (20-29)
315            TldrError::FunctionNotFound { .. } => 20,
316            TldrError::InvalidDirection(_) => 21,
317            TldrError::LineNotInFunction(_) => 22,
318            TldrError::NoSupportedFiles(_) => 23,
319            TldrError::NotFound { .. } => 24,
320            TldrError::InvalidArgs { .. } => 25,
321
322            // Network/daemon errors (30-39)
323            TldrError::DaemonError(_) => 30,
324            TldrError::ConnectionFailed(_) => 31,
325            TldrError::Timeout(_) => 32,
326            TldrError::McpError(_) => 33,
327
328            // Serialization errors (40-49)
329            TldrError::SerializationError(_) => 40,
330
331            // Semantic search errors (50-59)
332            TldrError::Embedding(_) => 50,
333            TldrError::ModelLoadError { .. } => 51,
334            TldrError::IndexTooLarge { .. } => 52,
335            TldrError::MemoryLimitExceeded { .. } => 53,
336            TldrError::ChunkNotFound { .. } => 54,
337
338            // General IO error
339            TldrError::IoError(_) => 1,
340        }
341    }
342
343    /// Get a short error category for logging/metrics
344    pub fn category(&self) -> &'static str {
345        match self {
346            TldrError::PathNotFound(_)
347            | TldrError::PathTraversal(_)
348            | TldrError::SymlinkCycle(_)
349            | TldrError::PermissionDenied(_)
350            | TldrError::FileTooLarge { .. }
351            | TldrError::EncodingError { .. } => "filesystem",
352
353            TldrError::NotGitRepository(_)
354            | TldrError::GitOperationInProgress(_)
355            | TldrError::GitError(_) => "git",
356
357            TldrError::ParseError { .. }
358            | TldrError::UnsupportedLanguage(_)
359            | TldrError::CoverageParseError { .. } => "parse",
360
361            TldrError::FunctionNotFound { .. }
362            | TldrError::InvalidDirection(_)
363            | TldrError::LineNotInFunction(_)
364            | TldrError::NoSupportedFiles(_)
365            | TldrError::NotFound { .. }
366            | TldrError::InvalidArgs { .. } => "analysis",
367
368            TldrError::DaemonError(_) | TldrError::ConnectionFailed(_) | TldrError::Timeout(_) => {
369                "daemon"
370            }
371
372            TldrError::McpError(_) => "mcp",
373
374            TldrError::SerializationError(_) => "serialization",
375
376            TldrError::Embedding(_)
377            | TldrError::ModelLoadError { .. }
378            | TldrError::IndexTooLarge { .. }
379            | TldrError::MemoryLimitExceeded { .. }
380            | TldrError::ChunkNotFound { .. } => "semantic",
381
382            TldrError::IoError(_) => "io",
383        }
384    }
385}
386
387// Allow conversion from serde_json errors
388impl From<serde_json::Error> for TldrError {
389    fn from(err: serde_json::Error) -> Self {
390        TldrError::SerializationError(err.to_string())
391    }
392}
393
394// Allow conversion from regex errors (used in references module for text search)
395impl From<regex::Error> for TldrError {
396    fn from(err: regex::Error) -> Self {
397        TldrError::ParseError {
398            file: std::path::PathBuf::new(),
399            line: None,
400            message: format!("Invalid regex pattern: {}", err),
401        }
402    }
403}
404
405#[cfg(test)]
406mod tests {
407    use super::*;
408
409    #[test]
410    fn test_error_display_path_not_found() {
411        let err = TldrError::PathNotFound(PathBuf::from("/some/path"));
412        assert_eq!(err.to_string(), "Path not found: /some/path");
413    }
414
415    #[test]
416    fn test_error_display_parse_error_with_line() {
417        let err = TldrError::parse_error(PathBuf::from("test.py"), Some(42), "unexpected token");
418        assert_eq!(
419            err.to_string(),
420            "Parse error in test.py at line 42: unexpected token"
421        );
422    }
423
424    #[test]
425    fn test_error_display_parse_error_without_line() {
426        let err = TldrError::parse_error(PathBuf::from("test.py"), None, "file is binary");
427        assert_eq!(err.to_string(), "Parse error in test.py: file is binary");
428    }
429
430    #[test]
431    fn test_error_display_function_not_found_simple() {
432        let err = TldrError::function_not_found("process_data");
433        assert_eq!(err.to_string(), "Function not found: process_data");
434    }
435
436    #[test]
437    fn test_error_display_function_not_found_with_file() {
438        let err =
439            TldrError::function_not_found_in_file("process_data", PathBuf::from("src/main.py"));
440        assert_eq!(
441            err.to_string(),
442            "Function not found: process_data in src/main.py"
443        );
444    }
445
446    #[test]
447    fn test_error_display_function_not_found_with_suggestions() {
448        let err = TldrError::function_not_found_with_suggestions(
449            "proces_data",
450            Some(PathBuf::from("src/main.py")),
451            vec!["process_data".to_string(), "process_data_v2".to_string()],
452        );
453        let msg = err.to_string();
454        assert!(msg.contains("proces_data"));
455        assert!(msg.contains("src/main.py"));
456        assert!(msg.contains("Did you mean:"));
457        assert!(msg.contains("process_data"));
458        assert!(msg.contains("process_data_v2"));
459    }
460
461    #[test]
462    fn test_error_is_recoverable() {
463        assert!(TldrError::function_not_found("foo").is_recoverable());
464        assert!(TldrError::parse_error(PathBuf::from("x"), None, "e").is_recoverable());
465        assert!(TldrError::PermissionDenied(PathBuf::from("/")).is_recoverable());
466
467        assert!(!TldrError::PathNotFound(PathBuf::from("/")).is_recoverable());
468        assert!(!TldrError::PathTraversal(PathBuf::from("/")).is_recoverable());
469    }
470
471    #[test]
472    fn test_error_exit_codes() {
473        assert_eq!(TldrError::PathNotFound(PathBuf::from("/")).exit_code(), 2);
474        assert_eq!(TldrError::PathTraversal(PathBuf::from("/")).exit_code(), 3);
475        assert_eq!(TldrError::function_not_found("foo").exit_code(), 20);
476        assert_eq!(TldrError::DaemonError("test".to_string()).exit_code(), 30);
477        assert_eq!(TldrError::McpError("test".to_string()).exit_code(), 33);
478        assert_eq!(
479            TldrError::SerializationError("test".to_string()).exit_code(),
480            40
481        );
482    }
483
484    #[test]
485    fn test_error_categories() {
486        assert_eq!(
487            TldrError::PathNotFound(PathBuf::from("/")).category(),
488            "filesystem"
489        );
490        assert_eq!(
491            TldrError::parse_error(PathBuf::from("x"), None, "e").category(),
492            "parse"
493        );
494        assert_eq!(TldrError::function_not_found("foo").category(), "analysis");
495        assert_eq!(TldrError::DaemonError("x".to_string()).category(), "daemon");
496        assert_eq!(TldrError::McpError("x".to_string()).category(), "mcp");
497    }
498}