1use std::fmt;
11
12#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
14pub enum FileErrorCode {
15 Aborted,
16 NotFound,
17 PermissionDenied,
18 NotDirectory,
19 IsDirectory,
20 Invalid,
21 NotSupported,
22 Unknown,
23}
24
25impl FileErrorCode {
26 pub fn as_str(self) -> &'static str {
27 match self {
28 FileErrorCode::Aborted => "aborted",
29 FileErrorCode::NotFound => "not_found",
30 FileErrorCode::PermissionDenied => "permission_denied",
31 FileErrorCode::NotDirectory => "not_directory",
32 FileErrorCode::IsDirectory => "is_directory",
33 FileErrorCode::Invalid => "invalid",
34 FileErrorCode::NotSupported => "not_supported",
35 FileErrorCode::Unknown => "unknown",
36 }
37 }
38}
39
40impl fmt::Display for FileErrorCode {
41 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
42 f.write_str(self.as_str())
43 }
44}
45
46#[derive(Debug)]
53pub struct FileError {
54 pub code: FileErrorCode,
55 pub message: String,
56 pub path: Option<String>,
57 pub source: Option<Box<dyn std::error::Error + Send + Sync>>,
58}
59
60impl FileError {
61 pub fn new(code: FileErrorCode, message: impl Into<String>) -> Self {
62 Self {
63 code,
64 message: message.into(),
65 path: None,
66 source: None,
67 }
68 }
69
70 pub fn with_path(mut self, path: impl Into<String>) -> Self {
71 self.path = Some(path.into());
72 self
73 }
74
75 pub fn with_source(mut self, source: Box<dyn std::error::Error + Send + Sync>) -> Self {
76 self.source = Some(source);
77 self
78 }
79
80 pub fn not_found(path: impl Into<String>) -> Self {
82 let p: String = path.into();
83 Self::new(FileErrorCode::NotFound, format!("path not found: {p}")).with_path(p)
84 }
85
86 pub fn aborted() -> Self {
88 Self::new(FileErrorCode::Aborted, "operation aborted")
89 }
90}
91
92impl fmt::Display for FileError {
93 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
94 match &self.path {
95 Some(p) => write!(f, "{}: {} (path: {})", self.code, self.message, p),
96 None => write!(f, "{}: {}", self.code, self.message),
97 }
98 }
99}
100
101impl std::error::Error for FileError {
102 fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
103 self.source
104 .as_ref()
105 .map(|b| b.as_ref() as &(dyn std::error::Error + 'static))
106 }
107}
108
109#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
111pub enum ExecutionErrorCode {
112 Aborted,
113 Timeout,
114 ShellUnavailable,
115 SpawnError,
116 CallbackError,
117 Unknown,
118}
119
120impl ExecutionErrorCode {
121 pub fn as_str(self) -> &'static str {
122 match self {
123 ExecutionErrorCode::Aborted => "aborted",
124 ExecutionErrorCode::Timeout => "timeout",
125 ExecutionErrorCode::ShellUnavailable => "shell_unavailable",
126 ExecutionErrorCode::SpawnError => "spawn_error",
127 ExecutionErrorCode::CallbackError => "callback_error",
128 ExecutionErrorCode::Unknown => "unknown",
129 }
130 }
131}
132
133impl fmt::Display for ExecutionErrorCode {
134 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
135 f.write_str(self.as_str())
136 }
137}
138
139#[derive(Debug, Clone)]
146pub struct ExecutionError {
147 pub code: ExecutionErrorCode,
148 pub message: String,
149 pub source_message: Option<String>,
150}
151
152impl ExecutionError {
153 pub fn new(code: ExecutionErrorCode, message: impl Into<String>) -> Self {
154 Self {
155 code,
156 message: message.into(),
157 source_message: None,
158 }
159 }
160
161 pub fn with_source(mut self, source: Box<dyn std::error::Error + Send + Sync>) -> Self {
162 self.source_message = Some(source.to_string());
163 self
164 }
165
166 pub fn with_source_message(mut self, source: impl Into<String>) -> Self {
167 self.source_message = Some(source.into());
168 self
169 }
170
171 pub fn aborted() -> Self {
172 Self::new(ExecutionErrorCode::Aborted, "command aborted")
173 }
174
175 pub fn timeout(seconds: u64) -> Self {
176 Self::new(
177 ExecutionErrorCode::Timeout,
178 format!("command timed out after {seconds}s"),
179 )
180 }
181}
182
183impl fmt::Display for ExecutionError {
184 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
185 write!(f, "{}: {}", self.code, self.message)
186 }
187}
188
189impl std::error::Error for ExecutionError {
190 fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
191 None
194 }
195}
196
197pub fn io_to_file_code(err: &std::io::Error) -> FileErrorCode {
200 use std::io::ErrorKind;
201 match err.kind() {
202 ErrorKind::NotFound => FileErrorCode::NotFound,
203 ErrorKind::PermissionDenied => FileErrorCode::PermissionDenied,
204 ErrorKind::AlreadyExists => FileErrorCode::Invalid,
205 ErrorKind::TimedOut => FileErrorCode::Aborted,
206 ErrorKind::Unsupported => FileErrorCode::NotSupported,
207 ErrorKind::Interrupted => FileErrorCode::Aborted,
208 _ => match err.raw_os_error() {
209 #[cfg(unix)]
213 Some(21) => FileErrorCode::IsDirectory,
214 #[cfg(unix)]
215 Some(20) => FileErrorCode::NotDirectory,
216 #[cfg(windows)]
218 Some(267) => FileErrorCode::NotDirectory,
219 _ => FileErrorCode::Unknown,
220 },
221 }
222}
223
224pub fn io_to_file_error(err: std::io::Error, path: Option<&str>) -> FileError {
227 let code = io_to_file_code(&err);
228 let message = err.to_string();
229 let mut e = FileError::new(code, message).with_source(Box::new(err));
230 if let Some(p) = path {
231 e = e.with_path(p);
232 }
233 e
234}
235
236#[cfg(test)]
237mod tests {
238 use super::*;
239
240 #[test]
241 fn file_error_codes_round_trip() {
242 for code in [
243 FileErrorCode::Aborted,
244 FileErrorCode::NotFound,
245 FileErrorCode::PermissionDenied,
246 FileErrorCode::NotDirectory,
247 FileErrorCode::IsDirectory,
248 FileErrorCode::Invalid,
249 FileErrorCode::NotSupported,
250 FileErrorCode::Unknown,
251 ] {
252 assert_eq!(FileErrorCode::as_str(code), code.to_string());
253 }
254 }
255
256 #[test]
257 fn execution_error_codes_round_trip() {
258 for code in [
259 ExecutionErrorCode::Aborted,
260 ExecutionErrorCode::Timeout,
261 ExecutionErrorCode::ShellUnavailable,
262 ExecutionErrorCode::SpawnError,
263 ExecutionErrorCode::CallbackError,
264 ExecutionErrorCode::Unknown,
265 ] {
266 assert_eq!(ExecutionErrorCode::as_str(code), code.to_string());
267 }
268 }
269
270 #[test]
271 fn io_error_classifies_not_found() {
272 let err = std::io::Error::from(std::io::ErrorKind::NotFound);
273 assert_eq!(io_to_file_code(&err), FileErrorCode::NotFound);
274 }
275
276 #[cfg(windows)]
277 #[test]
278 fn io_error_classifies_windows_not_directory() {
279 let err = std::io::Error::from_raw_os_error(267);
280 assert_eq!(io_to_file_code(&err), FileErrorCode::NotDirectory);
281 }
282
283 #[cfg(unix)]
284 #[test]
285 fn io_error_classifies_posix_directory_kinds() {
286 assert_eq!(
287 io_to_file_code(&std::io::Error::from_raw_os_error(21)),
288 FileErrorCode::IsDirectory
289 );
290 assert_eq!(
291 io_to_file_code(&std::io::Error::from_raw_os_error(20)),
292 FileErrorCode::NotDirectory
293 );
294 }
295
296 #[test]
297 fn io_to_file_error_preserves_path_and_source() {
298 let err = std::io::Error::new(std::io::ErrorKind::PermissionDenied, "denied");
299 let fe = io_to_file_error(err, Some("/x/y"));
300 assert_eq!(fe.code, FileErrorCode::PermissionDenied);
301 assert_eq!(fe.path.as_deref(), Some("/x/y"));
302 assert!(fe.source.is_some());
303 }
304
305 #[test]
306 fn display_formats_path_when_present() {
307 let s = FileError::not_found("/a/b").to_string();
308 assert!(s.contains("not_found"));
309 assert!(s.contains("/a/b"));
310 let s2 = FileError::new(FileErrorCode::Invalid, "bad").to_string();
311 assert!(s2.contains("invalid: bad"));
312 assert!(!s2.contains("path"));
313 }
314}