1use 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#[derive(Debug, Clone)]
22pub enum ThemeSpec {
23 Preset(ThemePreset),
24 File(PathBuf),
25}
26
27pub fn resolve_spec(value: &str) -> ThemeSpec {
30 resolve_spec_in(value, None)
31}
32
33pub 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
51pub 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#[derive(Debug, Clone)]
68pub struct LoadedTheme {
69 pub name: String,
71 pub theme: Theme,
73 pub source: PathBuf,
75}
76
77impl LoadedTheme {
78 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
93pub 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
101pub 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
113pub 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
129pub 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#[derive(Debug, Clone, Default)]
159pub struct ThemeRegistry {
160 entries: BTreeMap<String, PathBuf>,
161}
162
163impl ThemeRegistry {
164 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 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 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 pub fn get(&self, name: &str) -> Option<&PathBuf> {
210 self.entries.get(name)
211 }
212
213 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
227fn 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
267pub 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 let result = apply_and_persist(&spec, &session_manager, Some(®istry));
301 notify(result);
302 });
303 if let Ok(mut guard) = slot.lock() {
304 *guard = Some(handle);
305 }
306 }
307}
308
309pub 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
340pub 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 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(), ]);
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 assert!(
419 reconcile_after_reload(Some("light"), &ThemeRegistry::default(), &manager).is_none()
420 );
421 assert!(reconcile_after_reload(None, &ThemeRegistry::default(), &manager).is_none());
423 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"), ®istry, &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 let registry = ThemeRegistry::default();
471 restart_theme_watcher(
472 &slot,
473 Some(&spec),
474 ®istry,
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 restart_theme_watcher(&slot, Some("dark"), ®istry, &session_manager, |_| {});
484 assert!(slot.lock().unwrap().is_none());
485
486 let hits_ref2 = hits.clone();
489 restart_theme_watcher(
490 &slot,
491 Some(&spec),
492 ®istry,
493 &session_manager,
494 move |_result| {
495 hits_ref2.fetch_add(1, Ordering::SeqCst);
496 },
497 );
498 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 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, ®istry, &session_manager, |_| {});
524 assert!(slot.lock().unwrap().is_none());
525 });
526 std::fs::remove_dir_all(&dir).ok();
527 }
528
529 #[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 ®istry,
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 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(®istry));
581 assert!(matches!(spec, ThemeSpec::File(ref p) if *p == path));
582 assert!(matches!(
584 resolve_spec_in("other.json", Some(®istry)),
585 ThemeSpec::File(_)
586 ));
587 assert!(matches!(
588 resolve_spec_in("dark", Some(®istry)),
589 ThemeSpec::Preset(ThemePreset::Dark)
590 ));
591 let manager = ThemeManager::new();
593 let message = apply_and_persist("ocean", &manager, Some(®istry)).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 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 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 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 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 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 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 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"); 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 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 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 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 #[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 let manager = ThemeManager::new();
857 apply_startup_spec("ocean", Some(®istry), &manager).unwrap();
858 assert_eq!(
859 manager.get().colors.accent,
860 rpi_tui::theme::Color::Rgb(17, 34, 51)
861 );
862
863 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 #[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(®istry)).expect("Ocean resolves");
880 assert!(resolved.ends_with("Ocean.json"), "{resolved}");
881 assert!(registry.get("ocean").is_none());
883 assert_eq!(resolve_command_theme("DARK", None).as_deref(), Some("dark"));
885 assert!(resolve_command_theme("Nope", Some(®istry)).is_none());
886 std::fs::remove_dir_all(&root).ok();
887 }
888}