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 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#[derive(Debug)]
75#[non_exhaustive]
76pub enum Error {
77    /// Operation not supported on the current platform.
78    Unsupported(&'static str),
79
80    /// Data source exists but cannot be read right now.
81    Unavailable(String),
82
83    /// TOML parsing or serialization error.
84    Format(String),
85
86    /// Wrapped platform-specific error.
87    Platform(Box<dyn std::error::Error + Send + Sync>),
88
89    /// File I/O error (preserves the original `std::io::Error`).
90    Io(std::io::Error),
91
92    /// Theme resolution/validation found missing fields.
93    Resolution(ThemeResolutionError),
94}
95
96impl std::fmt::Display for Error {
97    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
98        match self {
99            Error::Unsupported(reason) => write!(f, "not supported: {reason}"),
100            Error::Unavailable(msg) => write!(f, "theme data unavailable: {msg}"),
101            Error::Format(msg) => write!(f, "theme format error: {msg}"),
102            Error::Platform(err) => write!(f, "platform error: {err}"),
103            Error::Io(err) => write!(f, "I/O error: {err}"),
104            Error::Resolution(e) => write!(f, "{e}"),
105        }
106    }
107}
108
109impl std::error::Error for Error {
110    fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
111        match self {
112            Error::Platform(err) => Some(&**err),
113            Error::Io(err) => Some(err),
114            Error::Resolution(e) => Some(e),
115            _ => None,
116        }
117    }
118}
119
120impl From<toml::de::Error> for Error {
121    fn from(err: toml::de::Error) -> Self {
122        Error::Format(err.to_string())
123    }
124}
125
126impl From<toml::ser::Error> for Error {
127    fn from(err: toml::ser::Error) -> Self {
128        Error::Format(err.to_string())
129    }
130}
131
132impl From<std::io::Error> for Error {
133    fn from(err: std::io::Error) -> Self {
134        Error::Io(err)
135    }
136}
137
138#[cfg(test)]
139#[allow(clippy::unwrap_used, clippy::expect_used)]
140mod tests {
141    use super::*;
142
143    #[test]
144    fn unsupported_display() {
145        let err = Error::Unsupported("KDE theme detection requires the `kde` feature");
146        let msg = err.to_string();
147        assert!(msg.contains("not supported"), "got: {msg}");
148        assert!(msg.contains("kde"), "got: {msg}");
149    }
150
151    #[test]
152    fn unavailable_display() {
153        let err = Error::Unavailable("file not found".into());
154        let msg = err.to_string();
155        assert!(msg.contains("unavailable"), "got: {msg}");
156        assert!(msg.contains("file not found"), "got: {msg}");
157    }
158
159    #[test]
160    fn format_display() {
161        let err = Error::Format("invalid TOML".into());
162        let msg = err.to_string();
163        assert!(msg.contains("format error"), "got: {msg}");
164        assert!(msg.contains("invalid TOML"), "got: {msg}");
165    }
166
167    #[test]
168    fn platform_display() {
169        let inner = std::io::Error::other("dbus failure");
170        let err = Error::Platform(Box::new(inner));
171        let msg = err.to_string();
172        assert!(msg.contains("platform error"), "got: {msg}");
173        assert!(msg.contains("dbus failure"), "got: {msg}");
174    }
175
176    #[test]
177    fn platform_source_returns_inner() {
178        let inner = std::io::Error::other("inner error");
179        let err = Error::Platform(Box::new(inner));
180        let source = std::error::Error::source(&err);
181        assert!(source.is_some());
182        assert!(source.unwrap().to_string().contains("inner error"));
183    }
184
185    #[test]
186    fn non_platform_source_is_none() {
187        assert!(std::error::Error::source(&Error::Unsupported("test")).is_none());
188        assert!(std::error::Error::source(&Error::Unavailable("x".into())).is_none());
189        assert!(std::error::Error::source(&Error::Format("x".into())).is_none());
190    }
191
192    #[test]
193    fn error_is_send_sync() {
194        fn assert_send_sync<T: Send + Sync>() {}
195        assert_send_sync::<Error>();
196    }
197
198    #[test]
199    fn from_toml_de_error() {
200        // Create a toml deserialization error by parsing invalid TOML
201        let toml_err: Result<toml::Value, toml::de::Error> = toml::from_str("=invalid");
202        let err: Error = toml_err.unwrap_err().into();
203        match &err {
204            Error::Format(msg) => assert!(!msg.is_empty()),
205            other => panic!("expected Format variant, got: {other:?}"),
206        }
207    }
208
209    #[test]
210    fn from_io_error() {
211        let io_err = std::io::Error::new(std::io::ErrorKind::NotFound, "missing file");
212        let err: Error = io_err.into();
213        match &err {
214            Error::Io(e) => {
215                assert_eq!(e.kind(), std::io::ErrorKind::NotFound);
216                assert!(e.to_string().contains("missing file"));
217            }
218            other => panic!("expected Io variant, got: {other:?}"),
219        }
220    }
221
222    // === ThemeResolutionError tests ===
223
224    #[test]
225    fn theme_resolution_error_construction() {
226        let e = ThemeResolutionError {
227            missing_fields: vec!["defaults.accent".into(), "button.font.family".into()],
228        };
229        assert_eq!(e.missing_fields.len(), 2);
230        assert_eq!(e.missing_fields[0], "defaults.accent");
231        assert_eq!(e.missing_fields[1], "button.font.family");
232    }
233
234    #[test]
235    fn theme_resolution_error_display_categorizes_fields() {
236        let e = ThemeResolutionError {
237            missing_fields: vec![
238                "defaults.accent".into(),
239                "button.foreground".into(),
240                "window.radius".into(),
241            ],
242        };
243        let msg = e.to_string();
244        assert!(msg.contains("3 missing field(s)"), "got: {msg}");
245        assert!(msg.contains("[root defaults]"), "got: {msg}");
246        assert!(msg.contains("defaults.accent"), "got: {msg}");
247        assert!(msg.contains("[widget fields]"), "got: {msg}");
248        assert!(msg.contains("button.foreground"), "got: {msg}");
249        assert!(msg.contains("window.radius"), "got: {msg}");
250        assert!(msg.contains("hint:"), "got: {msg}");
251        assert!(msg.contains("from_toml_with_base"), "got: {msg}");
252    }
253
254    #[test]
255    fn theme_resolution_error_display_no_hint_without_root_defaults() {
256        let e = ThemeResolutionError {
257            missing_fields: vec!["button.foreground".into()],
258        };
259        let msg = e.to_string();
260        assert!(msg.contains("[widget fields]"), "got: {msg}");
261        assert!(!msg.contains("hint:"), "got: {msg}");
262    }
263
264    #[test]
265    fn theme_resolution_error_display_groups_text_scale() {
266        let e = ThemeResolutionError {
267            missing_fields: vec!["text_scale.caption".into(), "defaults.font.family".into()],
268        };
269        let msg = e.to_string();
270        assert!(msg.contains("[text scale]"), "got: {msg}");
271        assert!(msg.contains("[root defaults]"), "got: {msg}");
272    }
273
274    #[test]
275    fn theme_resolution_error_display_icon_set_category() {
276        let e = ThemeResolutionError {
277            missing_fields: vec!["icon_set".into()],
278        };
279        let msg = e.to_string();
280        assert!(msg.contains("[icon set]"), "got: {msg}");
281        assert!(!msg.contains("hint:"), "got: {msg}");
282    }
283
284    #[test]
285    fn theme_resolution_error_implements_std_error() {
286        let e = ThemeResolutionError {
287            missing_fields: vec!["defaults.accent".into()],
288        };
289        // This compiles only if ThemeResolutionError: std::error::Error
290        let _: &dyn std::error::Error = &e;
291    }
292
293    #[test]
294    fn theme_resolution_error_is_clone() {
295        let e = ThemeResolutionError {
296            missing_fields: vec!["defaults.accent".into()],
297        };
298        let e2 = e.clone();
299        assert_eq!(e.missing_fields, e2.missing_fields);
300    }
301
302    // === Error::Resolution variant tests ===
303
304    #[test]
305    fn error_resolution_variant_wraps_theme_resolution_error() {
306        let inner = ThemeResolutionError {
307            missing_fields: vec!["defaults.accent".into()],
308        };
309        let err = Error::Resolution(inner);
310        match &err {
311            Error::Resolution(e) => assert_eq!(e.missing_fields.len(), 1),
312            other => panic!("expected Resolution variant, got: {other:?}"),
313        }
314    }
315
316    #[test]
317    fn error_resolution_display_delegates_to_inner() {
318        let inner = ThemeResolutionError {
319            missing_fields: vec!["defaults.accent".into(), "window.radius".into()],
320        };
321        let err = Error::Resolution(inner);
322        let msg = err.to_string();
323        assert!(msg.contains("2 missing field(s)"), "got: {msg}");
324        assert!(msg.contains("defaults.accent"), "got: {msg}");
325        assert!(msg.contains("window.radius"), "got: {msg}");
326    }
327
328    #[test]
329    fn error_resolution_source_returns_inner() {
330        let inner = ThemeResolutionError {
331            missing_fields: vec!["defaults.accent".into()],
332        };
333        let err = Error::Resolution(inner);
334        let source = std::error::Error::source(&err);
335        assert!(
336            source.is_some(),
337            "source() should return Some for Resolution variant"
338        );
339        let source_msg = source.unwrap().to_string();
340        assert!(
341            source_msg.contains("1 missing field(s)"),
342            "got: {source_msg}"
343        );
344    }
345}