Skip to main content

native_theme/
error.rs

1// Error enum with Display, std::error::Error, and From conversions
2
3/// Error returned when theme resolution finds missing fields.
4///
5/// Contains a list of field paths (e.g., `"defaults.accent"`, `"button.font.family"`)
6/// that were still `None` after resolution. Used by `validate()` to report exactly
7/// which fields need to be supplied before a [`crate::model::ResolvedThemeVariant`] can be built.
8#[derive(Debug, Clone)]
9pub struct ThemeResolutionError {
10    /// Dot-separated paths of fields that remained `None` after resolution.
11    pub missing_fields: Vec<String>,
12}
13
14impl std::fmt::Display for ThemeResolutionError {
15    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
16        write!(
17            f,
18            "theme resolution failed: {} missing field(s):",
19            self.missing_fields.len()
20        )?;
21        for field in &self.missing_fields {
22            write!(f, "\n  - {field}")?;
23        }
24        Ok(())
25    }
26}
27
28impl std::error::Error for ThemeResolutionError {}
29
30/// Errors that can occur when reading or processing theme data.
31#[derive(Debug)]
32#[non_exhaustive]
33pub enum Error {
34    /// Operation not supported on the current platform.
35    Unsupported(&'static str),
36
37    /// Data source exists but cannot be read right now.
38    Unavailable(String),
39
40    /// TOML parsing or serialization error.
41    Format(String),
42
43    /// Wrapped platform-specific error.
44    Platform(Box<dyn std::error::Error + Send + Sync>),
45
46    /// File I/O error (preserves the original `std::io::Error`).
47    Io(std::io::Error),
48
49    /// Theme resolution/validation found missing fields.
50    Resolution(ThemeResolutionError),
51}
52
53impl std::fmt::Display for Error {
54    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
55        match self {
56            Error::Unsupported(reason) => write!(f, "not supported: {reason}"),
57            Error::Unavailable(msg) => write!(f, "theme data unavailable: {msg}"),
58            Error::Format(msg) => write!(f, "theme format error: {msg}"),
59            Error::Platform(err) => write!(f, "platform error: {err}"),
60            Error::Io(err) => write!(f, "I/O error: {err}"),
61            Error::Resolution(e) => write!(f, "{e}"),
62        }
63    }
64}
65
66impl std::error::Error for Error {
67    fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
68        match self {
69            Error::Platform(err) => Some(&**err),
70            Error::Io(err) => Some(err),
71            Error::Resolution(e) => Some(e),
72            _ => None,
73        }
74    }
75}
76
77impl From<toml::de::Error> for Error {
78    fn from(err: toml::de::Error) -> Self {
79        Error::Format(err.to_string())
80    }
81}
82
83impl From<toml::ser::Error> for Error {
84    fn from(err: toml::ser::Error) -> Self {
85        Error::Format(err.to_string())
86    }
87}
88
89impl From<std::io::Error> for Error {
90    fn from(err: std::io::Error) -> Self {
91        Error::Io(err)
92    }
93}
94
95#[cfg(test)]
96#[allow(clippy::unwrap_used, clippy::expect_used)]
97mod tests {
98    use super::*;
99
100    #[test]
101    fn unsupported_display() {
102        let err = Error::Unsupported("KDE theme detection requires the `kde` feature");
103        let msg = err.to_string();
104        assert!(msg.contains("not supported"), "got: {msg}");
105        assert!(msg.contains("kde"), "got: {msg}");
106    }
107
108    #[test]
109    fn unavailable_display() {
110        let err = Error::Unavailable("file not found".into());
111        let msg = err.to_string();
112        assert!(msg.contains("unavailable"), "got: {msg}");
113        assert!(msg.contains("file not found"), "got: {msg}");
114    }
115
116    #[test]
117    fn format_display() {
118        let err = Error::Format("invalid TOML".into());
119        let msg = err.to_string();
120        assert!(msg.contains("format error"), "got: {msg}");
121        assert!(msg.contains("invalid TOML"), "got: {msg}");
122    }
123
124    #[test]
125    fn platform_display() {
126        let inner = std::io::Error::other("dbus failure");
127        let err = Error::Platform(Box::new(inner));
128        let msg = err.to_string();
129        assert!(msg.contains("platform error"), "got: {msg}");
130        assert!(msg.contains("dbus failure"), "got: {msg}");
131    }
132
133    #[test]
134    fn platform_source_returns_inner() {
135        let inner = std::io::Error::other("inner error");
136        let err = Error::Platform(Box::new(inner));
137        let source = std::error::Error::source(&err);
138        assert!(source.is_some());
139        assert!(source.unwrap().to_string().contains("inner error"));
140    }
141
142    #[test]
143    fn non_platform_source_is_none() {
144        assert!(std::error::Error::source(&Error::Unsupported("test")).is_none());
145        assert!(std::error::Error::source(&Error::Unavailable("x".into())).is_none());
146        assert!(std::error::Error::source(&Error::Format("x".into())).is_none());
147    }
148
149    #[test]
150    fn error_is_send_sync() {
151        fn assert_send_sync<T: Send + Sync>() {}
152        assert_send_sync::<Error>();
153    }
154
155    #[test]
156    fn from_toml_de_error() {
157        // Create a toml deserialization error by parsing invalid TOML
158        let toml_err: Result<toml::Value, toml::de::Error> = toml::from_str("=invalid");
159        let err: Error = toml_err.unwrap_err().into();
160        match &err {
161            Error::Format(msg) => assert!(!msg.is_empty()),
162            other => panic!("expected Format variant, got: {other:?}"),
163        }
164    }
165
166    #[test]
167    fn from_io_error() {
168        let io_err = std::io::Error::new(std::io::ErrorKind::NotFound, "missing file");
169        let err: Error = io_err.into();
170        match &err {
171            Error::Io(e) => {
172                assert_eq!(e.kind(), std::io::ErrorKind::NotFound);
173                assert!(e.to_string().contains("missing file"));
174            }
175            other => panic!("expected Io variant, got: {other:?}"),
176        }
177    }
178
179    // === ThemeResolutionError tests ===
180
181    #[test]
182    fn theme_resolution_error_construction() {
183        let e = ThemeResolutionError {
184            missing_fields: vec!["defaults.accent".into(), "button.font.family".into()],
185        };
186        assert_eq!(e.missing_fields.len(), 2);
187        assert_eq!(e.missing_fields[0], "defaults.accent");
188        assert_eq!(e.missing_fields[1], "button.font.family");
189    }
190
191    #[test]
192    fn theme_resolution_error_display_lists_count_and_fields() {
193        let e = ThemeResolutionError {
194            missing_fields: vec![
195                "defaults.accent".into(),
196                "button.foreground".into(),
197                "window.radius".into(),
198            ],
199        };
200        let msg = e.to_string();
201        assert!(msg.contains("3 missing field(s)"), "got: {msg}");
202        assert!(msg.contains("defaults.accent"), "got: {msg}");
203        assert!(msg.contains("button.foreground"), "got: {msg}");
204        assert!(msg.contains("window.radius"), "got: {msg}");
205    }
206
207    #[test]
208    fn theme_resolution_error_implements_std_error() {
209        let e = ThemeResolutionError {
210            missing_fields: vec!["defaults.accent".into()],
211        };
212        // This compiles only if ThemeResolutionError: std::error::Error
213        let _: &dyn std::error::Error = &e;
214    }
215
216    #[test]
217    fn theme_resolution_error_is_clone() {
218        let e = ThemeResolutionError {
219            missing_fields: vec!["defaults.accent".into()],
220        };
221        let e2 = e.clone();
222        assert_eq!(e.missing_fields, e2.missing_fields);
223    }
224
225    // === Error::Resolution variant tests ===
226
227    #[test]
228    fn error_resolution_variant_wraps_theme_resolution_error() {
229        let inner = ThemeResolutionError {
230            missing_fields: vec!["defaults.accent".into()],
231        };
232        let err = Error::Resolution(inner);
233        match &err {
234            Error::Resolution(e) => assert_eq!(e.missing_fields.len(), 1),
235            other => panic!("expected Resolution variant, got: {other:?}"),
236        }
237    }
238
239    #[test]
240    fn error_resolution_display_delegates_to_inner() {
241        let inner = ThemeResolutionError {
242            missing_fields: vec!["defaults.accent".into(), "window.radius".into()],
243        };
244        let err = Error::Resolution(inner);
245        let msg = err.to_string();
246        assert!(msg.contains("2 missing field(s)"), "got: {msg}");
247        assert!(msg.contains("defaults.accent"), "got: {msg}");
248        assert!(msg.contains("window.radius"), "got: {msg}");
249    }
250
251    #[test]
252    fn error_resolution_source_returns_inner() {
253        let inner = ThemeResolutionError {
254            missing_fields: vec!["defaults.accent".into()],
255        };
256        let err = Error::Resolution(inner);
257        let source = std::error::Error::source(&err);
258        assert!(
259            source.is_some(),
260            "source() should return Some for Resolution variant"
261        );
262        let source_msg = source.unwrap().to_string();
263        assert!(
264            source_msg.contains("1 missing field(s)"),
265            "got: {source_msg}"
266        );
267    }
268}