Skip to main content

native_theme/
pipeline.rs

1//! Theme pipeline: reader -> preset merge -> resolve -> validate.
2
3use std::fmt;
4
5#[cfg(target_os = "linux")]
6use crate::detect::{LinuxDesktop, detect_linux_desktop, parse_linux_desktop, system_is_dark};
7
8use crate::model::Theme;
9use crate::{OverlaySource, ReaderOutput, ReaderResult, SystemTheme};
10
11/// Run the OS-first pipeline: merge reader output onto a platform
12/// preset, resolve both light and dark variants, validate.
13///
14/// Accepts a [`ReaderResult`] containing the reader's variant data
15/// and metadata (name, icon_set, layout, font_dpi, accessibility).
16///
17/// `ReaderOutput::Single` readers provide one variant; the pipeline
18/// fills the other from the full platform preset. `ReaderOutput::Dual`
19/// readers provide both; the pipeline uses both after merging.
20pub(crate) fn run_pipeline(
21    reader: ReaderResult,
22    preset_name: &str,
23    mode: crate::ColorMode,
24) -> crate::Result<SystemTheme> {
25    let ReaderResult {
26        output: reader_output,
27        name: reader_name,
28        icon_set: reader_icon_set,
29        layout: reader_layout,
30        font_dpi,
31        accessibility,
32    } = reader;
33
34    let live_preset = Theme::preset(preset_name)?;
35
36    // For the inactive variant, load the full preset (with colors).
37    // Falls back to original name if not a live preset (e.g. user preset).
38    let full_preset_name = preset_name.strip_suffix("-live").unwrap_or(preset_name);
39    debug_assert!(
40        full_preset_name != preset_name || !preset_name.ends_with("-live"),
41        "live preset '{preset_name}' should have -live suffix stripped"
42    );
43    let full_preset = Theme::preset(full_preset_name)?;
44
45    // Reconstruct a Theme from the type-safe ReaderOutput for merge
46    let reader_as_theme = reader_output.to_theme(&reader_name, reader_icon_set, &reader_layout);
47
48    // Merge: full preset provides color/font defaults, live preset overrides
49    // geometry, reader output provides live OS data on top.
50    let mut merged = full_preset.clone();
51    merged.merge(&live_preset);
52    merged.merge(&reader_as_theme);
53
54    // Keep reader name if non-empty, else use preset name
55    let name = if reader_name.is_empty() {
56        merged.name.clone()
57    } else {
58        reader_name.clone()
59    };
60
61    // Resolve icon_set from Theme level (shared across variants)
62    let icon_set = merged
63        .icon_set
64        .unwrap_or_else(crate::model::icons::system_icon_set);
65
66    // Build ONE resolution-time context for this theme-build. The reader
67    // may have supplied an authoritative font_dpi (e.g. KDE forceFontDPI);
68    // when it does we override ctx.font_dpi. button_order + icon_theme
69    // come from the from_system() defaults (platform-detected).
70    let mut ctx = crate::resolve::ResolutionContext::from_system();
71    if let Some(dpi) = font_dpi {
72        ctx.font_dpi = dpi;
73    }
74
75    // Resolve icon_theme with three-tier precedence (per §G4 / doc 1 §20 Option C):
76    //   Tier 1: per-variant override (`ThemeMode::defaults.icon_theme`)
77    //   Tier 2: shared Theme-level value (`Theme::icon_theme`)
78    //   Tier 3: runtime system detect (from `ctx.icon_theme`)
79    // Must read before variants are consumed by unwrap_or_default().
80    // None where no tier names a theme: the TOML states none and detection
81    // failed (`ctx.icon_theme` is then None). No theme stands in for it.
82    // Both variants are resolved, kept by variant (`SystemTheme::icon_theme_for`);
83    // `icon_theme` is the active mode's copy.
84    let icon_theme_of =
85        |variant: &Option<crate::model::ThemeMode>| -> Option<std::borrow::Cow<'static, str>> {
86            variant
87                .as_ref()
88                .and_then(|v| v.defaults.icon_theme.clone()) // tier 1: per-variant override
89                .or_else(|| merged.icon_theme.clone()) // tier 2: Theme-level shared
90                .or_else(|| ctx.icon_theme.clone()) // tier 3: pre-detected system
91        };
92    let light_icon_theme = icon_theme_of(&merged.light);
93    let dark_icon_theme = icon_theme_of(&merged.dark);
94    let icon_theme = match mode {
95        crate::ColorMode::Light => light_icon_theme.clone(),
96        crate::ColorMode::Dark => dark_icon_theme.clone(),
97    };
98
99    // Shared across variants; read before the variants are moved out of `merged`.
100    let layout = merged.layout.clone();
101
102    // Match on ReaderOutput for type-safe variant selection:
103    // Single: active variant from merged, inactive from full preset.
104    // Dual: both variants from merged.
105    let (light_variant, dark_variant) = match &reader_output {
106        ReaderOutput::Single { is_dark, .. } => {
107            if *is_dark {
108                (
109                    full_preset.light.unwrap_or_default(),
110                    merged.dark.unwrap_or_default(),
111                )
112            } else {
113                (
114                    merged.light.unwrap_or_default(),
115                    full_preset.dark.unwrap_or_default(),
116                )
117            }
118        }
119        ReaderOutput::Dual { .. } => (
120            merged.light.unwrap_or_default(),
121            merged.dark.unwrap_or_default(),
122        ),
123    };
124
125    let light = light_variant.into_resolved(&ctx)?;
126    let dark = dark_variant.into_resolved(&ctx)?;
127
128    // Build OverlaySource from the original reader data + pipeline parameters
129    let overlay_source = OverlaySource {
130        reader_output,
131        name: reader_name,
132        icon_set: reader_icon_set,
133        layout: reader_layout,
134        preset_name: preset_name.to_string(),
135        context: ctx,
136    };
137
138    Ok(SystemTheme {
139        name,
140        mode,
141        light,
142        dark,
143        overlay_source,
144        preset: full_preset_name.to_string(),
145        live_preset: preset_name.to_string(),
146        icon_set,
147        icon_theme,
148        light_icon_theme,
149        dark_icon_theme,
150        layout,
151        accessibility,
152    })
153}
154
155// =============================================================================
156// Structured return types
157// =============================================================================
158
159/// A single diagnostic observation about platform theme support.
160///
161/// Returned by [`diagnose_platform_support()`]. Each variant represents
162/// a specific category of diagnostic information. Use `Display` to get
163/// a human-readable string, or pattern-match for programmatic inspection.
164///
165/// For simple tabular output, use the [`name()`](Self::name),
166/// [`status()`](Self::status), and [`detail()`](Self::detail) accessors:
167///
168/// ```
169/// let diagnostics = native_theme::pipeline::diagnose_platform_support();
170/// for entry in &diagnostics {
171///     print!("{}: {}", entry.name(), entry.status());
172///     if let Some(detail) = entry.detail() {
173///         print!(" ({})", detail);
174///     }
175///     println!();
176/// }
177/// ```
178#[derive(Clone, Debug, PartialEq, Eq)]
179#[non_exhaustive]
180pub enum DiagnosticEntry {
181    /// Platform identification (e.g. "Linux", "macOS", "Windows").
182    Platform(&'static str),
183    /// Detected desktop environment (Linux only).
184    #[cfg(target_os = "linux")]
185    DesktopEnv(crate::detect::LinuxDesktop),
186    /// An environment variable was read successfully.
187    EnvVar {
188        /// Variable name (e.g. `"XDG_CURRENT_DESKTOP"`).
189        name: &'static str,
190        /// Variable value as read from the environment.
191        value: String,
192    },
193    /// An environment variable was missing or empty.
194    EnvVarMissing(&'static str),
195    /// An external tool was found and operational.
196    ToolAvailable {
197        /// Tool binary name (e.g. `"gsettings"`).
198        name: &'static str,
199        /// Version string reported by the tool.
200        version: String,
201    },
202    /// An external tool was found but returned an error.
203    ToolError(&'static str),
204    /// An external tool was not found on PATH.
205    ToolMissing {
206        /// Tool binary name.
207        name: &'static str,
208        /// Human-readable description of what is lost.
209        impact: &'static str,
210    },
211    /// A config file was found at the given path.
212    ConfigFound {
213        /// Logical config name (e.g. `"KDE kdeglobals"`).
214        name: &'static str,
215        /// Filesystem path where the file was found.
216        path: std::path::PathBuf,
217    },
218    /// A config file was not found at the expected path.
219    ConfigMissing {
220        /// Logical config name.
221        name: &'static str,
222        /// Filesystem path that was checked.
223        path: std::path::PathBuf,
224    },
225    /// A cargo feature is enabled.
226    FeatureEnabled(&'static str),
227    /// A cargo feature is disabled.
228    FeatureDisabled {
229        /// Feature name (e.g. `"KDE"`, `"Portal"`).
230        feature: &'static str,
231        /// Human-readable description of what is lost.
232        impact: &'static str,
233    },
234}
235
236impl DiagnosticEntry {
237    /// A short label identifying what is being diagnosed.
238    ///
239    /// Examples: `"Platform"`, `"XDG_CURRENT_DESKTOP"`, `"gsettings"`,
240    /// `"KDE kdeglobals"`, `"Portal support"`.
241    #[must_use]
242    pub fn name(&self) -> &str {
243        match self {
244            Self::Platform(_) => "Platform",
245            #[cfg(target_os = "linux")]
246            Self::DesktopEnv(_) => "Detected DE",
247            Self::EnvVar { name, .. } | Self::EnvVarMissing(name) => name,
248            Self::ToolAvailable { name, .. }
249            | Self::ToolError(name)
250            | Self::ToolMissing { name, .. } => name,
251            Self::ConfigFound { name, .. } | Self::ConfigMissing { name, .. } => name,
252            Self::FeatureEnabled(feature) | Self::FeatureDisabled { feature, .. } => feature,
253        }
254    }
255
256    /// A short status string: the detected value or a state like
257    /// `"not set"`, `"available"`, `"found"`, `"enabled"`, etc.
258    #[must_use]
259    pub fn status(&self) -> &str {
260        match self {
261            Self::Platform(p) => p,
262            #[cfg(target_os = "linux")]
263            Self::DesktopEnv(_) => "detected",
264            Self::EnvVar { value, .. } => value.as_str(),
265            Self::EnvVarMissing(_) => "not set",
266            Self::ToolAvailable { .. } => "available",
267            Self::ToolError(_) => "found but returned error",
268            Self::ToolMissing { .. } => "not found",
269            Self::ConfigFound { .. } => "found",
270            Self::ConfigMissing { .. } => "not found",
271            Self::FeatureEnabled(_) => "enabled",
272            Self::FeatureDisabled { .. } => "disabled",
273        }
274    }
275
276    /// Optional extra detail (version string, file path, impact note, DE variant).
277    #[must_use]
278    pub fn detail(&self) -> Option<String> {
279        match self {
280            #[cfg(target_os = "linux")]
281            Self::DesktopEnv(de) => Some(format!("{de:?}")),
282            Self::ToolAvailable { version, .. } => Some(version.clone()),
283            Self::ToolMissing { impact, .. } => Some((*impact).to_string()),
284            Self::FeatureDisabled { impact, .. } => Some((*impact).to_string()),
285            Self::ConfigFound { path, .. } | Self::ConfigMissing { path, .. } => {
286                Some(path.display().to_string())
287            }
288            _ => None,
289        }
290    }
291}
292
293impl fmt::Display for DiagnosticEntry {
294    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
295        match self {
296            Self::Platform(p) => write!(f, "Platform: {p}"),
297            #[cfg(target_os = "linux")]
298            Self::DesktopEnv(de) => write!(f, "Detected DE: {de:?}"),
299            Self::EnvVar { name, value } => write!(f, "{name}: {value}"),
300            Self::EnvVarMissing(name) => write!(f, "{name}: not set"),
301            Self::ToolAvailable { name, version } => {
302                write!(f, "{name}: available ({version})")
303            }
304            Self::ToolError(name) => write!(f, "{name}: found but returned error"),
305            Self::ToolMissing { name, impact } => write!(f, "{name}: not found ({impact})"),
306            Self::ConfigFound { name, path } => {
307                write!(f, "{name}: found at {}", path.display())
308            }
309            Self::ConfigMissing { name, path } => {
310                write!(f, "{name}: not found at {}", path.display())
311            }
312            Self::FeatureEnabled(feature) => write!(f, "{feature} support: enabled"),
313            Self::FeatureDisabled { feature, impact } => {
314                write!(f, "{feature} support: disabled ({impact})")
315            }
316        }
317    }
318}
319
320/// Structured information about the platform's default preset.
321///
322/// Returned by [`platform_preset_name()`]. The `name` field is the
323/// user-facing preset name (e.g. `"macos-sonoma"`). The `is_live` field
324/// indicates whether the preset is a live preset (no colours or font
325/// families) used
326/// by the OS-first pipeline.
327///
328/// `Display` returns the user-facing name.
329#[derive(Clone, Debug, PartialEq, Eq)]
330pub struct PlatformPreset {
331    /// User-facing preset name (e.g. "kde-breeze", "adwaita", "macos-sonoma").
332    pub name: &'static str,
333    /// Whether this is a live preset (the merge base for OS readers: no
334    /// colours or font families).
335    pub is_live: bool,
336}
337
338impl PlatformPreset {
339    /// Returns the internal live preset name (e.g. `"kde-breeze-live"`)
340    /// when `is_live` is true, or the plain name when not.
341    ///
342    /// This is used internally by the pipeline to look up the correct
343    /// preset entry; callers should use [`name`](Self::name) for display.
344    #[must_use]
345    pub fn live_name(&self) -> String {
346        if self.is_live {
347            format!("{}-live", self.name)
348        } else {
349            self.name.to_string()
350        }
351    }
352}
353
354impl fmt::Display for PlatformPreset {
355    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
356        f.write_str(self.name)
357    }
358}
359
360/// Map a Linux desktop environment to its matching platform preset.
361///
362/// This is the single source of truth for the DE-to-preset mapping used
363/// by [`from_system_inner()`] and [`platform_preset_name()`].
364///
365/// - KDE -> `PlatformPreset { name: "kde-breeze", is_live: true }`
366/// - All others (GNOME, XFCE, Cinnamon, MATE, LXQt, Budgie, Unknown)
367///   -> `PlatformPreset { name: "adwaita", is_live: true }`
368#[cfg(target_os = "linux")]
369pub(crate) fn linux_preset_for_de(de: LinuxDesktop) -> PlatformPreset {
370    match de {
371        LinuxDesktop::Kde => PlatformPreset {
372            name: "kde-breeze",
373            is_live: true,
374        },
375        _ => PlatformPreset {
376            name: "adwaita",
377            is_live: true,
378        },
379    }
380}
381
382/// Map the current platform to its matching platform preset.
383///
384/// Live presets state no colours and no font families: geometry, metrics,
385/// font and icon sizes, and (except `kde-breeze-live`) the theme-level
386/// `icon_theme`. They are used as the merge base in the OS-first pipeline. Use
387/// [`PlatformPreset::live_name()`] to get the internal live preset key.
388///
389/// - macOS -> `PlatformPreset { name: "macos-sonoma", is_live: true }`
390/// - Windows -> `PlatformPreset { name: "windows-11", is_live: true }`
391/// - Linux KDE -> `PlatformPreset { name: "kde-breeze", is_live: true }`
392/// - Linux other/GNOME -> `PlatformPreset { name: "adwaita", is_live: true }`
393/// - Unknown platform -> `PlatformPreset { name: "adwaita", is_live: true }`
394///
395/// Returns a [`PlatformPreset`] with the user-facing preset name and
396/// whether it is a live (geometry-only) preset. Showcase UIs use
397/// `preset.name` to build the "default (...)" label.
398///
399/// # Examples
400///
401/// ```
402/// let preset = native_theme::pipeline::platform_preset_name();
403/// println!("Platform preset: {}", preset.name);
404/// assert!(!preset.name.contains("-live"));
405/// ```
406#[allow(unreachable_code)]
407#[must_use]
408pub fn platform_preset_name() -> PlatformPreset {
409    #[cfg(target_os = "macos")]
410    {
411        return PlatformPreset {
412            name: "macos-sonoma",
413            is_live: true,
414        };
415    }
416    #[cfg(target_os = "windows")]
417    {
418        return PlatformPreset {
419            name: "windows-11",
420            is_live: true,
421        };
422    }
423    #[cfg(target_os = "linux")]
424    {
425        linux_preset_for_de(detect_linux_desktop())
426    }
427    #[cfg(not(any(target_os = "linux", target_os = "windows", target_os = "macos")))]
428    {
429        PlatformPreset {
430            name: "adwaita",
431            is_live: true,
432        }
433    }
434}
435
436/// Check whether OS theme detection is available on this platform.
437///
438/// Returns a list of [`DiagnosticEntry`] values describing what detection
439/// capabilities are available and what might be missing. Useful for
440/// debugging theme detection failures in end-user applications.
441///
442/// Each entry can be printed directly via its `Display` impl, or
443/// inspected programmatically via [`DiagnosticEntry::name()`],
444/// [`DiagnosticEntry::status()`], and [`DiagnosticEntry::detail()`].
445///
446/// # Platform Behavior
447///
448/// - **Linux:** Reports detected desktop environment, `gsettings`
449///   availability, `XDG_CURRENT_DESKTOP` value, and KDE config file
450///   presence (when the `kde` feature is enabled).
451/// - **macOS:** Reports whether the `macos` feature is enabled.
452/// - **Windows:** Reports whether the `windows` feature is enabled.
453/// - **Other:** Reports that no platform detection is available.
454///
455/// # Examples
456///
457/// ```
458/// let diagnostics = native_theme::pipeline::diagnose_platform_support();
459/// for entry in &diagnostics {
460///     println!("{entry}");
461/// }
462/// ```
463#[must_use]
464pub fn diagnose_platform_support() -> Vec<DiagnosticEntry> {
465    let mut diagnostics = Vec::new();
466
467    #[cfg(target_os = "linux")]
468    {
469        diagnostics.push(DiagnosticEntry::Platform("Linux"));
470
471        // Check XDG_CURRENT_DESKTOP
472        match std::env::var("XDG_CURRENT_DESKTOP") {
473            Ok(val) if !val.is_empty() => {
474                let de = parse_linux_desktop(&val);
475                diagnostics.push(DiagnosticEntry::EnvVar {
476                    name: "XDG_CURRENT_DESKTOP",
477                    value: val,
478                });
479                diagnostics.push(DiagnosticEntry::DesktopEnv(de));
480            }
481            _ => {
482                diagnostics.push(DiagnosticEntry::EnvVarMissing("XDG_CURRENT_DESKTOP"));
483                diagnostics.push(DiagnosticEntry::DesktopEnv(LinuxDesktop::Unknown));
484            }
485        }
486
487        // Check gsettings availability
488        match std::process::Command::new("gsettings")
489            .arg("--version")
490            .output()
491        {
492            Ok(output) if output.status.success() => {
493                let version = String::from_utf8_lossy(&output.stdout).trim().to_string();
494                diagnostics.push(DiagnosticEntry::ToolAvailable {
495                    name: "gsettings",
496                    version,
497                });
498            }
499            Ok(_) => {
500                diagnostics.push(DiagnosticEntry::ToolError("gsettings"));
501            }
502            Err(_) => {
503                diagnostics.push(DiagnosticEntry::ToolMissing {
504                    name: "gsettings",
505                    impact: "dark mode and icon theme detection may be limited",
506                });
507            }
508        }
509
510        // Check KDE config files
511        #[cfg(feature = "kde")]
512        {
513            let path = crate::kde::kdeglobals_path();
514            if path.exists() {
515                diagnostics.push(DiagnosticEntry::ConfigFound {
516                    name: "KDE kdeglobals",
517                    path,
518                });
519            } else {
520                diagnostics.push(DiagnosticEntry::ConfigMissing {
521                    name: "KDE kdeglobals",
522                    path,
523                });
524            }
525        }
526
527        #[cfg(not(feature = "kde"))]
528        {
529            diagnostics.push(DiagnosticEntry::FeatureDisabled {
530                feature: "KDE",
531                impact: "kde feature not enabled",
532            });
533        }
534
535        // Report portal feature status
536        #[cfg(feature = "portal")]
537        diagnostics.push(DiagnosticEntry::FeatureEnabled("Portal"));
538
539        #[cfg(not(feature = "portal"))]
540        diagnostics.push(DiagnosticEntry::FeatureDisabled {
541            feature: "Portal",
542            impact: "portal feature not enabled",
543        });
544    }
545
546    #[cfg(target_os = "macos")]
547    {
548        diagnostics.push(DiagnosticEntry::Platform("macOS"));
549
550        #[cfg(feature = "macos")]
551        diagnostics.push(DiagnosticEntry::FeatureEnabled("macOS theme detection"));
552
553        #[cfg(not(feature = "macos"))]
554        diagnostics.push(DiagnosticEntry::FeatureDisabled {
555            feature: "macOS theme detection",
556            impact: "macos feature not enabled, using subprocess fallback",
557        });
558    }
559
560    #[cfg(target_os = "windows")]
561    {
562        diagnostics.push(DiagnosticEntry::Platform("Windows"));
563
564        #[cfg(feature = "windows")]
565        diagnostics.push(DiagnosticEntry::FeatureEnabled("Windows theme detection"));
566
567        #[cfg(not(feature = "windows"))]
568        diagnostics.push(DiagnosticEntry::FeatureDisabled {
569            feature: "Windows theme detection",
570            impact: "windows feature not enabled",
571        });
572    }
573
574    #[cfg(not(any(target_os = "linux", target_os = "macos", target_os = "windows")))]
575    {
576        diagnostics.push(DiagnosticEntry::Platform(
577            "unsupported (no native theme detection available)",
578        ));
579    }
580
581    diagnostics
582}
583
584/// Build a `ReaderResult` from a preset (for fallback paths where no
585/// platform reader is available).
586#[cfg(any(target_os = "linux", test))]
587fn preset_as_reader(preset_name: &str, mode: crate::ColorMode) -> crate::Result<ReaderResult> {
588    let theme = Theme::preset(preset_name)?;
589    let is_dark = mode == crate::ColorMode::Dark;
590    let output = if is_dark {
591        ReaderOutput::Single {
592            mode: Box::new(theme.dark.unwrap_or_default()),
593            is_dark: true,
594        }
595    } else {
596        ReaderOutput::Single {
597            mode: Box::new(theme.light.unwrap_or_default()),
598            is_dark: false,
599        }
600    };
601    Ok(ReaderResult {
602        output,
603        name: theme.name,
604        icon_set: theme.icon_set,
605        layout: theme.layout,
606        font_dpi: None,
607        accessibility: crate::AccessibilityPreferences::default(),
608    })
609}
610
611/// Platform + DE + feature cascade that picks the appropriate
612/// [`crate::reader::ThemeReader`] impl for the current system.
613///
614/// Returns `None` when no reader is available and the caller must fall back
615/// to a preset-only pipeline (non-KDE/GNOME Linux, unsupported platforms, or
616/// platforms whose reader feature is disabled). When `Some`, the companion
617/// `&'static str` is the `-live` preset name that should be passed to
618/// [`run_pipeline`] — the reader and the preset are tied because the preset
619/// is chosen from the detected DE.
620///
621/// Per plan 94-03 §G8: the `Box<dyn ThemeReader>` trait object is object-safe
622/// only because the trait carries a `#[async_trait::async_trait]` annotation
623/// (see [`crate::reader`] module docs for the full rationale).
624#[allow(unreachable_code)]
625#[allow(clippy::unnecessary_wraps)] // Option is part of the public contract.
626#[allow(clippy::needless_return)] // Explicit returns clarify cfg-gated dispatch.
627async fn select_reader() -> Option<(Box<dyn crate::reader::ThemeReader>, &'static str)> {
628    #[cfg(target_os = "macos")]
629    {
630        #[cfg(feature = "macos")]
631        {
632            return Some((Box::new(crate::macos::MacosReader), "macos-sonoma-live"));
633        }
634        #[cfg(not(feature = "macos"))]
635        return None;
636    }
637
638    #[cfg(target_os = "windows")]
639    {
640        #[cfg(feature = "windows")]
641        {
642            return Some((Box::new(crate::windows::WindowsReader), "windows-11-live"));
643        }
644        #[cfg(not(feature = "windows"))]
645        return None;
646    }
647
648    #[cfg(target_os = "linux")]
649    {
650        let de = detect_linux_desktop();
651        match de {
652            #[cfg(all(feature = "kde", feature = "portal"))]
653            LinuxDesktop::Kde => {
654                return Some((
655                    Box::new(crate::gnome::GnomePortalKdeReader),
656                    "kde-breeze-live",
657                ));
658            }
659            #[cfg(all(feature = "kde", not(feature = "portal")))]
660            LinuxDesktop::Kde => {
661                return Some((Box::new(crate::kde::KdeReader), "kde-breeze-live"));
662            }
663            #[cfg(not(feature = "kde"))]
664            LinuxDesktop::Kde => return None,
665            #[cfg(feature = "portal")]
666            LinuxDesktop::Gnome | LinuxDesktop::Budgie => {
667                return Some((Box::new(crate::gnome::GnomeReader), "adwaita-live"));
668            }
669            #[cfg(not(feature = "portal"))]
670            LinuxDesktop::Gnome | LinuxDesktop::Budgie => return None,
671            LinuxDesktop::Xfce
672            | LinuxDesktop::Cinnamon
673            | LinuxDesktop::Mate
674            | LinuxDesktop::LxQt
675            | LinuxDesktop::Hyprland
676            | LinuxDesktop::Sway
677            | LinuxDesktop::River
678            | LinuxDesktop::Niri
679            | LinuxDesktop::Wayfire
680            | LinuxDesktop::CosmicDe => return None,
681            LinuxDesktop::Unknown => {
682                // Refine heuristic via D-Bus portal backend detection
683                #[cfg(feature = "portal")]
684                {
685                    if let Some(detected) = crate::gnome::detect_portal_backend().await {
686                        return match detected {
687                            #[cfg(feature = "kde")]
688                            LinuxDesktop::Kde => Some((
689                                Box::new(crate::gnome::GnomePortalKdeReader),
690                                "kde-breeze-live",
691                            )),
692                            #[cfg(not(feature = "kde"))]
693                            LinuxDesktop::Kde => None,
694                            LinuxDesktop::Gnome => {
695                                Some((Box::new(crate::gnome::GnomeReader), "adwaita-live"))
696                            }
697                            // detect_portal_backend only returns Kde or Gnome;
698                            // any future extension falls back to Adwaita preset.
699                            _ => None,
700                        };
701                    }
702                }
703                // Sync fallback: try kdeglobals, then Adwaita
704                #[cfg(feature = "kde")]
705                {
706                    if crate::kde::kdeglobals_path().exists() {
707                        return Some((Box::new(crate::kde::KdeReader), "kde-breeze-live"));
708                    }
709                }
710                return None;
711            }
712        }
713    }
714
715    #[cfg(not(any(target_os = "linux", target_os = "windows", target_os = "macos")))]
716    {
717        None
718    }
719}
720
721/// Single async implementation for all platforms. On Linux this may contain
722/// `.await` points (portal D-Bus calls); on macOS/Windows the future resolves
723/// immediately (no `.await` points).
724///
725/// Called by:
726/// - `from_system()` via `pollster::block_on` on Linux, noop-waker single-poll
727///   on non-Linux.
728/// - `from_system_async()` via `.await`.
729///
730/// Delegates platform detection to [`select_reader`]: when a reader is
731/// available, its output feeds [`run_pipeline`]; when no reader is available,
732/// falls back to a preset-only pipeline or returns a feature/platform error.
733#[allow(unreachable_code)]
734#[allow(clippy::needless_return)] // Explicit returns clarify cfg-gated fallback.
735pub(crate) async fn from_system_inner() -> crate::Result<SystemTheme> {
736    if let Some((reader, preset_live)) = select_reader().await {
737        let result = reader.read().await?;
738        let mode = match &result.output {
739            ReaderOutput::Single { is_dark, .. } => {
740                if *is_dark {
741                    crate::ColorMode::Dark
742                } else {
743                    crate::ColorMode::Light
744                }
745            }
746            ReaderOutput::Dual { .. } => crate::ColorMode::Light,
747        };
748        return run_pipeline(result, preset_live, mode);
749    }
750
751    // Preset-only fallback paths — no reader available for this
752    // platform+feature combination.
753    #[cfg(target_os = "macos")]
754    {
755        return Err(crate::Error::FeatureDisabled {
756            name: "macos",
757            needed_for: "macOS theme detection",
758        });
759    }
760    #[cfg(target_os = "windows")]
761    {
762        return Err(crate::Error::FeatureDisabled {
763            name: "windows",
764            needed_for: "Windows theme detection",
765        });
766    }
767    #[cfg(target_os = "linux")]
768    {
769        let mode = if system_is_dark() {
770            crate::ColorMode::Dark
771        } else {
772            crate::ColorMode::Light
773        };
774        let preset_live = linux_preset_for_de(detect_linux_desktop()).live_name();
775        return run_pipeline(preset_as_reader("adwaita", mode)?, &preset_live, mode);
776    }
777    #[cfg(not(any(target_os = "linux", target_os = "windows", target_os = "macos")))]
778    {
779        Err(crate::Error::PlatformUnsupported {
780            platform: "unsupported",
781        })
782    }
783}
784
785/// Reader-only extraction for [`crate::AccessibilityPreferences::from_system`]:
786/// runs the same platform reader `from_system_inner` would run and returns its
787/// accessibility block without merging or resolving a theme. Where no reader
788/// is available (or it fails) the defaults are used. `reduce_motion` is OR-ed
789/// with [`crate::detect::detect_reduced_motion`], a floor for the cases where
790/// no reader supplies it and it stays at its default: Linux outside KDE and
791/// GNOME, and macOS or Windows builds without their reader feature. A reader's
792/// own `true` is never downgraded (spec §11.2).
793pub(crate) async fn accessibility_from_system_inner() -> crate::AccessibilityPreferences {
794    let mut prefs = match select_reader().await {
795        Some((reader, _preset)) => match reader.read().await {
796            Ok(result) => result.accessibility,
797            Err(_) => crate::AccessibilityPreferences::default(),
798        },
799        None => crate::AccessibilityPreferences::default(),
800    };
801    if !prefs.reduce_motion {
802        prefs.reduce_motion = crate::detect::detect_reduced_motion();
803    }
804    prefs
805}
806
807// =============================================================================
808// Tests
809// =============================================================================
810
811#[cfg(all(test, target_os = "linux"))]
812#[allow(clippy::unwrap_used, clippy::expect_used)]
813mod dispatch_tests {
814    use super::*;
815
816    // -- parse_linux_desktop() pure function tests --
817
818    #[test]
819    fn detect_kde_simple() {
820        assert_eq!(parse_linux_desktop("KDE"), LinuxDesktop::Kde);
821    }
822
823    #[test]
824    fn detect_kde_colon_separated_after() {
825        assert_eq!(parse_linux_desktop("ubuntu:KDE"), LinuxDesktop::Kde);
826    }
827
828    #[test]
829    fn detect_kde_colon_separated_before() {
830        assert_eq!(parse_linux_desktop("KDE:plasma"), LinuxDesktop::Kde);
831    }
832
833    #[test]
834    fn detect_gnome_simple() {
835        assert_eq!(parse_linux_desktop("GNOME"), LinuxDesktop::Gnome);
836    }
837
838    #[test]
839    fn detect_gnome_ubuntu() {
840        assert_eq!(parse_linux_desktop("ubuntu:GNOME"), LinuxDesktop::Gnome);
841    }
842
843    #[test]
844    fn detect_xfce() {
845        assert_eq!(parse_linux_desktop("XFCE"), LinuxDesktop::Xfce);
846    }
847
848    #[test]
849    fn detect_cinnamon() {
850        assert_eq!(parse_linux_desktop("X-Cinnamon"), LinuxDesktop::Cinnamon);
851    }
852
853    #[test]
854    fn detect_cinnamon_short() {
855        assert_eq!(parse_linux_desktop("Cinnamon"), LinuxDesktop::Cinnamon);
856    }
857
858    #[test]
859    fn detect_mate() {
860        assert_eq!(parse_linux_desktop("MATE"), LinuxDesktop::Mate);
861    }
862
863    #[test]
864    fn detect_lxqt() {
865        assert_eq!(parse_linux_desktop("LXQt"), LinuxDesktop::LxQt);
866    }
867
868    #[test]
869    fn detect_budgie() {
870        assert_eq!(parse_linux_desktop("Budgie:GNOME"), LinuxDesktop::Budgie);
871    }
872
873    #[test]
874    fn detect_empty_string() {
875        assert_eq!(parse_linux_desktop(""), LinuxDesktop::Unknown);
876    }
877
878    // -- Pure pipeline dispatch tests (no env var manipulation) --
879
880    #[test]
881    fn from_linux_non_kde_returns_adwaita() -> crate::Result<()> {
882        // GNOME desktop produces an Adwaita-named theme via the pure pipeline
883        let preset = linux_preset_for_de(LinuxDesktop::Gnome);
884        let result = preset_as_reader("adwaita", crate::ColorMode::Light)?;
885        let theme = run_pipeline(result, &preset.live_name(), crate::ColorMode::Light)?;
886        assert_eq!(theme.name, "Adwaita");
887        Ok(())
888    }
889
890    #[test]
891    #[cfg(feature = "kde")]
892    fn from_linux_unknown_de_with_kdeglobals_fallback() -> crate::Result<()> {
893        // Unknown DE with a kdeglobals file uses KDE reader -- test the dispatch
894        // branch by calling from_kde_content_pure directly with minimal fixture.
895        const MINIMAL_KDE_FIXTURE: &str = "\
896[General]
897ColorScheme=TestTheme
898
899[Colors:Window]
900BackgroundNormal=239,240,241
901
902[Colors:View]
903BackgroundNormal=252,252,252
904ForegroundNormal=35,38,41
905DecorationFocus=61,174,233
906BackgroundAlternate=239,240,241
907ForegroundLink=41,128,185";
908
909        let (reader_theme, dpi, acc) =
910            crate::kde::from_kde_content_pure(MINIMAL_KDE_FIXTURE, None)?;
911        let is_dark = reader_theme.dark.is_some() && reader_theme.light.is_none();
912        let output = if is_dark {
913            ReaderOutput::Single {
914                mode: Box::new(reader_theme.dark.unwrap_or_default()),
915                is_dark: true,
916            }
917        } else {
918            ReaderOutput::Single {
919                mode: Box::new(reader_theme.light.unwrap_or_default()),
920                is_dark: false,
921            }
922        };
923        let result = ReaderResult {
924            output,
925            name: reader_theme.name,
926            icon_set: reader_theme.icon_set,
927            layout: reader_theme.layout,
928            font_dpi: dpi,
929            accessibility: acc,
930        };
931        let preset = linux_preset_for_de(LinuxDesktop::Kde);
932        let theme = run_pipeline(result, &preset.live_name(), crate::ColorMode::Light)?;
933        assert_eq!(
934            theme.name, "TestTheme",
935            "should use KDE theme name from reader output"
936        );
937        Ok(())
938    }
939
940    #[test]
941    fn from_linux_unknown_de_without_kdeglobals_returns_adwaita() -> crate::Result<()> {
942        // Unknown DE without kdeglobals falls back to Adwaita preset
943        let preset = linux_preset_for_de(LinuxDesktop::Unknown);
944        let result = preset_as_reader("adwaita", crate::ColorMode::Light)?;
945        let theme = run_pipeline(result, &preset.live_name(), crate::ColorMode::Light)?;
946        assert_eq!(
947            theme.name, "Adwaita",
948            "should fall back to Adwaita without kdeglobals"
949        );
950        Ok(())
951    }
952
953    // -- LNXDE-03: Hyprland, Sway, COSMIC, River, Niri map to their own variants --
954
955    #[test]
956    fn detect_hyprland() {
957        assert_eq!(parse_linux_desktop("Hyprland"), LinuxDesktop::Hyprland);
958    }
959
960    #[test]
961    fn detect_sway() {
962        assert_eq!(parse_linux_desktop("sway"), LinuxDesktop::Sway);
963    }
964
965    #[test]
966    fn detect_cosmic() {
967        assert_eq!(parse_linux_desktop("COSMIC"), LinuxDesktop::CosmicDe);
968    }
969
970    #[test]
971    fn detect_river() {
972        assert_eq!(parse_linux_desktop("river"), LinuxDesktop::River);
973    }
974
975    #[test]
976    fn detect_niri() {
977        assert_eq!(parse_linux_desktop("niri"), LinuxDesktop::Niri);
978    }
979
980    #[test]
981    fn parses_xdg_wayfire() {
982        assert_eq!(parse_linux_desktop("Wayfire"), LinuxDesktop::Wayfire);
983        assert_eq!(parse_linux_desktop("Wayfire:GNOME"), LinuxDesktop::Wayfire);
984    }
985
986    #[test]
987    fn parses_xdg_empty_is_unknown() {
988        // Regression guard -- empty XDG_CURRENT_DESKTOP must keep mapping to Unknown
989        // even after the Wayfire variant addition.
990        assert_eq!(parse_linux_desktop(""), LinuxDesktop::Unknown);
991    }
992
993    #[test]
994    fn detect_cosmic_full_desktop() {
995        assert_eq!(
996            parse_linux_desktop("COSMIC:Freedesktop"),
997            LinuxDesktop::CosmicDe
998        );
999    }
1000
1001    // -- Pure pipeline smoke test (replaces from_system env var test) --
1002
1003    #[test]
1004    fn from_system_returns_result() {
1005        // Test the pure pipeline directly instead of mocking env vars for from_system()
1006        let result = preset_as_reader("adwaita", crate::ColorMode::Light).unwrap();
1007        let theme = run_pipeline(result, "adwaita-live", crate::ColorMode::Light)
1008            .expect("run_pipeline should succeed with adwaita preset");
1009        assert_eq!(theme.name, "Adwaita");
1010    }
1011}
1012
1013/// Tests for run_pipeline() -- internal pipeline functions.
1014/// These test functions moved from system_theme_tests in lib.rs since they
1015/// directly test pipeline internals rather than the SystemTheme public API.
1016#[cfg(test)]
1017#[allow(
1018    clippy::unwrap_used,
1019    clippy::expect_used,
1020    clippy::field_reassign_with_default
1021)]
1022mod pipeline_tests {
1023    use crate::color::Rgba;
1024    use crate::model::{LayoutTheme, Theme, ThemeMode};
1025    use crate::{ReaderOutput, ReaderResult};
1026
1027    use super::{preset_as_reader, run_pipeline};
1028
1029    /// Helper: build a ReaderResult from a preset for testing.
1030    fn reader_from_preset(preset_name: &str) -> ReaderResult {
1031        let preset = Theme::preset(preset_name).unwrap();
1032        ReaderResult {
1033            output: ReaderOutput::Dual {
1034                light: Box::new(preset.light.clone().unwrap_or_default()),
1035                dark: Box::new(preset.dark.clone().unwrap_or_default()),
1036            },
1037            name: preset.name,
1038            icon_set: preset.icon_set,
1039            layout: preset.layout,
1040            font_dpi: None,
1041            accessibility: crate::AccessibilityPreferences::default(),
1042        }
1043    }
1044
1045    // --- run_pipeline() tests ---
1046
1047    #[test]
1048    fn test_run_pipeline_produces_both_variants() {
1049        let reader = reader_from_preset("catppuccin-mocha");
1050        let result = run_pipeline(reader, "catppuccin-mocha", crate::ColorMode::Light);
1051        assert!(result.is_ok(), "run_pipeline should succeed");
1052        let st = result.unwrap();
1053        // Both light and dark exist as ResolvedTheme (non-Option)
1054        assert!(!st.name.is_empty(), "name should be populated");
1055        // If we get here, both variants validated successfully
1056    }
1057
1058    #[test]
1059    fn test_run_pipeline_reader_values_win() {
1060        // Create a reader output where the reader provides a custom accent
1061        // (simulating a platform reader that detected this accent from the OS)
1062        let custom_accent = Rgba::rgb(42, 100, 200);
1063        let mut variant = ThemeMode::default();
1064        variant.defaults.accent_color = Some(custom_accent);
1065
1066        let reader = ReaderResult {
1067            output: ReaderOutput::Single {
1068                mode: Box::new(variant),
1069                is_dark: false,
1070            },
1071            name: "CustomTheme".into(),
1072            icon_set: None,
1073            layout: LayoutTheme::default(),
1074            font_dpi: None,
1075            accessibility: crate::AccessibilityPreferences::default(),
1076        };
1077
1078        let result = run_pipeline(reader, "catppuccin-mocha", crate::ColorMode::Light);
1079        assert!(result.is_ok(), "run_pipeline should succeed");
1080        let st = result.unwrap();
1081        // The reader's accent should win over the preset's accent
1082        assert_eq!(
1083            st.light.defaults.accent_color, custom_accent,
1084            "reader accent should win over preset accent"
1085        );
1086        assert_eq!(st.name, "CustomTheme", "reader name should win");
1087    }
1088
1089    #[test]
1090    fn test_run_pipeline_single_variant() {
1091        // Simulate a real OS reader that provides a complete dark variant
1092        // (like KDE's from_kde() would) but no light variant.
1093        // Use a live preset so the inactive light variant gets the full preset.
1094        let full = Theme::preset("kde-breeze").unwrap();
1095        let mut dark_v = full.dark.clone().unwrap();
1096        // Override accent to prove reader values win (simulating OS-detected accent)
1097        dark_v.defaults.accent_color = Some(Rgba::rgb(200, 50, 50));
1098
1099        let reader = ReaderResult {
1100            output: ReaderOutput::Single {
1101                mode: Box::new(dark_v),
1102                is_dark: true,
1103            },
1104            name: std::borrow::Cow::Borrowed(""),
1105            icon_set: None,
1106            layout: LayoutTheme::default(),
1107            font_dpi: None,
1108            accessibility: crate::AccessibilityPreferences::default(),
1109        };
1110
1111        let result = run_pipeline(reader, "kde-breeze-live", crate::ColorMode::Dark);
1112        assert!(
1113            result.is_ok(),
1114            "run_pipeline should succeed with single variant"
1115        );
1116        let st = result.unwrap();
1117        // Dark should have the reader's overridden accent
1118        assert_eq!(
1119            st.dark.defaults.accent_color,
1120            Rgba::rgb(200, 50, 50),
1121            "dark variant should have reader accent"
1122        );
1123        // Light should still exist (from full preset, which has colors)
1124        // If we get here, both variants validated successfully
1125        assert_eq!(st.live_preset, "kde-breeze-live");
1126        assert_eq!(st.preset, "kde-breeze");
1127    }
1128
1129    #[test]
1130    fn test_run_pipeline_inactive_variant_from_full_preset() {
1131        // When reader provides only dark, light must come from the full preset
1132        // (not the live preset, which has no colors and would fail validation).
1133        let full = Theme::preset("kde-breeze").unwrap();
1134
1135        let reader = ReaderResult {
1136            output: ReaderOutput::Single {
1137                mode: Box::new(full.dark.clone().unwrap_or_default()),
1138                is_dark: true,
1139            },
1140            name: std::borrow::Cow::Borrowed(""),
1141            icon_set: None,
1142            layout: LayoutTheme::default(),
1143            font_dpi: None,
1144            accessibility: crate::AccessibilityPreferences::default(),
1145        };
1146
1147        let st = run_pipeline(reader, "kde-breeze-live", crate::ColorMode::Dark).unwrap();
1148
1149        // The light variant should have colors from the full "kde-breeze" preset
1150        let full_light = full.light.unwrap();
1151        assert_eq!(
1152            st.light.defaults.accent_color,
1153            full_light.defaults.accent_color.unwrap(),
1154            "inactive light variant should get accent from full preset"
1155        );
1156        assert_eq!(
1157            st.light.defaults.background_color,
1158            full_light.defaults.background_color.unwrap(),
1159            "inactive light variant should get background from full preset"
1160        );
1161    }
1162
1163    // --- run_pipeline with preset-as-reader (GNOME double-merge test) ---
1164
1165    #[test]
1166    fn test_run_pipeline_with_preset_as_reader() {
1167        // Simulates GNOME sync fallback: adwaita used as both reader and preset.
1168        // Double-merge is harmless: merge is idempotent for matching values.
1169        let reader = reader_from_preset("adwaita");
1170        let result = run_pipeline(reader, "adwaita", crate::ColorMode::Light);
1171        assert!(
1172            result.is_ok(),
1173            "double-merge with same preset should succeed"
1174        );
1175        let st = result.unwrap();
1176        assert_eq!(st.name, "Adwaita");
1177    }
1178
1179    // --- Single/Dual contract tests ---
1180
1181    #[test]
1182    fn test_single_variant_fills_inactive_from_preset() {
1183        // Simulate KDE-style single-variant reader: only dark variant provided.
1184        // The pipeline should fill light from the full preset.
1185        let full = Theme::preset("kde-breeze").unwrap();
1186        let dark_v = full.dark.clone().unwrap();
1187        let reader = ReaderResult {
1188            output: ReaderOutput::Single {
1189                mode: Box::new(dark_v),
1190                is_dark: true,
1191            },
1192            name: "Breeze Dark".into(),
1193            icon_set: full.icon_set,
1194            layout: full.layout.clone(),
1195            font_dpi: None,
1196            accessibility: crate::AccessibilityPreferences::default(),
1197        };
1198
1199        let st = run_pipeline(reader, "kde-breeze-live", crate::ColorMode::Dark).unwrap();
1200
1201        // Dark variant should have reader's data (merged with preset).
1202        // Light variant should come from the full preset (kde-breeze, not live).
1203        let full_light = Theme::preset("kde-breeze").unwrap().light.unwrap();
1204        assert_eq!(
1205            st.light.defaults.accent_color,
1206            full_light.defaults.accent_color.unwrap(),
1207            "Single: inactive light variant should come from full preset"
1208        );
1209        assert_eq!(st.mode, crate::ColorMode::Dark);
1210    }
1211
1212    #[test]
1213    fn test_dual_variant_uses_both_from_reader() {
1214        // Simulate macOS-style dual-variant reader: both variants provided.
1215        // The pipeline should use BOTH reader variants (merged with preset).
1216        let full = Theme::preset("macos-sonoma").unwrap();
1217        let custom_light_accent = Rgba::rgb(42, 100, 200);
1218        let custom_dark_accent = Rgba::rgb(200, 50, 50);
1219
1220        let mut light_v = full.light.clone().unwrap();
1221        light_v.defaults.accent_color = Some(custom_light_accent);
1222        let mut dark_v = full.dark.clone().unwrap();
1223        dark_v.defaults.accent_color = Some(custom_dark_accent);
1224
1225        let reader = ReaderResult {
1226            output: ReaderOutput::Dual {
1227                light: Box::new(light_v),
1228                dark: Box::new(dark_v),
1229            },
1230            name: "macOS Sonoma".into(),
1231            icon_set: full.icon_set,
1232            layout: full.layout.clone(),
1233            font_dpi: Some(72.0),
1234            accessibility: crate::AccessibilityPreferences::default(),
1235        };
1236
1237        let st = run_pipeline(reader, "macos-sonoma-live", crate::ColorMode::Light).unwrap();
1238
1239        // Both variants should reflect the reader's custom accents (merged).
1240        assert_eq!(
1241            st.light.defaults.accent_color, custom_light_accent,
1242            "Dual: light variant should have reader's light accent"
1243        );
1244        assert_eq!(
1245            st.dark.defaults.accent_color, custom_dark_accent,
1246            "Dual: dark variant should have reader's dark accent"
1247        );
1248    }
1249
1250    // --- Phase 93-04 G4: Theme.icon_theme + three-tier precedence ---
1251    //
1252    // Per docs/archive/v0.5.7_gaps.md §G4 and doc 1 §20 Option C:
1253    //   Tier 1 (highest): ThemeMode::defaults.icon_theme (per-variant override)
1254    //   Tier 2:           Theme::icon_theme (shared across variants)
1255    //   Tier 3 (fallback): system_icon_theme() (runtime detect), none where it fails
1256
1257    /// Helper: construct a ReaderResult that carries only the active variant
1258    /// (light) with no per-variant icon_theme. The pipeline must then consult
1259    /// the preset's Theme-level icon_theme (tier 2) or system detect (tier 3).
1260    fn reader_with_empty_variant(is_dark: bool) -> ReaderResult {
1261        ReaderResult {
1262            output: ReaderOutput::Single {
1263                mode: Box::new(ThemeMode::default()),
1264                is_dark,
1265            },
1266            name: std::borrow::Cow::Borrowed(""),
1267            icon_set: None,
1268            layout: LayoutTheme::default(),
1269            font_dpi: None,
1270            accessibility: crate::AccessibilityPreferences::default(),
1271        }
1272    }
1273
1274    #[test]
1275    fn icon_theme_tier2_theme_level_used_when_per_variant_none() {
1276        // Adwaita preset (after G4 migration) carries icon_theme = "Adwaita"
1277        // at the Theme level, and None on both variant defaults.
1278        // With a reader that provides an empty variant (no per-variant override),
1279        // the resolver must fall through to tier 2 and produce "Adwaita".
1280        let reader = reader_with_empty_variant(false);
1281        let st = run_pipeline(reader, "adwaita", crate::ColorMode::Light).unwrap();
1282        assert_eq!(
1283            st.icon_theme.as_deref(),
1284            Some("Adwaita"),
1285            "tier 2: Theme.icon_theme should be used when per-variant is None"
1286        );
1287    }
1288
1289    #[test]
1290    fn icon_theme_tier1_per_variant_wins_over_theme_level() {
1291        // Construct a reader whose active variant explicitly sets a per-variant
1292        // icon_theme override. Even with a preset that carries a Theme-level
1293        // icon_theme, the per-variant override must win.
1294        let mut variant = ThemeMode::default();
1295        variant.defaults.icon_theme = Some(std::borrow::Cow::Borrowed("custom-override"));
1296        let reader = ReaderResult {
1297            output: ReaderOutput::Single {
1298                mode: Box::new(variant),
1299                is_dark: false,
1300            },
1301            name: std::borrow::Cow::Borrowed(""),
1302            icon_set: None,
1303            layout: LayoutTheme::default(),
1304            font_dpi: None,
1305            accessibility: crate::AccessibilityPreferences::default(),
1306        };
1307        // Adwaita preset has Theme.icon_theme = "Adwaita" after migration.
1308        let st = run_pipeline(reader, "adwaita", crate::ColorMode::Light).unwrap();
1309        assert_eq!(
1310            st.icon_theme.as_deref(),
1311            Some("custom-override"),
1312            "tier 1: per-variant icon_theme override must win over Theme-level"
1313        );
1314    }
1315
1316    #[test]
1317    fn icon_theme_kde_per_variant_values_still_win() {
1318        // Regression guard for Phase 80-fix: KDE Breeze uses per-variant icon_theme
1319        // values ("breeze" light / "breeze-dark" dark) that MUST continue to win
1320        // over any Theme-level value. The kde-breeze preset keeps per-variant-only.
1321        let kde_full = Theme::preset("kde-breeze").unwrap();
1322        // Simulate KDE reader providing the dark variant with its per-variant value.
1323        let dark_v = kde_full.dark.clone().unwrap();
1324        assert_eq!(
1325            dark_v.defaults.icon_theme.as_deref(),
1326            Some("breeze-dark"),
1327            "precondition: kde-breeze dark variant must carry breeze-dark"
1328        );
1329        let reader = ReaderResult {
1330            output: ReaderOutput::Single {
1331                mode: Box::new(dark_v),
1332                is_dark: true,
1333            },
1334            name: std::borrow::Cow::Borrowed(""),
1335            icon_set: None,
1336            layout: LayoutTheme::default(),
1337            font_dpi: None,
1338            accessibility: crate::AccessibilityPreferences::default(),
1339        };
1340        let st = run_pipeline(reader, "kde-breeze-live", crate::ColorMode::Dark).unwrap();
1341        assert_eq!(
1342            st.icon_theme.as_deref(),
1343            Some("breeze-dark"),
1344            "KDE per-variant icon_theme must still win (Phase 80-fix invariant)"
1345        );
1346    }
1347
1348    #[test]
1349    fn theme_icon_theme_round_trips_when_some() {
1350        // Serialize a Theme with icon_theme: Some(...) and assert the
1351        // deserialized round-trip preserves the value.
1352        let theme = Theme {
1353            name: std::borrow::Cow::Borrowed("RoundTrip"),
1354            light: Some(ThemeMode::default()),
1355            dark: None,
1356            layout: LayoutTheme::default(),
1357            icon_set: None,
1358            icon_theme: Some(std::borrow::Cow::Borrowed("lucide")),
1359        };
1360        let toml_str = theme.to_toml().expect("serialize");
1361        let reparsed = Theme::from_toml(&toml_str).expect("deserialize");
1362        assert_eq!(
1363            reparsed.icon_theme.as_deref(),
1364            Some("lucide"),
1365            "Theme.icon_theme must round-trip through TOML"
1366        );
1367        // Verify the serialized form contains the key at the top level.
1368        assert!(
1369            toml_str.contains("icon_theme = \"lucide\""),
1370            "serialized TOML should contain top-level icon_theme, got:\n{toml_str}"
1371        );
1372    }
1373
1374    #[test]
1375    fn theme_icon_theme_skipped_when_none() {
1376        // Serialize a Theme with icon_theme: None and assert the TOML does NOT
1377        // emit the key (skip_serializing_if = Option::is_none).
1378        let theme = Theme {
1379            name: std::borrow::Cow::Borrowed("NoIcon"),
1380            light: Some(ThemeMode::default()),
1381            dark: None,
1382            layout: LayoutTheme::default(),
1383            icon_set: None,
1384            icon_theme: None,
1385        };
1386        let toml_str = theme.to_toml().expect("serialize");
1387        assert!(
1388            !toml_str.contains("icon_theme"),
1389            "serialized TOML should NOT contain icon_theme when None, got:\n{toml_str}"
1390        );
1391    }
1392
1393    #[test]
1394    fn lint_toml_accepts_top_level_icon_theme() {
1395        // lint_toml() must NOT warn about icon_theme at the top level.
1396        // Raw string uses ##"..."## because the TOML body contains `"#` hex colors.
1397        let toml_str = r##"
1398name = "Test"
1399icon_theme = "lucide"
1400
1401[light.defaults]
1402accent_color = "#0066cc"
1403"##;
1404        let warnings = Theme::lint_toml(toml_str).expect("lint");
1405        assert!(
1406            !warnings.iter().any(|w| w.contains("icon_theme")),
1407            "lint_toml must not flag top-level icon_theme as unknown; warnings: {warnings:?}"
1408        );
1409    }
1410
1411    /// §11.1: `SystemTheme.layout` is the reader's layout merged field-wise over
1412    /// the preset's. On the preset-only path the reader *is* the full preset, so
1413    /// the result equals full ⊕ live ⊕ full.
1414    #[test]
1415    fn system_theme_layout_is_the_merged_preset_layout() -> crate::Result<()> {
1416        let reader = preset_as_reader("adwaita", crate::ColorMode::Light)?;
1417        let sys = run_pipeline(reader, "adwaita-live", crate::ColorMode::Light)?;
1418
1419        let mut expected = Theme::preset("adwaita")?.layout;
1420        expected.merge(&Theme::preset("adwaita-live")?.layout);
1421        expected.merge(&Theme::preset("adwaita")?.layout);
1422
1423        assert_eq!(sys.layout, expected);
1424        assert!(
1425            sys.layout.widget_gap.is_some(),
1426            "adwaita defines all four layout keys (spec §1.3)"
1427        );
1428        Ok(())
1429    }
1430
1431    /// Both variants' icon themes are known whichever mode is active: the
1432    /// egui connector needs the name per `egui::Theme`, because egui draws
1433    /// either style after a scheme change (egui spec §4.2, §9).
1434    #[test]
1435    fn icon_theme_for_gives_each_variants_name() -> crate::Result<()> {
1436        for mode in [crate::ColorMode::Light, crate::ColorMode::Dark] {
1437            // A reader that names no icon theme: tier 1 (the reader's own
1438            // variant) is empty, so the preset's variants decide.
1439            let reader = ReaderResult {
1440                output: ReaderOutput::Single {
1441                    mode: Box::new(crate::model::ThemeMode::default()),
1442                    is_dark: mode == crate::ColorMode::Dark,
1443                },
1444                name: std::borrow::Cow::Borrowed(""),
1445                icon_set: None,
1446                layout: crate::theme::LayoutTheme::default(),
1447                font_dpi: None,
1448                accessibility: crate::AccessibilityPreferences::default(),
1449            };
1450            let theme = run_pipeline(reader, "kde-breeze-live", mode)?;
1451            assert_eq!(
1452                theme.icon_theme_for(crate::ColorMode::Light),
1453                Some("breeze"),
1454                "kde-breeze.toml:9 names breeze for light (active mode {mode:?})"
1455            );
1456            assert_eq!(
1457                theme.icon_theme_for(crate::ColorMode::Dark),
1458                Some("breeze-dark"),
1459                "kde-breeze.toml:317 names breeze-dark for dark (active mode {mode:?})"
1460            );
1461            assert_eq!(
1462                theme.icon_theme_for(mode),
1463                theme.icon_theme.as_deref(),
1464                "the active mode's name is `icon_theme` itself"
1465            );
1466            // `mode` is a public field: changing it after the build must not swap the names.
1467            let mut flipped = theme;
1468            flipped.mode = match mode {
1469                crate::ColorMode::Light => crate::ColorMode::Dark,
1470                crate::ColorMode::Dark => crate::ColorMode::Light,
1471            };
1472            assert_eq!(
1473                flipped.icon_theme_for(crate::ColorMode::Light),
1474                Some("breeze"),
1475                "built in {mode:?}, mode flipped"
1476            );
1477            assert_eq!(
1478                flipped.icon_theme_for(crate::ColorMode::Dark),
1479                Some("breeze-dark"),
1480                "built in {mode:?}, mode flipped"
1481            );
1482        }
1483        Ok(())
1484    }
1485}
1486
1487// =============================================================================
1488// Plan 94-03 (G8): ThemeReader trait + select_reader regression tests
1489// =============================================================================
1490//
1491// These tests lock the contract of the G8 ThemeReader refactor:
1492// - the `ThemeReader` trait exists under `crate::reader` and is object-safe
1493// - `select_reader()` exists and returns the correct platform-specific impl
1494//
1495// Object-safety is the load-bearing property: `select_reader()` is declared
1496// to return `Option<Box<dyn ThemeReader>>`, which only compiles if the trait
1497// is object-safe. On current stable Rust (1.95), native `async fn` in traits
1498// is NOT object-safe for `dyn Trait`; the trait definition must use
1499// `#[async_trait::async_trait]` to produce a `Pin<Box<dyn Future + Send>>`
1500// return type that vtables can hold. If an implementer deviates from the
1501// async-trait strategy (e.g., tries native async-fn-in-trait), the coercion
1502// below fails to compile with "the trait cannot be made into an object" —
1503// this is the structural enforcement of Option A (see plan 94-03 objective).
1504#[cfg(test)]
1505#[allow(clippy::unwrap_used, clippy::expect_used)]
1506mod theme_reader_trait_tests {
1507    /// Trait object-safety probe. With `#[async_trait::async_trait]` on the
1508    /// trait definition, this coercion compiles because the macro rewrite
1509    /// produces `Pin<Box<dyn Future + Send + '_>>` return types
1510    /// (vtable-compatible).
1511    #[test]
1512    fn theme_reader_trait_exists_and_is_object_safe() {
1513        // Plain function that requires its argument to be ?Sized — compile-time
1514        // proof that `Box<dyn ThemeReader>` is a valid trait object.
1515        //
1516        // `#[allow(dead_code)]` is required because on cfg permutations that
1517        // exclude every concrete reader (e.g. linux without kde feature) the
1518        // function is only name-resolved by the compiler but never called.
1519        #[allow(dead_code)]
1520        fn assert_object_safe<T: ?Sized>(_: &T) {}
1521
1522        // Use one of the concrete readers to construct the trait object on
1523        // the platforms where one exists.
1524        #[cfg(all(target_os = "linux", feature = "kde"))]
1525        {
1526            let r: Box<dyn crate::reader::ThemeReader> = Box::new(crate::kde::KdeReader);
1527            assert_object_safe(&r);
1528        }
1529        #[cfg(all(target_os = "macos", feature = "macos"))]
1530        {
1531            let r: Box<dyn crate::reader::ThemeReader> = Box::new(crate::macos::MacosReader);
1532            assert_object_safe(&r);
1533        }
1534        #[cfg(all(target_os = "windows", feature = "windows"))]
1535        {
1536            let r: Box<dyn crate::reader::ThemeReader> = Box::new(crate::windows::WindowsReader);
1537            assert_object_safe(&r);
1538        }
1539        // Without any active-platform feature we still want the test body to
1540        // compile; reference the trait path once so name resolution fires
1541        // regardless of cfg.
1542        let _: Option<&dyn crate::reader::ThemeReader> = None;
1543    }
1544
1545    /// `select_reader()` exists, is async, and returns an
1546    /// `Option<(Box<dyn ThemeReader>, &'static str)>`.
1547    ///
1548    /// On each platform with an available backend we expect `Some((_, live_preset))`;
1549    /// on platforms without one (non-Linux/macOS/Windows, or Linux with no
1550    /// compiled-in backend, or Linux-other-DE without portal) we expect `None`.
1551    ///
1552    /// The companion `&'static str` is the `-live` preset name that `run_pipeline`
1553    /// should use — kept in the same return type because the preset is chosen
1554    /// from the DE detected by `select_reader` (KDE -> kde-breeze-live, GNOME ->
1555    /// adwaita-live, macOS -> macos-sonoma-live, Windows -> windows-11-live).
1556    #[test]
1557    fn select_reader_returns_platform_specific_impl() {
1558        // Call select_reader and observe the Option. The structural invariant
1559        // is that the function exists, is callable from a sync context via
1560        // `pollster::block_on`, and returns the (reader, preset_live) tuple.
1561        let picked: Option<(Box<dyn crate::reader::ThemeReader>, &'static str)> =
1562            pollster::block_on(super::select_reader());
1563
1564        // Every cfg branch borrows `picked` (via `&picked`) rather than moving
1565        // it, so the final `drop(picked)` consumes it uniformly on all
1566        // permutations -- including linux-without-kde, which has no branch.
1567        #[cfg(all(target_os = "macos", feature = "macos"))]
1568        {
1569            assert!(
1570                picked.is_some(),
1571                "select_reader must return Some on macOS+macos"
1572            );
1573            if let Some((_reader, preset)) = &picked {
1574                assert_eq!(*preset, "macos-sonoma-live");
1575            }
1576        }
1577        #[cfg(all(target_os = "windows", feature = "windows"))]
1578        {
1579            assert!(
1580                picked.is_some(),
1581                "select_reader must return Some on windows+windows"
1582            );
1583            if let Some((_reader, preset)) = &picked {
1584                assert_eq!(*preset, "windows-11-live");
1585            }
1586        }
1587        #[cfg(all(target_os = "linux", feature = "kde", feature = "portal"))]
1588        {
1589            // On linux+kde+portal, select_reader returns Some(GnomePortalKdeReader, "kde-breeze-live")
1590            // whenever the active DE is KDE; for non-KDE DEs it may legitimately
1591            // return None (preset fallback). Assert the tuple shape regardless.
1592            if let Some((_reader, preset)) = &picked {
1593                assert!(
1594                    *preset == "kde-breeze-live" || *preset == "adwaita-live",
1595                    "select_reader on linux must return a known -live preset, got: {preset}"
1596                );
1597            }
1598        }
1599        #[cfg(not(any(target_os = "linux", target_os = "macos", target_os = "windows")))]
1600        {
1601            assert!(
1602                picked.is_none(),
1603                "select_reader() must return None on unsupported platforms"
1604            );
1605        }
1606        drop(picked);
1607    }
1608}