1#[derive(Debug, Clone)]
9pub struct ThemeResolutionError {
10 pub missing_fields: Vec<String>,
12}
13
14impl ThemeResolutionError {
15 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 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 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#[derive(Debug)]
75#[non_exhaustive]
76pub enum Error {
77 Unsupported(&'static str),
79
80 Unavailable(String),
82
83 Format(String),
85
86 Platform(Box<dyn std::error::Error + Send + Sync>),
88
89 Io(std::io::Error),
91
92 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 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 #[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 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 #[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}