Skip to main content

cli/theme/
mod.rs

1//! `shine theme sync`: resolves the terminal's light/dark theme and prints
2//! shell-safe export statements for `SHINE_TERMINAL_THEME` and `BAT_THEME`.
3//! See docs/terminal-theme-sync-prd.md for the full design and
4//! docs/kb/lessons.md (2026-07-14) for why the old shell-only OSC read loop
5//! was replaced.
6
7mod color;
8#[cfg(unix)]
9mod osc;
10
11use std::time::Duration;
12
13use anyhow::Result;
14
15use crate::config::Config;
16use crate::env::commands::format_env_export;
17
18pub use color::{Theme, parse_colorfgbg, parse_theme_str};
19
20/// Total time budget for an OSC 11 round trip. Deliberately generous
21/// relative to the sub-millisecond local RTT measured during the PRD's
22/// investigation, since it must also tolerate a genuinely slow/lossy SSH
23/// link without hanging shell startup (PRD §11: 200ms cap on "terminal
24/// unresponsive").
25const OSC_QUERY_BUDGET: Duration = Duration::from_millis(200);
26
27#[cfg(unix)]
28fn query_terminal_theme(budget: Duration) -> Option<Theme> {
29    osc::query_terminal_theme(budget)
30}
31
32#[cfg(not(unix))]
33fn query_terminal_theme(_budget: Duration) -> Option<Theme> {
34    None
35}
36
37/// Resolves the local terminal's theme for injection into a `shine ssh`
38/// remote session (PRD §6.1): prefers an already-exported
39/// `SHINE_TERMINAL_THEME`, otherwise queries the local tty directly — unlike
40/// a remote query, this is a same-host round trip with no fragmentation risk
41/// (PRD §2.2). Returns `None` rather than failing `shine ssh` itself: this
42/// is a display-layer nicety, never a reason to block a login.
43pub fn resolve_local_terminal_theme_for_injection() -> Option<Theme> {
44    if let Ok(existing) = std::env::var("SHINE_TERMINAL_THEME")
45        && let Some(theme) = parse_theme_str(existing.trim())
46    {
47        return Some(theme);
48    }
49    query_terminal_theme(OSC_QUERY_BUDGET)
50}
51
52/// `true` when auto-sync (profile-driven) should proceed: an explicit
53/// `SHINE_SYNC_TERMINAL_THEME` env var always wins over config (PRD §5:
54/// "环境变量...覆盖配置文件"); when unset, falls back to
55/// `config.sync_terminal_theme`. Manual invocations (`auto: false` in
56/// [`handle_sync`]) never call this — PRD §5: "手动同步命令不受该开关限制".
57fn auto_sync_enabled(config: &Config) -> bool {
58    match std::env::var("SHINE_SYNC_TERMINAL_THEME") {
59        Ok(value) => value.trim() != "0",
60        Err(_) => config.sync_terminal_theme,
61    }
62}
63
64/// Resolves the `BAT_THEME` value for `theme`, honoring the already-published
65/// `SHINE_BAT_LIGHT_THEME`/`SHINE_BAT_DARK_THEME` overrides (PRD §5.1) with
66/// their existing defaults.
67fn resolve_bat_theme_override(theme: Theme) -> String {
68    let (var, default) = match theme {
69        Theme::Light => ("SHINE_BAT_LIGHT_THEME", "GitHub"),
70        Theme::Dark => ("SHINE_BAT_DARK_THEME", "OneHalfDark"),
71    };
72    std::env::var(var)
73        .ok()
74        .filter(|value| !value.is_empty())
75        .unwrap_or_else(|| default.to_string())
76}
77
78/// Priority chain from PRD §6: an already-exported `SHINE_TERMINAL_THEME`
79/// (set by the user, a parent shell's own sync, or `shine ssh`'s injection)
80/// wins outright with no tty interaction; then `COLORFGBG`; then an OSC 11
81/// query. Returns `None` if nothing resolves.
82fn resolve_theme() -> Option<Theme> {
83    if let Ok(existing) = std::env::var("SHINE_TERMINAL_THEME")
84        && let Some(theme) = parse_theme_str(existing.trim())
85    {
86        return Some(theme);
87    }
88    if let Ok(colorfgbg) = std::env::var("COLORFGBG")
89        && let Some(theme) = parse_colorfgbg(&colorfgbg)
90    {
91        return Some(theme);
92    }
93    query_terminal_theme(OSC_QUERY_BUDGET)
94}
95
96/// `shine theme sync [--auto] [--quiet]`. Prints `eval`-able shell export
97/// statements to stdout; diagnostics go to stderr (suppressed by
98/// `--quiet`). Always exits successfully — an unresolved theme prints
99/// nothing rather than failing, so an old/broken binary or an unsupported
100/// terminal never blocks shell startup (PRD §7).
101pub async fn handle_sync(auto: bool, quiet: bool) -> Result<()> {
102    // Read-only: this runs on every interactive shell start and must never
103    // create shine state on disk (AGENTS.md: Config::load_or_init() writes
104    // to disk even for read-oriented commands).
105    let config = Config::load_global_runtime_for_dry_run().await?;
106
107    if auto && !auto_sync_enabled(&config) {
108        return Ok(());
109    }
110
111    let Some(theme) = resolve_theme() else {
112        if !quiet {
113            eprintln!("shine: could not determine terminal theme; leaving BAT_THEME unchanged");
114        }
115        return Ok(());
116    };
117
118    println!(
119        "{}",
120        format_env_export(&config.shell_type, "SHINE_TERMINAL_THEME", theme.as_str())
121    );
122
123    // Preserve a BAT_THEME the user (or anything other than shine) already
124    // set, regardless of its source — this is also what makes a nested
125    // shell that inherited a parent's already-shine-set BAT_THEME a no-op
126    // here (PRD §6.5, a deliberate behavior change from the old
127    // unconditional overwrite in profile.pre.sh).
128    if std::env::var("BAT_THEME").is_ok() {
129        return Ok(());
130    }
131    let bat_theme = resolve_bat_theme_override(theme);
132    println!(
133        "{}",
134        format_env_export(&config.shell_type, "BAT_THEME", &bat_theme)
135    );
136    Ok(())
137}
138
139#[cfg(test)]
140mod tests {
141    use super::*;
142    use crate::test_support::env_lock;
143
144    #[allow(clippy::await_holding_lock)]
145    #[tokio::test(flavor = "current_thread")]
146    async fn handle_sync_skips_when_auto_and_env_var_disabled() {
147        let _guard = env_lock();
148        // SAFETY: env_lock() is held for the duration of this block.
149        unsafe { std::env::set_var("SHINE_SYNC_TERMINAL_THEME", "0") };
150        unsafe { std::env::set_var("SHINE_TERMINAL_THEME", "dark") };
151
152        let result = handle_sync(true, true).await;
153        assert!(result.is_ok());
154
155        unsafe { std::env::remove_var("SHINE_SYNC_TERMINAL_THEME") };
156        unsafe { std::env::remove_var("SHINE_TERMINAL_THEME") };
157    }
158
159    #[allow(clippy::await_holding_lock)]
160    #[tokio::test(flavor = "current_thread")]
161    async fn handle_sync_manual_ignores_env_disable() {
162        let _guard = env_lock();
163        unsafe { std::env::set_var("SHINE_SYNC_TERMINAL_THEME", "0") };
164        unsafe { std::env::set_var("SHINE_TERMINAL_THEME", "dark") };
165        unsafe { std::env::remove_var("BAT_THEME") };
166
167        // auto = false: PRD §5 says manual sync must not be gated by
168        // SHINE_SYNC_TERMINAL_THEME/config, so this must still resolve
169        // rather than short-circuiting through the auto-gate.
170        let result = handle_sync(false, true).await;
171        assert!(result.is_ok());
172
173        unsafe { std::env::remove_var("SHINE_SYNC_TERMINAL_THEME") };
174        unsafe { std::env::remove_var("SHINE_TERMINAL_THEME") };
175    }
176
177    #[test]
178    fn resolve_bat_theme_override_uses_published_env_vars() {
179        let _guard = env_lock();
180        // SAFETY: env_lock() is held for the duration of this block.
181        unsafe { std::env::set_var("SHINE_BAT_LIGHT_THEME", "Solarized") };
182        assert_eq!(resolve_bat_theme_override(Theme::Light), "Solarized");
183        unsafe { std::env::remove_var("SHINE_BAT_LIGHT_THEME") };
184
185        assert_eq!(resolve_bat_theme_override(Theme::Light), "GitHub");
186        assert_eq!(resolve_bat_theme_override(Theme::Dark), "OneHalfDark");
187    }
188
189    #[test]
190    fn resolve_bat_theme_override_ignores_empty_env_var() {
191        let _guard = env_lock();
192        unsafe { std::env::set_var("SHINE_BAT_DARK_THEME", "") };
193        assert_eq!(resolve_bat_theme_override(Theme::Dark), "OneHalfDark");
194        unsafe { std::env::remove_var("SHINE_BAT_DARK_THEME") };
195    }
196
197    #[test]
198    fn resolve_theme_prefers_already_exported_var_over_colorfgbg() {
199        let _guard = env_lock();
200        unsafe { std::env::set_var("SHINE_TERMINAL_THEME", "light") };
201        unsafe { std::env::set_var("COLORFGBG", "15;0") }; // would resolve dark
202        assert_eq!(resolve_theme(), Some(Theme::Light));
203        unsafe { std::env::remove_var("SHINE_TERMINAL_THEME") };
204        unsafe { std::env::remove_var("COLORFGBG") };
205    }
206
207    #[test]
208    fn resolve_theme_falls_back_to_colorfgbg() {
209        let _guard = env_lock();
210        unsafe { std::env::remove_var("SHINE_TERMINAL_THEME") };
211        unsafe { std::env::set_var("COLORFGBG", "0;15") };
212        assert_eq!(resolve_theme(), Some(Theme::Light));
213        unsafe { std::env::remove_var("COLORFGBG") };
214    }
215}