Skip to main content

native_theme/model/
mod.rs

1// Theme model: ThemeVariant and ThemeSpec, plus sub-module re-exports
2
3/// Animated icon types (frame sequences and transforms).
4pub mod animated;
5/// Bundled SVG icon lookup tables.
6pub mod bundled;
7/// Global theme defaults shared across widgets.
8pub mod defaults;
9/// Dialog button ordering convention.
10pub mod dialog_order;
11/// Per-widget font specification and text scale.
12pub mod font;
13/// Per-context icon sizes.
14pub mod icon_sizes;
15/// Icon roles, sets, and provider trait.
16pub mod icons;
17/// Resolved (non-optional) theme types produced after resolution.
18pub mod resolved;
19/// Logical spacing scale (xxs through xxl).
20pub mod spacing;
21/// Per-widget struct pairs and macros.
22pub mod widgets;
23
24pub use animated::{AnimatedIcon, TransformAnimation};
25pub use bundled::{bundled_icon_by_name, bundled_icon_svg};
26pub use defaults::ThemeDefaults;
27pub use dialog_order::DialogButtonOrder;
28pub use font::{FontSpec, ResolvedFontSpec, TextScale, TextScaleEntry};
29pub use icon_sizes::IconSizes;
30pub use icons::{
31    IconData, IconProvider, IconRole, IconSet, icon_name, system_icon_set, system_icon_theme,
32};
33pub use resolved::{
34    ResolvedIconSizes, ResolvedTextScale, ResolvedTextScaleEntry, ResolvedThemeDefaults,
35    ResolvedThemeSpacing, ResolvedThemeVariant,
36};
37pub use spacing::ThemeSpacing;
38pub use widgets::*; // All 25 XxxTheme + ResolvedXxxTheme pairs
39
40use serde::{Deserialize, Serialize};
41
42/// A single light or dark theme variant containing all visual properties.
43///
44/// Composes defaults, per-widget structs, and optional text scale into one coherent set.
45/// Empty sub-structs are omitted from serialization to keep TOML files clean.
46///
47/// # Examples
48///
49/// ```
50/// use native_theme::{ThemeVariant, Rgba};
51///
52/// let mut variant = ThemeVariant::default();
53/// variant.defaults.accent = Some(Rgba::rgb(0, 120, 215));
54/// variant.defaults.font.family = Some("Inter".into());
55/// assert!(!variant.is_empty());
56/// ```
57#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
58#[serde(default)]
59pub struct ThemeVariant {
60    /// Global defaults inherited by all widgets.
61    #[serde(default, skip_serializing_if = "ThemeDefaults::is_empty")]
62    pub defaults: ThemeDefaults,
63
64    /// Per-role text scale overrides.
65    #[serde(default, skip_serializing_if = "TextScale::is_empty")]
66    pub text_scale: TextScale,
67
68    /// Window chrome: background, title bar, radius, shadow.
69    #[serde(default, skip_serializing_if = "WindowTheme::is_empty")]
70    pub window: WindowTheme,
71
72    /// Push button: colors, sizing, spacing, geometry.
73    #[serde(default, skip_serializing_if = "ButtonTheme::is_empty")]
74    pub button: ButtonTheme,
75
76    /// Single-line and multi-line text input fields.
77    #[serde(default, skip_serializing_if = "InputTheme::is_empty")]
78    pub input: InputTheme,
79
80    /// Checkbox and radio button indicator geometry.
81    #[serde(default, skip_serializing_if = "CheckboxTheme::is_empty")]
82    pub checkbox: CheckboxTheme,
83
84    /// Popup and context menu appearance.
85    #[serde(default, skip_serializing_if = "MenuTheme::is_empty")]
86    pub menu: MenuTheme,
87
88    /// Tooltip popup appearance.
89    #[serde(default, skip_serializing_if = "TooltipTheme::is_empty")]
90    pub tooltip: TooltipTheme,
91
92    /// Scrollbar colors and geometry.
93    #[serde(default, skip_serializing_if = "ScrollbarTheme::is_empty")]
94    pub scrollbar: ScrollbarTheme,
95
96    /// Slider control colors and geometry.
97    #[serde(default, skip_serializing_if = "SliderTheme::is_empty")]
98    pub slider: SliderTheme,
99
100    /// Progress bar colors and geometry.
101    #[serde(default, skip_serializing_if = "ProgressBarTheme::is_empty")]
102    pub progress_bar: ProgressBarTheme,
103
104    /// Tab bar colors and sizing.
105    #[serde(default, skip_serializing_if = "TabTheme::is_empty")]
106    pub tab: TabTheme,
107
108    /// Sidebar panel background and foreground colors.
109    #[serde(default, skip_serializing_if = "SidebarTheme::is_empty")]
110    pub sidebar: SidebarTheme,
111
112    /// Toolbar sizing, spacing, and font.
113    #[serde(default, skip_serializing_if = "ToolbarTheme::is_empty")]
114    pub toolbar: ToolbarTheme,
115
116    /// Status bar font.
117    #[serde(default, skip_serializing_if = "StatusBarTheme::is_empty")]
118    pub status_bar: StatusBarTheme,
119
120    /// List and table colors and row geometry.
121    #[serde(default, skip_serializing_if = "ListTheme::is_empty")]
122    pub list: ListTheme,
123
124    /// Popover / dropdown panel appearance.
125    #[serde(default, skip_serializing_if = "PopoverTheme::is_empty")]
126    pub popover: PopoverTheme,
127
128    /// Splitter handle width.
129    #[serde(default, skip_serializing_if = "SplitterTheme::is_empty")]
130    pub splitter: SplitterTheme,
131
132    /// Separator line color.
133    #[serde(default, skip_serializing_if = "SeparatorTheme::is_empty")]
134    pub separator: SeparatorTheme,
135
136    /// Toggle switch track, thumb, and geometry.
137    #[serde(default, skip_serializing_if = "SwitchTheme::is_empty")]
138    pub switch: SwitchTheme,
139
140    /// Dialog sizing, spacing, button order, and title font.
141    #[serde(default, skip_serializing_if = "DialogTheme::is_empty")]
142    pub dialog: DialogTheme,
143
144    /// Spinner / indeterminate progress indicator.
145    #[serde(default, skip_serializing_if = "SpinnerTheme::is_empty")]
146    pub spinner: SpinnerTheme,
147
148    /// ComboBox / dropdown trigger sizing.
149    #[serde(default, skip_serializing_if = "ComboBoxTheme::is_empty")]
150    pub combo_box: ComboBoxTheme,
151
152    /// Segmented control sizing.
153    #[serde(default, skip_serializing_if = "SegmentedControlTheme::is_empty")]
154    pub segmented_control: SegmentedControlTheme,
155
156    /// Card / container colors and geometry.
157    #[serde(default, skip_serializing_if = "CardTheme::is_empty")]
158    pub card: CardTheme,
159
160    /// Expander / disclosure row geometry.
161    #[serde(default, skip_serializing_if = "ExpanderTheme::is_empty")]
162    pub expander: ExpanderTheme,
163
164    /// Hyperlink colors and underline setting.
165    #[serde(default, skip_serializing_if = "LinkTheme::is_empty")]
166    pub link: LinkTheme,
167
168    /// Which icon loading mechanism to use (`Freedesktop`, `Material`, `Lucide`,
169    /// `SfSymbols`, `SegoeIcons`).  Determines *how* icons are looked up — e.g.
170    /// freedesktop theme directories vs. bundled SVG tables.
171    /// When `None`, filled by [`resolve()`](ThemeVariant::resolve) from
172    /// [`system_icon_set()`](crate::system_icon_set).
173    #[serde(default, skip_serializing_if = "Option::is_none")]
174    pub icon_set: Option<IconSet>,
175
176    /// The name of the visual icon theme that provides the actual icon files
177    /// (e.g. `"breeze"`, `"Adwaita"`, `"Lucide"`).  For `Freedesktop` this
178    /// selects the theme directory; for bundled sets it is a display label.
179    /// When `None`, filled by [`resolve_platform_defaults()`](ThemeVariant::resolve_platform_defaults)
180    /// from [`system_icon_theme()`](crate::system_icon_theme).
181    #[serde(default, skip_serializing_if = "Option::is_none")]
182    pub icon_theme: Option<String>,
183}
184
185impl_merge!(ThemeVariant {
186    option { icon_set, icon_theme }
187    nested {
188        defaults, text_scale, window, button, input, checkbox, menu,
189        tooltip, scrollbar, slider, progress_bar, tab, sidebar,
190        toolbar, status_bar, list, popover, splitter, separator,
191        switch, dialog, spinner, combo_box, segmented_control,
192        card, expander, link
193    }
194});
195
196/// A complete native theme with a name and optional light/dark variants.
197///
198/// This is the top-level type that theme files deserialize into and that
199/// platform readers produce.
200///
201/// # Examples
202///
203/// ```
204/// use native_theme::ThemeSpec;
205///
206/// // Load a bundled preset
207/// let theme = ThemeSpec::preset("dracula").unwrap();
208/// assert_eq!(theme.name, "Dracula");
209///
210/// // Parse from a TOML string
211/// let toml = r##"
212/// name = "Custom"
213/// [light.defaults]
214/// accent = "#ff6600"
215/// "##;
216/// let custom = ThemeSpec::from_toml(toml).unwrap();
217/// assert_eq!(custom.name, "Custom");
218///
219/// // Merge themes (overlay wins for populated fields)
220/// let mut base = ThemeSpec::preset("catppuccin-mocha").unwrap();
221/// base.merge(&custom);
222/// assert_eq!(base.name, "Catppuccin Mocha"); // base name is preserved
223/// ```
224#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
225#[must_use = "constructing a theme without using it is likely a bug"]
226pub struct ThemeSpec {
227    /// Theme name (e.g., "Breeze", "Adwaita", "Windows 11").
228    pub name: String,
229
230    /// Light variant of the theme.
231    #[serde(default, skip_serializing_if = "Option::is_none")]
232    pub light: Option<ThemeVariant>,
233
234    /// Dark variant of the theme.
235    #[serde(default, skip_serializing_if = "Option::is_none")]
236    pub dark: Option<ThemeVariant>,
237}
238
239impl ThemeSpec {
240    /// Create a new theme with the given name and no variants.
241    pub fn new(name: impl Into<String>) -> Self {
242        Self {
243            name: name.into(),
244            light: None,
245            dark: None,
246        }
247    }
248
249    /// Merge an overlay theme into this theme.
250    ///
251    /// The base name is kept. For each variant (light/dark):
252    /// - If both base and overlay have a variant, they are merged recursively.
253    /// - If only the overlay has a variant, it is cloned into the base.
254    /// - If only the base has a variant (or neither), no change.
255    pub fn merge(&mut self, overlay: &Self) {
256        // Keep base name (do not overwrite)
257
258        match (&mut self.light, &overlay.light) {
259            (Some(base), Some(over)) => base.merge(over),
260            (None, Some(over)) => self.light = Some(over.clone()),
261            _ => {}
262        }
263
264        match (&mut self.dark, &overlay.dark) {
265            (Some(base), Some(over)) => base.merge(over),
266            (None, Some(over)) => self.dark = Some(over.clone()),
267            _ => {}
268        }
269    }
270
271    /// Pick the appropriate variant for the given mode, with cross-fallback.
272    ///
273    /// When `is_dark` is true, prefers `dark` and falls back to `light`.
274    /// When `is_dark` is false, prefers `light` and falls back to `dark`.
275    /// Returns `None` only if the theme has no variants at all.
276    #[must_use = "this returns the selected variant; it does not apply it"]
277    pub fn pick_variant(&self, is_dark: bool) -> Option<&ThemeVariant> {
278        if is_dark {
279            self.dark.as_ref().or(self.light.as_ref())
280        } else {
281            self.light.as_ref().or(self.dark.as_ref())
282        }
283    }
284
285    /// Extract a variant by consuming the theme, avoiding a clone.
286    ///
287    /// When `is_dark` is true, returns the `dark` variant (falling back to
288    /// `light`). When false, returns `light` (falling back to `dark`).
289    /// Returns `None` only if the theme has no variants at all.
290    ///
291    /// Use this when you own the `ThemeSpec` and don't need it afterward.
292    /// For read-only inspection, use [`pick_variant()`](Self::pick_variant).
293    ///
294    /// # Examples
295    ///
296    /// ```
297    /// let theme = native_theme::ThemeSpec::preset("dracula").unwrap();
298    /// let variant = theme.into_variant(true).unwrap();
299    /// let resolved = variant.into_resolved().unwrap();
300    /// ```
301    #[must_use = "this returns the extracted variant; it does not apply it"]
302    pub fn into_variant(self, is_dark: bool) -> Option<ThemeVariant> {
303        if is_dark {
304            self.dark.or(self.light)
305        } else {
306            self.light.or(self.dark)
307        }
308    }
309
310    /// Returns true if the theme has no variants set.
311    pub fn is_empty(&self) -> bool {
312        self.light.is_none() && self.dark.is_none()
313    }
314
315    /// Load a bundled theme preset by name.
316    ///
317    /// Returns the preset as a fully populated [`ThemeSpec`] with both
318    /// light and dark variants.
319    ///
320    /// # Errors
321    /// Returns [`crate::Error::Unavailable`] if the preset name is not recognized.
322    ///
323    /// # Examples
324    /// ```
325    /// let theme = native_theme::ThemeSpec::preset("catppuccin-mocha").unwrap();
326    /// assert!(theme.light.is_some());
327    /// ```
328    #[must_use = "this returns a theme preset; it does not apply it"]
329    pub fn preset(name: &str) -> crate::Result<Self> {
330        crate::presets::preset(name)
331    }
332
333    /// Parse a TOML string into a [`ThemeSpec`].
334    ///
335    /// # TOML Format
336    ///
337    /// Theme files use the following structure. All fields are `Option<T>` --
338    /// omit any field you don't need. Unknown fields are silently ignored.
339    /// Hex colors accept `#RRGGBB` or `#RRGGBBAA` format.
340    ///
341    /// ```toml
342    /// name = "My Theme"
343    ///
344    /// [light.defaults]
345    /// accent = "#4a90d9"
346    /// background = "#fafafa"
347    /// foreground = "#2e3436"
348    /// surface = "#ffffff"
349    /// border = "#c0c0c0"
350    /// muted = "#929292"
351    /// shadow = "#00000018"
352    /// danger = "#dc3545"
353    /// warning = "#f0ad4e"
354    /// success = "#28a745"
355    /// info = "#4a90d9"
356    /// selection = "#4a90d9"
357    /// selection_foreground = "#ffffff"
358    /// link = "#2a6cb6"
359    /// focus_ring_color = "#4a90d9"
360    /// disabled_foreground = "#c0c0c0"
361    /// radius = 6.0
362    /// radius_lg = 12.0
363    /// frame_width = 1.0
364    /// disabled_opacity = 0.5
365    /// border_opacity = 0.15
366    /// shadow_enabled = true
367    ///
368    /// [light.defaults.font]
369    /// family = "sans-serif"
370    /// size = 10.0
371    ///
372    /// [light.defaults.mono_font]
373    /// family = "monospace"
374    /// size = 10.0
375    ///
376    /// [light.defaults.spacing]
377    /// xxs = 2.0
378    /// xs = 4.0
379    /// s = 6.0
380    /// m = 12.0
381    /// l = 18.0
382    /// xl = 24.0
383    /// xxl = 36.0
384    ///
385    /// [light.button]
386    /// background = "#e8e8e8"
387    /// foreground = "#2e3436"
388    /// min_height = 32.0
389    /// padding_horizontal = 12.0
390    /// padding_vertical = 6.0
391    ///
392    /// [light.tooltip]
393    /// background = "#2e3436"
394    /// foreground = "#f0f0f0"
395    /// padding_horizontal = 6.0
396    /// padding_vertical = 6.0
397    ///
398    /// # [dark.*] mirrors the same structure as [light.*]
399    /// ```
400    ///
401    /// # Errors
402    /// Returns [`crate::Error::Format`] if the TOML is invalid.
403    ///
404    /// # Examples
405    /// ```
406    /// let toml = r##"
407    /// name = "My Theme"
408    /// [light.defaults]
409    /// accent = "#ff0000"
410    /// "##;
411    /// let theme = native_theme::ThemeSpec::from_toml(toml).unwrap();
412    /// assert_eq!(theme.name, "My Theme");
413    /// ```
414    #[must_use = "this parses a TOML string into a theme; it does not apply it"]
415    pub fn from_toml(toml_str: &str) -> crate::Result<Self> {
416        crate::presets::from_toml(toml_str)
417    }
418
419    /// Parse custom TOML and merge onto a base preset.
420    ///
421    /// This is the recommended way to create custom themes. The base preset
422    /// provides geometry, spacing, and widget defaults. The custom TOML
423    /// overrides colors, fonts, and any other fields.
424    ///
425    /// # Errors
426    ///
427    /// Returns [`crate::Error::Unavailable`] if the base preset name is not
428    /// recognized, or [`crate::Error::Format`] if the custom TOML is invalid.
429    ///
430    /// # Examples
431    ///
432    /// ```
433    /// let theme = native_theme::ThemeSpec::from_toml_with_base(
434    ///     r##"name = "My Theme"
435    /// [dark.defaults]
436    /// accent = "#ff6600"
437    /// background = "#1e1e1e"
438    /// foreground = "#e0e0e0""##,
439    ///     "material",
440    /// ).unwrap();
441    /// assert!(theme.dark.is_some());
442    /// ```
443    pub fn from_toml_with_base(toml_str: &str, base: &str) -> crate::Result<Self> {
444        let mut theme = Self::preset(base)?;
445        let overlay = Self::from_toml(toml_str)?;
446        theme.merge(&overlay);
447        Ok(theme)
448    }
449
450    /// Load a [`ThemeSpec`] from a TOML file.
451    ///
452    /// # Errors
453    /// Returns [`crate::Error::Unavailable`] if the file cannot be read.
454    ///
455    /// # Examples
456    /// ```no_run
457    /// let theme = native_theme::ThemeSpec::from_file("my-theme.toml").unwrap();
458    /// ```
459    #[must_use = "this loads a theme from a file; it does not apply it"]
460    pub fn from_file(path: impl AsRef<std::path::Path>) -> crate::Result<Self> {
461        crate::presets::from_file(path)
462    }
463
464    /// List all available bundled preset names.
465    ///
466    /// # Examples
467    /// ```
468    /// let names = native_theme::ThemeSpec::list_presets();
469    /// assert_eq!(names.len(), 16);
470    /// ```
471    #[must_use = "this returns the list of preset names"]
472    pub fn list_presets() -> &'static [&'static str] {
473        crate::presets::list_presets()
474    }
475
476    /// List preset names appropriate for the current platform.
477    ///
478    /// Platform-specific presets (kde-breeze, adwaita, windows-11, macos-sonoma, ios)
479    /// are only included on their native platform. Community themes are always included.
480    ///
481    /// # Examples
482    /// ```
483    /// let names = native_theme::ThemeSpec::list_presets_for_platform();
484    /// // On Linux KDE: includes kde-breeze, adwaita, plus all community themes
485    /// // On Windows: includes windows-11 plus all community themes
486    /// assert!(!names.is_empty());
487    /// ```
488    #[must_use = "this returns the filtered list of preset names for this platform"]
489    pub fn list_presets_for_platform() -> Vec<&'static str> {
490        crate::presets::list_presets_for_platform()
491    }
492
493    /// Serialize this theme to a TOML string.
494    ///
495    /// # Errors
496    /// Returns [`crate::Error::Format`] if serialization fails.
497    ///
498    /// # Examples
499    /// ```
500    /// let theme = native_theme::ThemeSpec::preset("catppuccin-mocha").unwrap();
501    /// let toml_str = theme.to_toml().unwrap();
502    /// assert!(toml_str.contains("name = \"Catppuccin Mocha\""));
503    /// ```
504    #[must_use = "this serializes the theme to TOML; it does not write to a file"]
505    pub fn to_toml(&self) -> crate::Result<String> {
506        crate::presets::to_toml(self)
507    }
508
509    /// Check a TOML string for unrecognized field names.
510    ///
511    /// Parses the TOML as a generic table and walks all keys, comparing
512    /// against the known fields for each section. Returns a `Vec<String>`
513    /// of warnings for any keys that don't match a known field. An empty
514    /// vec means all keys are recognized.
515    ///
516    /// This is an opt-in linting tool for theme authors. It does NOT affect
517    /// `from_toml()` behavior (which silently ignores unknown fields via serde).
518    ///
519    /// # Errors
520    ///
521    /// Returns `Err` if the TOML string cannot be parsed at all.
522    ///
523    /// # Examples
524    ///
525    /// ```
526    /// let warnings = native_theme::ThemeSpec::lint_toml(r##"
527    /// name = "Test"
528    /// [light.defaults]
529    /// backround = "#ffffff"
530    /// "##).unwrap();
531    /// assert_eq!(warnings.len(), 1);
532    /// assert!(warnings[0].contains("backround"));
533    /// ```
534    pub fn lint_toml(toml_str: &str) -> crate::Result<Vec<String>> {
535        use crate::model::defaults::ThemeDefaults;
536
537        let value: toml::Value = toml::from_str(toml_str)
538            .map_err(|e: toml::de::Error| crate::Error::Format(e.to_string()))?;
539
540        let mut warnings = Vec::new();
541
542        let top_table = match &value {
543            toml::Value::Table(t) => t,
544            _ => return Ok(warnings),
545        };
546
547        // Known top-level keys
548        const TOP_KEYS: &[&str] = &["name", "light", "dark"];
549
550        for key in top_table.keys() {
551            if !TOP_KEYS.contains(&key.as_str()) {
552                warnings.push(format!("unknown field: {key}"));
553            }
554        }
555
556        // Variant-level known keys: widget names + special fields
557        const VARIANT_KEYS: &[&str] = &[
558            "defaults",
559            "text_scale",
560            "window",
561            "button",
562            "input",
563            "checkbox",
564            "menu",
565            "tooltip",
566            "scrollbar",
567            "slider",
568            "progress_bar",
569            "tab",
570            "sidebar",
571            "toolbar",
572            "status_bar",
573            "list",
574            "popover",
575            "splitter",
576            "separator",
577            "switch",
578            "dialog",
579            "spinner",
580            "combo_box",
581            "segmented_control",
582            "card",
583            "expander",
584            "link",
585            "icon_set",
586            "icon_theme",
587        ];
588
589        // TextScaleEntry known fields
590        const TEXT_SCALE_ENTRY_FIELDS: &[&str] = &["size", "weight", "line_height"];
591
592        // TextScale known keys (entry names)
593        const TEXT_SCALE_KEYS: &[&str] = &["caption", "section_heading", "dialog_title", "display"];
594
595        // FontSpec known fields (for nested sub-tables like defaults.font)
596        const FONT_FIELDS: &[&str] = &["family", "size", "weight"];
597
598        // SpacingTheme known fields
599        const SPACING_FIELDS: &[&str] = &["xxs", "xs", "s", "m", "l", "xl", "xxl"];
600
601        // IconSizes known fields
602        const ICON_SIZES_FIELDS: &[&str] = &["toolbar", "small", "large", "dialog", "panel"];
603
604        /// Look up the known field names for a given widget section key.
605        fn widget_fields(section: &str) -> Option<&'static [&'static str]> {
606            match section {
607                "window" => Some(WindowTheme::FIELD_NAMES),
608                "button" => Some(ButtonTheme::FIELD_NAMES),
609                "input" => Some(InputTheme::FIELD_NAMES),
610                "checkbox" => Some(CheckboxTheme::FIELD_NAMES),
611                "menu" => Some(MenuTheme::FIELD_NAMES),
612                "tooltip" => Some(TooltipTheme::FIELD_NAMES),
613                "scrollbar" => Some(ScrollbarTheme::FIELD_NAMES),
614                "slider" => Some(SliderTheme::FIELD_NAMES),
615                "progress_bar" => Some(ProgressBarTheme::FIELD_NAMES),
616                "tab" => Some(TabTheme::FIELD_NAMES),
617                "sidebar" => Some(SidebarTheme::FIELD_NAMES),
618                "toolbar" => Some(ToolbarTheme::FIELD_NAMES),
619                "status_bar" => Some(StatusBarTheme::FIELD_NAMES),
620                "list" => Some(ListTheme::FIELD_NAMES),
621                "popover" => Some(PopoverTheme::FIELD_NAMES),
622                "splitter" => Some(SplitterTheme::FIELD_NAMES),
623                "separator" => Some(SeparatorTheme::FIELD_NAMES),
624                "switch" => Some(SwitchTheme::FIELD_NAMES),
625                "dialog" => Some(DialogTheme::FIELD_NAMES),
626                "spinner" => Some(SpinnerTheme::FIELD_NAMES),
627                "combo_box" => Some(ComboBoxTheme::FIELD_NAMES),
628                "segmented_control" => Some(SegmentedControlTheme::FIELD_NAMES),
629                "card" => Some(CardTheme::FIELD_NAMES),
630                "expander" => Some(ExpanderTheme::FIELD_NAMES),
631                "link" => Some(LinkTheme::FIELD_NAMES),
632                _ => None,
633            }
634        }
635
636        // Lint a text_scale section
637        fn lint_text_scale(
638            table: &toml::map::Map<String, toml::Value>,
639            prefix: &str,
640            warnings: &mut Vec<String>,
641        ) {
642            for key in table.keys() {
643                if !TEXT_SCALE_KEYS.contains(&key.as_str()) {
644                    warnings.push(format!("unknown field: {prefix}.{key}"));
645                } else if let Some(toml::Value::Table(entry_table)) = table.get(key) {
646                    for ekey in entry_table.keys() {
647                        if !TEXT_SCALE_ENTRY_FIELDS.contains(&ekey.as_str()) {
648                            warnings.push(format!("unknown field: {prefix}.{key}.{ekey}"));
649                        }
650                    }
651                }
652            }
653        }
654
655        // Lint a defaults section (with nested font, mono_font, spacing, icon_sizes)
656        fn lint_defaults(
657            table: &toml::map::Map<String, toml::Value>,
658            prefix: &str,
659            warnings: &mut Vec<String>,
660        ) {
661            for key in table.keys() {
662                if !ThemeDefaults::FIELD_NAMES.contains(&key.as_str()) {
663                    warnings.push(format!("unknown field: {prefix}.{key}"));
664                    continue;
665                }
666                // Check sub-tables for nested struct fields
667                if let Some(toml::Value::Table(sub)) = table.get(key) {
668                    let known = match key.as_str() {
669                        "font" | "mono_font" => FONT_FIELDS,
670                        "spacing" => SPACING_FIELDS,
671                        "icon_sizes" => ICON_SIZES_FIELDS,
672                        _ => continue,
673                    };
674                    for skey in sub.keys() {
675                        if !known.contains(&skey.as_str()) {
676                            warnings.push(format!("unknown field: {prefix}.{key}.{skey}"));
677                        }
678                    }
679                }
680            }
681        }
682
683        // Lint a variant section (light or dark)
684        fn lint_variant(
685            table: &toml::map::Map<String, toml::Value>,
686            prefix: &str,
687            warnings: &mut Vec<String>,
688        ) {
689            for key in table.keys() {
690                if !VARIANT_KEYS.contains(&key.as_str()) {
691                    warnings.push(format!("unknown field: {prefix}.{key}"));
692                    continue;
693                }
694
695                if let Some(toml::Value::Table(sub)) = table.get(key) {
696                    let sub_prefix = format!("{prefix}.{key}");
697                    match key.as_str() {
698                        "defaults" => lint_defaults(sub, &sub_prefix, warnings),
699                        "text_scale" => lint_text_scale(sub, &sub_prefix, warnings),
700                        _ => {
701                            if let Some(fields) = widget_fields(key) {
702                                for skey in sub.keys() {
703                                    if !fields.contains(&skey.as_str()) {
704                                        warnings
705                                            .push(format!("unknown field: {sub_prefix}.{skey}"));
706                                    }
707                                }
708                            }
709                        }
710                    }
711                }
712            }
713        }
714
715        // Lint light and dark variant sections
716        for variant_key in &["light", "dark"] {
717            if let Some(toml::Value::Table(variant_table)) = top_table.get(*variant_key) {
718                lint_variant(variant_table, variant_key, &mut warnings);
719            }
720        }
721
722        Ok(warnings)
723    }
724}
725
726#[cfg(test)]
727#[allow(clippy::unwrap_used, clippy::expect_used)]
728mod tests {
729    use super::*;
730    use crate::Rgba;
731
732    // === ThemeVariant tests ===
733
734    #[test]
735    fn theme_variant_default_is_empty() {
736        assert!(ThemeVariant::default().is_empty());
737    }
738
739    #[test]
740    fn theme_variant_not_empty_when_color_set() {
741        let mut v = ThemeVariant::default();
742        v.defaults.accent = Some(Rgba::rgb(0, 120, 215));
743        assert!(!v.is_empty());
744    }
745
746    #[test]
747    fn theme_variant_not_empty_when_font_set() {
748        let mut v = ThemeVariant::default();
749        v.defaults.font.family = Some("Inter".into());
750        assert!(!v.is_empty());
751    }
752
753    #[test]
754    fn theme_variant_merge_recursively() {
755        let mut base = ThemeVariant::default();
756        base.defaults.background = Some(Rgba::rgb(255, 255, 255));
757        base.defaults.font.family = Some("Noto Sans".into());
758
759        let mut overlay = ThemeVariant::default();
760        overlay.defaults.accent = Some(Rgba::rgb(0, 120, 215));
761        overlay.defaults.spacing.m = Some(12.0);
762
763        base.merge(&overlay);
764
765        // base background preserved
766        assert_eq!(base.defaults.background, Some(Rgba::rgb(255, 255, 255)));
767        // overlay accent applied
768        assert_eq!(base.defaults.accent, Some(Rgba::rgb(0, 120, 215)));
769        // base font preserved
770        assert_eq!(base.defaults.font.family.as_deref(), Some("Noto Sans"));
771        // overlay spacing applied
772        assert_eq!(base.defaults.spacing.m, Some(12.0));
773    }
774
775    #[test]
776    fn theme_variant_has_all_widgets() {
777        let mut v = ThemeVariant::default();
778        // Set a field on each of the 25 widgets
779        v.window.radius = Some(4.0);
780        v.button.min_height = Some(32.0);
781        v.input.min_height = Some(32.0);
782        v.checkbox.indicator_size = Some(18.0);
783        v.menu.item_height = Some(28.0);
784        v.tooltip.padding_horizontal = Some(6.0);
785        v.scrollbar.width = Some(14.0);
786        v.slider.track_height = Some(4.0);
787        v.progress_bar.height = Some(6.0);
788        v.tab.min_height = Some(32.0);
789        v.sidebar.background = Some(Rgba::rgb(240, 240, 240));
790        v.toolbar.height = Some(40.0);
791        v.status_bar.font = Some(crate::model::FontSpec::default());
792        v.list.item_height = Some(28.0);
793        v.popover.radius = Some(6.0);
794        v.splitter.width = Some(4.0);
795        v.separator.color = Some(Rgba::rgb(200, 200, 200));
796        v.switch.track_width = Some(32.0);
797        v.dialog.min_width = Some(320.0);
798        v.spinner.diameter = Some(24.0);
799        v.combo_box.min_height = Some(32.0);
800        v.segmented_control.segment_height = Some(28.0);
801        v.card.radius = Some(8.0);
802        v.expander.header_height = Some(32.0);
803        v.link.underline = Some(true);
804
805        assert!(!v.is_empty());
806        assert!(!v.window.is_empty());
807        assert!(!v.button.is_empty());
808        assert!(!v.input.is_empty());
809        assert!(!v.checkbox.is_empty());
810        assert!(!v.menu.is_empty());
811        assert!(!v.tooltip.is_empty());
812        assert!(!v.scrollbar.is_empty());
813        assert!(!v.slider.is_empty());
814        assert!(!v.progress_bar.is_empty());
815        assert!(!v.tab.is_empty());
816        assert!(!v.sidebar.is_empty());
817        assert!(!v.toolbar.is_empty());
818        assert!(!v.status_bar.is_empty());
819        assert!(!v.list.is_empty());
820        assert!(!v.popover.is_empty());
821        assert!(!v.splitter.is_empty());
822        assert!(!v.separator.is_empty());
823        assert!(!v.switch.is_empty());
824        assert!(!v.dialog.is_empty());
825        assert!(!v.spinner.is_empty());
826        assert!(!v.combo_box.is_empty());
827        assert!(!v.segmented_control.is_empty());
828        assert!(!v.card.is_empty());
829        assert!(!v.expander.is_empty());
830        assert!(!v.link.is_empty());
831    }
832
833    #[test]
834    fn theme_variant_merge_per_widget() {
835        let mut base = ThemeVariant::default();
836        base.button.background = Some(Rgba::rgb(200, 200, 200));
837        base.button.foreground = Some(Rgba::rgb(0, 0, 0));
838        base.tooltip.background = Some(Rgba::rgb(50, 50, 50));
839
840        let mut overlay = ThemeVariant::default();
841        overlay.button.background = Some(Rgba::rgb(255, 255, 255));
842        overlay.button.min_height = Some(32.0);
843
844        base.merge(&overlay);
845
846        // overlay background wins
847        assert_eq!(base.button.background, Some(Rgba::rgb(255, 255, 255)));
848        // overlay min_height added
849        assert_eq!(base.button.min_height, Some(32.0));
850        // base foreground preserved
851        assert_eq!(base.button.foreground, Some(Rgba::rgb(0, 0, 0)));
852        // tooltip from base preserved
853        assert_eq!(base.tooltip.background, Some(Rgba::rgb(50, 50, 50)));
854    }
855
856    // === ThemeSpec tests ===
857
858    #[test]
859    fn native_theme_new_constructor() {
860        let theme = ThemeSpec::new("Breeze");
861        assert_eq!(theme.name, "Breeze");
862        assert!(theme.light.is_none());
863        assert!(theme.dark.is_none());
864    }
865
866    #[test]
867    fn native_theme_default_is_empty() {
868        let theme = ThemeSpec::default();
869        assert!(theme.is_empty());
870        assert_eq!(theme.name, "");
871    }
872
873    #[test]
874    fn native_theme_merge_keeps_base_name() {
875        let mut base = ThemeSpec::new("Base Theme");
876        let overlay = ThemeSpec::new("Overlay Theme");
877        base.merge(&overlay);
878        assert_eq!(base.name, "Base Theme");
879    }
880
881    #[test]
882    fn native_theme_merge_overlay_light_into_none() {
883        let mut base = ThemeSpec::new("Theme");
884
885        let mut overlay = ThemeSpec::new("Overlay");
886        let mut light = ThemeVariant::default();
887        light.defaults.accent = Some(Rgba::rgb(0, 120, 215));
888        overlay.light = Some(light);
889
890        base.merge(&overlay);
891
892        assert!(base.light.is_some());
893        assert_eq!(
894            base.light.as_ref().unwrap().defaults.accent,
895            Some(Rgba::rgb(0, 120, 215))
896        );
897    }
898
899    #[test]
900    fn native_theme_merge_both_light_variants() {
901        let mut base = ThemeSpec::new("Theme");
902        let mut base_light = ThemeVariant::default();
903        base_light.defaults.background = Some(Rgba::rgb(255, 255, 255));
904        base.light = Some(base_light);
905
906        let mut overlay = ThemeSpec::new("Overlay");
907        let mut overlay_light = ThemeVariant::default();
908        overlay_light.defaults.accent = Some(Rgba::rgb(0, 120, 215));
909        overlay.light = Some(overlay_light);
910
911        base.merge(&overlay);
912
913        let light = base.light.as_ref().unwrap();
914        // base background preserved
915        assert_eq!(light.defaults.background, Some(Rgba::rgb(255, 255, 255)));
916        // overlay accent merged in
917        assert_eq!(light.defaults.accent, Some(Rgba::rgb(0, 120, 215)));
918    }
919
920    #[test]
921    fn native_theme_merge_base_light_only_preserved() {
922        let mut base = ThemeSpec::new("Theme");
923        let mut base_light = ThemeVariant::default();
924        base_light.defaults.font.family = Some("Inter".into());
925        base.light = Some(base_light);
926
927        let overlay = ThemeSpec::new("Overlay"); // no light
928
929        base.merge(&overlay);
930
931        assert!(base.light.is_some());
932        assert_eq!(
933            base.light.as_ref().unwrap().defaults.font.family.as_deref(),
934            Some("Inter")
935        );
936    }
937
938    #[test]
939    fn native_theme_merge_dark_variant() {
940        let mut base = ThemeSpec::new("Theme");
941
942        let mut overlay = ThemeSpec::new("Overlay");
943        let mut dark = ThemeVariant::default();
944        dark.defaults.background = Some(Rgba::rgb(30, 30, 30));
945        overlay.dark = Some(dark);
946
947        base.merge(&overlay);
948
949        assert!(base.dark.is_some());
950        assert_eq!(
951            base.dark.as_ref().unwrap().defaults.background,
952            Some(Rgba::rgb(30, 30, 30))
953        );
954    }
955
956    #[test]
957    fn native_theme_not_empty_with_light() {
958        let mut theme = ThemeSpec::new("Theme");
959        theme.light = Some(ThemeVariant::default());
960        assert!(!theme.is_empty());
961    }
962
963    // === pick_variant tests ===
964
965    #[test]
966    fn pick_variant_dark_with_both_variants_returns_dark() {
967        let mut theme = ThemeSpec::new("Test");
968        let mut light = ThemeVariant::default();
969        light.defaults.background = Some(Rgba::rgb(255, 255, 255));
970        theme.light = Some(light);
971        let mut dark = ThemeVariant::default();
972        dark.defaults.background = Some(Rgba::rgb(30, 30, 30));
973        theme.dark = Some(dark);
974
975        let picked = theme.pick_variant(true).unwrap();
976        assert_eq!(picked.defaults.background, Some(Rgba::rgb(30, 30, 30)));
977    }
978
979    #[test]
980    fn pick_variant_light_with_both_variants_returns_light() {
981        let mut theme = ThemeSpec::new("Test");
982        let mut light = ThemeVariant::default();
983        light.defaults.background = Some(Rgba::rgb(255, 255, 255));
984        theme.light = Some(light);
985        let mut dark = ThemeVariant::default();
986        dark.defaults.background = Some(Rgba::rgb(30, 30, 30));
987        theme.dark = Some(dark);
988
989        let picked = theme.pick_variant(false).unwrap();
990        assert_eq!(picked.defaults.background, Some(Rgba::rgb(255, 255, 255)));
991    }
992
993    #[test]
994    fn pick_variant_dark_with_only_light_falls_back() {
995        let mut theme = ThemeSpec::new("Test");
996        let mut light = ThemeVariant::default();
997        light.defaults.background = Some(Rgba::rgb(255, 255, 255));
998        theme.light = Some(light);
999
1000        let picked = theme.pick_variant(true).unwrap();
1001        assert_eq!(picked.defaults.background, Some(Rgba::rgb(255, 255, 255)));
1002    }
1003
1004    #[test]
1005    fn pick_variant_light_with_only_dark_falls_back() {
1006        let mut theme = ThemeSpec::new("Test");
1007        let mut dark = ThemeVariant::default();
1008        dark.defaults.background = Some(Rgba::rgb(30, 30, 30));
1009        theme.dark = Some(dark);
1010
1011        let picked = theme.pick_variant(false).unwrap();
1012        assert_eq!(picked.defaults.background, Some(Rgba::rgb(30, 30, 30)));
1013    }
1014
1015    #[test]
1016    fn pick_variant_with_no_variants_returns_none() {
1017        let theme = ThemeSpec::new("Empty");
1018        assert!(theme.pick_variant(true).is_none());
1019        assert!(theme.pick_variant(false).is_none());
1020    }
1021
1022    // === icon_set tests ===
1023
1024    #[test]
1025    fn icon_set_default_is_none() {
1026        assert!(ThemeVariant::default().icon_set.is_none());
1027    }
1028
1029    #[test]
1030    fn icon_set_merge_overlay() {
1031        let mut base = ThemeVariant::default();
1032        let overlay = ThemeVariant {
1033            icon_set: Some(IconSet::Material),
1034            ..Default::default()
1035        };
1036        base.merge(&overlay);
1037        assert_eq!(base.icon_set, Some(IconSet::Material));
1038    }
1039
1040    #[test]
1041    fn icon_set_merge_none_preserves() {
1042        let mut base = ThemeVariant {
1043            icon_set: Some(IconSet::SfSymbols),
1044            ..Default::default()
1045        };
1046        let overlay = ThemeVariant::default();
1047        base.merge(&overlay);
1048        assert_eq!(base.icon_set, Some(IconSet::SfSymbols));
1049    }
1050
1051    #[test]
1052    fn icon_set_is_empty_when_set() {
1053        assert!(ThemeVariant::default().is_empty());
1054        let v = ThemeVariant {
1055            icon_set: Some(IconSet::Material),
1056            ..Default::default()
1057        };
1058        assert!(!v.is_empty());
1059    }
1060
1061    #[test]
1062    fn icon_set_toml_round_trip() {
1063        let variant = ThemeVariant {
1064            icon_set: Some(IconSet::Material),
1065            ..Default::default()
1066        };
1067        let toml_str = toml::to_string(&variant).unwrap();
1068        assert!(toml_str.contains("icon_set"));
1069        let deserialized: ThemeVariant = toml::from_str(&toml_str).unwrap();
1070        assert_eq!(deserialized.icon_set, Some(IconSet::Material));
1071    }
1072
1073    #[test]
1074    fn icon_set_toml_absent_deserializes_to_none() {
1075        let toml_str = r##"
1076[defaults]
1077accent = "#ff0000"
1078"##;
1079        let variant: ThemeVariant = toml::from_str(toml_str).unwrap();
1080        assert!(variant.icon_set.is_none());
1081    }
1082
1083    #[test]
1084    fn native_theme_serde_toml_round_trip() {
1085        let mut theme = ThemeSpec::new("Test Theme");
1086        let mut light = ThemeVariant::default();
1087        light.defaults.accent = Some(Rgba::rgb(0, 120, 215));
1088        light.defaults.font.family = Some("Segoe UI".into());
1089        light.defaults.radius = Some(4.0);
1090        light.defaults.spacing.m = Some(12.0);
1091        theme.light = Some(light);
1092
1093        let toml_str = toml::to_string(&theme).unwrap();
1094        let deserialized: ThemeSpec = toml::from_str(&toml_str).unwrap();
1095
1096        assert_eq!(deserialized.name, "Test Theme");
1097        let l = deserialized.light.unwrap();
1098        assert_eq!(l.defaults.accent, Some(Rgba::rgb(0, 120, 215)));
1099        assert_eq!(l.defaults.font.family.as_deref(), Some("Segoe UI"));
1100        assert_eq!(l.defaults.radius, Some(4.0));
1101        assert_eq!(l.defaults.spacing.m, Some(12.0));
1102    }
1103
1104    // === from_toml_with_base tests ===
1105
1106    #[test]
1107    fn from_toml_with_base_merges_colors_onto_preset() {
1108        let custom_toml = r##"
1109name = "Custom Colors"
1110
1111[dark.defaults]
1112accent = "#ff6600"
1113background = "#1e1e1e"
1114foreground = "#e0e0e0"
1115"##;
1116        let theme = ThemeSpec::from_toml_with_base(custom_toml, "material").unwrap();
1117
1118        // Base name is preserved (material's name)
1119        assert_eq!(theme.name, "Material");
1120
1121        // Both variants should exist (material has both)
1122        assert!(theme.light.is_some());
1123        assert!(theme.dark.is_some());
1124
1125        // Custom dark colors applied
1126        let dark = theme.dark.as_ref().unwrap();
1127        assert_eq!(dark.defaults.accent, Some(Rgba::rgb(255, 102, 0)));
1128        assert_eq!(dark.defaults.background, Some(Rgba::rgb(30, 30, 30)));
1129        assert_eq!(dark.defaults.foreground, Some(Rgba::rgb(224, 224, 224)));
1130
1131        // Base geometry preserved (material has these)
1132        assert!(dark.button.min_height.is_some());
1133        assert!(dark.defaults.spacing.m.is_some());
1134
1135        // resolve_all + validate should succeed
1136        let mut dark_clone = dark.clone();
1137        dark_clone.resolve_all();
1138        dark_clone.validate().unwrap();
1139    }
1140
1141    #[test]
1142    fn from_toml_with_base_unknown_preset_returns_error() {
1143        let err = ThemeSpec::from_toml_with_base("name = \"X\"", "nonexistent").unwrap_err();
1144        match err {
1145            crate::Error::Unavailable(msg) => assert!(msg.contains("nonexistent")),
1146            other => panic!("expected Unavailable, got: {other:?}"),
1147        }
1148    }
1149
1150    #[test]
1151    fn from_toml_with_base_invalid_toml_returns_error() {
1152        let err = ThemeSpec::from_toml_with_base("{{{{invalid", "material").unwrap_err();
1153        match err {
1154            crate::Error::Format(_) => {}
1155            other => panic!("expected Format, got: {other:?}"),
1156        }
1157    }
1158
1159    // === lint_toml tests ===
1160
1161    #[test]
1162    fn lint_toml_valid_returns_empty() {
1163        let toml = r##"
1164name = "Valid Theme"
1165[light.defaults]
1166accent = "#ff0000"
1167background = "#ffffff"
1168[light.defaults.font]
1169family = "Inter"
1170size = 14.0
1171[light.button]
1172min_height = 32.0
1173padding_horizontal = 12.0
1174"##;
1175        let warnings = ThemeSpec::lint_toml(toml).unwrap();
1176        assert!(
1177            warnings.is_empty(),
1178            "Expected no warnings, got: {warnings:?}"
1179        );
1180    }
1181
1182    #[test]
1183    fn lint_toml_detects_unknown_top_level() {
1184        let toml = r##"
1185name = "Test"
1186theme_version = 2
1187"##;
1188        let warnings = ThemeSpec::lint_toml(toml).unwrap();
1189        assert_eq!(warnings.len(), 1);
1190        assert!(warnings[0].contains("theme_version"));
1191    }
1192
1193    #[test]
1194    fn lint_toml_detects_misspelled_defaults_field() {
1195        let toml = r##"
1196name = "Test"
1197[light.defaults]
1198backround = "#ffffff"
1199"##;
1200        let warnings = ThemeSpec::lint_toml(toml).unwrap();
1201        assert_eq!(warnings.len(), 1);
1202        assert!(warnings[0].contains("backround"));
1203        assert!(warnings[0].contains("light.defaults.backround"));
1204    }
1205
1206    #[test]
1207    fn lint_toml_detects_unknown_widget_field() {
1208        let toml = r##"
1209name = "Test"
1210[dark.button]
1211primary_bg = "#0078d7"
1212"##;
1213        let warnings = ThemeSpec::lint_toml(toml).unwrap();
1214        assert_eq!(warnings.len(), 1);
1215        assert!(warnings[0].contains("primary_bg"));
1216    }
1217
1218    #[test]
1219    fn lint_toml_detects_unknown_variant_section() {
1220        let toml = r##"
1221name = "Test"
1222[light.badges]
1223color = "#ff0000"
1224"##;
1225        let warnings = ThemeSpec::lint_toml(toml).unwrap();
1226        assert_eq!(warnings.len(), 1);
1227        assert!(warnings[0].contains("badges"));
1228    }
1229
1230    #[test]
1231    fn lint_toml_detects_unknown_font_subfield() {
1232        let toml = r##"
1233name = "Test"
1234[light.defaults.font]
1235famly = "Inter"
1236"##;
1237        let warnings = ThemeSpec::lint_toml(toml).unwrap();
1238        assert_eq!(warnings.len(), 1);
1239        assert!(warnings[0].contains("famly"));
1240    }
1241
1242    #[test]
1243    fn lint_toml_detects_unknown_spacing_subfield() {
1244        let toml = r##"
1245name = "Test"
1246[light.defaults.spacing]
1247medium = 12.0
1248"##;
1249        let warnings = ThemeSpec::lint_toml(toml).unwrap();
1250        assert_eq!(warnings.len(), 1);
1251        assert!(warnings[0].contains("medium"));
1252    }
1253
1254    #[test]
1255    fn lint_toml_detects_unknown_text_scale_entry() {
1256        let toml = r##"
1257name = "Test"
1258[light.text_scale.headline]
1259size = 24.0
1260"##;
1261        let warnings = ThemeSpec::lint_toml(toml).unwrap();
1262        assert_eq!(warnings.len(), 1);
1263        assert!(warnings[0].contains("headline"));
1264    }
1265
1266    #[test]
1267    fn lint_toml_detects_unknown_text_scale_entry_field() {
1268        let toml = r##"
1269name = "Test"
1270[light.text_scale.caption]
1271font_size = 12.0
1272"##;
1273        let warnings = ThemeSpec::lint_toml(toml).unwrap();
1274        assert_eq!(warnings.len(), 1);
1275        assert!(warnings[0].contains("font_size"));
1276    }
1277
1278    #[test]
1279    fn lint_toml_multiple_errors() {
1280        let toml = r##"
1281name = "Test"
1282author = "Me"
1283[light.defaults]
1284backround = "#ffffff"
1285[light.button]
1286primay_bg = "#0078d7"
1287"##;
1288        let warnings = ThemeSpec::lint_toml(toml).unwrap();
1289        assert_eq!(warnings.len(), 3);
1290    }
1291
1292    #[test]
1293    fn lint_toml_invalid_toml_returns_error() {
1294        let result = ThemeSpec::lint_toml("{{{{invalid");
1295        assert!(result.is_err());
1296    }
1297
1298    #[test]
1299    fn lint_toml_preset_has_no_warnings() {
1300        let theme = ThemeSpec::preset("catppuccin-mocha").unwrap();
1301        let toml_str = theme.to_toml().unwrap();
1302        let warnings = ThemeSpec::lint_toml(&toml_str).unwrap();
1303        assert!(
1304            warnings.is_empty(),
1305            "Preset catppuccin-mocha should have no lint warnings, got: {warnings:?}"
1306        );
1307    }
1308
1309    #[test]
1310    fn lint_toml_all_presets_clean() {
1311        for name in ThemeSpec::list_presets() {
1312            let theme = ThemeSpec::preset(name).unwrap();
1313            let toml_str = theme.to_toml().unwrap();
1314            let warnings = ThemeSpec::lint_toml(&toml_str).unwrap();
1315            assert!(
1316                warnings.is_empty(),
1317                "Preset '{name}' has lint warnings: {warnings:?}"
1318            );
1319        }
1320    }
1321}