Skip to main content

netspeed_cli/
error.rs

1use thiserror::Error;
2
3/// Error category for machine-readable error classification.
4#[derive(Debug, Clone, Copy, PartialEq, Eq)]
5pub enum ErrorCategory {
6    /// Network-related errors (connectivity, timeouts, etc.)
7    Network,
8    /// Configuration errors (invalid settings, missing files, etc.)
9    Config,
10    /// Parse errors (invalid JSON, XML, CSV, etc.)
11    Parse,
12    /// Output errors (file writing, formatting, etc.)
13    Output,
14    /// Internal errors (bugs, unexpected states, etc.)
15    Internal,
16}
17
18/// Unified error type for netspeed-cli operations.
19///
20/// This enum preserves the original error cause chains by storing
21/// the underlying errors directly, enabling better debugging and
22/// error reporting via the `std::error::Error::source()` method.
23#[derive(Debug, Error)]
24pub enum Error {
25    /// Network-related errors from HTTP requests
26    #[error("Network error: {0}")]
27    NetworkError(#[from] reqwest::Error),
28
29    /// Failed to fetch the server list from speedtest.net
30    #[error("Failed to fetch server list: {0}")]
31    ServerListFetch(#[source] reqwest::Error),
32
33    /// Failed during download bandwidth test
34    #[error("Download test failed: {0}")]
35    DownloadTest(#[source] reqwest::Error),
36
37    /// Failed during upload bandwidth test
38    #[error("Upload test failed: {0}")]
39    UploadTest(#[source] reqwest::Error),
40
41    /// Download test failed for a non-HTTP reason
42    #[error("Download test failed: {0}")]
43    DownloadFailure(String),
44
45    /// Upload test failed for a non-HTTP reason
46    #[error("Upload test failed: {0}")]
47    UploadFailure(String),
48
49    /// Failed to discover client IP address
50    #[error("Failed to discover client IP: {0}")]
51    IpDiscovery(#[source] reqwest::Error),
52
53    /// XML parsing errors
54    #[error("XML parse error: {0}")]
55    ParseXml(#[from] quick_xml::Error),
56
57    /// JSON parsing/serialization errors
58    #[error("JSON parse error: {0}")]
59    ParseJson(#[from] serde_json::Error),
60
61    /// XML deserialization errors
62    #[error("XML deserialization error: {0}")]
63    DeserializeXml(#[from] quick_xml::de::DeError),
64
65    /// CSV parsing/serialization errors
66    #[error("CSV error: {0}")]
67    Csv(#[from] csv::Error),
68
69    /// Server selection errors
70    #[error("Server not found: {0}")]
71    ServerNotFound(String),
72
73    /// I/O errors from file operations
74    #[error("I/O error: {0}")]
75    IoError(#[from] std::io::Error),
76
77    /// Application-specific errors with context
78    #[error("{msg}")]
79    Context {
80        msg: String,
81        source: Option<Box<dyn std::error::Error + Send + Sync>>,
82    },
83}
84
85impl Error {
86    /// Create a contextual error with an optional source error.
87    #[must_use]
88    pub fn context(msg: impl Into<String>) -> Self {
89        Self::Context {
90            msg: msg.into(),
91            source: None,
92        }
93    }
94
95    /// Create a contextual error with a source error chain.
96    #[must_use]
97    pub fn with_source(
98        msg: impl Into<String>,
99        source: impl std::error::Error + Send + Sync + 'static,
100    ) -> Self {
101        Self::Context {
102            msg: msg.into(),
103            source: Some(Box::new(source)),
104        }
105    }
106
107    /// Get the category for this error.
108    #[must_use]
109    pub fn category(&self) -> ErrorCategory {
110        match self {
111            Error::NetworkError(_) => ErrorCategory::Network,
112            Error::ServerListFetch(_) => ErrorCategory::Network,
113            Error::DownloadTest(_) => ErrorCategory::Network,
114            Error::DownloadFailure(_) => ErrorCategory::Network,
115            Error::UploadTest(_) => ErrorCategory::Network,
116            Error::UploadFailure(_) => ErrorCategory::Network,
117            Error::IpDiscovery(_) => ErrorCategory::Network,
118            Error::ParseJson(_) => ErrorCategory::Parse,
119            Error::ParseXml(_) => ErrorCategory::Parse,
120            Error::DeserializeXml(_) => ErrorCategory::Parse,
121            Error::Csv(_) => ErrorCategory::Output,
122            Error::ServerNotFound(_) => ErrorCategory::Config,
123            Error::IoError(_) => ErrorCategory::Output,
124            Error::Context { .. } => ErrorCategory::Internal,
125        }
126    }
127}
128
129#[cfg(test)]
130mod tests {
131    use super::*;
132    use std::error::Error as _;
133
134    #[test]
135    fn test_network_error_display() {
136        // Test display via context method since we can't easily create reqwest::Error
137        let err = Error::context("connection failed");
138        assert_eq!(format!("{err}"), "connection failed");
139    }
140
141    #[test]
142    fn test_json_error_display() {
143        let invalid_json = "{invalid}";
144        let result: Result<serde_json::Value, _> = serde_json::from_str(invalid_json);
145        assert!(result.is_err());
146        let err = Error::from(result.unwrap_err());
147        assert!(format!("{err}").contains("JSON parse error"));
148    }
149
150    #[test]
151    fn test_server_not_found_display() {
152        let err = Error::ServerNotFound("no servers".to_string());
153        assert_eq!(format!("{err}"), "Server not found: no servers");
154    }
155
156    #[test]
157    fn test_io_error_display() {
158        let io_err = std::io::Error::new(std::io::ErrorKind::NotFound, "file not found");
159        let speedtest_err = Error::from(io_err);
160        assert!(format!("{speedtest_err}").contains("I/O error"));
161    }
162
163    #[test]
164    fn test_context_error_display() {
165        let err = Error::context("custom error");
166        assert_eq!(format!("{err}"), "custom error");
167    }
168
169    #[test]
170    fn test_context_with_source() {
171        let io_err = std::io::Error::new(std::io::ErrorKind::NotFound, "file not found");
172        let err = Error::with_source("Failed to read config", io_err);
173        assert_eq!(format!("{err}"), "Failed to read config");
174        assert!(err.source().is_some());
175    }
176
177    #[test]
178    fn test_from_io_error() {
179        let io_err = std::io::Error::new(std::io::ErrorKind::NotFound, "file not found");
180        let speedtest_err: Error = io_err.into();
181        assert!(matches!(speedtest_err, Error::IoError(_)));
182        assert!(format!("{speedtest_err}").contains("I/O error"));
183    }
184
185    #[test]
186    fn test_error_trait_implementation() {
187        let err = Error::context("test error");
188        // Test that Error trait is implemented
189        let _: &dyn std::error::Error = &err;
190    }
191
192    #[test]
193    fn test_debug_trait() {
194        let err = Error::context("debug test");
195        let debug_str = format!("{err:?}");
196        assert!(debug_str.contains("Context"));
197        assert!(debug_str.contains("debug test"));
198    }
199
200    #[test]
201    fn test_from_serde_json_error() {
202        let invalid_json = "{invalid}";
203        let result: Result<serde_json::Value, _> = serde_json::from_str(invalid_json);
204        assert!(result.is_err());
205        let err: Error = result.unwrap_err().into();
206        assert!(matches!(err, Error::ParseJson(_)));
207    }
208
209    #[test]
210    fn test_from_quick_xml_de_error() {
211        let invalid_xml = "<unclosed>";
212        let result: Result<serde_json::Value, _> = quick_xml::de::from_str(invalid_xml);
213        assert!(result.is_err());
214        let err: Error = result.unwrap_err().into();
215        assert!(matches!(err, Error::DeserializeXml(_)));
216    }
217
218    #[test]
219    fn test_from_csv_error_direct() {
220        let data = b"a,b\n1,2,3";
221        let mut reader = csv::ReaderBuilder::new()
222            .has_headers(true)
223            .flexible(false)
224            .from_reader(&data[..]);
225        for result in reader.records() {
226            if let Err(e) = result {
227                let err: Error = e.into();
228                assert!(matches!(err, Error::Csv(_)));
229                return;
230            }
231        }
232        panic!("Expected CSV parse error");
233    }
234
235    #[test]
236    fn test_error_source_chain() {
237        let io_err = std::io::Error::new(std::io::ErrorKind::NotFound, "file not found");
238        let err = Error::with_source("Failed to load history", io_err);
239
240        // Verify source chain is preserved
241        assert!(matches!(err, Error::Context { .. }));
242        let source = err.source();
243        assert!(source.is_some());
244
245        // Verify it's an io::Error
246        let source = source.unwrap();
247        assert!(source.is::<std::io::Error>());
248    }
249
250    #[test]
251    fn test_context_without_source() {
252        let err = Error::context("standalone error");
253        assert!(matches!(err, Error::Context { source: None, .. }));
254        assert!(err.source().is_none());
255    }
256}