1use std::path::{Path, PathBuf};
4
5use miette::{Diagnostic, SourceSpan};
6use owo_colors::OwoColorize;
7use thiserror::Error;
8
9#[derive(Error, Debug, Diagnostic)]
11pub enum TaleError {
12 #[error("File operation failed")]
14 #[diagnostic()]
15 File(#[from] Box<FileError>),
16
17 #[error("JSON format error")]
19 #[diagnostic()]
20 Json(#[from] Box<JsonError>),
21
22 #[error("Configuration error")]
24 #[diagnostic()]
25 Config(#[from] Box<ConfigError>),
26
27 #[error("I/O operation failed")]
29 #[diagnostic()]
30 Io(#[from] Box<IoError>),
31
32 #[error("Memory management error: {0}")]
34 #[diagnostic(code(tale::memory::error))]
35 MemoryError(String),
36
37 #[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#[derive(Error, Debug, Diagnostic)]
60pub enum FileError {
61 #[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 #[error("Permission denied: {}", path.display().yellow().bold())]
68 #[diagnostic(code(tale::file::permission_denied))]
69 PermissionDenied { path: PathBuf, suggestion: String },
70
71 #[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#[derive(Error, Debug, Diagnostic)]
79pub enum JsonError {
80 #[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 #[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#[derive(Error, Debug, Diagnostic)]
105pub enum ConfigError {
106 #[error("Invalid argument combination")]
108 #[diagnostic(code(tale::config::invalid_args))]
109 InvalidArgs {
110 message: String,
111 conflicting_args: Vec<String>,
112 },
113
114 #[error("Invalid offset value: {}", value.blue())]
116 #[diagnostic(code(tale::config::invalid_offset))]
117 InvalidOffset { value: String, reason: String },
118}
119
120#[derive(Error, Debug, Diagnostic)]
122pub enum IoError {
123 #[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
134impl FileError {
136 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 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 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 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
173pub 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
188impl 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
200impl 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
212pub 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 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); similar
248}
249
250fn is_similar(target: &str, candidate: &str) -> bool {
252 let target = target.to_lowercase();
253 let candidate = candidate.to_lowercase();
254
255 let distance = edit_distance(&target, &candidate);
257 distance <= 3 && distance > 0
258}
259
260fn 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}