Skip to main content

gglib_core/download/
errors.rs

1//! Download error types.
2//!
3//! These errors are designed to be serializable and not depend on external
4//! error types like `std::io::Error`. For I/O errors, we capture the kind
5//! and message as strings.
6
7use serde::{Deserialize, Serialize};
8use thiserror::Error;
9
10/// Error type for download operations.
11///
12/// Designed to be serializable across FFI boundaries (Tauri, CLI, etc.)
13/// without depending on non-serializable types like `std::io::Error`.
14#[derive(Clone, Debug, Error, Serialize, Deserialize, PartialEq, Eq)]
15pub enum DownloadError {
16    /// I/O error during file operations.
17    #[error("I/O error ({kind}): {message}")]
18    Io {
19        /// The kind of I/O error (e.g., "not found", "permission denied").
20        kind: String,
21        /// Detailed error message.
22        message: String,
23    },
24
25    /// Network/HTTP error during download.
26    #[error("Network error: {message}")]
27    Network {
28        /// Detailed error message.
29        message: String,
30        /// HTTP status code if available.
31        #[serde(skip_serializing_if = "Option::is_none")]
32        status_code: Option<u16>,
33    },
34
35    /// Model or file not found on the remote server.
36    #[error("Not found: {message}")]
37    NotFound {
38        /// What was not found (model ID, file, etc.).
39        message: String,
40    },
41
42    /// Invalid quantization specified.
43    #[error("Invalid quantization: {value}")]
44    InvalidQuantization {
45        /// The invalid quantization string.
46        value: String,
47    },
48
49    /// Failed to resolve quantization to a file.
50    #[error("Resolution failed: {message}")]
51    ResolutionFailed {
52        /// Detailed error message.
53        message: String,
54    },
55
56    /// Queue is full, cannot add more downloads.
57    #[error("Queue full: maximum {max_size} downloads allowed")]
58    QueueFull {
59        /// Maximum queue capacity.
60        max_size: u32,
61    },
62
63    /// Download is already queued.
64    #[error("Already queued: {id}")]
65    AlreadyQueued {
66        /// The download ID that's already in the queue.
67        id: String,
68    },
69
70    /// Download not found in queue.
71    #[error("Not in queue: {id}")]
72    NotInQueue {
73        /// The download ID that wasn't found.
74        id: String,
75    },
76
77    /// Download was cancelled by user.
78    #[error("Download cancelled")]
79    Cancelled,
80
81    /// Download was interrupted and can be resumed.
82    #[error("Download interrupted at {bytes_downloaded} bytes")]
83    Interrupted {
84        /// Bytes downloaded before interruption.
85        bytes_downloaded: u64,
86    },
87
88    /// Integrity check failed (checksum mismatch).
89    #[error("Integrity check failed: expected {expected}, got {actual}")]
90    IntegrityFailed {
91        /// Expected checksum.
92        expected: String,
93        /// Actual checksum computed.
94        actual: String,
95    },
96
97    /// General/uncategorized error.
98    #[error("{message}")]
99    Other {
100        /// Error message.
101        message: String,
102    },
103}
104
105impl DownloadError {
106    /// Create an I/O error from kind and message strings.
107    pub fn io(kind: impl Into<String>, message: impl Into<String>) -> Self {
108        Self::Io {
109            kind: kind.into(),
110            message: message.into(),
111        }
112    }
113
114    /// Create a network error.
115    pub fn network(message: impl Into<String>) -> Self {
116        Self::Network {
117            message: message.into(),
118            status_code: None,
119        }
120    }
121
122    /// Create a network error with HTTP status code.
123    pub fn network_with_status(message: impl Into<String>, status_code: u16) -> Self {
124        Self::Network {
125            message: message.into(),
126            status_code: Some(status_code),
127        }
128    }
129
130    /// Create a not found error.
131    pub fn not_found(message: impl Into<String>) -> Self {
132        Self::NotFound {
133            message: message.into(),
134        }
135    }
136
137    /// Create a resolution failed error.
138    pub fn resolution_failed(message: impl Into<String>) -> Self {
139        Self::ResolutionFailed {
140            message: message.into(),
141        }
142    }
143
144    /// Create a queue full error.
145    #[must_use]
146    pub const fn queue_full(max_size: u32) -> Self {
147        Self::QueueFull { max_size }
148    }
149
150    /// Create an already queued error.
151    pub fn already_queued(id: impl Into<String>) -> Self {
152        Self::AlreadyQueued { id: id.into() }
153    }
154
155    /// Create a not in queue error.
156    pub fn not_in_queue(id: impl Into<String>) -> Self {
157        Self::NotInQueue { id: id.into() }
158    }
159
160    /// Create an integrity check failed error.
161    pub fn integrity_failed(expected: impl Into<String>, actual: impl Into<String>) -> Self {
162        Self::IntegrityFailed {
163            expected: expected.into(),
164            actual: actual.into(),
165        }
166    }
167
168    /// Create a generic error.
169    pub fn other(message: impl Into<String>) -> Self {
170        Self::Other {
171            message: message.into(),
172        }
173    }
174
175    /// Check if this error is recoverable (can retry).
176    #[must_use]
177    pub const fn is_recoverable(&self) -> bool {
178        matches!(
179            self,
180            Self::Network { .. } | Self::Interrupted { .. } | Self::Io { .. }
181        )
182    }
183
184    /// Check if this is a cancellation.
185    #[must_use]
186    pub const fn is_cancelled(&self) -> bool {
187        matches!(self, Self::Cancelled)
188    }
189
190    /// Convert to a user-friendly message.
191    #[must_use]
192    pub fn user_message(&self) -> String {
193        match self {
194            Self::Io { message, .. } => format!("File operation failed: {message}"),
195            Self::Network {
196                message,
197                status_code: Some(code),
198            } => {
199                format!("Network error (HTTP {code}): {message}")
200            }
201            Self::Network { message, .. } => format!("Network error: {message}"),
202            Self::NotFound { message } => format!("Not found: {message}"),
203            Self::InvalidQuantization { value } => {
204                format!("Invalid quantization '{value}'. Use values like `Q4_K_M`, `Q5_K_S`, etc.")
205            }
206            Self::ResolutionFailed { message } => format!("Could not resolve file: {message}"),
207            Self::QueueFull { max_size } => {
208                format!(
209                    "Download queue is full (max {max_size} items). Wait for a download to complete."
210                )
211            }
212            Self::AlreadyQueued { id } => {
213                format!("Download '{id}' is already in the queue.")
214            }
215            Self::NotInQueue { id } => {
216                format!("Download '{id}' is not in the queue.")
217            }
218            Self::Cancelled => "Download was cancelled.".to_string(),
219            Self::Interrupted { bytes_downloaded } => {
220                format!("Download interrupted after {bytes_downloaded} bytes. You can resume it.")
221            }
222            Self::IntegrityFailed { .. } => {
223                "File integrity check failed. The download may be corrupted.".to_string()
224            }
225            Self::Other { message } => message.clone(),
226        }
227    }
228}
229
230#[cfg(test)]
231mod tests {
232    use super::*;
233
234    #[test]
235    fn test_error_serialization() {
236        let err = DownloadError::network_with_status("timeout", 408);
237        let json = serde_json::to_string(&err).unwrap();
238        assert!(json.contains("408"));
239        assert!(json.contains("timeout"));
240
241        let parsed: DownloadError = serde_json::from_str(&json).unwrap();
242        assert_eq!(parsed, err);
243    }
244
245    #[test]
246    fn test_is_recoverable() {
247        assert!(DownloadError::network("timeout").is_recoverable());
248        assert!(
249            DownloadError::Interrupted {
250                bytes_downloaded: 100
251            }
252            .is_recoverable()
253        );
254        assert!(!DownloadError::Cancelled.is_recoverable());
255        assert!(
256            !DownloadError::InvalidQuantization {
257                value: "bad".to_string(),
258            }
259            .is_recoverable()
260        );
261    }
262
263    #[test]
264    fn test_user_messages() {
265        let err = DownloadError::queue_full(5);
266        assert!(err.user_message().contains('5'));
267        assert!(err.user_message().contains("full"));
268    }
269}