Skip to main content

rpi_cli/
themes.rs

1//! Theme spec resolution and loading.
2//!
3//! A *theme spec* is the value of `--theme` / `settings.json.theme`: either a
4//! built-in preset name (`dark` / `light` / `monochrome`) or a path to a pi
5//! theme JSON file (`ThemeJson`, see `rpi_tui::theme_json`). File specs expand
6//! `~` and load lazily at the point of application so parse errors surface as
7//! warnings plus a fallback to the built-in default, never a startup veto —
8//! the same gentle style as `emit_startup_warnings`.
9//!
10//! `--no-themes` is enforced upstream (`app.rs` passes no theme at all), so
11//! custom files cannot bypass it here.
12
13use std::collections::BTreeMap;
14use std::path::{Path, PathBuf};
15use std::sync::{Arc, Mutex};
16
17use rpi_tui::theme::{global_theme_manager, Theme, ThemeManager, ThemePreset};
18use rpi_tui::theme_json::ThemeJson;
19
20/// A resolved theme spec: built-in preset or theme file path.
21#[derive(Debug, Clone)]
22pub enum ThemeSpec {
23    Preset(ThemePreset),
24    File(PathBuf),
25}
26
27/// Classify a theme spec string. Anything that is not a built-in preset name
28/// is treated as a file path (the documented `--theme` semantics).
29pub fn resolve_spec(value: &str) -> ThemeSpec {
30    resolve_spec_in(value, None)
31}
32
33/// [`resolve_spec`] with a discovery registry: an exact registry name resolves
34/// to its file, so a theme picked from the selector (persisted by name) loads
35/// again on the next startup. Direct paths still win for values that are not
36/// registry names.
37pub fn resolve_spec_in(value: &str, registry: Option<&ThemeRegistry>) -> ThemeSpec {
38    match value {
39        "dark" => ThemeSpec::Preset(ThemePreset::Dark),
40        "light" => ThemeSpec::Preset(ThemePreset::Light),
41        "monochrome" => ThemeSpec::Preset(ThemePreset::Monochrome),
42        other => {
43            if let Some(path) = registry.and_then(|registry| registry.get(other)) {
44                return ThemeSpec::File(path.clone());
45            }
46            ThemeSpec::File(expand_home(other))
47        }
48    }
49}
50
51/// Expand a leading `~` to the user's home directory. `~user` forms are left
52/// untouched (only the current user's home is resolvable here).
53pub fn expand_home(value: &str) -> PathBuf {
54    if value == "~" {
55        if let Some(home) = dirs::home_dir() {
56            return home;
57        }
58    } else if let Some(rest) = value.strip_prefix("~/") {
59        if let Some(home) = dirs::home_dir() {
60            return home.join(rest);
61        }
62    }
63    PathBuf::from(value)
64}
65
66/// A successfully loaded file theme, ready to apply.
67#[derive(Debug, Clone)]
68pub struct LoadedTheme {
69    /// Theme name from the JSON (`name` field), for messages.
70    pub name: String,
71    /// The parsed theme (default spacing/border styles).
72    pub theme: Theme,
73    /// The file it came from, for diagnostics.
74    pub source: PathBuf,
75}
76
77impl LoadedTheme {
78    /// Parse a pi theme file. The error message includes the path and reason.
79    pub fn load(path: &Path) -> Result<Self, String> {
80        let json = ThemeJson::from_file(path)
81            .map_err(|e| format!("cannot load theme {}: {e}", path.display()))?;
82        let theme = json
83            .to_theme()
84            .map_err(|e| format!("cannot load theme {}: {e}", path.display()))?;
85        Ok(Self {
86            name: json.name,
87            theme,
88            source: path.to_path_buf(),
89        })
90    }
91}
92
93/// Apply a file theme to both the process-wide theme (what components read)
94/// and the session's theme manager (what `/theme` swaps), mirroring the
95/// preset path's dual application.
96pub fn apply_loaded(loaded: &LoadedTheme, session_manager: &ThemeManager) {
97    global_theme_manager().set(loaded.theme.clone());
98    session_manager.set(loaded.theme.clone());
99}
100
101/// Resolve a `--theme`/settings spec against the discovery registry and apply
102/// it. Kept as one helper so the startup call site cannot resolve before the
103/// registry is built (the saved value may be a registry name, which only
104/// resolves when the registry is in scope).
105pub fn apply_startup_spec(
106    value: &str,
107    registry: Option<&ThemeRegistry>,
108    session_manager: &ThemeManager,
109) -> Result<(), String> {
110    apply_spec(&resolve_spec_in(value, registry), session_manager)
111}
112
113/// Resolve a `/theme <name>` argument to the spec `apply_and_persist`
114/// consumes. Built-in presets are case-folded; discovered custom themes keep
115/// the original case because the registry is case-sensitive and discovery
116/// preserves file stems (`/theme Ocean` must find `Ocean.json`).
117/// Returns `None` when the name matches neither a preset nor the registry.
118pub fn resolve_command_theme(arg: &str, registry: Option<&ThemeRegistry>) -> Option<String> {
119    let folded = arg.to_ascii_lowercase();
120    let is_preset = matches!(folded.as_str(), "light" | "monochrome" | "dark");
121    if is_preset {
122        return Some(folded);
123    }
124    registry
125        .and_then(|registry| registry.get(arg))
126        .map(|path| path.to_string_lossy().into_owned())
127}
128
129/// Resolve + apply a theme spec at TUI startup.
130///
131/// Returns `Err(message)` when a file spec fails to load; the caller falls
132/// back to the built-in default (no theme is applied) and surfaces the
133/// message as a warning.
134pub fn apply_spec(spec: &ThemeSpec, session_manager: &ThemeManager) -> Result<(), String> {
135    match spec {
136        ThemeSpec::Preset(preset) => {
137            session_manager.apply_preset(*preset);
138            rpi_tui::theme::apply_theme_preset(*preset);
139            Ok(())
140        }
141        ThemeSpec::File(path) => match LoadedTheme::load(path) {
142            Ok(loaded) => {
143                apply_loaded(&loaded, session_manager);
144                Ok(())
145            }
146            Err(message) => Err(message),
147        },
148    }
149}
150
151/// A registry of discovered custom theme files: display name → path.
152///
153/// Built once per TUI session by scanning the resource theme dirs; names are
154/// file stems, first-wins on conflict (project `.rpi/themes` overrides the
155/// global `~/.rpi/agent/themes`). Parsing is lazy: the registry only lists
156/// files, and a theme is parsed when selected or applied, so a broken JSON
157/// file cannot break the other themes.
158#[derive(Debug, Clone, Default)]
159pub struct ThemeRegistry {
160    entries: BTreeMap<String, PathBuf>,
161}
162
163impl ThemeRegistry {
164    /// Merge bare extension-contributed theme file paths (`resources_discover`
165    /// `themePaths`) into this registry.
166    ///
167    /// Priority sits **after** the static dirs: a static dir theme of the same
168    /// stem wins, extension themes fill the remaining names. This matches the
169    /// skill/prompt precedence (static first-wins, discovered lower). Paths are
170    /// concrete `.json` files (the pi contract, unlike skills' dir-or-file);
171    /// names are file stems. v1 divergence (documented upstream parity gap):
172    /// paths are unattributed, so diagnostics can only name the path.
173    pub fn merge_discovered(&mut self, paths: &[String]) {
174        for path in paths {
175            let path = PathBuf::from(path);
176            let Some(name) = path.file_stem().and_then(|stem| stem.to_str()) else {
177                continue;
178            };
179            self.entries.entry(name.to_string()).or_insert(path);
180        }
181    }
182
183    /// Scan the global + project theme dirs. Project themes win on name
184    /// conflicts. `project_trusted == false` skips the project dir entirely
185    /// (the same trust boundary as skills: an untrusted repo must not shape
186    /// the UI on first open).
187    ///
188    /// Returns the registry plus soft diagnostics for unreadable dirs.
189    pub fn discover(cwd: &Path, project_trusted: bool) -> (Self, Vec<String>) {
190        let global = crate::resource_dirs::global_dir("themes");
191        let project = project_trusted.then(|| crate::resource_dirs::project_dir(cwd, "themes"));
192        Self::discover_from(global.as_deref(), project.as_deref())
193    }
194
195    /// Scan explicit dirs (test seam; `discover` resolves the standard dirs).
196    pub fn discover_from(global: Option<&Path>, project: Option<&Path>) -> (Self, Vec<String>) {
197        let mut diagnostics = Vec::new();
198        let mut entries = BTreeMap::new();
199        if let Some(global) = global {
200            scan_dir(global, &mut entries, &mut diagnostics, false);
201        }
202        if let Some(project) = project {
203            scan_dir(project, &mut entries, &mut diagnostics, true);
204        }
205        (Self { entries }, diagnostics)
206    }
207
208    /// Look up a discovered theme by name (file stem, case-sensitive).
209    pub fn get(&self, name: &str) -> Option<&PathBuf> {
210        self.entries.get(name)
211    }
212
213    /// Discovered names in display order (alphabetical).
214    pub fn names(&self) -> impl Iterator<Item = &String> {
215        self.entries.keys()
216    }
217
218    pub fn is_empty(&self) -> bool {
219        self.entries.is_empty()
220    }
221
222    pub fn len(&self) -> usize {
223        self.entries.len()
224    }
225}
226
227/// Scan one theme directory into `entries`. `override_existing` is `true` for
228/// the project dir (higher priority) and `false` for the global dir (first
229/// wins within the pass, so two same-stem files in one dir keep the
230/// alphabetically first).
231fn scan_dir(
232    dir: &Path,
233    entries: &mut BTreeMap<String, PathBuf>,
234    diagnostics: &mut Vec<String>,
235    override_existing: bool,
236) {
237    let read = match std::fs::read_dir(dir) {
238        Ok(read) => read,
239        Err(err) if err.kind() == std::io::ErrorKind::NotFound => return,
240        Err(err) => {
241            diagnostics.push(format!("cannot read theme dir {}: {err}", dir.display()));
242            return;
243        }
244    };
245    let mut files: Vec<PathBuf> = read
246        .filter_map(|entry| entry.ok().map(|e| e.path()))
247        .filter(|path| {
248            path.is_file()
249                && path
250                    .extension()
251                    .is_some_and(|ext| ext.eq_ignore_ascii_case("json"))
252        })
253        .collect();
254    files.sort();
255    for path in files {
256        let Some(name) = path.file_stem().and_then(|stem| stem.to_str()) else {
257            continue;
258        };
259        if override_existing {
260            entries.insert(name.to_string(), path);
261        } else {
262            entries.entry(name.to_string()).or_insert(path);
263        }
264    }
265}
266
267/// Swap the theme watcher onto the current active spec.
268///
269/// File themes get an mtime watcher (poll → re-parse → apply → notify via the
270/// `notify` callback); presets and unresolvable specs drop any existing watcher
271/// (nothing to poll). The old handle is closed first so at most one poll task
272/// runs. The caller owns the handle slot plus the current spec (this module
273/// stays independent of the TUI state type); `registry` is the snapshot the
274/// watcher re-applies through, and `session_manager` is the session's own
275/// theme manager so a reload updates the same palette the UI reads — the
276/// global manager is applied too, mirroring the normal selection path.
277pub fn restart_theme_watcher<N>(
278    slot: &Mutex<Option<crate::fs_watch::WatchHandle>>,
279    active_spec: Option<&str>,
280    registry: &ThemeRegistry,
281    session_manager: &Arc<ThemeManager>,
282    notify: N,
283) where
284    N: Fn(Result<String, String>) + Send + Sync + 'static,
285{
286    let path = active_spec.and_then(|spec| match resolve_spec_in(spec, Some(registry)) {
287        ThemeSpec::File(path) if path.is_file() => Some(path),
288        _ => None,
289    });
290    if let Some(handle) = slot.lock().ok().and_then(|mut guard| guard.take()) {
291        handle.close();
292    }
293    if let Some(path) = path {
294        let registry = registry.clone();
295        let session_manager = session_manager.clone();
296        let spec = active_spec.unwrap_or_default().to_string();
297        let handle = crate::fs_watch::watch_path(path, move || {
298            // Re-apply through the CURRENT registry so a name-persisted spec
299            // follows the file the registry points at.
300            let result = apply_and_persist(&spec, &session_manager, Some(&registry));
301            notify(result);
302        });
303        if let Ok(mut guard) = slot.lock() {
304            *guard = Some(handle);
305        }
306    }
307}
308
309/// Reconcile a rebuilt registry after `/reload`: if the currently active file
310/// theme (tracked as its spec value) is no longer resolvable, fall back to the
311/// default preset and return the user-facing notice; otherwise leave the theme
312/// alone and return `None`.
313///
314/// Resolution mirrors startup: registry name first (the persisted form for
315/// discovered themes), then a direct path. A file that still exists keeps
316/// working across reloads even though it came from a package that reloaded.
317pub fn reconcile_after_reload(
318    active_spec: Option<&str>,
319    registry: &ThemeRegistry,
320    session_manager: &ThemeManager,
321) -> Option<String> {
322    let active_spec = active_spec?;
323    if active_spec == "dark" || active_spec == "light" || active_spec == "monochrome" {
324        return None;
325    }
326    let still_resolvable = match resolve_spec_in(active_spec, Some(registry)) {
327        ThemeSpec::File(path) => path.is_file(),
328        ThemeSpec::Preset(_) => true,
329    };
330    if still_resolvable {
331        return None;
332    }
333    session_manager.apply_preset(ThemePreset::Dark);
334    rpi_tui::theme::apply_theme_preset(ThemePreset::Dark);
335    Some(format!(
336        "Theme \"{active_spec}\" is no longer available; reverted to the default dark theme."
337    ))
338}
339
340/// Apply a theme spec (preset or file path) live and persist it.
341///
342/// Shared by `/theme <name>`, the `/theme` selector, and the settings panel:
343/// applies to both managers, saves the spec string to `settings.json`, and
344/// returns the user-facing message. Unknown names and load failures return
345/// `Err` with the message to show.
346pub fn apply_and_persist(
347    spec_value: &str,
348    session_manager: &ThemeManager,
349    registry: Option<&ThemeRegistry>,
350) -> Result<String, String> {
351    let spec = resolve_spec_in(spec_value, registry);
352    let label = match &spec {
353        ThemeSpec::Preset(preset) => match preset {
354            ThemePreset::Dark => "Dark".to_string(),
355            ThemePreset::Light => "Light".to_string(),
356            ThemePreset::Monochrome => "Monochrome".to_string(),
357        },
358        ThemeSpec::File(path) => {
359            // Surface a load error before touching any state; the label is
360            // the file stem (the discovery name), not the JSON `name` field.
361            LoadedTheme::load(path).map_err(|message| message)?;
362            path.file_stem()
363                .and_then(|stem| stem.to_str())
364                .unwrap_or("theme")
365                .to_string()
366        }
367    };
368    apply_spec(&spec, session_manager)?;
369    let mut settings = crate::settings::load_settings().unwrap_or_default();
370    settings.theme = Some(spec_value.to_string());
371    let saved = crate::settings::save_settings(&settings);
372    Ok(format!(
373        "Theme set to {label} (saved{})",
374        if saved.is_ok() { "" } else { ", not saved" }
375    ))
376}
377
378#[cfg(test)]
379mod tests {
380    use super::*;
381    use rpi_tui::theme::ThemeColors;
382
383    fn temp_theme_file(dir: &Path, name: &str, accent: &str) -> PathBuf {
384        let path = dir.join(name);
385        std::fs::write(
386            &path,
387            format!(r##"{{"name":"{name}","colors":{{"accent":"{accent}"}}}}"##),
388        )
389        .unwrap();
390        path
391    }
392
393    #[test]
394    fn merge_discovered_fills_after_static_dirs() {
395        let root = std::env::temp_dir().join(format!("rpi-themes-ext-{}", std::process::id()));
396        let project = root.join("proj/.rpi/themes");
397        write_theme(&project, "shared.json");
398        let ext_a = root.join("pkgA/themes/shared.json");
399        let ext_b = root.join("pkgB/themes/extra.json");
400        write_theme(&ext_a.parent().unwrap(), "shared.json");
401        write_theme(&ext_b.parent().unwrap(), "extra.json");
402
403        let (mut registry, _) = ThemeRegistry::discover_from(None, Some(&project));
404        registry.merge_discovered(&[
405            ext_a.to_string_lossy().to_string(),
406            ext_b.to_string_lossy().to_string(),
407            "/does/not/matter.md".to_string(), // non-json path is still keyed by stem
408        ]);
409        assert!(registry.get("shared").unwrap().starts_with(&project));
410        assert!(registry.get("extra").unwrap().ends_with("extra.json"));
411        std::fs::remove_dir_all(&root).ok();
412    }
413
414    #[test]
415    fn reconcile_keeps_preset_and_existing_file() {
416        let manager = ThemeManager::new();
417        // Preset specs never fall back.
418        assert!(
419            reconcile_after_reload(Some("light"), &ThemeRegistry::default(), &manager).is_none()
420        );
421        // No active spec: nothing to do.
422        assert!(reconcile_after_reload(None, &ThemeRegistry::default(), &manager).is_none());
423        // An existing file theme stays.
424        let dir = std::env::temp_dir().join(format!("rpi-themes-recon-{}", std::process::id()));
425        std::fs::create_dir_all(&dir).unwrap();
426        let path = temp_theme_file(&dir, "ocean.json", "#123456");
427        assert!(reconcile_after_reload(
428            Some(&path.to_string_lossy()),
429            &ThemeRegistry::default(),
430            &manager
431        )
432        .is_none());
433        std::fs::remove_dir_all(&dir).ok();
434    }
435
436    #[test]
437    fn reconcile_falls_back_when_active_theme_vanishes() {
438        let manager = ThemeManager::new();
439        manager.apply_preset(ThemePreset::Light);
440        let (registry, _) = ThemeRegistry::discover_from(None, None);
441        let notice = reconcile_after_reload(Some("gone-theme"), &registry, &manager);
442        assert!(notice.is_some());
443        assert!(notice.unwrap().contains("gone-theme"));
444        assert!(matches!(
445            manager.get().colors.accent,
446            rpi_tui::theme::Color::Ansi256(108)
447        ));
448    }
449
450    #[test]
451    fn restart_watcher_targets_files_only_and_swaps_handles() {
452        use std::sync::atomic::{AtomicUsize, Ordering};
453
454        let dir = std::env::temp_dir().join(format!("rpi-theme-watch-{}", std::process::id()));
455        std::fs::create_dir_all(&dir).unwrap();
456        let path = temp_theme_file(&dir, "ocean.json", "#112233");
457        let spec = path.to_string_lossy().to_string();
458
459        let rt = tokio::runtime::Builder::new_current_thread()
460            .enable_time()
461            .build()
462            .unwrap();
463        rt.block_on(async {
464            let slot: Mutex<Option<crate::fs_watch::WatchHandle>> = Mutex::new(None);
465            let session_manager = Arc::new(ThemeManager::new());
466            let hits = std::sync::Arc::new(AtomicUsize::new(0));
467            let hits_ref = hits.clone();
468
469            // File spec: a watcher is installed.
470            let registry = ThemeRegistry::default();
471            restart_theme_watcher(
472                &slot,
473                Some(&spec),
474                &registry,
475                &session_manager,
476                move |_result| {
477                    hits_ref.fetch_add(1, Ordering::SeqCst);
478                },
479            );
480            assert!(slot.lock().unwrap().is_some());
481
482            // Preset spec: the watcher is dropped.
483            restart_theme_watcher(&slot, Some("dark"), &registry, &session_manager, |_| {});
484            assert!(slot.lock().unwrap().is_none());
485
486            // Re-install and edit the file: the callback fires (bounded wait;
487            // the fs_watch poll is 1s) and re-applies through the registry.
488            let hits_ref2 = hits.clone();
489            restart_theme_watcher(
490                &slot,
491                Some(&spec),
492                &registry,
493                &session_manager,
494                move |_result| {
495                    hits_ref2.fetch_add(1, Ordering::SeqCst);
496                },
497            );
498            // Let the spawned task take its baseline mtime snapshot before
499            // writing, otherwise the edit lands in the baseline and never
500            // registers as a change.
501            tokio::time::sleep(std::time::Duration::from_millis(200)).await;
502            std::fs::write(
503                &path,
504                r##"{"name":"ocean.json","colors":{"accent":"#445566"}}"##,
505            )
506            .unwrap();
507            let deadline = std::time::Instant::now() + std::time::Duration::from_secs(4);
508            while hits.load(Ordering::SeqCst) == 0 && std::time::Instant::now() < deadline {
509                tokio::time::sleep(std::time::Duration::from_millis(100)).await;
510            }
511            assert!(hits.load(Ordering::SeqCst) >= 1, "mtime change must fire");
512            // The reload must reach the session manager (what autocomplete and
513            // /theme read), not only the process-wide one.
514            assert_eq!(
515                session_manager.get().colors.accent,
516                rpi_tui::theme::Color::Rgb(68, 85, 102),
517                "session manager must carry the reloaded palette"
518            );
519            assert!(
520                slot.lock().unwrap().is_some(),
521                "watcher survives after firing"
522            );
523            restart_theme_watcher(&slot, None, &registry, &session_manager, |_| {});
524            assert!(slot.lock().unwrap().is_none());
525        });
526        std::fs::remove_dir_all(&dir).ok();
527    }
528
529    // review_pr202: a spec persisted as a registry NAME must resolve through
530    // the supplied registry (watch the discovered file), not be probed as a
531    // cwd-relative path.
532    #[test]
533    fn review_pr202_watcher_follows_registry_names() {
534        let root = std::env::temp_dir().join(format!("rpi-theme-watchreg-{}", std::process::id()));
535        let project = root.join("proj/.rpi/themes");
536        std::fs::create_dir_all(&project).unwrap();
537        let path = temp_theme_file(&project, "ocean.json", "#112233");
538        let (registry, _) = ThemeRegistry::discover_from(None, Some(&project));
539
540        let rt = tokio::runtime::Builder::new_current_thread()
541            .enable_time()
542            .build()
543            .unwrap();
544        rt.block_on(async {
545            let slot: Mutex<Option<crate::fs_watch::WatchHandle>> = Mutex::new(None);
546            let session_manager = Arc::new(ThemeManager::new());
547            restart_theme_watcher(
548                &slot,
549                Some("ocean"),
550                &registry,
551                &session_manager,
552                move |_result| {},
553            );
554            assert!(
555                slot.lock().unwrap().is_some(),
556                "a registry-named theme must install a watcher"
557            );
558            // Control: with no registry the same name resolves to nothing and
559            // installs no watcher (the pre-fix behavior).
560            restart_theme_watcher(
561                &slot,
562                Some("ocean"),
563                &ThemeRegistry::default(),
564                &session_manager,
565                move |_result| {},
566            );
567            assert!(slot.lock().unwrap().is_none());
568        });
569        let _ = path;
570        std::fs::remove_dir_all(&root).ok();
571    }
572
573    #[test]
574    fn registry_name_resolves_through_resolve_spec_in() {
575        let root = std::env::temp_dir().join(format!("rpi-themes-loop-{}", std::process::id()));
576        let project = root.join("proj/.rpi/themes");
577        let path = write_theme(&project, "ocean.json");
578        let (registry, _) = ThemeRegistry::discover_from(None, Some(&project));
579
580        let spec = resolve_spec_in("ocean", Some(&registry));
581        assert!(matches!(spec, ThemeSpec::File(ref p) if *p == path));
582        // Non-registry values still go through the path/tilde path.
583        assert!(matches!(
584            resolve_spec_in("other.json", Some(&registry)),
585            ThemeSpec::File(_)
586        ));
587        assert!(matches!(
588            resolve_spec_in("dark", Some(&registry)),
589            ThemeSpec::Preset(ThemePreset::Dark)
590        ));
591        // apply_and_persist with the registry resolves the name to the file.
592        let manager = ThemeManager::new();
593        let message = apply_and_persist("ocean", &manager, Some(&registry)).unwrap();
594        assert!(message.starts_with("Theme set to ocean"), "{message}");
595        std::fs::remove_dir_all(&root).ok();
596    }
597
598    #[test]
599    fn apply_and_persist_preset_message_and_error() {
600        let manager = ThemeManager::new();
601        let message = apply_and_persist("light", &manager, None).unwrap();
602        assert!(message.starts_with("Theme set to Light"), "{message}");
603        assert!(message.contains("(saved"), "{message}");
604        let err = apply_and_persist("no-such.json", &manager, None).unwrap_err();
605        assert!(err.contains("no-such.json"), "{err}");
606        // Failed apply leaves the previous theme intact.
607        assert!(matches!(
608            manager.get().colors.muted,
609            rpi_tui::theme::Color::Ansi256(241)
610        ));
611    }
612
613    #[test]
614    fn apply_and_persist_file_theme_message_uses_stem() {
615        let manager = ThemeManager::new();
616        let dir = std::env::temp_dir().join(format!("rpi-theme-persist-{}", std::process::id()));
617        std::fs::create_dir_all(&dir).unwrap();
618        let path = temp_theme_file(&dir, "ocean.json", "#112233");
619        let spec = path.to_string_lossy().to_string();
620        let message = apply_and_persist(&spec, &manager, None).unwrap();
621        assert!(message.starts_with("Theme set to ocean"), "{message}");
622        assert_eq!(
623            manager.get().colors.accent,
624            rpi_tui::theme::Color::Rgb(17, 34, 51)
625        );
626        std::fs::remove_dir_all(&dir).ok();
627    }
628
629    #[test]
630    fn preset_names_classify_to_presets() {
631        assert!(matches!(
632            resolve_spec("dark"),
633            ThemeSpec::Preset(ThemePreset::Dark)
634        ));
635        assert!(matches!(
636            resolve_spec("light"),
637            ThemeSpec::Preset(ThemePreset::Light)
638        ));
639        assert!(matches!(
640            resolve_spec("monochrome"),
641            ThemeSpec::Preset(ThemePreset::Monochrome)
642        ));
643        // Case-sensitive: preset names are lowercase identifiers.
644        assert!(matches!(resolve_spec("Dark"), ThemeSpec::File(_)));
645    }
646
647    #[test]
648    fn everything_else_is_a_file_path() {
649        assert!(matches!(
650            resolve_spec("themes/ocean.json"),
651            ThemeSpec::File(ref p) if p == &PathBuf::from("themes/ocean.json")
652        ));
653        assert!(matches!(
654            resolve_spec("~/.rpi/agent/themes/ocean.json"),
655            ThemeSpec::File(ref p) if *p == expand_home("~/.rpi/agent/themes/ocean.json")
656        ));
657    }
658
659    #[test]
660    fn expand_home_forms() {
661        let home = dirs::home_dir().unwrap();
662        assert_eq!(expand_home("~"), home);
663        assert_eq!(expand_home("~/x.json"), home.join("x.json"));
664        assert_eq!(expand_home("/tmp/x.json"), PathBuf::from("/tmp/x.json"));
665        assert_eq!(expand_home("x.json"), PathBuf::from("x.json"));
666        // ~user is not expanded (only the current user's home is resolvable).
667        assert_eq!(expand_home("~root/x.json"), PathBuf::from("~root/x.json"));
668    }
669
670    #[test]
671    fn load_file_theme_end_to_end() {
672        let dir = std::env::temp_dir().join(format!("rpi-theme-test-{}", std::process::id()));
673        std::fs::create_dir_all(&dir).unwrap();
674        let path = temp_theme_file(&dir, "ocean.json", "#3366ff");
675        let loaded = LoadedTheme::load(&path).unwrap();
676        assert_eq!(loaded.name, "ocean.json");
677        assert_eq!(
678            loaded.theme.colors.accent,
679            rpi_tui::theme::Color::Rgb(51, 102, 255)
680        );
681        // Everything else stays at the default palette.
682        assert_eq!(
683            loaded.theme.colors.md_heading,
684            ThemeColors::default().md_heading
685        );
686        std::fs::remove_file(&path).ok();
687    }
688
689    #[test]
690    fn load_failure_message_contains_path_and_reason() {
691        let dir = std::env::temp_dir().join(format!("rpi-theme-bad-{}", std::process::id()));
692        std::fs::create_dir_all(&dir).unwrap();
693        let path = dir.join("broken.json");
694        std::fs::write(&path, r#"{"name":"broken","colors":{"accent":"nope"}}"#).unwrap();
695        let err = LoadedTheme::load(&path).unwrap_err();
696        assert!(err.contains("broken.json"), "{err}");
697        assert!(err.contains("nope"), "{err}");
698
699        let missing = dir.join("missing.json");
700        let err = LoadedTheme::load(&missing).unwrap_err();
701        assert!(err.contains("missing.json"), "{err}");
702        std::fs::remove_dir_all(&dir).ok();
703    }
704
705    #[test]
706    fn apply_spec_preset_and_file_and_failure() {
707        let manager = ThemeManager::new();
708        let revision_before = rpi_tui::theme::theme_revision();
709
710        // Preset applies to both managers.
711        apply_spec(&ThemeSpec::Preset(ThemePreset::Light), &manager).unwrap();
712        assert!(matches!(
713            manager.get().colors.muted,
714            rpi_tui::theme::Color::Ansi256(241)
715        ));
716
717        // File spec applies the parsed colors.
718        let dir = std::env::temp_dir().join(format!("rpi-theme-apply-{}", std::process::id()));
719        std::fs::create_dir_all(&dir).unwrap();
720        let path = temp_theme_file(&dir, "ocean.json", "#ff0000");
721        apply_spec(&ThemeSpec::File(path.clone()), &manager).unwrap();
722        assert_eq!(
723            manager.get().colors.accent,
724            rpi_tui::theme::Color::Rgb(255, 0, 0)
725        );
726        assert_eq!(
727            global_theme_manager().get().colors.accent,
728            rpi_tui::theme::Color::Rgb(255, 0, 0)
729        );
730
731        // Failed file spec returns the message; managers keep the last theme.
732        let err = apply_spec(&ThemeSpec::File(dir.join("missing.json")), &manager).unwrap_err();
733        assert!(err.contains("missing.json"), "{err}");
734        assert_eq!(
735            manager.get().colors.accent,
736            rpi_tui::theme::Color::Rgb(255, 0, 0)
737        );
738
739        assert!(rpi_tui::theme::theme_revision() > revision_before);
740        std::fs::remove_dir_all(&dir).ok();
741    }
742
743    fn write_theme(dir: &Path, name: &str) -> PathBuf {
744        std::fs::create_dir_all(dir).unwrap();
745        let path = dir.join(name);
746        std::fs::write(&path, format!(r#"{{"name":"{}","colors":{{}}}}"#, name)).unwrap();
747        path
748    }
749
750    #[test]
751    fn discover_scans_global_and_project_dirs() {
752        let root = std::env::temp_dir().join(format!("rpi-themes-disc-{}", std::process::id()));
753        let global = root.join("global/themes");
754        std::fs::create_dir_all(&global).unwrap();
755        write_theme(&global, "ocean.json");
756        write_theme(&global, "sunset.json");
757        write_theme(&root.join("global/other"), "ignored.json"); // not scanned
758        std::fs::write(global.join("notes.txt"), "not json").unwrap();
759
760        let (registry, diagnostics) = ThemeRegistry::discover_from(Some(&global), None);
761        assert!(diagnostics.is_empty(), "{diagnostics:?}");
762        let names: Vec<_> = registry.names().cloned().collect();
763        assert_eq!(names, vec!["ocean".to_string(), "sunset".to_string()]);
764        assert!(!registry.is_empty());
765        assert!(registry.get("ocean").unwrap().ends_with("ocean.json"));
766        assert!(registry.get("missing").is_none());
767        std::fs::remove_dir_all(&root).ok();
768    }
769
770    #[test]
771    fn project_theme_overrides_global_same_name() {
772        let root = std::env::temp_dir().join(format!("rpi-themes-ovr-{}", std::process::id()));
773        let global = root.join("global/themes");
774        let project = root.join("proj/.rpi/themes");
775        std::fs::create_dir_all(&global).unwrap();
776        write_theme(&global, "ocean.json");
777        write_theme(&project, "ocean.json");
778
779        let (registry, _) = ThemeRegistry::discover_from(Some(&global), Some(&project));
780        assert_eq!(registry.len(), 1);
781        let path = registry.get("ocean").unwrap();
782        assert!(path.starts_with(&project), "{path:?}");
783        std::fs::remove_dir_all(&root).ok();
784    }
785
786    #[test]
787    fn untrusted_project_dir_is_skipped() {
788        let root = std::env::temp_dir().join(format!("rpi-themes-untrust-{}", std::process::id()));
789        let cwd = root.join("proj");
790        let project = cwd.join(".rpi/themes");
791        write_theme(&project, "evil.json");
792
793        // Untrusted: the project dir is never scanned, so evil.json cannot
794        // appear regardless of what the global dir holds. Trusted: it does.
795        // (The global dir resolves to the real agent dir; "evil" is unique to
796        // this test's project, so the assertions are collision-free.)
797        let (registry, _) = ThemeRegistry::discover(&cwd, false);
798        assert!(registry.get("evil").is_none());
799        let (registry, _) = ThemeRegistry::discover(&cwd, true);
800        assert!(registry.get("evil").is_some());
801        std::fs::remove_dir_all(&root).ok();
802    }
803
804    #[test]
805    fn missing_dirs_are_silent_bad_dir_is_a_diagnostic() {
806        let root = std::env::temp_dir().join(format!("rpi-themes-miss-{}", std::process::id()));
807        let (registry, diagnostics) =
808            ThemeRegistry::discover_from(None, Some(&root.join("proj/.rpi/themes")));
809        assert!(registry.is_empty());
810        assert!(diagnostics.is_empty(), "{diagnostics:?}");
811
812        // A *file* where a theme dir should be is a soft diagnostic.
813        let global = root.join("global/themes");
814        std::fs::create_dir_all(&root.join("global")).unwrap();
815        std::fs::write(&global, "not a dir").unwrap();
816        let (registry, diagnostics) = ThemeRegistry::discover_from(Some(&global), None);
817        assert!(registry.is_empty());
818        assert!(!diagnostics.is_empty());
819        assert!(
820            diagnostics[0].contains("cannot read theme dir"),
821            "{diagnostics:?}"
822        );
823        std::fs::remove_dir_all(&root).ok();
824    }
825
826    #[test]
827    fn loaded_theme_load_parses_without_applying() {
828        // Note: global-manager side effects cannot be observed reliably from a
829        // parallel test suite (sibling tests share the process-wide manager),
830        // so this only pins that loading produces the parsed colors; by
831        // construction `LoadedTheme::load` touches no manager.
832        let dir = std::env::temp_dir().join(format!("rpi-theme-prev-{}", std::process::id()));
833        std::fs::create_dir_all(&dir).unwrap();
834        let path = temp_theme_file(&dir, "solo.json", "#00ff00");
835        let loaded = LoadedTheme::load(&path).unwrap();
836        assert_eq!(
837            loaded.theme.colors.accent,
838            rpi_tui::theme::Color::Rgb(0, 255, 0)
839        );
840        std::fs::remove_dir_all(&dir).ok();
841    }
842
843    // review_pr200: the saved selector name (a registry name, not a cwd
844    // relative path) must survive a restart once the registry is in scope.
845    #[test]
846    fn review_pr200_startup_saved_registry_name_round_trips() {
847        let root = std::env::temp_dir().join(format!("rpi-theme-start-{}", std::process::id()));
848        let project = root.join("proj/.rpi/themes");
849        std::fs::create_dir_all(&project).unwrap();
850        let path = temp_theme_file(&project, "ocean.json", "#112233");
851        let (registry, _) = ThemeRegistry::discover_from(None, Some(&project));
852
853        // Startup order contract: the registry exists before the saved spec
854        // is resolved. `apply_startup_spec` takes the registry explicitly, so
855        // the name resolves instead of degrading to a cwd-relative path.
856        let manager = ThemeManager::new();
857        apply_startup_spec("ocean", Some(&registry), &manager).unwrap();
858        assert_eq!(
859            manager.get().colors.accent,
860            rpi_tui::theme::Color::Rgb(17, 34, 51)
861        );
862
863        // Control: without the registry the same value is a missing cwd file.
864        let err = apply_startup_spec("ocean", None, &ThemeManager::new()).unwrap_err();
865        assert!(err.contains("ocean"), "{err}");
866        let _ = path;
867        std::fs::remove_dir_all(&root).ok();
868    }
869
870    // review_pr200: /theme <name> keeps custom-name case for the registry
871    // lookup (case-sensitive stems) while presets stay case-folded.
872    #[test]
873    fn review_pr200_command_theme_preserves_custom_case() {
874        let root = std::env::temp_dir().join(format!("rpi-theme-case-{}", std::process::id()));
875        let project = root.join("proj/.rpi/themes");
876        write_theme(&project, "Ocean.json");
877        let (registry, _) = ThemeRegistry::discover_from(None, Some(&project));
878
879        let resolved = resolve_command_theme("Ocean", Some(&registry)).expect("Ocean resolves");
880        assert!(resolved.ends_with("Ocean.json"), "{resolved}");
881        // Folded lookup would miss the stem; pin that this is not what happens.
882        assert!(registry.get("ocean").is_none());
883        // Presets still fold, and unknown names return None.
884        assert_eq!(resolve_command_theme("DARK", None).as_deref(), Some("dark"));
885        assert!(resolve_command_theme("Nope", Some(&registry)).is_none());
886        std::fs::remove_dir_all(&root).ok();
887    }
888}