Skip to main content

smtp_test_tool/
theme.rs

1//! Cross-platform OS appearance (dark/light) detection.
2//!
3//! We hand-roll this so we don't depend on `dark-light`, which in v2.x
4//! pulls in the now-unmaintained `async-std` (RUSTSEC-2025-0052).
5//!
6//! Per AGENTS.md ยง4: dark + light mode follow MUST work on Windows,
7//! macOS, and Linux without third-party crates beyond the standard
8//! ecosystem.  The Python reference implementation (the previous
9//! generation of this tool) demonstrated that the algorithm fits in
10//! about thirty lines per platform; this is the Rust translation.
11//!
12//! Precedence:
13//!
14//! 1. `NO_COLOR`             -> [`Appearance::Unknown`] (let caller decide).
15//! 2. `COLORFGBG`            -> parsed foreground;background colours.
16//! 3. Per-OS native probe    -> Windows registry / macOS `defaults` /
17//!    GNOME gsettings / KDE `kdeglobals`.
18//! 4. Fallback               -> [`Appearance::Unknown`].
19
20#[derive(Debug, Clone, Copy, PartialEq, Eq)]
21pub enum Appearance {
22    Dark,
23    Light,
24    /// OS did not advertise a preference, or detection failed.
25    Unknown,
26}
27
28/// User-visible theme preference, stored in the config file as a string
29/// (`"auto"`, `"dark"`, `"light"`).  A string is used on disk so older
30/// configs (and hand-edited files with typos) keep loading; unknown
31/// values silently fall back to [`ThemeChoice::Auto`].
32#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
33pub enum ThemeChoice {
34    /// Follow the OS appearance via [`detect`].
35    #[default]
36    Auto,
37    /// Force dark theme regardless of OS.
38    Dark,
39    /// Force light theme regardless of OS.
40    Light,
41}
42
43impl ThemeChoice {
44    /// Parse from the on-disk config string.  Unknown values fall back
45    /// to [`Self::Auto`] (no panic, no error) so a malformed file
46    /// degrades gracefully.
47    pub fn from_config_str(s: &str) -> Self {
48        match s.trim().to_ascii_lowercase().as_str() {
49            "dark" => Self::Dark,
50            "light" => Self::Light,
51            // Includes "auto", "", and anything we don't recognise.
52            _ => Self::Auto,
53        }
54    }
55
56    pub fn as_str(self) -> &'static str {
57        match self {
58            Self::Auto => "auto",
59            Self::Dark => "dark",
60            Self::Light => "light",
61        }
62    }
63
64    /// Resolve to a concrete [`Appearance`] given an OS detection.
65    /// `Auto` uses the OS hint (falling back to [`Appearance::Dark`]
66    /// when the OS is silent); explicit choices always win.
67    pub fn resolve(self, os_hint: Appearance) -> Appearance {
68        match self {
69            Self::Dark => Appearance::Dark,
70            Self::Light => Appearance::Light,
71            Self::Auto => match os_hint {
72                Appearance::Dark | Appearance::Light => os_hint,
73                Appearance::Unknown => Appearance::Dark,
74            },
75        }
76    }
77}
78
79/// Best-effort current OS appearance.
80///
81/// Never panics, never blocks for more than a short subprocess call on
82/// macOS / Linux, never touches the filesystem outside the OS-provided
83/// settings store.
84pub fn detect() -> Appearance {
85    if std::env::var_os("NO_COLOR").is_some() {
86        return Appearance::Unknown;
87    }
88
89    if let Some(a) = from_colorfgbg() {
90        return a;
91    }
92
93    #[cfg(target_os = "windows")]
94    {
95        if let Some(a) = windows::detect() {
96            return a;
97        }
98    }
99    #[cfg(target_os = "macos")]
100    {
101        if let Some(a) = macos::detect() {
102            return a;
103        }
104    }
105    #[cfg(all(unix, not(target_os = "macos")))]
106    {
107        if let Some(a) = linux::detect() {
108            return a;
109        }
110    }
111
112    Appearance::Unknown
113}
114
115/// `COLORFGBG` is set by xterm / Konsole / rxvt / iTerm2 in the form
116/// `<fg>;<bg>` or `<fg>;<extra>;<bg>` (8 or 16 ANSI colours).  ANSI
117/// background codes 0..6 and 8 are dark; 7 and 9..15 are light.
118fn from_colorfgbg() -> Option<Appearance> {
119    let raw = std::env::var("COLORFGBG").ok()?;
120    let bg: u8 = raw.rsplit(';').next()?.trim().parse().ok()?;
121    Some(if matches!(bg, 0..=6 | 8) {
122        Appearance::Dark
123    } else {
124        Appearance::Light
125    })
126}
127
128// =====================================================================
129// Windows
130// =====================================================================
131#[cfg(target_os = "windows")]
132mod windows {
133    use super::Appearance;
134    use std::ffi::{c_void, OsStr};
135    use std::os::windows::ffi::OsStrExt;
136
137    // Minimal hand-rolled binding to RegGetValueW to avoid the
138    // `winreg` crate (extra dependency).  We only need to read one
139    // DWORD from HKEY_CURRENT_USER, so the surface is tiny.
140    // The Win32 type is HKEY (handle).  Lower-cased here so clippy's
141    // upper-case-acronym lint stays quiet without an allow attribute.
142    type Hkey = *mut c_void;
143    const HKEY_CURRENT_USER: Hkey = 0x8000_0001 as Hkey;
144    const RRF_RT_REG_DWORD: u32 = 0x0000_0010;
145    const ERROR_SUCCESS: i32 = 0;
146
147    #[link(name = "advapi32")]
148    unsafe extern "system" {
149        fn RegGetValueW(
150            hkey: Hkey,
151            lp_subkey: *const u16,
152            lp_value: *const u16,
153            dw_flags: u32,
154            pdw_type: *mut u32,
155            pv_data: *mut c_void,
156            pcb_data: *mut u32,
157        ) -> i32;
158    }
159
160    fn wide(s: &str) -> Vec<u16> {
161        OsStr::new(s).encode_wide().chain(Some(0)).collect()
162    }
163
164    pub fn detect() -> Option<Appearance> {
165        // The user-mode "Apps" theme - the one humans actually toggle
166        // via Settings > Personalisation > Colours > "Choose your mode".
167        let subkey = wide(r"Software\Microsoft\Windows\CurrentVersion\Themes\Personalize");
168        let value = wide("AppsUseLightTheme");
169        let mut data: u32 = 0;
170        let mut data_len: u32 = std::mem::size_of::<u32>() as u32;
171        let rc = unsafe {
172            RegGetValueW(
173                HKEY_CURRENT_USER,
174                subkey.as_ptr(),
175                value.as_ptr(),
176                RRF_RT_REG_DWORD,
177                std::ptr::null_mut(),
178                &mut data as *mut u32 as *mut c_void,
179                &mut data_len,
180            )
181        };
182        if rc != ERROR_SUCCESS {
183            return None;
184        }
185        Some(if data == 1 {
186            Appearance::Light
187        } else {
188            Appearance::Dark
189        })
190    }
191}
192
193// =====================================================================
194// macOS
195// =====================================================================
196#[cfg(target_os = "macos")]
197mod macos {
198    use super::Appearance;
199    use std::process::Command;
200
201    pub fn detect() -> Option<Appearance> {
202        // `defaults read -g AppleInterfaceStyle` returns "Dark" iff the
203        // key exists; in Light mode the key is absent and the command
204        // exits non-zero.  We map both signals into Appearance.
205        let out = Command::new("defaults")
206            .args(["read", "-g", "AppleInterfaceStyle"])
207            .output()
208            .ok()?;
209        if out.status.success() && String::from_utf8_lossy(&out.stdout).contains("Dark") {
210            Some(Appearance::Dark)
211        } else {
212            // Missing key = Light, per Apple's documented behaviour.
213            Some(Appearance::Light)
214        }
215    }
216}
217
218// =====================================================================
219// Linux / other Unix - GNOME, KDE, and freedesktop conventions
220// =====================================================================
221#[cfg(all(unix, not(target_os = "macos")))]
222mod linux {
223    use super::Appearance;
224    use std::process::Command;
225
226    pub fn detect() -> Option<Appearance> {
227        // 1. GNOME (and forks that respect this key).
228        if let Some(a) = gsettings("org.gnome.desktop.interface", "color-scheme") {
229            return Some(a);
230        }
231        // 2. KDE Plasma writes ColorScheme=BreezeDark in kdeglobals; reading
232        //    that requires the `dirs` crate which we already depend on.
233        if let Some(a) = kde_kdeglobals() {
234            return Some(a);
235        }
236        // 3. Fallback: not detected.
237        None
238    }
239
240    fn gsettings(schema: &str, key: &str) -> Option<Appearance> {
241        let out = Command::new("gsettings")
242            .args(["get", schema, key])
243            .output()
244            .ok()?;
245        if !out.status.success() {
246            return None;
247        }
248        let s = String::from_utf8_lossy(&out.stdout).to_lowercase();
249        if s.contains("dark") {
250            Some(Appearance::Dark)
251        } else if s.contains("light") || s.contains("default") {
252            Some(Appearance::Light)
253        } else {
254            None
255        }
256    }
257
258    fn kde_kdeglobals() -> Option<Appearance> {
259        let home = dirs::config_dir()?;
260        let path = home.join("kdeglobals");
261        let text = std::fs::read_to_string(path).ok()?;
262        for line in text.lines() {
263            if let Some(v) = line.strip_prefix("ColorScheme=") {
264                let v = v.trim().to_lowercase();
265                return Some(if v.contains("dark") {
266                    Appearance::Dark
267                } else {
268                    Appearance::Light
269                });
270            }
271        }
272        None
273    }
274}
275
276#[cfg(test)]
277mod tests {
278    use super::*;
279
280    // Cargo runs tests in parallel by default; environment variables are
281    // process-global, so we serialise the three cases into one ordered test
282    // rather than fight the test runner.
283    #[test]
284    fn theme_choice_parses_and_resolves() {
285        assert_eq!(ThemeChoice::from_config_str("dark"), ThemeChoice::Dark);
286        assert_eq!(ThemeChoice::from_config_str("LIGHT"), ThemeChoice::Light);
287        assert_eq!(ThemeChoice::from_config_str("auto"), ThemeChoice::Auto);
288        // Unknown / empty values must NOT panic and must fall back to Auto.
289        assert_eq!(ThemeChoice::from_config_str(""), ThemeChoice::Auto);
290        assert_eq!(
291            ThemeChoice::from_config_str("high-contrast"),
292            ThemeChoice::Auto
293        );
294
295        // Auto follows the OS, Dark/Light override regardless.
296        assert_eq!(
297            ThemeChoice::Auto.resolve(Appearance::Dark),
298            Appearance::Dark
299        );
300        assert_eq!(
301            ThemeChoice::Auto.resolve(Appearance::Light),
302            Appearance::Light
303        );
304        assert_eq!(
305            ThemeChoice::Auto.resolve(Appearance::Unknown),
306            Appearance::Dark
307        );
308        assert_eq!(
309            ThemeChoice::Dark.resolve(Appearance::Light),
310            Appearance::Dark
311        );
312        assert_eq!(
313            ThemeChoice::Light.resolve(Appearance::Dark),
314            Appearance::Light
315        );
316
317        // as_str round-trips through from_config_str.
318        for choice in [ThemeChoice::Auto, ThemeChoice::Dark, ThemeChoice::Light] {
319            assert_eq!(ThemeChoice::from_config_str(choice.as_str()), choice);
320        }
321    }
322
323    #[test]
324    fn colorfgbg_parsing() {
325        // SAFETY: this is the only test in the crate that touches
326        // COLORFGBG, and it runs all three cases in sequence.
327        unsafe {
328            std::env::set_var("COLORFGBG", "15;0");
329            assert_eq!(from_colorfgbg(), Some(Appearance::Dark), "bg=0 -> dark");
330
331            std::env::set_var("COLORFGBG", "0;15");
332            assert_eq!(from_colorfgbg(), Some(Appearance::Light), "bg=15 -> light");
333
334            // ANSI bg code 8 is grey-on-black for many terminals - still dark.
335            std::env::set_var("COLORFGBG", "7;8");
336            assert_eq!(from_colorfgbg(), Some(Appearance::Dark), "bg=8 -> dark");
337
338            // Malformed values return None and let the caller fall through.
339            std::env::set_var("COLORFGBG", "nonsense");
340            assert_eq!(from_colorfgbg(), None);
341
342            std::env::remove_var("COLORFGBG");
343            assert_eq!(from_colorfgbg(), None);
344        }
345    }
346}