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, Clone)]
81#[non_exhaustive]
82pub enum Error {
83 Unsupported(&'static str),
85
86 Unavailable(String),
88
89 Format(String),
91
92 Platform(std::sync::Arc<dyn std::error::Error + Send + Sync>),
94
95 Io(std::sync::Arc<std::io::Error>),
97
98 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 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 #[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 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 #[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}