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/// Best-effort current OS appearance.
29///
30/// Never panics, never blocks for more than a short subprocess call on
31/// macOS / Linux, never touches the filesystem outside the OS-provided
32/// settings store.
33pub fn detect() -> Appearance {
34    if std::env::var_os("NO_COLOR").is_some() {
35        return Appearance::Unknown;
36    }
37
38    if let Some(a) = from_colorfgbg() {
39        return a;
40    }
41
42    #[cfg(target_os = "windows")]
43    {
44        if let Some(a) = windows::detect() {
45            return a;
46        }
47    }
48    #[cfg(target_os = "macos")]
49    {
50        if let Some(a) = macos::detect() {
51            return a;
52        }
53    }
54    #[cfg(all(unix, not(target_os = "macos")))]
55    {
56        if let Some(a) = linux::detect() {
57            return a;
58        }
59    }
60
61    Appearance::Unknown
62}
63
64/// `COLORFGBG` is set by xterm / Konsole / rxvt / iTerm2 in the form
65/// `<fg>;<bg>` or `<fg>;<extra>;<bg>` (8 or 16 ANSI colours).  ANSI
66/// background codes 0..6 and 8 are dark; 7 and 9..15 are light.
67fn from_colorfgbg() -> Option<Appearance> {
68    let raw = std::env::var("COLORFGBG").ok()?;
69    let bg: u8 = raw.rsplit(';').next()?.trim().parse().ok()?;
70    Some(if matches!(bg, 0..=6 | 8) {
71        Appearance::Dark
72    } else {
73        Appearance::Light
74    })
75}
76
77// =====================================================================
78// Windows
79// =====================================================================
80#[cfg(target_os = "windows")]
81mod windows {
82    use super::Appearance;
83    use std::ffi::{c_void, OsStr};
84    use std::os::windows::ffi::OsStrExt;
85
86    // Minimal hand-rolled binding to RegGetValueW to avoid the
87    // `winreg` crate (extra dependency).  We only need to read one
88    // DWORD from HKEY_CURRENT_USER, so the surface is tiny.
89    // The Win32 type is HKEY (handle).  Lower-cased here so clippy's
90    // upper-case-acronym lint stays quiet without an allow attribute.
91    type Hkey = *mut c_void;
92    const HKEY_CURRENT_USER: Hkey = 0x8000_0001 as Hkey;
93    const RRF_RT_REG_DWORD: u32 = 0x0000_0010;
94    const ERROR_SUCCESS: i32 = 0;
95
96    #[link(name = "advapi32")]
97    unsafe extern "system" {
98        fn RegGetValueW(
99            hkey: Hkey,
100            lp_subkey: *const u16,
101            lp_value: *const u16,
102            dw_flags: u32,
103            pdw_type: *mut u32,
104            pv_data: *mut c_void,
105            pcb_data: *mut u32,
106        ) -> i32;
107    }
108
109    fn wide(s: &str) -> Vec<u16> {
110        OsStr::new(s).encode_wide().chain(Some(0)).collect()
111    }
112
113    pub fn detect() -> Option<Appearance> {
114        // The user-mode "Apps" theme - the one humans actually toggle
115        // via Settings > Personalisation > Colours > "Choose your mode".
116        let subkey = wide(r"Software\Microsoft\Windows\CurrentVersion\Themes\Personalize");
117        let value = wide("AppsUseLightTheme");
118        let mut data: u32 = 0;
119        let mut data_len: u32 = std::mem::size_of::<u32>() as u32;
120        let rc = unsafe {
121            RegGetValueW(
122                HKEY_CURRENT_USER,
123                subkey.as_ptr(),
124                value.as_ptr(),
125                RRF_RT_REG_DWORD,
126                std::ptr::null_mut(),
127                &mut data as *mut u32 as *mut c_void,
128                &mut data_len,
129            )
130        };
131        if rc != ERROR_SUCCESS {
132            return None;
133        }
134        Some(if data == 1 {
135            Appearance::Light
136        } else {
137            Appearance::Dark
138        })
139    }
140}
141
142// =====================================================================
143// macOS
144// =====================================================================
145#[cfg(target_os = "macos")]
146mod macos {
147    use super::Appearance;
148    use std::process::Command;
149
150    pub fn detect() -> Option<Appearance> {
151        // `defaults read -g AppleInterfaceStyle` returns "Dark" iff the
152        // key exists; in Light mode the key is absent and the command
153        // exits non-zero.  We map both signals into Appearance.
154        let out = Command::new("defaults")
155            .args(["read", "-g", "AppleInterfaceStyle"])
156            .output()
157            .ok()?;
158        if out.status.success() && String::from_utf8_lossy(&out.stdout).contains("Dark") {
159            Some(Appearance::Dark)
160        } else {
161            // Missing key = Light, per Apple's documented behaviour.
162            Some(Appearance::Light)
163        }
164    }
165}
166
167// =====================================================================
168// Linux / other Unix - GNOME, KDE, and freedesktop conventions
169// =====================================================================
170#[cfg(all(unix, not(target_os = "macos")))]
171mod linux {
172    use super::Appearance;
173    use std::process::Command;
174
175    pub fn detect() -> Option<Appearance> {
176        // 1. GNOME (and forks that respect this key).
177        if let Some(a) = gsettings("org.gnome.desktop.interface", "color-scheme") {
178            return Some(a);
179        }
180        // 2. KDE Plasma writes ColorScheme=BreezeDark in kdeglobals; reading
181        //    that requires the `dirs` crate which we already depend on.
182        if let Some(a) = kde_kdeglobals() {
183            return Some(a);
184        }
185        // 3. Fallback: not detected.
186        None
187    }
188
189    fn gsettings(schema: &str, key: &str) -> Option<Appearance> {
190        let out = Command::new("gsettings")
191            .args(["get", schema, key])
192            .output()
193            .ok()?;
194        if !out.status.success() {
195            return None;
196        }
197        let s = String::from_utf8_lossy(&out.stdout).to_lowercase();
198        if s.contains("dark") {
199            Some(Appearance::Dark)
200        } else if s.contains("light") || s.contains("default") {
201            Some(Appearance::Light)
202        } else {
203            None
204        }
205    }
206
207    fn kde_kdeglobals() -> Option<Appearance> {
208        let home = dirs::config_dir()?;
209        let path = home.join("kdeglobals");
210        let text = std::fs::read_to_string(path).ok()?;
211        for line in text.lines() {
212            if let Some(v) = line.strip_prefix("ColorScheme=") {
213                let v = v.trim().to_lowercase();
214                return Some(if v.contains("dark") {
215                    Appearance::Dark
216                } else {
217                    Appearance::Light
218                });
219            }
220        }
221        None
222    }
223}
224
225#[cfg(test)]
226mod tests {
227    use super::*;
228
229    // Cargo runs tests in parallel by default; environment variables are
230    // process-global, so we serialise the three cases into one ordered test
231    // rather than fight the test runner.
232    #[test]
233    fn colorfgbg_parsing() {
234        // SAFETY: this is the only test in the crate that touches
235        // COLORFGBG, and it runs all three cases in sequence.
236        unsafe {
237            std::env::set_var("COLORFGBG", "15;0");
238            assert_eq!(from_colorfgbg(), Some(Appearance::Dark), "bg=0 -> dark");
239
240            std::env::set_var("COLORFGBG", "0;15");
241            assert_eq!(from_colorfgbg(), Some(Appearance::Light), "bg=15 -> light");
242
243            // ANSI bg code 8 is grey-on-black for many terminals - still dark.
244            std::env::set_var("COLORFGBG", "7;8");
245            assert_eq!(from_colorfgbg(), Some(Appearance::Dark), "bg=8 -> dark");
246
247            // Malformed values return None and let the caller fall through.
248            std::env::set_var("COLORFGBG", "nonsense");
249            assert_eq!(from_colorfgbg(), None);
250
251            std::env::remove_var("COLORFGBG");
252            assert_eq!(from_colorfgbg(), None);
253        }
254    }
255}