Skip to main content

tale_ndjson/
errors.rs

1//! Custom error types for tale with rich diagnostic information
2
3use std::path::{Path, PathBuf};
4
5use miette::{Diagnostic, SourceSpan};
6use owo_colors::OwoColorize;
7use thiserror::Error;
8
9/// Main error type for tale operations
10#[derive(Error, Debug, Diagnostic)]
11pub enum TaleError {
12    /// File-related errors with helpful context
13    #[error("File operation failed")]
14    #[diagnostic()]
15    File(#[from] Box<FileError>),
16
17    /// JSON parsing errors with location information
18    #[error("JSON format error")]
19    #[diagnostic()]
20    Json(#[from] Box<JsonError>),
21
22    /// Configuration errors
23    #[error("Configuration error")]
24    #[diagnostic()]
25    Config(#[from] Box<ConfigError>),
26
27    /// I/O errors with context
28    #[error("I/O operation failed")]
29    #[diagnostic()]
30    Io(#[from] Box<IoError>),
31
32    /// Memory management errors
33    #[error("Memory management error: {0}")]
34    #[diagnostic(code(tale::memory::error))]
35    MemoryError(String),
36
37    /// Globular errors.
38    #[error(transparent)]
39    #[diagnostic()]
40    GlobPattern(#[from] glob::PatternError),
41
42    #[error("Internal error: failed to set up async channels for lines")]
43    LineReceiver,
44
45    #[error("Internal error: failed to set up async channels")]
46    BatchSender,
47
48    #[error("Internal error: trouble sending batched lines on async channels")]
49    BatchedLineSender,
50    #[error("Internal error: trouble sending batched vectors of lines on async channels")]
51    BatchedLineVecSender,
52
53    #[error(transparent)]
54    #[diagnostic()]
55    NotifyError(#[from] notify::Error),
56}
57
58/// File-related error types
59#[derive(Error, Debug, Diagnostic)]
60pub enum FileError {
61    /// File not found with suggestions
62    #[error("File not found: {}", path.display().yellow().bold())]
63    #[diagnostic(code(tale::file::not_found))]
64    NotFound { path: PathBuf, similar_files: Vec<String> },
65
66    /// Permission denied with actionable advice
67    #[error("Permission denied: {}", path.display().yellow().bold())]
68    #[diagnostic(code(tale::file::permission_denied))]
69    PermissionDenied { path: PathBuf, suggestion: String },
70
71    /// Not a file (directory, device, etc.)
72    #[error("Not a regular file: {} is a {}", path.display().yellow().bold(), actual_type.bold())]
73    #[diagnostic(code(tale::file::not_a_file))]
74    NotAFile { path: PathBuf, actual_type: String },
75}
76
77/// JSON parsing error types
78#[derive(Error, Debug, Diagnostic)]
79pub enum JsonError {
80    /// Invalid JSON syntax
81    #[error("Invalid JSON syntax")]
82    #[diagnostic(code(tale::json::invalid_syntax))]
83    InvalidSyntax {
84        #[source_code]
85        src: String,
86        #[label("invalid JSON here")]
87        span: SourceSpan,
88        details: String,
89    },
90
91    /// Missing required field
92    #[error("Missing required field: {}", field.blue())]
93    #[diagnostic(code(tale::json::missing_field))]
94    MissingField {
95        field: String,
96        #[source_code]
97        src: String,
98        #[label("in this JSON object")]
99        span: SourceSpan,
100    },
101}
102
103/// Configuration error types
104#[derive(Error, Debug, Diagnostic)]
105pub enum ConfigError {
106    /// Invalid argument combination
107    #[error("Invalid argument combination")]
108    #[diagnostic(code(tale::config::invalid_args))]
109    InvalidArgs {
110        message: String,
111        conflicting_args: Vec<String>,
112    },
113
114    /// Invalid offset value
115    #[error("Invalid offset value: {}", value.blue())]
116    #[diagnostic(code(tale::config::invalid_offset))]
117    InvalidOffset { value: String, reason: String },
118}
119
120/// I/O error wrapper with context
121#[derive(Error, Debug, Diagnostic)]
122pub enum IoError {
123    /// Generic I/O error with context
124    #[error("I/O error: {}", operation.blue())]
125    #[diagnostic(code(tale::io::operation_failed))]
126    OperationFailed {
127        operation: String,
128        path: Option<PathBuf>,
129        #[source]
130        source: std::io::Error,
131    },
132}
133
134// Helper functions for creating errors with context
135impl FileError {
136    /// Create a file not found error with suggestions
137    pub fn not_found_with_suggestions(path: PathBuf, similar_files: Vec<PathBuf>) -> Self {
138        let similar_files = similar_files.into_iter().map(|p| p.display().to_string()).collect();
139        Self::NotFound { path, similar_files }
140    }
141
142    /// Create a permission denied error with helpful context
143    pub fn permission_denied_with_suggestion(path: PathBuf, suggestion: Option<String>) -> Self {
144        let suggestion = suggestion.unwrap_or_else(|| "Check file permissions".to_string());
145        Self::PermissionDenied { path, suggestion }
146    }
147
148    /// Create a not-a-file error with type information
149    pub fn not_a_file_with_type(path: PathBuf) -> Self {
150        let actual_type = if path.is_dir() {
151            "directory".to_string()
152        } else if path.is_symlink() {
153            "symbolic link".to_string()
154        } else {
155            "special file".to_string()
156        };
157
158        Self::NotAFile { path, actual_type }
159    }
160}
161
162impl JsonError {
163    /// Create a JSON syntax error with source location
164    pub fn invalid_syntax_at(src: String, offset: usize, len: usize, details: String) -> Self {
165        Self::InvalidSyntax {
166            src,
167            span: SourceSpan::new(offset.into(), len),
168            details,
169        }
170    }
171}
172
173/// Helper trait for converting std::io::Error to our IoError with context
174pub trait IoErrorExt<T> {
175    fn with_context(self, operation: &str, path: Option<&Path>) -> Result<T, IoError>;
176}
177
178impl<T> IoErrorExt<T> for Result<T, std::io::Error> {
179    fn with_context(self, operation: &str, path: Option<&Path>) -> Result<T, IoError> {
180        self.map_err(|e| IoError::OperationFailed {
181            operation: operation.to_string(),
182            path: path.map(|p| p.to_path_buf()),
183            source: e,
184        })
185    }
186}
187
188/// Automatic conversion from std::io::Error to our TaleError for easier use
189/// with ?
190impl From<std::io::Error> for TaleError {
191    fn from(err: std::io::Error) -> Self {
192        TaleError::Io(Box::new(IoError::OperationFailed {
193            operation: "I/O operation".to_string(),
194            path: None,
195            source: err,
196        }))
197    }
198}
199
200/// Automatic conversion from serde_json::Error to our TaleError for easier use
201/// with ?
202impl From<serde_json::Error> for TaleError {
203    fn from(err: serde_json::Error) -> Self {
204        TaleError::Json(Box::new(JsonError::InvalidSyntax {
205            src: "JSON input".to_string(),
206            span: miette::SourceSpan::new(0.into(), 0usize),
207            details: err.to_string(),
208        }))
209    }
210}
211
212/// Helper for finding similar file names
213pub fn find_similar_files(target: &Path) -> Vec<PathBuf> {
214    let Some(parent) = target.parent() else {
215        return Vec::new();
216    };
217
218    let Some(target_name) = target.file_name().and_then(|n| n.to_str()) else {
219        return Vec::new();
220    };
221
222    let Ok(entries) = std::fs::read_dir(parent) else {
223        return Vec::new();
224    };
225
226    let mut similar = Vec::new();
227
228    for entry in entries.flatten() {
229        if let Some(name) = entry.file_name().to_str()
230            && name != target_name
231            && is_similar(target_name, name)
232        {
233            similar.push(entry.path());
234        }
235    }
236
237    // Sort by similarity/distance
238    similar.sort_by(|a, b| {
239        let a_name = a.file_name().unwrap_or_default().to_str().unwrap_or_default();
240        let b_name = b.file_name().unwrap_or_default().to_str().unwrap_or_default();
241        let a_dist = edit_distance(target_name, a_name);
242        let b_dist = edit_distance(target_name, b_name);
243        a_dist.cmp(&b_dist)
244    });
245
246    similar.truncate(3); // Show at most 3 suggestions
247    similar
248}
249
250/// Simple similarity check (case-insensitive, simple character differences)
251fn is_similar(target: &str, candidate: &str) -> bool {
252    let target = target.to_lowercase();
253    let candidate = candidate.to_lowercase();
254
255    // Check for simple typos or file extension changes (1-3 character differences)
256    let distance = edit_distance(&target, &candidate);
257    distance <= 3 && distance > 0
258}
259
260/// Simple edit distance calculation
261fn edit_distance(s1: &str, s2: &str) -> usize {
262    let len1 = s1.len();
263    let len2 = s2.len();
264    let mut matrix = vec![vec![0; len2 + 1]; len1 + 1];
265
266    for (i, row) in matrix.iter_mut().enumerate().take(len1 + 1) {
267        row[0] = i;
268    }
269    for j in 0..=len2 {
270        matrix[0][j] = j;
271    }
272
273    for i in 1..=len1 {
274        for j in 1..=len2 {
275            let cost = if s1.chars().nth(i - 1) == s2.chars().nth(j - 1) {
276                0
277            } else {
278                1
279            };
280            matrix[i][j] = std::cmp::min(
281                std::cmp::min(matrix[i - 1][j] + 1, matrix[i][j - 1] + 1),
282                matrix[i - 1][j - 1] + cost,
283            );
284        }
285    }
286
287    matrix[len1][len2]
288}
289
290#[cfg(test)]
291mod tests {
292    use std::path::PathBuf;
293
294    use super::*;
295
296    #[test]
297    fn edit_distance_calc_works() {
298        assert_eq!(edit_distance("hello", "hello"), 0);
299        assert_eq!(edit_distance("hello", "helo"), 1);
300        assert_eq!(edit_distance("hello", "help"), 2);
301        assert_eq!(edit_distance("file.txt", "file.log"), 3);
302    }
303
304    #[test]
305    fn can_find_similar_files() {
306        assert!(is_similar("file.txt", "file.log"));
307        assert!(is_similar("config.json", "config.jsn"));
308        assert!(!is_similar("file.txt", "completely_different.py"));
309    }
310
311    #[test]
312    fn good_error_on_enofile() {
313        let path = PathBuf::from("/nonexistent/file.txt");
314        let similar = vec![PathBuf::from("/nonexistent/file.log")];
315
316        let error = FileError::not_found_with_suggestions(path.clone(), similar);
317
318        match error {
319            FileError::NotFound {
320                path: error_path,
321                similar_files: suggestions,
322            } => {
323                assert_eq!(error_path, path);
324                assert!(!suggestions.is_empty());
325                assert_eq!(suggestions.len(), 1);
326            }
327            _ => panic!("Wrong error type created"),
328        }
329    }
330}