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_color"`, `"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 ThemeResolutionError {
15    /// Categorize a field path into a human-readable group name.
16    fn field_category(field: &str) -> &'static str {
17        if field == "icon_set" {
18            return "icon set";
19        }
20        match field.split('.').next() {
21            Some("defaults") => "root defaults",
22            Some("text_scale") => "text scale",
23            _ => "widget fields",
24        }
25    }
26}
27
28impl std::fmt::Display for ThemeResolutionError {
29    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
30        write!(
31            f,
32            "theme resolution failed: {} missing field(s):",
33            self.missing_fields.len()
34        )?;
35
36        // Group fields by category, preserving insertion order within each group.
37        let categories: &[&str] = &["root defaults", "text scale", "widget fields", "icon set"];
38        for &cat in categories {
39            let fields: Vec<&str> = self
40                .missing_fields
41                .iter()
42                .filter(|f| Self::field_category(f) == cat)
43                .map(|s| s.as_str())
44                .collect();
45            if fields.is_empty() {
46                continue;
47            }
48            write!(f, "\n  [{cat}]")?;
49            for field in &fields {
50                write!(f, "\n    - {field}")?;
51            }
52        }
53
54        // Hint when root defaults are missing (the most common user mistake).
55        let has_root = self
56            .missing_fields
57            .iter()
58            .any(|f| Self::field_category(f) == "root defaults");
59        if has_root {
60            write!(
61                f,
62                "\n  hint: root defaults drive widget inheritance; \
63                 consider using ThemeSpec::from_toml_with_base() to inherit from a complete preset"
64            )?;
65        }
66
67        Ok(())
68    }
69}
70
71impl std::error::Error for ThemeResolutionError {}
72
73/// Errors that can occur when reading or processing theme data.
74///
75/// This type is [`Clone`] so it can be stored in caches alongside
76/// [`crate::ThemeSpec`]. The `Platform` and `Io` variants use [`Arc`]
77/// internally to enable cloning without losing the original error chain.
78///
79/// [`Arc`]: std::sync::Arc
80#[derive(Debug, Clone)]
81#[non_exhaustive]
82pub enum Error {
83    /// Operation not supported on the current platform.
84    Unsupported(&'static str),
85
86    /// Data source exists but cannot be read right now.
87    Unavailable(String),
88
89    /// TOML parsing or serialization error.
90    Format(String),
91
92    /// Wrapped platform-specific error.
93    Platform(std::sync::Arc<dyn std::error::Error + Send + Sync>),
94
95    /// File I/O error (preserves the original `std::io::Error`).
96    Io(std::sync::Arc<std::io::Error>),
97
98    /// Theme resolution/validation found missing fields.
99    Resolution(ThemeResolutionError),
100}
101
102impl std::fmt::Display for Error {
103    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
104        match self {
105            Error::Unsupported(reason) => write!(f, "not supported: {reason}"),
106            Error::Unavailable(msg) => write!(f, "theme data unavailable: {msg}"),
107            Error::Format(msg) => write!(f, "theme format error: {msg}"),
108            Error::Platform(err) => write!(f, "platform error: {err}"),
109            Error::Io(err) => write!(f, "I/O error: {err}"),
110            Error::Resolution(e) => write!(f, "{e}"),
111        }
112    }
113}
114
115impl std::error::Error for Error {
116    fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
117        match self {
118            Error::Platform(err) => Some(err.as_ref()),
119            Error::Io(err) => Some(err.as_ref()),
120            Error::Resolution(e) => Some(e),
121            _ => None,
122        }
123    }
124}
125
126impl From<toml::de::Error> for Error {
127    fn from(err: toml::de::Error) -> Self {
128        Error::Format(err.to_string())
129    }
130}
131
132impl From<toml::ser::Error> for Error {
133    fn from(err: toml::ser::Error) -> Self {
134        Error::Format(err.to_string())
135    }
136}
137
138impl From<std::io::Error> for Error {
139    fn from(err: std::io::Error) -> Self {
140        Error::Io(std::sync::Arc::new(err))
141    }
142}
143
144#[cfg(test)]
145#[allow(clippy::unwrap_used, clippy::expect_used)]
146mod tests {
147    use super::*;
148
149    #[test]
150    fn unsupported_display() {
151        let err = Error::Unsupported("KDE theme detection requires the `kde` feature");
152        let msg = err.to_string();
153        assert!(msg.contains("not supported"), "got: {msg}");
154        assert!(msg.contains("kde"), "got: {msg}");
155    }
156
157    #[test]
158    fn unavailable_display() {
159        let err = Error::Unavailable("file not found".into());
160        let msg = err.to_string();
161        assert!(msg.contains("unavailable"), "got: {msg}");
162        assert!(msg.contains("file not found"), "got: {msg}");
163    }
164
165    #[test]
166    fn format_display() {
167        let err = Error::Format("invalid TOML".into());
168        let msg = err.to_string();
169        assert!(msg.contains("format error"), "got: {msg}");
170        assert!(msg.contains("invalid TOML"), "got: {msg}");
171    }
172
173    #[test]
174    fn platform_display() {
175        let inner = std::io::Error::other("dbus failure");
176        let err = Error::Platform(std::sync::Arc::new(inner));
177        let msg = err.to_string();
178        assert!(msg.contains("platform error"), "got: {msg}");
179        assert!(msg.contains("dbus failure"), "got: {msg}");
180    }
181
182    #[test]
183    fn platform_source_returns_inner() {
184        let inner = std::io::Error::other("inner error");
185        let err = Error::Platform(std::sync::Arc::new(inner));
186        let source = std::error::Error::source(&err);
187        assert!(source.is_some());
188        assert!(source.unwrap().to_string().contains("inner error"));
189    }
190
191    #[test]
192    fn source_is_none_for_unsupported_unavailable_format() {
193        assert!(
194            std::error::Error::source(&Error::Unsupported("test")).is_none(),
195            "Unsupported should return None from source()"
196        );
197        assert!(
198            std::error::Error::source(&Error::Unavailable("x".into())).is_none(),
199            "Unavailable should return None from source()"
200        );
201        assert!(
202            std::error::Error::source(&Error::Format("x".into())).is_none(),
203            "Format should return None from source()"
204        );
205    }
206
207    #[test]
208    fn source_is_some_for_io_and_platform() {
209        let io_err = Error::Io(std::sync::Arc::new(std::io::Error::other("io failure")));
210        assert!(
211            std::error::Error::source(&io_err).is_some(),
212            "Io should return Some from source()"
213        );
214
215        let platform_err = Error::Platform(std::sync::Arc::new(std::io::Error::other(
216            "platform failure",
217        )));
218        assert!(
219            std::error::Error::source(&platform_err).is_some(),
220            "Platform should return Some from source()"
221        );
222
223        let resolution_err = Error::Resolution(ThemeResolutionError {
224            missing_fields: vec!["test".into()],
225        });
226        assert!(
227            std::error::Error::source(&resolution_err).is_some(),
228            "Resolution should return Some from source()"
229        );
230    }
231
232    #[test]
233    fn error_is_send_sync() {
234        fn assert_send_sync<T: Send + Sync>() {}
235        assert_send_sync::<Error>();
236    }
237
238    #[test]
239    fn error_is_clone() {
240        fn assert_clone<T: Clone>() {}
241        assert_clone::<Error>();
242
243        let io_err: Error = std::io::Error::other("clone me").into();
244        let cloned = io_err.clone();
245        assert_eq!(io_err.to_string(), cloned.to_string());
246
247        let platform_err = Error::Platform(std::sync::Arc::new(std::io::Error::other("p")));
248        let cloned_p = platform_err.clone();
249        assert_eq!(platform_err.to_string(), cloned_p.to_string());
250    }
251
252    #[test]
253    fn from_toml_de_error() {
254        // Create a toml deserialization error by parsing invalid TOML
255        let toml_err: Result<toml::Value, toml::de::Error> = toml::from_str("=invalid");
256        let err: Error = toml_err.unwrap_err().into();
257        match &err {
258            Error::Format(msg) => assert!(!msg.is_empty()),
259            other => panic!("expected Format variant, got: {other:?}"),
260        }
261    }
262
263    #[test]
264    fn from_io_error() {
265        let io_err = std::io::Error::new(std::io::ErrorKind::NotFound, "missing file");
266        let err: Error = io_err.into();
267        match &err {
268            Error::Io(e) => {
269                assert_eq!(e.kind(), std::io::ErrorKind::NotFound);
270                assert!(e.to_string().contains("missing file"));
271            }
272            other => panic!("expected Io variant, got: {other:?}"),
273        }
274    }
275
276    // === ThemeResolutionError tests ===
277
278    #[test]
279    fn theme_resolution_error_construction() {
280        let e = ThemeResolutionError {
281            missing_fields: vec!["defaults.accent_color".into(), "button.font.family".into()],
282        };
283        assert_eq!(e.missing_fields.len(), 2);
284        assert_eq!(e.missing_fields[0], "defaults.accent_color");
285        assert_eq!(e.missing_fields[1], "button.font.family");
286    }
287
288    #[test]
289    fn theme_resolution_error_display_categorizes_fields() {
290        let e = ThemeResolutionError {
291            missing_fields: vec![
292                "defaults.accent_color".into(),
293                "button.font.color".into(),
294                "window.border.corner_radius".into(),
295            ],
296        };
297        let msg = e.to_string();
298        assert!(msg.contains("3 missing field(s)"), "got: {msg}");
299        assert!(msg.contains("[root defaults]"), "got: {msg}");
300        assert!(msg.contains("defaults.accent_color"), "got: {msg}");
301        assert!(msg.contains("[widget fields]"), "got: {msg}");
302        assert!(msg.contains("button.font.color"), "got: {msg}");
303        assert!(msg.contains("window.border.corner_radius"), "got: {msg}");
304        assert!(msg.contains("hint:"), "got: {msg}");
305        assert!(msg.contains("from_toml_with_base"), "got: {msg}");
306    }
307
308    #[test]
309    fn theme_resolution_error_display_no_hint_without_root_defaults() {
310        let e = ThemeResolutionError {
311            missing_fields: vec!["button.font.color".into()],
312        };
313        let msg = e.to_string();
314        assert!(msg.contains("[widget fields]"), "got: {msg}");
315        assert!(!msg.contains("hint:"), "got: {msg}");
316    }
317
318    #[test]
319    fn theme_resolution_error_display_groups_text_scale() {
320        let e = ThemeResolutionError {
321            missing_fields: vec!["text_scale.caption".into(), "defaults.font.family".into()],
322        };
323        let msg = e.to_string();
324        assert!(msg.contains("[text scale]"), "got: {msg}");
325        assert!(msg.contains("[root defaults]"), "got: {msg}");
326    }
327
328    #[test]
329    fn theme_resolution_error_display_icon_set_category() {
330        let e = ThemeResolutionError {
331            missing_fields: vec!["icon_set".into()],
332        };
333        let msg = e.to_string();
334        assert!(msg.contains("[icon set]"), "got: {msg}");
335        assert!(!msg.contains("hint:"), "got: {msg}");
336    }
337
338    #[test]
339    fn theme_resolution_error_implements_std_error() {
340        let e = ThemeResolutionError {
341            missing_fields: vec!["defaults.accent_color".into()],
342        };
343        // This compiles only if ThemeResolutionError: std::error::Error
344        let _: &dyn std::error::Error = &e;
345    }
346
347    #[test]
348    fn theme_resolution_error_is_clone() {
349        let e = ThemeResolutionError {
350            missing_fields: vec!["defaults.accent_color".into()],
351        };
352        let e2 = e.clone();
353        assert_eq!(e.missing_fields, e2.missing_fields);
354    }
355
356    // === Error::Resolution variant tests ===
357
358    #[test]
359    fn error_resolution_variant_wraps_theme_resolution_error() {
360        let inner = ThemeResolutionError {
361            missing_fields: vec!["defaults.accent_color".into()],
362        };
363        let err = Error::Resolution(inner);
364        match &err {
365            Error::Resolution(e) => assert_eq!(e.missing_fields.len(), 1),
366            other => panic!("expected Resolution variant, got: {other:?}"),
367        }
368    }
369
370    #[test]
371    fn error_resolution_display_delegates_to_inner() {
372        let inner = ThemeResolutionError {
373            missing_fields: vec![
374                "defaults.accent_color".into(),
375                "window.border.corner_radius".into(),
376            ],
377        };
378        let err = Error::Resolution(inner);
379        let msg = err.to_string();
380        assert!(msg.contains("2 missing field(s)"), "got: {msg}");
381        assert!(msg.contains("defaults.accent_color"), "got: {msg}");
382        assert!(msg.contains("window.border.corner_radius"), "got: {msg}");
383    }
384
385    #[test]
386    fn error_resolution_source_returns_inner() {
387        let inner = ThemeResolutionError {
388            missing_fields: vec!["defaults.accent_color".into()],
389        };
390        let err = Error::Resolution(inner);
391        let source = std::error::Error::source(&err);
392        assert!(
393            source.is_some(),
394            "source() should return Some for Resolution variant"
395        );
396        let source_msg = source.unwrap().to_string();
397        assert!(
398            source_msg.contains("1 missing field(s)"),
399            "got: {source_msg}"
400        );
401    }
402}