Skip to main content

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}