Skip to main content

native_theme/
error.rs

1// Error enum with Display, std::error::Error, and From conversions
2//
3// Option F: flat 10-variant Error + ErrorKind + RangeViolation
4
5use std::fmt;
6
7/// A range-violation error for a single theme property.
8///
9/// Produced during validation when a resolved numeric property falls outside
10/// its allowed range.
11#[derive(Debug, Clone)]
12pub struct RangeViolation {
13    /// Dot-separated path of the property (e.g. `"button.min_width"`).
14    pub path: String,
15    /// The actual value found.
16    pub value: f64,
17    /// Lower bound (inclusive), or `None` for open-ended.
18    pub min: Option<f64>,
19    /// Upper bound (inclusive), or `None` for open-ended.
20    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/// Coarse error category returned by [`Error::kind()`].
38///
39/// Follows the `std::io::ErrorKind` precedent: callers can match on the kind
40/// for broad dispatch without inspecting each variant.
41#[derive(Debug, Clone, Copy, PartialEq, Eq)]
42#[non_exhaustive]
43pub enum ErrorKind {
44    /// Platform feature not available or not supported.
45    Platform,
46    /// Parsing or lookup failure (TOML, preset name).
47    Parse,
48    /// Theme resolution failure (missing fields, range violations).
49    Resolution,
50    /// File I/O error.
51    Io,
52}
53
54/// Errors that can occur when reading or processing theme data.
55///
56/// This is a flat enum with 10 variants. Use [`Error::kind()`] for coarse
57/// dispatch without matching every variant.
58#[derive(Debug)]
59#[non_exhaustive]
60pub enum Error {
61    /// The platform has a theme reader, but the Cargo feature that compiles it
62    /// is not enabled (`macos` on macOS, `windows` on Windows).
63    FeatureDisabled {
64        /// Feature name (e.g. `"kde"`, `"portal"`).
65        name: &'static str,
66        /// What the feature is needed for (e.g. `"KDE theme detection"`).
67        needed_for: &'static str,
68    },
69
70    /// The current platform is not supported.
71    PlatformUnsupported {
72        /// Platform identifier (e.g. `"wasm"`, `"freebsd"`).
73        platform: &'static str,
74    },
75
76    /// A preset name was not found in the bundled set.
77    UnknownPreset {
78        /// The requested preset name.
79        name: String,
80        /// Available preset names.
81        known: &'static [&'static str],
82    },
83
84    /// Theme-change watching is not available: no backend for this desktop
85    /// environment, or the platform's feature is disabled.
86    WatchUnavailable {
87        /// Why watching is unavailable.
88        reason: &'static str,
89    },
90
91    /// The theme has no variant for the requested color mode.
92    ///
93    /// Returned by [`Theme::pick_variant()`](crate::Theme::pick_variant) and
94    /// [`Theme::into_variant()`](crate::Theme::into_variant) when the theme
95    /// has neither a light nor a dark variant set.
96    NoVariant {
97        /// The color mode that was requested.
98        mode: crate::theme::ColorMode,
99    },
100
101    /// TOML parsing error. (Serialization errors from `Theme::to_toml` are
102    /// `ReaderFailed` with reader `"toml-serializer"`.)
103    Toml(toml::de::Error),
104
105    /// File I/O error.
106    Io(std::io::Error),
107
108    /// Theme resolution found missing fields that could not be inherited.
109    ResolutionIncomplete {
110        /// Dot-separated paths of fields still `None` after resolution.
111        missing: Vec<String>,
112    },
113
114    /// Theme resolution found numeric values outside allowed ranges.
115    ResolutionInvalid {
116        /// One entry per out-of-range property.
117        errors: Vec<RangeViolation>,
118    },
119
120    /// A platform reader failed with a platform-specific error.
121    ReaderFailed {
122        /// Reader name (e.g. `"kde"`, `"windows"`, `"icon-theme"`).
123        reader: &'static str,
124        /// The underlying error.
125        source: Box<dyn std::error::Error + Send + Sync>,
126    },
127}
128
129impl Error {
130    /// Returns the coarse [`ErrorKind`] for this error.
131    ///
132    /// Useful for broad dispatch (e.g. "is this a platform problem or a
133    /// parse problem?") without matching every variant.
134    #[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                // Group fields by category, preserving insertion order within each group.
183                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                // Hint when root defaults are missing (the most common user mistake).
201                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
232/// Categorize a field path into a human-readable group name.
233fn 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        // Serialization errors are stringified because Error::Toml wraps only
267        // toml::de::Error. This preserves the existing From<toml::ser::Error>
268        // conversion used by presets::to_toml().
269        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    // === ErrorKind dispatch tests ===
288
289    #[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    // === Send + Sync ===
365
366    #[test]
367    fn error_is_send_sync() {
368        fn assert_send_sync<T: Send + Sync>() {}
369        assert_send_sync::<Error>();
370    }
371
372    // === Display tests ===
373
374    #[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        // icon_set is no longer validated per-variant; test a widget field instead
428        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    // === source() tests ===
475
476    #[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    // === From impls ===
563
564    #[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(_) => {} // correct
570            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    // === Derive checks ===
588
589    #[test]
590    fn error_kind_derives() {
591        // Debug
592        let k = ErrorKind::Platform;
593        let dbg = format!("{k:?}");
594        assert!(dbg.contains("Platform"));
595
596        // Clone + Copy
597        let k2 = k;
598        let k3 = k2;
599        assert_eq!(k, k3);
600
601        // PartialEq + Eq
602        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        // Debug
615        let dbg = format!("{v:?}");
616        assert!(dbg.contains("RangeViolation"));
617
618        // Clone
619        let v2 = v.clone();
620        assert_eq!(v2.path, "x");
621        assert!((v2.value - 1.0).abs() < f64::EPSILON);
622    }
623
624    // === RangeViolation Display ===
625
626    #[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}