Skip to main content

native_theme/
pipeline.rs

1//! Theme pipeline: reader -> preset merge -> resolve -> validate.
2
3#[cfg(not(target_os = "linux"))]
4use crate::detect::system_is_dark;
5#[cfg(target_os = "linux")]
6use crate::detect::{LinuxDesktop, detect_linux_de, system_is_dark, xdg_current_desktop};
7
8use crate::SystemTheme;
9use crate::model::ThemeSpec;
10
11/// Run the OS-first pipeline: merge reader output onto a platform
12/// preset, resolve both light and dark variants, validate.
13///
14/// For the variant the reader supplied, the merged (reader + live preset)
15/// version is used. For the variant the reader did NOT supply, the full
16/// platform preset (with colors/fonts) is used as fallback.
17pub(crate) fn run_pipeline(
18    reader_output: ThemeSpec,
19    preset_name: &str,
20    is_dark: bool,
21) -> crate::Result<SystemTheme> {
22    let live_preset = ThemeSpec::preset(preset_name)?;
23
24    // For the inactive variant, load the full preset (with colors).
25    // Falls back to original name if not a live preset (e.g. user preset).
26    let full_preset_name = preset_name.strip_suffix("-live").unwrap_or(preset_name);
27    debug_assert!(
28        full_preset_name != preset_name || !preset_name.ends_with("-live"),
29        "live preset '{preset_name}' should have -live suffix stripped"
30    );
31    let full_preset = ThemeSpec::preset(full_preset_name)?;
32
33    // Merge: full preset provides color/font defaults, live preset overrides
34    // geometry, reader output provides live OS data on top.
35    let mut merged = full_preset.clone();
36    merged.merge(&live_preset);
37    merged.merge(&reader_output);
38
39    // Keep reader name if non-empty, else use preset name
40    let name = if reader_output.name.is_empty() {
41        merged.name.clone()
42    } else {
43        reader_output.name.clone()
44    };
45
46    // For the variant the reader provided: use merged (live geometry + reader colors)
47    // For the variant the reader didn't provide: use FULL preset (has colors).
48    // unwrap_or_default() yields an empty ThemeVariant -- valid for merge.
49    let mut light_variant = if reader_output.light.is_some() {
50        merged.light.unwrap_or_default()
51    } else {
52        full_preset.light.unwrap_or_default()
53    };
54
55    let mut dark_variant = if reader_output.dark.is_some() {
56        merged.dark.unwrap_or_default()
57    } else {
58        full_preset.dark.unwrap_or_default()
59    };
60
61    // Propagate font_dpi from the reader to both variants so the
62    // pt->px conversion uses the system-detected DPI for both.
63    // The active variant already has font_dpi via merge; the inactive
64    // variant comes from the full preset (no reader data) and needs it.
65    let reader_dpi = reader_output
66        .light
67        .as_ref()
68        .and_then(|v| v.defaults.font_dpi)
69        .or_else(|| {
70            reader_output
71                .dark
72                .as_ref()
73                .and_then(|v| v.defaults.font_dpi)
74        });
75    if let Some(dpi) = reader_dpi {
76        light_variant.defaults.font_dpi = light_variant.defaults.font_dpi.or(Some(dpi));
77        dark_variant.defaults.font_dpi = dark_variant.defaults.font_dpi.or(Some(dpi));
78    }
79
80    // Clone pre-resolve variants for overlay support (Plan 02)
81    let light_variant_pre = light_variant.clone();
82    let dark_variant_pre = dark_variant.clone();
83
84    let light = light_variant.into_resolved()?;
85    let dark = dark_variant.into_resolved()?;
86
87    Ok(SystemTheme {
88        name,
89        is_dark,
90        light,
91        dark,
92        light_variant: light_variant_pre,
93        dark_variant: dark_variant_pre,
94        preset: full_preset_name.to_string(),
95        live_preset: preset_name.to_string(),
96    })
97}
98
99/// Map a Linux desktop environment to its matching live preset name.
100///
101/// This is the single source of truth for the DE-to-preset mapping used
102/// by [`from_linux()`], [`from_system_async_inner()`], and
103/// [`platform_preset_name()`].
104///
105/// - KDE -> `"kde-breeze-live"`
106/// - All others (GNOME, XFCE, Cinnamon, MATE, LXQt, Budgie, Unknown)
107///   -> `"adwaita-live"`
108#[cfg(target_os = "linux")]
109fn linux_preset_for_de(de: LinuxDesktop) -> &'static str {
110    match de {
111        LinuxDesktop::Kde => "kde-breeze-live",
112        _ => "adwaita-live",
113    }
114}
115
116/// Map the current platform to its matching live preset name.
117///
118/// Live presets contain only geometry/metrics (no colors, fonts, or icons)
119/// and are used as the merge base in the OS-first pipeline.
120///
121/// - macOS -> `"macos-sonoma-live"`
122/// - Windows -> `"windows-11-live"`
123/// - Linux KDE -> `"kde-breeze-live"`
124/// - Linux other/GNOME -> `"adwaita-live"`
125/// - Unknown platform -> `"adwaita-live"`
126///
127/// Returns the live preset name for the current platform.
128///
129/// This is the public API for what [`SystemTheme::from_system()`] uses internally.
130/// Showcase UIs use this to build the "default (...)" label.
131#[allow(unreachable_code)]
132#[must_use]
133pub fn platform_preset_name() -> &'static str {
134    #[cfg(target_os = "macos")]
135    {
136        return "macos-sonoma-live";
137    }
138    #[cfg(target_os = "windows")]
139    {
140        return "windows-11-live";
141    }
142    #[cfg(target_os = "linux")]
143    {
144        linux_preset_for_de(detect_linux_de(&xdg_current_desktop()))
145    }
146    #[cfg(not(any(target_os = "linux", target_os = "windows", target_os = "macos")))]
147    {
148        "adwaita-live"
149    }
150}
151
152/// Check whether OS theme detection is available on this platform.
153///
154/// Returns a list of human-readable diagnostic messages describing what
155/// detection capabilities are available and what might be missing. Useful
156/// for debugging theme detection failures in end-user applications.
157///
158/// # Platform Behavior
159///
160/// - **Linux:** Reports detected desktop environment, `gsettings`
161///   availability, `XDG_CURRENT_DESKTOP` value, and KDE config file
162///   presence (when the `kde` feature is enabled).
163/// - **macOS:** Reports whether the `macos` feature is enabled.
164/// - **Windows:** Reports whether the `windows` feature is enabled.
165/// - **Other:** Reports that no platform detection is available.
166///
167/// # Examples
168///
169/// ```
170/// let diagnostics = native_theme::diagnose_platform_support();
171/// for line in &diagnostics {
172///     println!("{}", line);
173/// }
174/// ```
175#[must_use]
176pub fn diagnose_platform_support() -> Vec<String> {
177    let mut diagnostics = Vec::new();
178
179    #[cfg(target_os = "linux")]
180    {
181        diagnostics.push("Platform: Linux".to_string());
182
183        // Check XDG_CURRENT_DESKTOP
184        match std::env::var("XDG_CURRENT_DESKTOP") {
185            Ok(val) if !val.is_empty() => {
186                let de = detect_linux_de(&val);
187                diagnostics.push(format!("XDG_CURRENT_DESKTOP: {val}"));
188                diagnostics.push(format!("Detected DE: {de:?}"));
189            }
190            _ => {
191                diagnostics.push("XDG_CURRENT_DESKTOP: not set".to_string());
192                diagnostics.push("Detected DE: Unknown (env var missing)".to_string());
193            }
194        }
195
196        // Check gsettings availability
197        match std::process::Command::new("gsettings")
198            .arg("--version")
199            .output()
200        {
201            Ok(output) if output.status.success() => {
202                let version = String::from_utf8_lossy(&output.stdout);
203                diagnostics.push(format!("gsettings: available ({})", version.trim()));
204            }
205            Ok(_) => {
206                diagnostics.push("gsettings: found but returned error".to_string());
207            }
208            Err(_) => {
209                diagnostics.push(
210                    "gsettings: not found (dark mode and icon theme detection may be limited)"
211                        .to_string(),
212                );
213            }
214        }
215
216        // Check KDE config files
217        #[cfg(feature = "kde")]
218        {
219            let path = crate::kde::kdeglobals_path();
220            if path.exists() {
221                diagnostics.push(format!("KDE kdeglobals: found at {}", path.display()));
222            } else {
223                diagnostics.push(format!("KDE kdeglobals: not found at {}", path.display()));
224            }
225        }
226
227        #[cfg(not(feature = "kde"))]
228        {
229            diagnostics.push("KDE support: disabled (kde feature not enabled)".to_string());
230        }
231
232        // Report portal feature status
233        #[cfg(feature = "portal")]
234        diagnostics.push("Portal support: enabled".to_string());
235
236        #[cfg(not(feature = "portal"))]
237        diagnostics.push("Portal support: disabled (portal feature not enabled)".to_string());
238    }
239
240    #[cfg(target_os = "macos")]
241    {
242        diagnostics.push("Platform: macOS".to_string());
243
244        #[cfg(feature = "macos")]
245        diagnostics.push("macOS theme detection: enabled (macos feature active)".to_string());
246
247        #[cfg(not(feature = "macos"))]
248        diagnostics.push(
249            "macOS theme detection: limited (macos feature not enabled, using subprocess fallback)"
250                .to_string(),
251        );
252    }
253
254    #[cfg(target_os = "windows")]
255    {
256        diagnostics.push("Platform: Windows".to_string());
257
258        #[cfg(feature = "windows")]
259        diagnostics.push("Windows theme detection: enabled (windows feature active)".to_string());
260
261        #[cfg(not(feature = "windows"))]
262        diagnostics
263            .push("Windows theme detection: disabled (windows feature not enabled)".to_string());
264    }
265
266    #[cfg(not(any(target_os = "linux", target_os = "macos", target_os = "windows")))]
267    {
268        diagnostics.push("Platform: unsupported (no native theme detection available)".to_string());
269    }
270
271    diagnostics
272}
273
274/// Infer dark-mode preference from the reader's output.
275///
276/// Returns `true` if the reader populated only the dark variant,
277/// `false` if it populated only light or both variants.
278/// On platforms that produce both variants (macOS), this defaults to
279/// `false` (light); callers can use [`SystemTheme::pick()`] for
280/// explicit variant selection regardless of this default.
281#[allow(dead_code)]
282pub(crate) fn reader_is_dark(reader: &ThemeSpec) -> bool {
283    reader.dark.is_some() && reader.light.is_none()
284}
285
286/// Read the current system theme on Linux by detecting the desktop
287/// environment and calling the appropriate reader or returning a
288/// preset fallback.
289///
290/// Runs the full OS-first pipeline: reader -> preset merge -> resolve -> validate.
291#[cfg(target_os = "linux")]
292pub(crate) fn from_linux() -> crate::Result<SystemTheme> {
293    let is_dark = system_is_dark();
294    let de = detect_linux_de(&xdg_current_desktop());
295    let preset = linux_preset_for_de(de);
296    match de {
297        #[cfg(feature = "kde")]
298        LinuxDesktop::Kde => {
299            let reader = crate::kde::from_kde()?;
300            run_pipeline(reader, preset, is_dark)
301        }
302        #[cfg(not(feature = "kde"))]
303        LinuxDesktop::Kde => run_pipeline(ThemeSpec::preset("adwaita")?, "adwaita-live", is_dark),
304        LinuxDesktop::Gnome | LinuxDesktop::Budgie => {
305            // GNOME sync path: no portal, just adwaita preset
306            run_pipeline(ThemeSpec::preset("adwaita")?, preset, is_dark)
307        }
308        LinuxDesktop::Xfce | LinuxDesktop::Cinnamon | LinuxDesktop::Mate | LinuxDesktop::LxQt => {
309            run_pipeline(ThemeSpec::preset("adwaita")?, preset, is_dark)
310        }
311        LinuxDesktop::Unknown => {
312            #[cfg(feature = "kde")]
313            {
314                let path = crate::kde::kdeglobals_path();
315                if path.exists() {
316                    let reader = crate::kde::from_kde()?;
317                    return run_pipeline(reader, linux_preset_for_de(LinuxDesktop::Kde), is_dark);
318                }
319            }
320            run_pipeline(ThemeSpec::preset("adwaita")?, preset, is_dark)
321        }
322    }
323}
324
325pub(crate) fn from_system_inner() -> crate::Result<SystemTheme> {
326    #[cfg(target_os = "macos")]
327    {
328        #[cfg(feature = "macos")]
329        {
330            let reader = crate::macos::from_macos()?;
331            let is_dark = reader_is_dark(&reader);
332            return run_pipeline(reader, "macos-sonoma-live", is_dark);
333        }
334
335        #[cfg(not(feature = "macos"))]
336        return Err(crate::Error::Unsupported(
337            "macOS theme detection requires the `macos` feature",
338        ));
339    }
340
341    #[cfg(target_os = "windows")]
342    {
343        #[cfg(feature = "windows")]
344        {
345            let reader = crate::windows::from_windows()?;
346            let is_dark = reader_is_dark(&reader);
347            return run_pipeline(reader, "windows-11-live", is_dark);
348        }
349
350        #[cfg(not(feature = "windows"))]
351        return Err(crate::Error::Unsupported(
352            "Windows theme detection requires the `windows` feature",
353        ));
354    }
355
356    #[cfg(target_os = "linux")]
357    {
358        from_linux()
359    }
360
361    #[cfg(not(any(target_os = "linux", target_os = "windows", target_os = "macos")))]
362    {
363        Err(crate::Error::Unsupported(
364            "no theme reader available for this platform",
365        ))
366    }
367}
368
369#[cfg(target_os = "linux")]
370pub(crate) async fn from_system_async_inner() -> crate::Result<SystemTheme> {
371    let is_dark = system_is_dark();
372    let de = detect_linux_de(&xdg_current_desktop());
373    let preset = linux_preset_for_de(de);
374    match de {
375        #[cfg(feature = "kde")]
376        LinuxDesktop::Kde => {
377            #[cfg(feature = "portal")]
378            {
379                let reader = crate::gnome::from_kde_with_portal().await?;
380                run_pipeline(reader, preset, is_dark)
381            }
382            #[cfg(not(feature = "portal"))]
383            {
384                let reader = crate::kde::from_kde()?;
385                run_pipeline(reader, preset, is_dark)
386            }
387        }
388        #[cfg(not(feature = "kde"))]
389        LinuxDesktop::Kde => run_pipeline(ThemeSpec::preset("adwaita")?, "adwaita-live", is_dark),
390        #[cfg(feature = "portal")]
391        LinuxDesktop::Gnome | LinuxDesktop::Budgie => {
392            let reader = crate::gnome::from_gnome().await?;
393            run_pipeline(reader, preset, is_dark)
394        }
395        #[cfg(not(feature = "portal"))]
396        LinuxDesktop::Gnome | LinuxDesktop::Budgie => {
397            run_pipeline(ThemeSpec::preset("adwaita")?, preset, is_dark)
398        }
399        LinuxDesktop::Xfce | LinuxDesktop::Cinnamon | LinuxDesktop::Mate | LinuxDesktop::LxQt => {
400            run_pipeline(ThemeSpec::preset("adwaita")?, preset, is_dark)
401        }
402        LinuxDesktop::Unknown => {
403            // Use D-Bus portal backend detection to refine heuristic
404            #[cfg(feature = "portal")]
405            {
406                if let Some(detected) = crate::gnome::detect_portal_backend().await {
407                    let detected_preset = linux_preset_for_de(detected);
408                    return match detected {
409                        #[cfg(feature = "kde")]
410                        LinuxDesktop::Kde => {
411                            let reader = crate::gnome::from_kde_with_portal().await?;
412                            run_pipeline(reader, detected_preset, is_dark)
413                        }
414                        #[cfg(not(feature = "kde"))]
415                        LinuxDesktop::Kde => {
416                            run_pipeline(ThemeSpec::preset("adwaita")?, "adwaita-live", is_dark)
417                        }
418                        LinuxDesktop::Gnome => {
419                            let reader = crate::gnome::from_gnome().await?;
420                            run_pipeline(reader, detected_preset, is_dark)
421                        }
422                        _ => {
423                            // detect_portal_backend only returns Kde or Gnome;
424                            // fall back to Adwaita if the set ever grows.
425                            run_pipeline(ThemeSpec::preset("adwaita")?, detected_preset, is_dark)
426                        }
427                    };
428                }
429            }
430            // Sync fallback: try kdeglobals, then Adwaita
431            #[cfg(feature = "kde")]
432            {
433                let path = crate::kde::kdeglobals_path();
434                if path.exists() {
435                    let reader = crate::kde::from_kde()?;
436                    return run_pipeline(reader, linux_preset_for_de(LinuxDesktop::Kde), is_dark);
437                }
438            }
439            run_pipeline(ThemeSpec::preset("adwaita")?, preset, is_dark)
440        }
441    }
442}
443
444// =============================================================================
445// Tests
446// =============================================================================
447
448#[cfg(all(test, target_os = "linux"))]
449#[allow(clippy::unwrap_used, clippy::expect_used)]
450mod dispatch_tests {
451    use super::*;
452
453    // -- detect_linux_de() pure function tests --
454
455    #[test]
456    fn detect_kde_simple() {
457        assert_eq!(detect_linux_de("KDE"), LinuxDesktop::Kde);
458    }
459
460    #[test]
461    fn detect_kde_colon_separated_after() {
462        assert_eq!(detect_linux_de("ubuntu:KDE"), LinuxDesktop::Kde);
463    }
464
465    #[test]
466    fn detect_kde_colon_separated_before() {
467        assert_eq!(detect_linux_de("KDE:plasma"), LinuxDesktop::Kde);
468    }
469
470    #[test]
471    fn detect_gnome_simple() {
472        assert_eq!(detect_linux_de("GNOME"), LinuxDesktop::Gnome);
473    }
474
475    #[test]
476    fn detect_gnome_ubuntu() {
477        assert_eq!(detect_linux_de("ubuntu:GNOME"), LinuxDesktop::Gnome);
478    }
479
480    #[test]
481    fn detect_xfce() {
482        assert_eq!(detect_linux_de("XFCE"), LinuxDesktop::Xfce);
483    }
484
485    #[test]
486    fn detect_cinnamon() {
487        assert_eq!(detect_linux_de("X-Cinnamon"), LinuxDesktop::Cinnamon);
488    }
489
490    #[test]
491    fn detect_cinnamon_short() {
492        assert_eq!(detect_linux_de("Cinnamon"), LinuxDesktop::Cinnamon);
493    }
494
495    #[test]
496    fn detect_mate() {
497        assert_eq!(detect_linux_de("MATE"), LinuxDesktop::Mate);
498    }
499
500    #[test]
501    fn detect_lxqt() {
502        assert_eq!(detect_linux_de("LXQt"), LinuxDesktop::LxQt);
503    }
504
505    #[test]
506    fn detect_budgie() {
507        assert_eq!(detect_linux_de("Budgie:GNOME"), LinuxDesktop::Budgie);
508    }
509
510    #[test]
511    fn detect_empty_string() {
512        assert_eq!(detect_linux_de(""), LinuxDesktop::Unknown);
513    }
514
515    // -- from_linux() fallback test --
516
517    #[test]
518    #[allow(unsafe_code)]
519    fn from_linux_non_kde_returns_adwaita() {
520        let _guard = crate::test_util::ENV_MUTEX.lock().unwrap();
521        // Temporarily set XDG_CURRENT_DESKTOP to GNOME so from_linux()
522        // takes the preset fallback path.
523        // SAFETY: ENV_MUTEX serializes env var access across parallel tests
524        unsafe { std::env::set_var("XDG_CURRENT_DESKTOP", "GNOME") };
525        let result = from_linux();
526        unsafe { std::env::remove_var("XDG_CURRENT_DESKTOP") };
527
528        let theme = result.expect("from_linux() should return Ok for non-KDE desktop");
529        assert_eq!(theme.name, "Adwaita");
530    }
531
532    // -- from_linux() kdeglobals fallback tests --
533
534    #[test]
535    #[cfg(feature = "kde")]
536    #[allow(unsafe_code)]
537    fn from_linux_unknown_de_with_kdeglobals_fallback() {
538        let _guard = crate::test_util::ENV_MUTEX.lock().unwrap();
539        use std::io::Write;
540
541        // Create a temp dir with a minimal kdeglobals file
542        let tmp_dir = std::env::temp_dir().join("native_theme_test_kde_fallback");
543        std::fs::create_dir_all(&tmp_dir).unwrap();
544        let kdeglobals = tmp_dir.join("kdeglobals");
545        let mut f = std::fs::File::create(&kdeglobals).unwrap();
546        writeln!(
547            f,
548            "[General]\nColorScheme=TestTheme\n\n[Colors:Window]\nBackgroundNormal=239,240,241\n"
549        )
550        .unwrap();
551
552        // SAFETY: ENV_MUTEX serializes env var access across parallel tests
553        let orig_xdg = std::env::var("XDG_CONFIG_HOME").ok();
554        let orig_desktop = std::env::var("XDG_CURRENT_DESKTOP").ok();
555
556        unsafe { std::env::set_var("XDG_CONFIG_HOME", &tmp_dir) };
557        unsafe { std::env::set_var("XDG_CURRENT_DESKTOP", "SomeUnknownDE") };
558
559        let result = from_linux();
560
561        // Restore env
562        match orig_xdg {
563            Some(val) => unsafe { std::env::set_var("XDG_CONFIG_HOME", val) },
564            None => unsafe { std::env::remove_var("XDG_CONFIG_HOME") },
565        }
566        match orig_desktop {
567            Some(val) => unsafe { std::env::set_var("XDG_CURRENT_DESKTOP", val) },
568            None => unsafe { std::env::remove_var("XDG_CURRENT_DESKTOP") },
569        }
570
571        // Cleanup
572        let _ = std::fs::remove_dir_all(&tmp_dir);
573
574        let theme = result.expect("from_linux() should return Ok with kdeglobals fallback");
575        assert_eq!(
576            theme.name, "TestTheme",
577            "should use KDE theme name from kdeglobals"
578        );
579    }
580
581    #[test]
582    #[allow(unsafe_code)]
583    fn from_linux_unknown_de_without_kdeglobals_returns_adwaita() {
584        let _guard = crate::test_util::ENV_MUTEX.lock().unwrap();
585        // SAFETY: ENV_MUTEX serializes env var access across parallel tests
586        let orig_xdg = std::env::var("XDG_CONFIG_HOME").ok();
587        let orig_desktop = std::env::var("XDG_CURRENT_DESKTOP").ok();
588
589        unsafe {
590            std::env::set_var(
591                "XDG_CONFIG_HOME",
592                "/tmp/nonexistent_native_theme_test_no_kde",
593            )
594        };
595        unsafe { std::env::set_var("XDG_CURRENT_DESKTOP", "SomeUnknownDE") };
596
597        let result = from_linux();
598
599        // Restore env
600        match orig_xdg {
601            Some(val) => unsafe { std::env::set_var("XDG_CONFIG_HOME", val) },
602            None => unsafe { std::env::remove_var("XDG_CONFIG_HOME") },
603        }
604        match orig_desktop {
605            Some(val) => unsafe { std::env::set_var("XDG_CURRENT_DESKTOP", val) },
606            None => unsafe { std::env::remove_var("XDG_CURRENT_DESKTOP") },
607        }
608
609        let theme = result.expect("from_linux() should return Ok (adwaita fallback)");
610        assert_eq!(
611            theme.name, "Adwaita",
612            "should fall back to Adwaita without kdeglobals"
613        );
614    }
615
616    // -- LNXDE-03: Hyprland, Sway, COSMIC map to Unknown --
617
618    #[test]
619    fn detect_hyprland_returns_unknown() {
620        assert_eq!(detect_linux_de("Hyprland"), LinuxDesktop::Unknown);
621    }
622
623    #[test]
624    fn detect_sway_returns_unknown() {
625        assert_eq!(detect_linux_de("sway"), LinuxDesktop::Unknown);
626    }
627
628    #[test]
629    fn detect_cosmic_returns_unknown() {
630        assert_eq!(detect_linux_de("COSMIC"), LinuxDesktop::Unknown);
631    }
632
633    // -- from_system() smoke test --
634
635    #[test]
636    #[allow(unsafe_code)]
637    fn from_system_returns_result() {
638        let _guard = crate::test_util::ENV_MUTEX.lock().unwrap();
639        // On Linux (our test platform), from_system() should return a Result.
640        // With GNOME set, it should return the Adwaita preset.
641        // SAFETY: ENV_MUTEX serializes env var access across parallel tests
642        unsafe { std::env::set_var("XDG_CURRENT_DESKTOP", "GNOME") };
643        let result = crate::SystemTheme::from_system();
644        unsafe { std::env::remove_var("XDG_CURRENT_DESKTOP") };
645
646        let theme = result.expect("from_system() should return Ok on Linux");
647        assert_eq!(theme.name, "Adwaita");
648    }
649}
650
651/// Tests for run_pipeline() and reader_is_dark() -- internal pipeline functions.
652/// These test functions moved from system_theme_tests in lib.rs since they
653/// directly test pipeline internals rather than the SystemTheme public API.
654#[cfg(test)]
655#[allow(
656    clippy::unwrap_used,
657    clippy::expect_used,
658    clippy::field_reassign_with_default
659)]
660mod pipeline_tests {
661    use crate::color::Rgba;
662    use crate::model::{ThemeDefaults, ThemeSpec, ThemeVariant};
663
664    use super::{reader_is_dark, run_pipeline};
665
666    // --- run_pipeline() tests ---
667
668    #[test]
669    fn test_run_pipeline_produces_both_variants() {
670        let reader = ThemeSpec::preset("catppuccin-mocha").unwrap();
671        let result = run_pipeline(reader, "catppuccin-mocha", false);
672        assert!(result.is_ok(), "run_pipeline should succeed");
673        let st = result.unwrap();
674        // Both light and dark exist as ResolvedThemeVariant (non-Option)
675        assert!(!st.name.is_empty(), "name should be populated");
676        // If we get here, both variants validated successfully
677    }
678
679    #[test]
680    fn test_run_pipeline_reader_values_win() {
681        // Create a reader output where the reader provides a custom accent
682        // (simulating a platform reader that detected this accent from the OS)
683        let custom_accent = Rgba::rgb(42, 100, 200);
684        let mut reader = ThemeSpec::default();
685        reader.name = "CustomTheme".into();
686        let mut variant = ThemeVariant::default();
687        variant.defaults.accent_color = Some(custom_accent);
688        reader.light = Some(variant);
689
690        let result = run_pipeline(reader, "catppuccin-mocha", false);
691        assert!(result.is_ok(), "run_pipeline should succeed");
692        let st = result.unwrap();
693        // The reader's accent should win over the preset's accent
694        assert_eq!(
695            st.light.defaults.accent_color, custom_accent,
696            "reader accent should win over preset accent"
697        );
698        assert_eq!(st.name, "CustomTheme", "reader name should win");
699    }
700
701    #[test]
702    fn test_run_pipeline_single_variant() {
703        // Simulate a real OS reader that provides a complete dark variant
704        // (like KDE's from_kde() would) but no light variant.
705        // Use a live preset so the inactive light variant gets the full preset.
706        let full = ThemeSpec::preset("kde-breeze").unwrap();
707        let mut reader = ThemeSpec::default();
708        let mut dark_v = full.dark.clone().unwrap();
709        // Override accent to prove reader values win (simulating OS-detected accent)
710        dark_v.defaults.accent_color = Some(Rgba::rgb(200, 50, 50));
711        reader.dark = Some(dark_v);
712        reader.light = None;
713
714        let result = run_pipeline(reader, "kde-breeze-live", true);
715        assert!(
716            result.is_ok(),
717            "run_pipeline should succeed with single variant"
718        );
719        let st = result.unwrap();
720        // Dark should have the reader's overridden accent
721        assert_eq!(
722            st.dark.defaults.accent_color,
723            Rgba::rgb(200, 50, 50),
724            "dark variant should have reader accent"
725        );
726        // Light should still exist (from full preset, which has colors)
727        // If we get here, both variants validated successfully
728        assert_eq!(st.live_preset, "kde-breeze-live");
729        assert_eq!(st.preset, "kde-breeze");
730    }
731
732    #[test]
733    fn test_run_pipeline_inactive_variant_from_full_preset() {
734        // When reader provides only dark, light must come from the full preset
735        // (not the live preset, which has no colors and would fail validation).
736        let full = ThemeSpec::preset("kde-breeze").unwrap();
737        let mut reader = ThemeSpec::default();
738        reader.dark = Some(full.dark.clone().unwrap());
739        reader.light = None;
740
741        let st = run_pipeline(reader, "kde-breeze-live", true).unwrap();
742
743        // The light variant should have colors from the full "kde-breeze" preset
744        let full_light = full.light.unwrap();
745        assert_eq!(
746            st.light.defaults.accent_color,
747            full_light.defaults.accent_color.unwrap(),
748            "inactive light variant should get accent from full preset"
749        );
750        assert_eq!(
751            st.light.defaults.background_color,
752            full_light.defaults.background_color.unwrap(),
753            "inactive light variant should get background from full preset"
754        );
755    }
756
757    // --- run_pipeline with preset-as-reader (GNOME double-merge test) ---
758
759    #[test]
760    fn test_run_pipeline_with_preset_as_reader() {
761        // Simulates GNOME sync fallback: adwaita used as both reader and preset.
762        // Double-merge is harmless: merge is idempotent for matching values.
763        let reader = ThemeSpec::preset("adwaita").unwrap();
764        let result = run_pipeline(reader, "adwaita", false);
765        assert!(
766            result.is_ok(),
767            "double-merge with same preset should succeed"
768        );
769        let st = result.unwrap();
770        assert_eq!(st.name, "Adwaita");
771    }
772
773    // --- run_pipeline font_dpi propagation ---
774
775    #[test]
776    fn test_run_pipeline_propagates_font_dpi_to_inactive_variant() {
777        // Create a reader that provides only dark variant with font_dpi=120
778        // (simulating a platform reader that detected this DPI from the OS)
779        let mut reader = ThemeSpec::default();
780        reader.dark = Some(ThemeVariant {
781            defaults: ThemeDefaults {
782                font_dpi: Some(120.0),
783                ..Default::default()
784            },
785            ..Default::default()
786        });
787
788        let st = run_pipeline(reader, "kde-breeze-live", true).unwrap();
789        // The light variant (inactive, from full preset) should have gotten
790        // font_dpi propagated and used for conversion.
791        //
792        // After resolution, font_dpi is consumed (cleared during conversion,
793        // then filled with DEFAULT_FONT_DPI in validate.rs), so we cannot
794        // check font_dpi directly. Instead, verify the conversion effect:
795        // the preset's default font size is in points. With font_dpi=120:
796        //   px = pt * 120 / 72 = pt * 1.6667
797        //
798        // The Breeze preset default font size is 10.0 pt.
799        // With DPI 120: 10.0 * 120/72 = 16.667 px
800        // Without propagation (no conversion): 10.0 px
801        let resolved_size = st.light.defaults.font.size;
802        assert!(
803            resolved_size > 10.0,
804            "inactive variant font size should be DPI-converted (got {resolved_size}, expected > 10.0)"
805        );
806        // Check it matches the expected conversion
807        let expected = 10.0 * 120.0 / 72.0; // ~16.667
808        assert!(
809            (resolved_size - expected).abs() < 0.1,
810            "font size should be 10pt * 120/72 = {expected:.1}px, got {resolved_size}"
811        );
812    }
813
814    // --- reader_is_dark() tests ---
815
816    #[test]
817    fn test_reader_is_dark_only_dark() {
818        let mut theme = ThemeSpec::default();
819        theme.dark = Some(ThemeVariant::default());
820        theme.light = None;
821        assert!(
822            reader_is_dark(&theme),
823            "should be true when only dark is set"
824        );
825    }
826
827    #[test]
828    fn test_reader_is_dark_only_light() {
829        let mut theme = ThemeSpec::default();
830        theme.light = Some(ThemeVariant::default());
831        theme.dark = None;
832        assert!(
833            !reader_is_dark(&theme),
834            "should be false when only light is set"
835        );
836    }
837
838    #[test]
839    fn test_reader_is_dark_both() {
840        let mut theme = ThemeSpec::default();
841        theme.light = Some(ThemeVariant::default());
842        theme.dark = Some(ThemeVariant::default());
843        assert!(
844            !reader_is_dark(&theme),
845            "should be false when both are set (macOS case)"
846        );
847    }
848
849    #[test]
850    fn test_reader_is_dark_neither() {
851        let theme = ThemeSpec::default();
852        assert!(
853            !reader_is_dark(&theme),
854            "should be false when neither is set"
855        );
856    }
857}