gosh_dl/
error.rs

1//! Typed error hierarchy for gosh-dl
2//!
3//! Every error type includes context about what went wrong and whether
4//! the operation can be retried.
5
6use std::path::PathBuf;
7use thiserror::Error;
8
9/// Main error type for the download engine
10#[derive(Debug, Error)]
11pub enum EngineError {
12    /// Network-related errors (connection, timeout, DNS, etc.)
13    #[error("Network error: {message}")]
14    Network {
15        kind: NetworkErrorKind,
16        message: String,
17        retryable: bool,
18    },
19
20    /// Storage/filesystem errors
21    #[error("Storage error at {path:?}: {message}")]
22    Storage {
23        kind: StorageErrorKind,
24        path: PathBuf,
25        message: String,
26    },
27
28    /// Protocol-level errors (HTTP, BitTorrent)
29    #[error("Protocol error: {message}")]
30    Protocol {
31        kind: ProtocolErrorKind,
32        message: String,
33    },
34
35    /// Invalid input from user
36    #[error("Invalid input for '{field}': {message}")]
37    InvalidInput {
38        field: &'static str,
39        message: String,
40    },
41
42    /// Resource limits exceeded
43    #[error("Resource limit exceeded: {resource} (limit: {limit})")]
44    ResourceLimit {
45        resource: &'static str,
46        limit: usize,
47    },
48
49    /// Download not found
50    #[error("Download not found: {0}")]
51    NotFound(String),
52
53    /// Download already exists
54    #[error("Download already exists: {0}")]
55    AlreadyExists(String),
56
57    /// Invalid state transition
58    #[error("Invalid state: cannot {action} while {current_state}")]
59    InvalidState {
60        action: &'static str,
61        current_state: String,
62    },
63
64    /// Engine is shutting down
65    #[error("Engine is shutting down")]
66    Shutdown,
67
68    /// Database error
69    #[error("Database error: {0}")]
70    Database(String),
71
72    /// Internal error (bug)
73    #[error("Internal error: {0}")]
74    Internal(String),
75}
76
77/// Network error subtypes
78#[derive(Debug, Clone, Copy, PartialEq, Eq)]
79pub enum NetworkErrorKind {
80    /// DNS resolution failed
81    DnsResolution,
82    /// Connection refused
83    ConnectionRefused,
84    /// Connection reset
85    ConnectionReset,
86    /// Connection timeout
87    Timeout,
88    /// TLS/SSL error
89    Tls,
90    /// Server returned error status
91    HttpStatus(u16),
92    /// Server not reachable
93    Unreachable,
94    /// Too many redirects
95    TooManyRedirects,
96    /// Other network error
97    Other,
98}
99
100/// Storage error subtypes
101#[derive(Debug, Clone, Copy, PartialEq, Eq)]
102pub enum StorageErrorKind {
103    /// File/directory not found
104    NotFound,
105    /// Permission denied
106    PermissionDenied,
107    /// Disk full
108    DiskFull,
109    /// Path is outside allowed directory (security)
110    PathTraversal,
111    /// File already exists
112    AlreadyExists,
113    /// Invalid path
114    InvalidPath,
115    /// I/O error
116    Io,
117}
118
119/// Protocol error subtypes
120#[derive(Debug, Clone, Copy, PartialEq, Eq)]
121pub enum ProtocolErrorKind {
122    /// Invalid URL
123    InvalidUrl,
124    /// Server doesn't support range requests
125    RangeNotSupported,
126    /// Invalid HTTP response
127    InvalidResponse,
128    /// Invalid torrent file
129    InvalidTorrent,
130    /// Invalid magnet URI
131    InvalidMagnet,
132    /// Piece hash verification failed
133    HashMismatch,
134    /// Tracker error
135    TrackerError,
136    /// Peer protocol violation
137    PeerProtocol,
138    /// Bencode parsing error
139    BencodeParse,
140    /// Peer Exchange (PEX) error
141    PexError,
142    /// DHT error
143    DhtError,
144    /// Local Peer Discovery error
145    LpdError,
146    /// Metadata fetch error (BEP 9)
147    MetadataError,
148}
149
150impl EngineError {
151    /// Check if this error is retryable
152    pub fn is_retryable(&self) -> bool {
153        match self {
154            Self::Network { retryable, .. } => *retryable,
155            Self::Storage { kind, .. } => matches!(kind, StorageErrorKind::Io),
156            Self::Protocol { kind, .. } => matches!(
157                kind,
158                ProtocolErrorKind::TrackerError | ProtocolErrorKind::PeerProtocol
159            ),
160            _ => false,
161        }
162    }
163
164    /// Create a network error
165    pub fn network(kind: NetworkErrorKind, message: impl Into<String>) -> Self {
166        let retryable = matches!(
167            kind,
168            NetworkErrorKind::Timeout
169                | NetworkErrorKind::ConnectionReset
170                | NetworkErrorKind::Unreachable
171        );
172        Self::Network {
173            kind,
174            message: message.into(),
175            retryable,
176        }
177    }
178
179    /// Create a storage error
180    pub fn storage(
181        kind: StorageErrorKind,
182        path: impl Into<PathBuf>,
183        message: impl Into<String>,
184    ) -> Self {
185        Self::Storage {
186            kind,
187            path: path.into(),
188            message: message.into(),
189        }
190    }
191
192    /// Create a protocol error
193    pub fn protocol(kind: ProtocolErrorKind, message: impl Into<String>) -> Self {
194        Self::Protocol {
195            kind,
196            message: message.into(),
197        }
198    }
199
200    /// Create an invalid input error
201    pub fn invalid_input(field: &'static str, message: impl Into<String>) -> Self {
202        Self::InvalidInput {
203            field,
204            message: message.into(),
205        }
206    }
207}
208
209/// Result type alias for engine operations
210pub type Result<T> = std::result::Result<T, EngineError>;
211
212// Implement From traits for common error types
213
214impl From<std::io::Error> for EngineError {
215    fn from(err: std::io::Error) -> Self {
216        use std::io::ErrorKind;
217        let kind = match err.kind() {
218            ErrorKind::NotFound => StorageErrorKind::NotFound,
219            ErrorKind::PermissionDenied => StorageErrorKind::PermissionDenied,
220            ErrorKind::AlreadyExists => StorageErrorKind::AlreadyExists,
221            _ => StorageErrorKind::Io,
222        };
223        Self::Storage {
224            kind,
225            path: PathBuf::new(),
226            message: err.to_string(),
227        }
228    }
229}
230
231impl From<reqwest::Error> for EngineError {
232    fn from(err: reqwest::Error) -> Self {
233        let kind = if err.is_timeout() {
234            NetworkErrorKind::Timeout
235        } else if err.is_connect() {
236            NetworkErrorKind::ConnectionRefused
237        } else if err.is_redirect() {
238            NetworkErrorKind::TooManyRedirects
239        } else if let Some(status) = err.status() {
240            NetworkErrorKind::HttpStatus(status.as_u16())
241        } else {
242            NetworkErrorKind::Other
243        };
244
245        let retryable = matches!(
246            kind,
247            NetworkErrorKind::Timeout | NetworkErrorKind::ConnectionRefused
248        );
249
250        Self::Network {
251            kind,
252            message: err.to_string(),
253            retryable,
254        }
255    }
256}
257
258impl From<url::ParseError> for EngineError {
259    fn from(err: url::ParseError) -> Self {
260        Self::Protocol {
261            kind: ProtocolErrorKind::InvalidUrl,
262            message: err.to_string(),
263        }
264    }
265}
266
267impl From<rusqlite::Error> for EngineError {
268    fn from(err: rusqlite::Error) -> Self {
269        Self::Database(err.to_string())
270    }
271}
272
273impl From<serde_json::Error> for EngineError {
274    fn from(err: serde_json::Error) -> Self {
275        Self::Internal(format!("JSON error: {}", err))
276    }
277}
278
279impl From<tokio::sync::broadcast::error::SendError<crate::protocol::DownloadEvent>>
280    for EngineError
281{
282    fn from(_: tokio::sync::broadcast::error::SendError<crate::protocol::DownloadEvent>) -> Self {
283        Self::Shutdown
284    }
285}
286
287// Conversion from EngineError to ProtocolError for the public API boundary
288impl From<EngineError> for crate::protocol::ProtocolError {
289    fn from(e: EngineError) -> Self {
290        use crate::protocol::ProtocolError;
291        match e {
292            EngineError::NotFound(id) => ProtocolError::NotFound { id },
293            EngineError::InvalidState {
294                action,
295                current_state,
296            } => ProtocolError::InvalidState {
297                action: action.to_string(),
298                current_state,
299            },
300            EngineError::InvalidInput { field, message } => ProtocolError::InvalidInput {
301                field: field.to_string(),
302                message,
303            },
304            EngineError::Network {
305                message, retryable, ..
306            } => ProtocolError::Network { message, retryable },
307            EngineError::Storage { message, .. } => ProtocolError::Storage { message },
308            EngineError::Protocol { message, .. } => ProtocolError::Network {
309                message,
310                retryable: false,
311            },
312            EngineError::Shutdown => ProtocolError::Shutdown,
313            EngineError::AlreadyExists(id) => ProtocolError::InvalidInput {
314                field: "id".to_string(),
315                message: format!("Download already exists: {}", id),
316            },
317            EngineError::ResourceLimit { resource, limit } => ProtocolError::InvalidInput {
318                field: resource.to_string(),
319                message: format!("Resource limit exceeded (limit: {})", limit),
320            },
321            EngineError::Database(msg) => ProtocolError::Storage { message: msg },
322            EngineError::Internal(msg) => ProtocolError::Internal { message: msg },
323        }
324    }
325}