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}