rusty_cat/error.rs
1use std::error::Error as StdError;
2use std::fmt::{Display, Formatter};
3use std::sync::Arc;
4
5#[derive(Debug, Clone, PartialEq, Eq)]
6pub enum InnerErrorCode {
7 /// Unknown/unclassified error.
8 Unknown = -1,
9 /// Success (non-error sentinel).
10 Success = 0,
11 /// Runtime creation failed.
12 RuntimeCreationFailedError = 101,
13 /// Required parameter is empty or invalid.
14 ParameterEmpty = 102,
15 /// The same file/task is already queued or running.
16 DuplicateTaskError = 103,
17 /// Failed to enqueue task.
18 EnqueueError = 104,
19 /// Local I/O operation failed.
20 IoError = 105,
21 /// HTTP request/response operation failed.
22 HttpError = 106,
23 /// Client has already been closed and can no longer accept operations.
24 ClientClosed = 107,
25 /// Unknown task ID in control API.
26 TaskNotFound = 108,
27 /// HTTP response status is not expected.
28 ResponseStatusError = 109,
29 /// `Content-Length` from HEAD is missing or invalid.
30 MissingOrInvalidContentLengthFromHead = 110,
31 /// Failed to send command to scheduler thread.
32 CommandSendFailed = 111,
33 /// Command response channel closed unexpectedly.
34 CommandResponseFailed = 112,
35 /// Failed to parse response payload (for example JSON).
36 ResponseParseError = 113,
37 /// Invalid HTTP range semantics or headers.
38 InvalidRange = 114,
39 /// Local file does not exist.
40 FileNotFound = 115,
41 /// File checksum/signature does not match expected value.
42 ChecksumMismatch = 116,
43 /// Current task state does not allow requested operation.
44 InvalidTaskState = 117,
45 /// Internal lock is poisoned.
46 LockPoisoned = 118,
47 /// Failed to build internal HTTP client.
48 HttpClientBuildFailed = 119,
49 /// Task was canceled before reaching `Complete`.
50 TaskCanceled = 120,
51 /// Local disk ran out of space (`ENOSPC` / `ERROR_DISK_FULL`).
52 DiskFull = 121,
53 /// Local source/target file was removed or replaced while a transfer was
54 /// in progress (for example the user deleted it mid-download).
55 LocalFileRemoved = 122,
56}
57
58/// Library error type returned by most public APIs.
59#[derive(Debug, Clone)]
60pub struct MeowError {
61 /// Numeric error code, usually mapped from [`InnerErrorCode`].
62 code: i32,
63 /// Human-readable error message.
64 msg: String,
65 /// Optional chained source error.
66 source: Option<Arc<dyn StdError + Send + Sync>>,
67}
68
69impl MeowError {
70 /// Creates a new error with raw numeric code and message.
71 ///
72 /// # Examples
73 ///
74 /// ```no_run
75 /// use rusty_cat::api::MeowError;
76 ///
77 /// let err = MeowError::new(9999, "custom failure".to_string());
78 /// assert_eq!(err.code(), 9999);
79 /// ```
80 pub fn new(code: i32, msg: String) -> Self {
81 crate::log::emit_lazy(|| {
82 crate::log::Log::debug("error", format!("MeowError::new code={} msg={}", code, msg))
83 });
84 MeowError {
85 code,
86 msg,
87 source: None,
88 }
89 }
90
91 /// Returns numeric error code.
92 ///
93 /// # Examples
94 ///
95 /// ```no_run
96 /// use rusty_cat::api::{InnerErrorCode, MeowError};
97 ///
98 /// let err = MeowError::from_code1(InnerErrorCode::ClientClosed);
99 /// assert_eq!(err.code(), InnerErrorCode::ClientClosed as i32);
100 /// ```
101 pub fn code(&self) -> i32 {
102 self.code
103 }
104
105 /// Returns the error message as a borrowed `&str`.
106 ///
107 /// Borrowing avoids an allocation on every call; callers that need an
108 /// owned `String` can do `err.msg().to_owned()` explicitly.
109 ///
110 /// # Examples
111 ///
112 /// ```no_run
113 /// use rusty_cat::api::{InnerErrorCode, MeowError};
114 ///
115 /// let err = MeowError::from_code_str(InnerErrorCode::InvalidRange, "bad range");
116 /// assert_eq!(err.msg(), "bad range");
117 /// ```
118 pub fn msg(&self) -> &str {
119 &self.msg
120 }
121
122 /// Creates an error from [`InnerErrorCode`] with empty message.
123 ///
124 /// # Examples
125 ///
126 /// ```no_run
127 /// use rusty_cat::api::{InnerErrorCode, MeowError};
128 ///
129 /// let err = MeowError::from_code1(InnerErrorCode::ParameterEmpty);
130 /// assert_eq!(err.code(), InnerErrorCode::ParameterEmpty as i32);
131 /// ```
132 pub fn from_code1(code: InnerErrorCode) -> Self {
133 crate::log::emit_lazy(|| {
134 crate::log::Log::debug("error", format!("MeowError::from_code1 code={:?}", code))
135 });
136 MeowError {
137 code: code as i32,
138 msg: String::new(),
139 source: None,
140 }
141 }
142
143 /// Creates an error from [`InnerErrorCode`] and message.
144 ///
145 /// # Examples
146 ///
147 /// ```no_run
148 /// use rusty_cat::api::{InnerErrorCode, MeowError};
149 ///
150 /// let err = MeowError::from_code(InnerErrorCode::EnqueueError, "enqueue failed".to_string());
151 /// assert_eq!(err.code(), InnerErrorCode::EnqueueError as i32);
152 /// ```
153 pub fn from_code(code: InnerErrorCode, msg: String) -> Self {
154 crate::log::emit_lazy(|| {
155 crate::log::Log::debug(
156 "error",
157 format!("MeowError::from_code code={:?} msg={}", code, msg),
158 )
159 });
160 MeowError {
161 code: code as i32,
162 msg,
163 source: None,
164 }
165 }
166
167 /// Creates an error from [`InnerErrorCode`] and `&str` message.
168 ///
169 /// # Examples
170 ///
171 /// ```no_run
172 /// use rusty_cat::api::{InnerErrorCode, MeowError};
173 ///
174 /// let err = MeowError::from_code_str(InnerErrorCode::TaskNotFound, "unknown id");
175 /// assert_eq!(err.code(), InnerErrorCode::TaskNotFound as i32);
176 /// ```
177 pub fn from_code_str(code: InnerErrorCode, msg: &str) -> Self {
178 crate::log::emit_lazy(|| {
179 crate::log::Log::debug(
180 "error",
181 format!("MeowError::from_code_str code={:?} msg={}", code, msg),
182 )
183 });
184 MeowError {
185 code: code as i32,
186 msg: msg.to_string(),
187 source: None,
188 }
189 }
190
191 /// Creates an error with source chaining.
192 ///
193 /// Use this helper to preserve original low-level errors.
194 ///
195 /// # Examples
196 ///
197 /// ```no_run
198 /// use rusty_cat::api::{InnerErrorCode, MeowError};
199 ///
200 /// let source = std::io::Error::other("disk error");
201 /// let err = MeowError::from_source(InnerErrorCode::IoError, "upload failed", source);
202 /// assert_eq!(err.code(), InnerErrorCode::IoError as i32);
203 /// ```
204 pub fn from_source<E>(code: InnerErrorCode, msg: impl Into<String>, source: E) -> Self
205 where
206 E: StdError + Send + Sync + 'static,
207 {
208 let msg = msg.into();
209 let source_preview = source.to_string();
210 crate::log::emit_lazy(|| {
211 crate::log::Log::debug(
212 "error",
213 format!(
214 "MeowError::from_source code={:?} msg={} source={}",
215 code, msg, source_preview
216 ),
217 )
218 });
219 MeowError {
220 code: code as i32,
221 msg,
222 source: Some(Arc::new(source)),
223 }
224 }
225
226 /// Creates an error from a local I/O error, automatically classifying
227 /// common failure modes into more specific codes:
228 ///
229 /// - out-of-space (`ENOSPC` on Unix / `ERROR_DISK_FULL` on Windows) maps to
230 /// [`InnerErrorCode::DiskFull`];
231 /// - a missing target (`std::io::ErrorKind::NotFound`) maps to
232 /// [`InnerErrorCode::LocalFileRemoved`], which is what surfaces when a
233 /// source/target file is deleted while a transfer is running;
234 /// - anything else falls back to [`InnerErrorCode::IoError`].
235 ///
236 /// The original [`std::io::Error`] is preserved in the error source chain so
237 /// callers can still inspect `raw_os_error()` / `kind()` if needed.
238 ///
239 /// # Examples
240 ///
241 /// ```no_run
242 /// use rusty_cat::api::{InnerErrorCode, MeowError};
243 ///
244 /// let not_found = std::io::Error::new(std::io::ErrorKind::NotFound, "gone");
245 /// let err = MeowError::from_io("download file missing", not_found);
246 /// assert_eq!(err.code(), InnerErrorCode::LocalFileRemoved as i32);
247 /// ```
248 pub fn from_io(msg: impl Into<String>, source: std::io::Error) -> Self {
249 let code = classify_io_error(&source);
250 Self::from_source(code, msg, source)
251 }
252}
253
254/// Classifies a local I/O error into the most specific SDK error code.
255///
256/// Used by [`MeowError::from_io`]; kept as a standalone function so the mapping
257/// can be unit-tested in isolation.
258pub(crate) fn classify_io_error(e: &std::io::Error) -> InnerErrorCode {
259 if is_disk_full(e) {
260 InnerErrorCode::DiskFull
261 } else if e.kind() == std::io::ErrorKind::NotFound {
262 InnerErrorCode::LocalFileRemoved
263 } else {
264 InnerErrorCode::IoError
265 }
266}
267
268/// Detects "no space left on device" across platforms via raw OS error codes.
269///
270/// `std::io::ErrorKind` does not expose a stable out-of-space variant across the
271/// toolchains this crate targets, so the raw OS error number is checked instead:
272/// `ENOSPC` (28) on Unix-like systems, and `ERROR_DISK_FULL` (112) /
273/// `ERROR_HANDLE_DISK_FULL` (39) on Windows.
274fn is_disk_full(e: &std::io::Error) -> bool {
275 if let Some(code) = e.raw_os_error() {
276 #[cfg(unix)]
277 if code == 28 {
278 return true;
279 }
280 #[cfg(windows)]
281 if code == 112 || code == 39 {
282 return true;
283 }
284 let _ = code;
285 }
286 false
287}
288
289impl PartialEq for MeowError {
290 fn eq(&self, other: &Self) -> bool {
291 self.code == other.code && self.msg == other.msg
292 }
293}
294
295impl Eq for MeowError {}
296
297impl Display for MeowError {
298 fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
299 if self.msg.is_empty() {
300 write!(f, "MeowError(code={})", self.code)
301 } else {
302 write!(f, "MeowError(code={}, msg={})", self.code, self.msg)
303 }
304 }
305}
306
307impl StdError for MeowError {
308 fn source(&self) -> Option<&(dyn StdError + 'static)> {
309 self.source
310 .as_deref()
311 .map(|e| e as &(dyn StdError + 'static))
312 }
313}
314
315#[cfg(test)]
316mod tests {
317 use super::{InnerErrorCode, MeowError};
318
319 #[test]
320 fn meow_error_display_contains_code_and_message() {
321 let err = MeowError::from_code_str(InnerErrorCode::InvalidRange, "bad range");
322 let s = format!("{err}");
323 assert!(s.contains("code="));
324 assert!(s.contains("bad range"));
325 }
326
327 #[test]
328 fn meow_error_source_is_accessible() {
329 let io = std::io::Error::other("disk io");
330 let err = MeowError::from_source(InnerErrorCode::IoError, "io failed", io);
331 assert!(std::error::Error::source(&err).is_some());
332 }
333
334 #[test]
335 fn from_io_classifies_not_found_as_local_file_removed() {
336 let not_found = std::io::Error::new(std::io::ErrorKind::NotFound, "gone");
337 let err = MeowError::from_io("target file missing", not_found);
338 assert_eq!(err.code(), InnerErrorCode::LocalFileRemoved as i32);
339 // Original io error is preserved for callers that want to inspect it.
340 assert!(std::error::Error::source(&err).is_some());
341 }
342
343 #[test]
344 fn from_io_classifies_generic_error_as_io_error() {
345 let denied = std::io::Error::new(std::io::ErrorKind::PermissionDenied, "denied");
346 let err = MeowError::from_io("write failed", denied);
347 assert_eq!(err.code(), InnerErrorCode::IoError as i32);
348 }
349
350 #[cfg(any(unix, windows))]
351 #[test]
352 fn from_io_classifies_out_of_space_as_disk_full() {
353 // ENOSPC on Unix, ERROR_DISK_FULL on Windows.
354 #[cfg(unix)]
355 let full = std::io::Error::from_raw_os_error(28);
356 #[cfg(windows)]
357 let full = std::io::Error::from_raw_os_error(112);
358
359 let err = MeowError::from_io("write download file failed", full);
360 assert_eq!(err.code(), InnerErrorCode::DiskFull as i32);
361 }
362
363 #[cfg(windows)]
364 #[test]
365 fn from_io_classifies_handle_disk_full_as_disk_full() {
366 // ERROR_HANDLE_DISK_FULL (39) is the other Windows out-of-space code.
367 let full = std::io::Error::from_raw_os_error(39);
368 let err = MeowError::from_io("write download file failed", full);
369 assert_eq!(err.code(), InnerErrorCode::DiskFull as i32);
370 }
371}