1use std::fmt;
6
7#[derive(Debug, Clone)]
12pub struct RangeViolation {
13 pub path: String,
15 pub value: f64,
17 pub min: Option<f64>,
19 pub max: Option<f64>,
21}
22
23impl fmt::Display for RangeViolation {
24 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
25 let lo = self
26 .min
27 .map_or_else(|| "-inf".to_owned(), |v| v.to_string());
28 let hi = self.max.map_or_else(|| "inf".to_owned(), |v| v.to_string());
29 write!(
30 f,
31 "{} must be {}..={}, got {}",
32 self.path, lo, hi, self.value
33 )
34 }
35}
36
37#[derive(Debug, Clone, Copy, PartialEq, Eq)]
42#[non_exhaustive]
43pub enum ErrorKind {
44 Platform,
46 Parse,
48 Resolution,
50 Io,
52}
53
54#[derive(Debug)]
59#[non_exhaustive]
60pub enum Error {
61 FeatureDisabled {
64 name: &'static str,
66 needed_for: &'static str,
68 },
69
70 PlatformUnsupported {
72 platform: &'static str,
74 },
75
76 UnknownPreset {
78 name: String,
80 known: &'static [&'static str],
82 },
83
84 WatchUnavailable {
87 reason: &'static str,
89 },
90
91 NoVariant {
97 mode: crate::theme::ColorMode,
99 },
100
101 Toml(toml::de::Error),
104
105 Io(std::io::Error),
107
108 ResolutionIncomplete {
110 missing: Vec<String>,
112 },
113
114 ResolutionInvalid {
116 errors: Vec<RangeViolation>,
118 },
119
120 ReaderFailed {
122 reader: &'static str,
124 source: Box<dyn std::error::Error + Send + Sync>,
126 },
127}
128
129impl Error {
130 #[must_use]
135 pub fn kind(&self) -> ErrorKind {
136 match self {
137 Error::FeatureDisabled { .. } => ErrorKind::Platform,
138 Error::PlatformUnsupported { .. } => ErrorKind::Platform,
139 Error::WatchUnavailable { .. } => ErrorKind::Platform,
140 Error::NoVariant { .. } => ErrorKind::Resolution,
141 Error::ReaderFailed { .. } => ErrorKind::Platform,
142 Error::UnknownPreset { .. } => ErrorKind::Parse,
143 Error::Toml(_) => ErrorKind::Parse,
144 Error::Io(_) => ErrorKind::Io,
145 Error::ResolutionIncomplete { .. } => ErrorKind::Resolution,
146 Error::ResolutionInvalid { .. } => ErrorKind::Resolution,
147 }
148 }
149}
150
151impl fmt::Display for Error {
152 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
153 match self {
154 Error::FeatureDisabled { name, needed_for } => {
155 write!(f, "feature \"{name}\" is required for {needed_for}")
156 }
157 Error::PlatformUnsupported { platform } => {
158 write!(f, "platform not supported: {platform}")
159 }
160 Error::UnknownPreset { name, known } => {
161 write!(
162 f,
163 "unknown preset \"{name}\"; available: {}",
164 known.join(", ")
165 )
166 }
167 Error::WatchUnavailable { reason } => {
168 write!(f, "theme watching unavailable: {reason}")
169 }
170 Error::NoVariant { mode } => {
171 write!(f, "theme has no variant for {mode:?}")
172 }
173 Error::Toml(err) => write!(f, "TOML error: {err}"),
174 Error::Io(err) => write!(f, "I/O error: {err}"),
175 Error::ResolutionIncomplete { missing } => {
176 write!(
177 f,
178 "theme resolution failed: {} missing field(s):",
179 missing.len()
180 )?;
181
182 let categories: &[&str] =
184 &["root defaults", "text scale", "widget fields", "icon set"];
185 for &cat in categories {
186 let fields: Vec<&str> = missing
187 .iter()
188 .filter(|field| field_category(field) == cat)
189 .map(|s| s.as_str())
190 .collect();
191 if fields.is_empty() {
192 continue;
193 }
194 write!(f, "\n [{cat}]")?;
195 for field in &fields {
196 write!(f, "\n - {field}")?;
197 }
198 }
199
200 let has_root = missing
202 .iter()
203 .any(|field| field_category(field) == "root defaults");
204 if has_root {
205 write!(
206 f,
207 "\n hint: root defaults drive widget inheritance; \
208 consider using Theme::preset(name) and then Theme::merge() to inherit from a complete preset"
209 )?;
210 }
211
212 Ok(())
213 }
214 Error::ResolutionInvalid { errors } => {
215 write!(
216 f,
217 "theme resolution failed: {} range violation(s):",
218 errors.len()
219 )?;
220 for violation in errors {
221 write!(f, "\n - {violation}")?;
222 }
223 Ok(())
224 }
225 Error::ReaderFailed { reader, source } => {
226 write!(f, "{reader} reader failed: {source}")
227 }
228 }
229 }
230}
231
232fn field_category(field: &str) -> &'static str {
234 match field.split('.').next() {
235 Some("defaults") => "root defaults",
236 Some("text_scale") => "text scale",
237 _ => "widget fields",
238 }
239}
240
241impl std::error::Error for Error {
242 fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
243 match self {
244 Error::Toml(err) => Some(err),
245 Error::Io(err) => Some(err),
246 Error::ReaderFailed { source, .. } => Some(source.as_ref()),
247 Error::FeatureDisabled { .. }
248 | Error::PlatformUnsupported { .. }
249 | Error::UnknownPreset { .. }
250 | Error::WatchUnavailable { .. }
251 | Error::NoVariant { .. }
252 | Error::ResolutionIncomplete { .. }
253 | Error::ResolutionInvalid { .. } => None,
254 }
255 }
256}
257
258impl From<toml::de::Error> for Error {
259 fn from(err: toml::de::Error) -> Self {
260 Error::Toml(err)
261 }
262}
263
264impl From<toml::ser::Error> for Error {
265 fn from(err: toml::ser::Error) -> Self {
266 Error::ReaderFailed {
270 reader: "toml-serializer",
271 source: Box::new(err),
272 }
273 }
274}
275
276impl From<std::io::Error> for Error {
277 fn from(err: std::io::Error) -> Self {
278 Error::Io(err)
279 }
280}
281
282#[cfg(test)]
283#[allow(clippy::unwrap_used, clippy::expect_used)]
284mod tests {
285 use super::*;
286
287 #[test]
290 fn kind_platform_for_feature_disabled() {
291 let err = Error::FeatureDisabled {
292 name: "kde",
293 needed_for: "KDE theme detection",
294 };
295 assert_eq!(err.kind(), ErrorKind::Platform);
296 }
297
298 #[test]
299 fn kind_platform_for_platform_unsupported() {
300 let err = Error::PlatformUnsupported { platform: "wasm" };
301 assert_eq!(err.kind(), ErrorKind::Platform);
302 }
303
304 #[test]
305 fn kind_platform_for_watch_unavailable() {
306 let err = Error::WatchUnavailable {
307 reason: "notify crate not compiled",
308 };
309 assert_eq!(err.kind(), ErrorKind::Platform);
310 }
311
312 #[test]
313 fn kind_platform_for_reader_failed() {
314 let err = Error::ReaderFailed {
315 reader: "kde",
316 source: Box::new(std::io::Error::other("dbus down")),
317 };
318 assert_eq!(err.kind(), ErrorKind::Platform);
319 }
320
321 #[test]
322 fn kind_parse_for_unknown_preset() {
323 let err = Error::UnknownPreset {
324 name: "foobar".into(),
325 known: &["adwaita", "kde-breeze"],
326 };
327 assert_eq!(err.kind(), ErrorKind::Parse);
328 }
329
330 #[test]
331 fn kind_parse_for_toml() {
332 let toml_err: Result<toml::Value, toml::de::Error> = toml::from_str("=invalid");
333 let err = Error::Toml(toml_err.unwrap_err());
334 assert_eq!(err.kind(), ErrorKind::Parse);
335 }
336
337 #[test]
338 fn kind_resolution_for_incomplete() {
339 let err = Error::ResolutionIncomplete {
340 missing: vec!["defaults.accent_color".into()],
341 };
342 assert_eq!(err.kind(), ErrorKind::Resolution);
343 }
344
345 #[test]
346 fn kind_resolution_for_invalid() {
347 let err = Error::ResolutionInvalid {
348 errors: vec![RangeViolation {
349 path: "button.min_width".into(),
350 value: -5.0,
351 min: Some(0.0),
352 max: None,
353 }],
354 };
355 assert_eq!(err.kind(), ErrorKind::Resolution);
356 }
357
358 #[test]
359 fn kind_io_for_io() {
360 let err = Error::Io(std::io::Error::other("disk failure"));
361 assert_eq!(err.kind(), ErrorKind::Io);
362 }
363
364 #[test]
367 fn error_is_send_sync() {
368 fn assert_send_sync<T: Send + Sync>() {}
369 assert_send_sync::<Error>();
370 }
371
372 #[test]
375 fn display_feature_disabled_includes_name_and_needed_for() {
376 let err = Error::FeatureDisabled {
377 name: "kde",
378 needed_for: "KDE theme detection",
379 };
380 let msg = err.to_string();
381 assert!(msg.contains("kde"), "got: {msg}");
382 assert!(msg.contains("KDE theme detection"), "got: {msg}");
383 }
384
385 #[test]
386 fn display_resolution_incomplete_categorizes_fields() {
387 let err = Error::ResolutionIncomplete {
388 missing: vec![
389 "defaults.accent_color".into(),
390 "button.font.color".into(),
391 "window.border.corner_radius".into(),
392 ],
393 };
394 let msg = err.to_string();
395 assert!(msg.contains("3 missing field(s)"), "got: {msg}");
396 assert!(msg.contains("[root defaults]"), "got: {msg}");
397 assert!(msg.contains("defaults.accent_color"), "got: {msg}");
398 assert!(msg.contains("[widget fields]"), "got: {msg}");
399 assert!(msg.contains("button.font.color"), "got: {msg}");
400 assert!(msg.contains("window.border.corner_radius"), "got: {msg}");
401 assert!(msg.contains("hint:"), "got: {msg}");
402 assert!(msg.contains("preset"), "got: {msg}");
403 }
404
405 #[test]
406 fn display_resolution_incomplete_no_hint_without_root_defaults() {
407 let err = Error::ResolutionIncomplete {
408 missing: vec!["button.font.color".into()],
409 };
410 let msg = err.to_string();
411 assert!(msg.contains("[widget fields]"), "got: {msg}");
412 assert!(!msg.contains("hint:"), "got: {msg}");
413 }
414
415 #[test]
416 fn display_resolution_incomplete_groups_text_scale() {
417 let err = Error::ResolutionIncomplete {
418 missing: vec!["text_scale.caption".into(), "defaults.font.family".into()],
419 };
420 let msg = err.to_string();
421 assert!(msg.contains("[text scale]"), "got: {msg}");
422 assert!(msg.contains("[root defaults]"), "got: {msg}");
423 }
424
425 #[test]
426 fn display_resolution_incomplete_widget_category() {
427 let err = Error::ResolutionIncomplete {
429 missing: vec!["button.font.color".into()],
430 };
431 let msg = err.to_string();
432 assert!(msg.contains("[widget fields]"), "got: {msg}");
433 assert!(!msg.contains("hint:"), "got: {msg}");
434 }
435
436 #[test]
437 fn display_resolution_invalid_lists_violations() {
438 let err = Error::ResolutionInvalid {
439 errors: vec![
440 RangeViolation {
441 path: "button.min_width".into(),
442 value: -5.0,
443 min: Some(0.0),
444 max: None,
445 },
446 RangeViolation {
447 path: "slider.track_height".into(),
448 value: 200.0,
449 min: Some(1.0),
450 max: Some(100.0),
451 },
452 ],
453 };
454 let msg = err.to_string();
455 assert!(msg.contains("2 range violation(s)"), "got: {msg}");
456 assert!(msg.contains("button.min_width"), "got: {msg}");
457 assert!(msg.contains("-5"), "got: {msg}");
458 assert!(msg.contains("slider.track_height"), "got: {msg}");
459 assert!(msg.contains("200"), "got: {msg}");
460 }
461
462 #[test]
463 fn display_unknown_preset_shows_name_and_available() {
464 let err = Error::UnknownPreset {
465 name: "foobar".into(),
466 known: &["adwaita", "kde-breeze"],
467 };
468 let msg = err.to_string();
469 assert!(msg.contains("foobar"), "got: {msg}");
470 assert!(msg.contains("adwaita"), "got: {msg}");
471 assert!(msg.contains("kde-breeze"), "got: {msg}");
472 }
473
474 #[test]
477 fn source_some_for_io() {
478 let err = Error::Io(std::io::Error::other("disk failure"));
479 assert!(
480 std::error::Error::source(&err).is_some(),
481 "Io should return Some from source()"
482 );
483 }
484
485 #[test]
486 fn source_some_for_reader_failed() {
487 let err = Error::ReaderFailed {
488 reader: "kde",
489 source: Box::new(std::io::Error::other("dbus down")),
490 };
491 let source = std::error::Error::source(&err);
492 assert!(
493 source.is_some(),
494 "ReaderFailed should return Some from source()"
495 );
496 assert!(source.unwrap().to_string().contains("dbus down"));
497 }
498
499 #[test]
500 fn source_some_for_toml() {
501 let toml_err: Result<toml::Value, toml::de::Error> = toml::from_str("=invalid");
502 let err = Error::Toml(toml_err.unwrap_err());
503 assert!(
504 std::error::Error::source(&err).is_some(),
505 "Toml should return Some from source()"
506 );
507 }
508
509 #[test]
510 fn source_none_for_feature_disabled() {
511 let err = Error::FeatureDisabled {
512 name: "kde",
513 needed_for: "detection",
514 };
515 assert!(std::error::Error::source(&err).is_none());
516 }
517
518 #[test]
519 fn source_none_for_platform_unsupported() {
520 let err = Error::PlatformUnsupported { platform: "wasm" };
521 assert!(std::error::Error::source(&err).is_none());
522 }
523
524 #[test]
525 fn source_none_for_watch_unavailable() {
526 let err = Error::WatchUnavailable {
527 reason: "not compiled",
528 };
529 assert!(std::error::Error::source(&err).is_none());
530 }
531
532 #[test]
533 fn source_none_for_unknown_preset() {
534 let err = Error::UnknownPreset {
535 name: "x".into(),
536 known: &[],
537 };
538 assert!(std::error::Error::source(&err).is_none());
539 }
540
541 #[test]
542 fn source_none_for_resolution_incomplete() {
543 let err = Error::ResolutionIncomplete {
544 missing: vec!["a".into()],
545 };
546 assert!(std::error::Error::source(&err).is_none());
547 }
548
549 #[test]
550 fn source_none_for_resolution_invalid() {
551 let err = Error::ResolutionInvalid {
552 errors: vec![RangeViolation {
553 path: "x".into(),
554 value: 0.0,
555 min: None,
556 max: None,
557 }],
558 };
559 assert!(std::error::Error::source(&err).is_none());
560 }
561
562 #[test]
565 fn from_toml_de_error_produces_toml_variant() {
566 let toml_err: Result<toml::Value, toml::de::Error> = toml::from_str("=invalid");
567 let err: Error = toml_err.unwrap_err().into();
568 match &err {
569 Error::Toml(_) => {} other => panic!("expected Toml variant, got: {other:?}"),
571 }
572 }
573
574 #[test]
575 fn from_io_error_produces_io_variant_no_arc() {
576 let io_err = std::io::Error::new(std::io::ErrorKind::NotFound, "missing file");
577 let err: Error = io_err.into();
578 match &err {
579 Error::Io(e) => {
580 assert_eq!(e.kind(), std::io::ErrorKind::NotFound);
581 assert!(e.to_string().contains("missing file"));
582 }
583 other => panic!("expected Io variant, got: {other:?}"),
584 }
585 }
586
587 #[test]
590 fn error_kind_derives() {
591 let k = ErrorKind::Platform;
593 let dbg = format!("{k:?}");
594 assert!(dbg.contains("Platform"));
595
596 let k2 = k;
598 let k3 = k2;
599 assert_eq!(k, k3);
600
601 assert_eq!(ErrorKind::Parse, ErrorKind::Parse);
603 assert_ne!(ErrorKind::Io, ErrorKind::Resolution);
604 }
605
606 #[test]
607 fn range_violation_derives_debug_clone() {
608 let v = RangeViolation {
609 path: "x".into(),
610 value: 1.0,
611 min: Some(0.0),
612 max: Some(10.0),
613 };
614 let dbg = format!("{v:?}");
616 assert!(dbg.contains("RangeViolation"));
617
618 let v2 = v.clone();
620 assert_eq!(v2.path, "x");
621 assert!((v2.value - 1.0).abs() < f64::EPSILON);
622 }
623
624 #[test]
627 fn range_violation_display_both_bounds() {
628 let v = RangeViolation {
629 path: "button.min_width".into(),
630 value: -5.0,
631 min: Some(0.0),
632 max: Some(1000.0),
633 };
634 let msg = v.to_string();
635 assert!(msg.contains("button.min_width"), "got: {msg}");
636 assert!(msg.contains("0..=1000"), "got: {msg}");
637 assert!(msg.contains("-5"), "got: {msg}");
638 }
639
640 #[test]
641 fn range_violation_display_open_max() {
642 let v = RangeViolation {
643 path: "x".into(),
644 value: -1.0,
645 min: Some(0.0),
646 max: None,
647 };
648 let msg = v.to_string();
649 assert!(msg.contains("0..=inf"), "got: {msg}");
650 }
651
652 #[test]
653 fn range_violation_display_open_min() {
654 let v = RangeViolation {
655 path: "x".into(),
656 value: 999.0,
657 min: None,
658 max: Some(100.0),
659 };
660 let msg = v.to_string();
661 assert!(msg.contains("-inf..=100"), "got: {msg}");
662 }
663}