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,
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 => write!(f, "operation not supported on this platform"),
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;
103        let msg = err.to_string();
104        assert!(msg.contains("not supported"), "got: {msg}");
105    }
106
107    #[test]
108    fn unavailable_display() {
109        let err = Error::Unavailable("file not found".into());
110        let msg = err.to_string();
111        assert!(msg.contains("unavailable"), "got: {msg}");
112        assert!(msg.contains("file not found"), "got: {msg}");
113    }
114
115    #[test]
116    fn format_display() {
117        let err = Error::Format("invalid TOML".into());
118        let msg = err.to_string();
119        assert!(msg.contains("format error"), "got: {msg}");
120        assert!(msg.contains("invalid TOML"), "got: {msg}");
121    }
122
123    #[test]
124    fn platform_display() {
125        let inner = std::io::Error::other("dbus failure");
126        let err = Error::Platform(Box::new(inner));
127        let msg = err.to_string();
128        assert!(msg.contains("platform error"), "got: {msg}");
129        assert!(msg.contains("dbus failure"), "got: {msg}");
130    }
131
132    #[test]
133    fn platform_source_returns_inner() {
134        let inner = std::io::Error::other("inner error");
135        let err = Error::Platform(Box::new(inner));
136        let source = std::error::Error::source(&err);
137        assert!(source.is_some());
138        assert!(source.unwrap().to_string().contains("inner error"));
139    }
140
141    #[test]
142    fn non_platform_source_is_none() {
143        assert!(std::error::Error::source(&Error::Unsupported).is_none());
144        assert!(std::error::Error::source(&Error::Unavailable("x".into())).is_none());
145        assert!(std::error::Error::source(&Error::Format("x".into())).is_none());
146    }
147
148    #[test]
149    fn error_is_send_sync() {
150        fn assert_send_sync<T: Send + Sync>() {}
151        assert_send_sync::<Error>();
152    }
153
154    #[test]
155    fn from_toml_de_error() {
156        // Create a toml deserialization error by parsing invalid TOML
157        let toml_err: Result<toml::Value, toml::de::Error> = toml::from_str("=invalid");
158        let err: Error = toml_err.unwrap_err().into();
159        match &err {
160            Error::Format(msg) => assert!(!msg.is_empty()),
161            other => panic!("expected Format variant, got: {other:?}"),
162        }
163    }
164
165    #[test]
166    fn from_io_error() {
167        let io_err = std::io::Error::new(std::io::ErrorKind::NotFound, "missing file");
168        let err: Error = io_err.into();
169        match &err {
170            Error::Io(e) => {
171                assert_eq!(e.kind(), std::io::ErrorKind::NotFound);
172                assert!(e.to_string().contains("missing file"));
173            }
174            other => panic!("expected Io variant, got: {other:?}"),
175        }
176    }
177
178    // === ThemeResolutionError tests ===
179
180    #[test]
181    fn theme_resolution_error_construction() {
182        let e = ThemeResolutionError {
183            missing_fields: vec!["defaults.accent".into(), "button.font.family".into()],
184        };
185        assert_eq!(e.missing_fields.len(), 2);
186        assert_eq!(e.missing_fields[0], "defaults.accent");
187        assert_eq!(e.missing_fields[1], "button.font.family");
188    }
189
190    #[test]
191    fn theme_resolution_error_display_lists_count_and_fields() {
192        let e = ThemeResolutionError {
193            missing_fields: vec![
194                "defaults.accent".into(),
195                "button.foreground".into(),
196                "window.radius".into(),
197            ],
198        };
199        let msg = e.to_string();
200        assert!(msg.contains("3 missing field(s)"), "got: {msg}");
201        assert!(msg.contains("defaults.accent"), "got: {msg}");
202        assert!(msg.contains("button.foreground"), "got: {msg}");
203        assert!(msg.contains("window.radius"), "got: {msg}");
204    }
205
206    #[test]
207    fn theme_resolution_error_implements_std_error() {
208        let e = ThemeResolutionError {
209            missing_fields: vec!["defaults.accent".into()],
210        };
211        // This compiles only if ThemeResolutionError: std::error::Error
212        let _: &dyn std::error::Error = &e;
213    }
214
215    #[test]
216    fn theme_resolution_error_is_clone() {
217        let e = ThemeResolutionError {
218            missing_fields: vec!["defaults.accent".into()],
219        };
220        let e2 = e.clone();
221        assert_eq!(e.missing_fields, e2.missing_fields);
222    }
223
224    // === Error::Resolution variant tests ===
225
226    #[test]
227    fn error_resolution_variant_wraps_theme_resolution_error() {
228        let inner = ThemeResolutionError {
229            missing_fields: vec!["defaults.accent".into()],
230        };
231        let err = Error::Resolution(inner);
232        match &err {
233            Error::Resolution(e) => assert_eq!(e.missing_fields.len(), 1),
234            other => panic!("expected Resolution variant, got: {other:?}"),
235        }
236    }
237
238    #[test]
239    fn error_resolution_display_delegates_to_inner() {
240        let inner = ThemeResolutionError {
241            missing_fields: vec!["defaults.accent".into(), "window.radius".into()],
242        };
243        let err = Error::Resolution(inner);
244        let msg = err.to_string();
245        assert!(msg.contains("2 missing field(s)"), "got: {msg}");
246        assert!(msg.contains("defaults.accent"), "got: {msg}");
247        assert!(msg.contains("window.radius"), "got: {msg}");
248    }
249
250    #[test]
251    fn error_resolution_source_returns_inner() {
252        let inner = ThemeResolutionError {
253            missing_fields: vec!["defaults.accent".into()],
254        };
255        let err = Error::Resolution(inner);
256        let source = std::error::Error::source(&err);
257        assert!(
258            source.is_some(),
259            "source() should return Some for Resolution variant"
260        );
261        let source_msg = source.unwrap().to_string();
262        assert!(
263            source_msg.contains("1 missing field(s)"),
264            "got: {source_msg}"
265        );
266    }
267}