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::ResolvedTheme`] 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,
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    /// Theme resolution/validation found missing fields.
47    Resolution(ThemeResolutionError),
48}
49
50impl std::fmt::Display for Error {
51    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
52        match self {
53            Error::Unsupported => write!(f, "operation not supported on this platform"),
54            Error::Unavailable(msg) => write!(f, "theme data unavailable: {msg}"),
55            Error::Format(msg) => write!(f, "theme format error: {msg}"),
56            Error::Platform(err) => write!(f, "platform error: {err}"),
57            Error::Resolution(e) => write!(f, "{e}"),
58        }
59    }
60}
61
62impl std::error::Error for Error {
63    fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
64        match self {
65            Error::Platform(err) => Some(&**err),
66            Error::Resolution(e) => Some(e),
67            _ => None,
68        }
69    }
70}
71
72impl From<toml::de::Error> for Error {
73    fn from(err: toml::de::Error) -> Self {
74        Error::Format(err.to_string())
75    }
76}
77
78impl From<toml::ser::Error> for Error {
79    fn from(err: toml::ser::Error) -> Self {
80        Error::Format(err.to_string())
81    }
82}
83
84impl From<std::io::Error> for Error {
85    fn from(err: std::io::Error) -> Self {
86        Error::Unavailable(err.to_string())
87    }
88}
89
90#[cfg(test)]
91#[allow(clippy::unwrap_used, clippy::expect_used)]
92mod tests {
93    use super::*;
94
95    #[test]
96    fn unsupported_display() {
97        let err = Error::Unsupported;
98        let msg = err.to_string();
99        assert!(msg.contains("not supported"), "got: {msg}");
100    }
101
102    #[test]
103    fn unavailable_display() {
104        let err = Error::Unavailable("file not found".into());
105        let msg = err.to_string();
106        assert!(msg.contains("unavailable"), "got: {msg}");
107        assert!(msg.contains("file not found"), "got: {msg}");
108    }
109
110    #[test]
111    fn format_display() {
112        let err = Error::Format("invalid TOML".into());
113        let msg = err.to_string();
114        assert!(msg.contains("format error"), "got: {msg}");
115        assert!(msg.contains("invalid TOML"), "got: {msg}");
116    }
117
118    #[test]
119    fn platform_display() {
120        let inner = std::io::Error::other("dbus failure");
121        let err = Error::Platform(Box::new(inner));
122        let msg = err.to_string();
123        assert!(msg.contains("platform error"), "got: {msg}");
124        assert!(msg.contains("dbus failure"), "got: {msg}");
125    }
126
127    #[test]
128    fn platform_source_returns_inner() {
129        let inner = std::io::Error::other("inner error");
130        let err = Error::Platform(Box::new(inner));
131        let source = std::error::Error::source(&err);
132        assert!(source.is_some());
133        assert!(source.unwrap().to_string().contains("inner error"));
134    }
135
136    #[test]
137    fn non_platform_source_is_none() {
138        assert!(std::error::Error::source(&Error::Unsupported).is_none());
139        assert!(std::error::Error::source(&Error::Unavailable("x".into())).is_none());
140        assert!(std::error::Error::source(&Error::Format("x".into())).is_none());
141    }
142
143    #[test]
144    fn error_is_send_sync() {
145        fn assert_send_sync<T: Send + Sync>() {}
146        assert_send_sync::<Error>();
147    }
148
149    #[test]
150    fn from_toml_de_error() {
151        // Create a toml deserialization error by parsing invalid TOML
152        let toml_err: Result<toml::Value, toml::de::Error> = toml::from_str("=invalid");
153        let err: Error = toml_err.unwrap_err().into();
154        match &err {
155            Error::Format(msg) => assert!(!msg.is_empty()),
156            other => panic!("expected Format variant, got: {other:?}"),
157        }
158    }
159
160    #[test]
161    fn from_io_error() {
162        let io_err = std::io::Error::new(std::io::ErrorKind::NotFound, "missing file");
163        let err: Error = io_err.into();
164        match &err {
165            Error::Unavailable(msg) => assert!(msg.contains("missing file")),
166            other => panic!("expected Unavailable variant, got: {other:?}"),
167        }
168    }
169
170    // === ThemeResolutionError tests ===
171
172    #[test]
173    fn theme_resolution_error_construction() {
174        let e = ThemeResolutionError {
175            missing_fields: vec!["defaults.accent".into(), "button.font.family".into()],
176        };
177        assert_eq!(e.missing_fields.len(), 2);
178        assert_eq!(e.missing_fields[0], "defaults.accent");
179        assert_eq!(e.missing_fields[1], "button.font.family");
180    }
181
182    #[test]
183    fn theme_resolution_error_display_lists_count_and_fields() {
184        let e = ThemeResolutionError {
185            missing_fields: vec![
186                "defaults.accent".into(),
187                "button.foreground".into(),
188                "window.radius".into(),
189            ],
190        };
191        let msg = e.to_string();
192        assert!(msg.contains("3 missing field(s)"), "got: {msg}");
193        assert!(msg.contains("defaults.accent"), "got: {msg}");
194        assert!(msg.contains("button.foreground"), "got: {msg}");
195        assert!(msg.contains("window.radius"), "got: {msg}");
196    }
197
198    #[test]
199    fn theme_resolution_error_implements_std_error() {
200        let e = ThemeResolutionError {
201            missing_fields: vec!["defaults.accent".into()],
202        };
203        // This compiles only if ThemeResolutionError: std::error::Error
204        let _: &dyn std::error::Error = &e;
205    }
206
207    #[test]
208    fn theme_resolution_error_is_clone() {
209        let e = ThemeResolutionError {
210            missing_fields: vec!["defaults.accent".into()],
211        };
212        let e2 = e.clone();
213        assert_eq!(e.missing_fields, e2.missing_fields);
214    }
215
216    // === Error::Resolution variant tests ===
217
218    #[test]
219    fn error_resolution_variant_wraps_theme_resolution_error() {
220        let inner = ThemeResolutionError {
221            missing_fields: vec!["defaults.accent".into()],
222        };
223        let err = Error::Resolution(inner);
224        match &err {
225            Error::Resolution(e) => assert_eq!(e.missing_fields.len(), 1),
226            other => panic!("expected Resolution variant, got: {other:?}"),
227        }
228    }
229
230    #[test]
231    fn error_resolution_display_delegates_to_inner() {
232        let inner = ThemeResolutionError {
233            missing_fields: vec!["defaults.accent".into(), "window.radius".into()],
234        };
235        let err = Error::Resolution(inner);
236        let msg = err.to_string();
237        assert!(msg.contains("2 missing field(s)"), "got: {msg}");
238        assert!(msg.contains("defaults.accent"), "got: {msg}");
239        assert!(msg.contains("window.radius"), "got: {msg}");
240    }
241
242    #[test]
243    fn error_resolution_source_returns_inner() {
244        let inner = ThemeResolutionError {
245            missing_fields: vec!["defaults.accent".into()],
246        };
247        let err = Error::Resolution(inner);
248        let source = std::error::Error::source(&err);
249        assert!(
250            source.is_some(),
251            "source() should return Some for Resolution variant"
252        );
253        let source_msg = source.unwrap().to_string();
254        assert!(
255            source_msg.contains("1 missing field(s)"),
256            "got: {source_msg}"
257        );
258    }
259}