Skip to main content

netspeed_cli/
error.rs

1use thiserror::Error;
2
3/// Unified error type for netspeed-cli operations.
4///
5/// This enum preserves the original error cause chains by storing
6/// the underlying errors directly, enabling better debugging and
7/// error reporting via the `std::error::Error::source()` method.
8#[derive(Debug, Error)]
9pub enum SpeedtestError {
10    /// Network-related errors from HTTP requests
11    #[error("Network error: {0}")]
12    NetworkError(#[from] reqwest::Error),
13
14    /// XML parsing errors
15    #[error("XML parse error: {0}")]
16    ParseXml(#[from] quick_xml::Error),
17
18    /// JSON parsing/serialization errors
19    #[error("JSON parse error: {0}")]
20    ParseJson(#[from] serde_json::Error),
21
22    /// XML deserialization errors
23    #[error("XML deserialization error: {0}")]
24    DeserializeXml(#[from] quick_xml::de::DeError),
25
26    /// CSV parsing/serialization errors
27    #[error("CSV error: {0}")]
28    Csv(#[from] csv::Error),
29
30    /// Server selection errors
31    #[error("Server not found: {0}")]
32    ServerNotFound(String),
33
34    /// I/O errors from file operations
35    #[error("I/O error: {0}")]
36    IoError(#[from] std::io::Error),
37
38    /// Application-specific errors with context
39    #[error("{msg}")]
40    Context {
41        msg: String,
42        source: Option<Box<dyn std::error::Error + Send + Sync>>,
43    },
44}
45
46impl SpeedtestError {
47    /// Create a contextual error with an optional source error.
48    #[must_use]
49    pub fn context(msg: impl Into<String>) -> Self {
50        Self::Context {
51            msg: msg.into(),
52            source: None,
53        }
54    }
55
56    /// Create a contextual error with a source error chain.
57    #[must_use]
58    pub fn with_source(
59        msg: impl Into<String>,
60        source: impl std::error::Error + Send + Sync + 'static,
61    ) -> Self {
62        Self::Context {
63            msg: msg.into(),
64            source: Some(Box::new(source)),
65        }
66    }
67}
68
69#[cfg(test)]
70mod tests {
71    use super::*;
72    use std::error::Error;
73
74    #[test]
75    fn test_network_error_display() {
76        // Test display via context method since we can't easily create reqwest::Error
77        let err = SpeedtestError::context("connection failed");
78        assert_eq!(format!("{err}"), "connection failed");
79    }
80
81    #[test]
82    fn test_json_error_display() {
83        let invalid_json = "{invalid}";
84        let result: Result<serde_json::Value, _> = serde_json::from_str(invalid_json);
85        assert!(result.is_err());
86        let err = SpeedtestError::from(result.unwrap_err());
87        assert!(format!("{err}").contains("JSON parse error"));
88    }
89
90    #[test]
91    fn test_server_not_found_display() {
92        let err = SpeedtestError::ServerNotFound("no servers".to_string());
93        assert_eq!(format!("{err}"), "Server not found: no servers");
94    }
95
96    #[test]
97    fn test_io_error_display() {
98        let io_err = std::io::Error::new(std::io::ErrorKind::NotFound, "file not found");
99        let speedtest_err = SpeedtestError::from(io_err);
100        assert!(format!("{speedtest_err}").contains("I/O error"));
101    }
102
103    #[test]
104    fn test_context_error_display() {
105        let err = SpeedtestError::context("custom error");
106        assert_eq!(format!("{err}"), "custom error");
107    }
108
109    #[test]
110    fn test_context_with_source() {
111        let io_err = std::io::Error::new(std::io::ErrorKind::NotFound, "file not found");
112        let err = SpeedtestError::with_source("Failed to read config", io_err);
113        assert_eq!(format!("{err}"), "Failed to read config");
114        assert!(err.source().is_some());
115    }
116
117    #[test]
118    fn test_from_io_error() {
119        let io_err = std::io::Error::new(std::io::ErrorKind::NotFound, "file not found");
120        let speedtest_err: SpeedtestError = io_err.into();
121        assert!(matches!(speedtest_err, SpeedtestError::IoError(_)));
122        assert!(format!("{speedtest_err}").contains("I/O error"));
123    }
124
125    #[test]
126    fn test_error_trait_implementation() {
127        let err = SpeedtestError::context("test error");
128        // Test that Error trait is implemented
129        let _: &dyn std::error::Error = &err;
130    }
131
132    #[test]
133    fn test_debug_trait() {
134        let err = SpeedtestError::context("debug test");
135        let debug_str = format!("{err:?}");
136        assert!(debug_str.contains("Context"));
137        assert!(debug_str.contains("debug test"));
138    }
139
140    #[test]
141    fn test_from_serde_json_error() {
142        let invalid_json = "{invalid}";
143        let result: Result<serde_json::Value, _> = serde_json::from_str(invalid_json);
144        assert!(result.is_err());
145        let err: SpeedtestError = result.unwrap_err().into();
146        assert!(matches!(err, SpeedtestError::ParseJson(_)));
147    }
148
149    #[test]
150    fn test_from_quick_xml_de_error() {
151        let invalid_xml = "<unclosed>";
152        let result: Result<serde_json::Value, _> = quick_xml::de::from_str(invalid_xml);
153        assert!(result.is_err());
154        let err: SpeedtestError = result.unwrap_err().into();
155        assert!(matches!(err, SpeedtestError::DeserializeXml(_)));
156    }
157
158    #[test]
159    fn test_from_csv_error_direct() {
160        let data = b"a,b\n1,2,3";
161        let mut reader = csv::ReaderBuilder::new()
162            .has_headers(true)
163            .flexible(false)
164            .from_reader(&data[..]);
165        for result in reader.records() {
166            if let Err(e) = result {
167                let err: SpeedtestError = e.into();
168                assert!(matches!(err, SpeedtestError::Csv(_)));
169                return;
170            }
171        }
172        panic!("Expected CSV parse error");
173    }
174
175    #[test]
176    fn test_error_source_chain() {
177        let io_err = std::io::Error::new(std::io::ErrorKind::NotFound, "file not found");
178        let err = SpeedtestError::with_source("Failed to load history", io_err);
179
180        // Verify source chain is preserved
181        assert!(matches!(err, SpeedtestError::Context { .. }));
182        let source = err.source();
183        assert!(source.is_some());
184
185        // Verify it's an io::Error
186        let source = source.unwrap();
187        assert!(source.is::<std::io::Error>());
188    }
189
190    #[test]
191    fn test_context_without_source() {
192        let err = SpeedtestError::context("standalone error");
193        assert!(matches!(err, SpeedtestError::Context { source: None, .. }));
194        assert!(err.source().is_none());
195    }
196}