//! `nahual_theme` — paleta de colores y backgrounds compartidos. //! //! El `Theme` se instala como `Global` de GPUI. Los widgets lo leen vía //! `cx.global::()` durante su `render`, y se subscriben con //! `cx.observe_global::(…)` para auto-redibujarse cuando cambia. //! //! Filosofía: el theme es **dato puro** (sin lógica de UI). No conoce a //! ningún widget concreto. Cada widget pide slots semánticos (panel_bg, //! row_hover, accent…) sin acoplarse a colores hex específicos. use gpui::{hsla, linear_color_stop, linear_gradient, Background, Global, Hsla}; pub mod toolkit; /// Paleta semántica del theme. Cada slot tiene un nombre funcional, no /// cromático — así los widgets piden "fondo de panel" sin acoplarse a /// "azul oscuro". /// /// Convención de slots: /// - `bg_*` se devuelve como `Background` (soporta gradientes); los widgets /// lo pasan a `.bg(...)` directamente. /// - `fg_*`, `accent`, `border` son `Hsla` (colores planos para texto y /// ornamentos). /// - `bg_row_*` son `Hsla` porque las filas de una lista virtualizada se /// beneficiarían poco de un gradiente individual. #[derive(Clone, Debug)] pub struct Theme { pub name: &'static str, pub is_dark: bool, // Fondos. pub bg_app: Background, pub bg_panel: Background, pub bg_panel_alt: Background, pub bg_row_hover: Hsla, pub bg_row_active: Hsla, // Foregrounds. pub fg_text: Hsla, pub fg_muted: Hsla, pub fg_disabled: Hsla, // Acentos y ornamentos. pub accent: Hsla, pub accent_strong: Hsla, pub border: Hsla, pub border_strong: Hsla, /// Marker colors para indicar "este file está abierto en container N". /// Paleta circular — el N-ésimo container usa `marker_palette[n % len]`. pub marker_palette: Vec, } impl Global for Theme {} /// Helper privado: deriva los 5 slots "ornament secundario" /// (bg_input/button/button_hover + accent_destructive + /// bg_destructive_hover) según `is_dark`. /// /// Devuelve los slots en el orden de los métodos públicos del /// `Theme`. Los métodos del impl los exponen individualmente. fn ornament_slots(is_dark: bool) -> (Hsla, Hsla, Hsla, Hsla, Hsla) { if is_dark { ( // bg_input: muy oscuro, sutil tinte azul/gris hsla(220.0 / 360.0, 0.20, 0.07, 1.0), // bg_button: medio oscuro hsla(220.0 / 360.0, 0.18, 0.20, 1.0), // bg_button_hover: un poco más claro hsla(220.0 / 360.0, 0.20, 0.27, 1.0), // accent_destructive: rojo medio-claro para visibilidad hsla(0.0, 0.55, 0.65, 1.0), // bg_destructive_hover: rojo oscuro de fondo hsla(0.0, 0.55, 0.18, 1.0), ) } else { ( hsla(220.0 / 360.0, 0.10, 0.97, 1.0), hsla(220.0 / 360.0, 0.15, 0.85, 1.0), hsla(220.0 / 360.0, 0.20, 0.75, 1.0), hsla(0.0, 0.65, 0.45, 1.0), hsla(0.0, 0.55, 0.92, 1.0), ) } } impl Theme { /// Bg sutil para fields editables que se quieren marcar como /// "input target" sin ser un panel. Derivado de `is_dark`. pub fn bg_input(&self) -> Hsla { ornament_slots(self.is_dark).0 } /// Bg para clickable controls (botones secundarios, edit/delete /// icons en filas). Más prominente que `bg_panel_alt`, menos que /// `accent`. Derivado de `is_dark`. pub fn bg_button(&self) -> Hsla { ornament_slots(self.is_dark).1 } /// Hover de [`Self::bg_button`]. pub fn bg_button_hover(&self) -> Hsla { ornament_slots(self.is_dark).2 } /// Accent rojo para acciones destructivas (delete, drop, force). pub fn accent_destructive(&self) -> Hsla { ornament_slots(self.is_dark).3 } /// Bg de hover sobre clickable destructive elements (icon ✕, /// botones de "borrar"). Más oscuro que `accent_destructive`. pub fn bg_destructive_hover(&self) -> Hsla { ornament_slots(self.is_dark).4 } pub fn global(cx: &gpui::App) -> &Self { cx.global::() } /// Carga el theme persistido si existe + es válido; sino default /// a Nebula. El persisted lo escribe [`Self::set`] cada vez que el /// theme cambia (típicamente vía `theme_switcher`). pub fn install_default(cx: &mut gpui::App) { let theme = load_persisted().unwrap_or_else(Self::nebula); // Asegura que GTK/Qt arranquen con el `gtk.css` del tema activo. let _ = toolkit::export_toolkit_configs(&theme); cx.set_global(theme); } /// Reemplaza el theme global y persiste su `name` al config file. /// GPUI notifica a todos los `observe_global` suscriptores en el /// siguiente frame. /// /// La persistencia es best-effort: si write falla (no hay home, /// permission denied, etc.), el theme cambia in-memory pero no /// sobrevive al restart. No se rebota — la UX no se interrumpe /// por un I/O secundario. pub fn set(cx: &mut gpui::App, theme: Self) { let _ = persist(&theme); // Reexporta `gtk.css` para que las apps GTK/Qt sigan el cambio. let _ = toolkit::export_toolkit_configs(&theme); cx.set_global(theme); } /// Lista todos los presets en orden estable. Usado por el switcher para /// ciclar y por el menú de "Tema" cuando lo agreguemos. pub fn all() -> Vec { vec![ Self::nebula(), Self::aurora(), Self::sunset(), Self::flat_dark(), Self::solarized_light(), Self::high_contrast(), Self::print_color(), Self::print_bw(), ] } /// Devuelve el preset cuyo `name` matchea (case-insensitive). `None` si /// el nombre no existe — útil para validar input de usuario al cargar /// preferencias persistidas. pub fn by_name(name: &str) -> Option { Self::all() .into_iter() .find(|t| t.name.eq_ignore_ascii_case(name)) } /// Próximo preset en la rotación de `all()`. Si `current` no está, se /// vuelve al primero. La rotación es circular (último → primero). pub fn next_after(current: &str) -> Self { let all = Self::all(); let idx = all.iter().position(|t| t.name == current); match idx { Some(i) => all[(i + 1) % all.len()].clone(), None => all[0].clone(), } } // ===================================================================== // Presets // ===================================================================== /// **Nebula** — default. Gradiente vertical violáceo profundo → teal /// medianoche. Pensado para sentirse moderno y descansado de noche. pub fn nebula() -> Self { let bg_app = linear_gradient( 165.0, linear_color_stop(hsla(265.0 / 360.0, 0.38, 0.07, 1.0), 0.0), linear_color_stop(hsla(195.0 / 360.0, 0.42, 0.09, 1.0), 1.0), ); let bg_panel = linear_gradient( 165.0, linear_color_stop(hsla(245.0 / 360.0, 0.28, 0.10, 1.0), 0.0), linear_color_stop(hsla(210.0 / 360.0, 0.30, 0.12, 1.0), 1.0), ); let bg_panel_alt = linear_gradient( 165.0, linear_color_stop(hsla(255.0 / 360.0, 0.25, 0.13, 1.0), 0.0), linear_color_stop(hsla(220.0 / 360.0, 0.27, 0.14, 1.0), 1.0), ); Self { name: "Nebula", is_dark: true, bg_app, bg_panel, bg_panel_alt, bg_row_hover: hsla(220.0 / 360.0, 0.30, 0.20, 0.45), bg_row_active: hsla(280.0 / 360.0, 0.55, 0.28, 0.65), fg_text: hsla(210.0 / 360.0, 0.35, 0.88, 1.0), fg_muted: hsla(215.0 / 360.0, 0.22, 0.58, 1.0), fg_disabled: hsla(215.0 / 360.0, 0.10, 0.40, 1.0), accent: hsla(280.0 / 360.0, 0.65, 0.65, 1.0), accent_strong: hsla(285.0 / 360.0, 0.78, 0.74, 1.0), border: hsla(225.0 / 360.0, 0.20, 0.22, 1.0), border_strong: hsla(280.0 / 360.0, 0.40, 0.45, 1.0), marker_palette: vec![ hsla(280.0 / 360.0, 0.65, 0.55, 0.45), hsla(195.0 / 360.0, 0.65, 0.50, 0.45), hsla(35.0 / 360.0, 0.75, 0.55, 0.45), hsla(135.0 / 360.0, 0.55, 0.50, 0.45), hsla(0.0, 0.60, 0.55, 0.45), ], } } /// **Aurora** — verdes-cian-azul, evoca aurora boreal. Más frío que /// Nebula, contraste alto. pub fn aurora() -> Self { let bg_app = linear_gradient( 190.0, linear_color_stop(hsla(170.0 / 360.0, 0.45, 0.06, 1.0), 0.0), linear_color_stop(hsla(220.0 / 360.0, 0.50, 0.09, 1.0), 1.0), ); let bg_panel = linear_gradient( 190.0, linear_color_stop(hsla(165.0 / 360.0, 0.32, 0.10, 1.0), 0.0), linear_color_stop(hsla(215.0 / 360.0, 0.36, 0.12, 1.0), 1.0), ); let bg_panel_alt = linear_gradient( 190.0, linear_color_stop(hsla(170.0 / 360.0, 0.30, 0.13, 1.0), 0.0), linear_color_stop(hsla(220.0 / 360.0, 0.32, 0.15, 1.0), 1.0), ); Self { name: "Aurora", is_dark: true, bg_app, bg_panel, bg_panel_alt, bg_row_hover: hsla(180.0 / 360.0, 0.40, 0.22, 0.50), bg_row_active: hsla(160.0 / 360.0, 0.55, 0.30, 0.65), fg_text: hsla(180.0 / 360.0, 0.20, 0.92, 1.0), fg_muted: hsla(185.0 / 360.0, 0.18, 0.62, 1.0), fg_disabled: hsla(185.0 / 360.0, 0.10, 0.40, 1.0), accent: hsla(150.0 / 360.0, 0.70, 0.55, 1.0), accent_strong: hsla(160.0 / 360.0, 0.85, 0.65, 1.0), border: hsla(195.0 / 360.0, 0.25, 0.20, 1.0), border_strong: hsla(160.0 / 360.0, 0.55, 0.45, 1.0), marker_palette: vec![ hsla(150.0 / 360.0, 0.75, 0.50, 0.45), hsla(195.0 / 360.0, 0.70, 0.50, 0.45), hsla(225.0 / 360.0, 0.70, 0.55, 0.45), hsla(85.0 / 360.0, 0.65, 0.50, 0.45), hsla(330.0 / 360.0, 0.65, 0.55, 0.45), ], } } /// **Sunset** — naranjas-rosas-violetas profundos. Cálido, alto contraste /// con texto claro. pub fn sunset() -> Self { let bg_app = linear_gradient( 170.0, linear_color_stop(hsla(20.0 / 360.0, 0.50, 0.08, 1.0), 0.0), linear_color_stop(hsla(310.0 / 360.0, 0.45, 0.10, 1.0), 1.0), ); let bg_panel = linear_gradient( 170.0, linear_color_stop(hsla(15.0 / 360.0, 0.32, 0.12, 1.0), 0.0), linear_color_stop(hsla(315.0 / 360.0, 0.30, 0.13, 1.0), 1.0), ); let bg_panel_alt = linear_gradient( 170.0, linear_color_stop(hsla(20.0 / 360.0, 0.30, 0.15, 1.0), 0.0), linear_color_stop(hsla(320.0 / 360.0, 0.28, 0.16, 1.0), 1.0), ); Self { name: "Sunset", is_dark: true, bg_app, bg_panel, bg_panel_alt, bg_row_hover: hsla(25.0 / 360.0, 0.40, 0.25, 0.45), bg_row_active: hsla(5.0 / 360.0, 0.55, 0.32, 0.65), fg_text: hsla(30.0 / 360.0, 0.30, 0.92, 1.0), fg_muted: hsla(25.0 / 360.0, 0.20, 0.62, 1.0), fg_disabled: hsla(25.0 / 360.0, 0.10, 0.42, 1.0), accent: hsla(15.0 / 360.0, 0.78, 0.62, 1.0), accent_strong: hsla(355.0 / 360.0, 0.85, 0.68, 1.0), border: hsla(15.0 / 360.0, 0.25, 0.25, 1.0), border_strong: hsla(355.0 / 360.0, 0.55, 0.45, 1.0), marker_palette: vec![ hsla(15.0 / 360.0, 0.80, 0.55, 0.45), hsla(310.0 / 360.0, 0.65, 0.55, 0.45), hsla(45.0 / 360.0, 0.80, 0.55, 0.45), hsla(285.0 / 360.0, 0.65, 0.60, 0.45), hsla(355.0 / 360.0, 0.70, 0.55, 0.45), ], } } /// **Flat Dark** — sin gradientes, paleta cool gris-azulado. Para quien /// prefiere monocromía. Útil para contrastar visualmente con los temas /// de gradiente. pub fn flat_dark() -> Self { let bg_app: Background = hsla(220.0 / 360.0, 0.15, 0.09, 1.0).into(); let bg_panel: Background = hsla(220.0 / 360.0, 0.15, 0.12, 1.0).into(); let bg_panel_alt: Background = hsla(220.0 / 360.0, 0.15, 0.14, 1.0).into(); Self { name: "Flat Dark", is_dark: true, bg_app, bg_panel, bg_panel_alt, bg_row_hover: hsla(220.0 / 360.0, 0.20, 0.20, 1.0), bg_row_active: hsla(220.0 / 360.0, 0.40, 0.30, 1.0), fg_text: hsla(210.0 / 360.0, 0.20, 0.85, 1.0), fg_muted: hsla(215.0 / 360.0, 0.15, 0.55, 1.0), fg_disabled: hsla(215.0 / 360.0, 0.10, 0.40, 1.0), accent: hsla(210.0 / 360.0, 0.70, 0.55, 1.0), accent_strong: hsla(210.0 / 360.0, 0.85, 0.65, 1.0), border: hsla(220.0 / 360.0, 0.15, 0.20, 1.0), border_strong: hsla(220.0 / 360.0, 0.30, 0.35, 1.0), marker_palette: vec![ hsla(210.0 / 360.0, 0.65, 0.55, 0.40), hsla(160.0 / 360.0, 0.55, 0.50, 0.40), hsla(30.0 / 360.0, 0.75, 0.55, 0.40), hsla(0.0, 0.55, 0.55, 0.40), ], } } /// **Solarized Light** — preset claro inspirado en la paleta clásica de /// Schoonover. Sin gradientes (en light un gradiente sutil pasa /// desapercibido y solo introduce ruido). pub fn solarized_light() -> Self { let bg_app: Background = hsla(44.0 / 360.0, 0.87, 0.94, 1.0).into(); let bg_panel: Background = hsla(46.0 / 360.0, 0.42, 0.88, 1.0).into(); let bg_panel_alt: Background = hsla(46.0 / 360.0, 0.42, 0.92, 1.0).into(); Self { name: "Solarized Light", is_dark: false, bg_app, bg_panel, bg_panel_alt, bg_row_hover: hsla(46.0 / 360.0, 0.45, 0.80, 0.65), bg_row_active: hsla(45.0 / 360.0, 0.55, 0.72, 0.85), fg_text: hsla(196.0 / 360.0, 0.13, 0.30, 1.0), fg_muted: hsla(196.0 / 360.0, 0.13, 0.45, 1.0), fg_disabled: hsla(196.0 / 360.0, 0.10, 0.62, 1.0), accent: hsla(205.0 / 360.0, 0.69, 0.42, 1.0), accent_strong: hsla(205.0 / 360.0, 0.82, 0.38, 1.0), border: hsla(46.0 / 360.0, 0.30, 0.78, 1.0), border_strong: hsla(205.0 / 360.0, 0.40, 0.55, 1.0), marker_palette: vec![ hsla(205.0 / 360.0, 0.69, 0.42, 0.30), hsla(175.0 / 360.0, 0.74, 0.32, 0.30), hsla(45.0 / 360.0, 1.00, 0.36, 0.30), hsla(331.0 / 360.0, 0.74, 0.42, 0.30), hsla(18.0 / 360.0, 0.89, 0.40, 0.30), ], } } /// **Print Color** — preview de impresión a color sobre papel. /// Fondo crema cálido (#f7f4ea-ish), texto y ornamentos en /// luminancias bajas para que sobrevivan ink-bleed. Sin gradientes /// (los gradients no imprimen bien) y sin glow. pub fn print_color() -> Self { let bg_app: Background = hsla(42.0 / 360.0, 0.30, 0.94, 1.0).into(); let bg_panel: Background = hsla(40.0 / 360.0, 0.25, 0.97, 1.0).into(); let bg_panel_alt: Background = hsla(40.0 / 360.0, 0.20, 0.92, 1.0).into(); Self { name: "Print Color", is_dark: false, bg_app, bg_panel, bg_panel_alt, bg_row_hover: hsla(40.0 / 360.0, 0.30, 0.86, 0.70), bg_row_active: hsla(35.0 / 360.0, 0.45, 0.78, 0.85), fg_text: hsla(30.0 / 360.0, 0.15, 0.18, 1.0), fg_muted: hsla(30.0 / 360.0, 0.12, 0.40, 1.0), fg_disabled: hsla(30.0 / 360.0, 0.08, 0.62, 1.0), accent: hsla(15.0 / 360.0, 0.70, 0.40, 1.0), accent_strong: hsla(355.0 / 360.0, 0.78, 0.36, 1.0), border: hsla(40.0 / 360.0, 0.22, 0.82, 1.0), border_strong: hsla(30.0 / 360.0, 0.30, 0.55, 1.0), marker_palette: vec![ hsla(15.0 / 360.0, 0.70, 0.35, 0.30), hsla(210.0 / 360.0, 0.65, 0.35, 0.30), hsla(140.0 / 360.0, 0.55, 0.30, 0.30), hsla(285.0 / 360.0, 0.55, 0.38, 0.30), hsla(40.0 / 360.0, 0.85, 0.40, 0.30), ], } } /// **Print B&W** — preview de impresión monocromática. Fondo /// blanco puro, todo en escala de grises. Cualquier slot que /// dependa de "color" en widgets astrológicos se diferencia por /// forma o por dash pattern, no por tinte. pub fn print_bw() -> Self { let bg_app: Background = hsla(0.0, 0.0, 1.00, 1.0).into(); let bg_panel: Background = hsla(0.0, 0.0, 0.99, 1.0).into(); let bg_panel_alt: Background = hsla(0.0, 0.0, 0.95, 1.0).into(); Self { name: "Print B&W", is_dark: false, bg_app, bg_panel, bg_panel_alt, bg_row_hover: hsla(0.0, 0.0, 0.88, 0.85), bg_row_active: hsla(0.0, 0.0, 0.78, 0.95), fg_text: hsla(0.0, 0.0, 0.10, 1.0), fg_muted: hsla(0.0, 0.0, 0.40, 1.0), fg_disabled: hsla(0.0, 0.0, 0.65, 1.0), accent: hsla(0.0, 0.0, 0.20, 1.0), accent_strong: hsla(0.0, 0.0, 0.05, 1.0), border: hsla(0.0, 0.0, 0.80, 1.0), border_strong: hsla(0.0, 0.0, 0.40, 1.0), marker_palette: vec![ hsla(0.0, 0.0, 0.30, 0.35), hsla(0.0, 0.0, 0.50, 0.35), hsla(0.0, 0.0, 0.20, 0.35), hsla(0.0, 0.0, 0.60, 0.35), ], } } /// **High Contrast** — accesibilidad. Negro puro con texto blanco y /// ornamentos amarillo/verde fuertes. Suficientemente diferente para /// notar inmediatamente al usar el switcher. pub fn high_contrast() -> Self { let bg_app: Background = hsla(0.0, 0.0, 0.0, 1.0).into(); let bg_panel: Background = hsla(0.0, 0.0, 0.05, 1.0).into(); let bg_panel_alt: Background = hsla(0.0, 0.0, 0.10, 1.0).into(); Self { name: "High Contrast", is_dark: true, bg_app, bg_panel, bg_panel_alt, bg_row_hover: hsla(60.0 / 360.0, 1.00, 0.50, 0.35), bg_row_active: hsla(120.0 / 360.0, 1.00, 0.40, 0.55), fg_text: hsla(0.0, 0.0, 1.0, 1.0), fg_muted: hsla(0.0, 0.0, 0.75, 1.0), fg_disabled: hsla(0.0, 0.0, 0.50, 1.0), accent: hsla(60.0 / 360.0, 1.00, 0.60, 1.0), accent_strong: hsla(60.0 / 360.0, 1.00, 0.75, 1.0), border: hsla(0.0, 0.0, 0.30, 1.0), border_strong: hsla(60.0 / 360.0, 1.00, 0.60, 1.0), marker_palette: vec![ hsla(60.0 / 360.0, 1.00, 0.55, 0.50), hsla(120.0 / 360.0, 1.00, 0.50, 0.50), hsla(180.0 / 360.0, 1.00, 0.55, 0.50), hsla(0.0, 1.00, 0.60, 0.50), hsla(300.0 / 360.0, 1.00, 0.65, 0.50), ], } } } // ============================================================================ // Persistencia de la preferencia de theme // ============================================================================ use std::path::{Path, PathBuf}; const CONFIG_SUBDIR: &str = "nahual"; const CONFIG_FILE: &str = "theme"; /// Path al archivo donde se persiste la preferencia de theme. /// /// Convención XDG: `$XDG_CONFIG_HOME/nahual/theme` si está set; /// sino `$HOME/.config/nahual/theme`. `None` si ni `XDG_CONFIG_HOME` /// ni `HOME` están definidos (típicamente en sandboxes / CI). pub fn config_path() -> Option { Some(config_home()?.join(CONFIG_SUBDIR).join(CONFIG_FILE)) } /// Directorio base de configuración del usuario: `$XDG_CONFIG_HOME` si /// está definido, sino `$HOME/.config`. `None` si ninguno está set /// (típicamente en sandboxes / CI). Lo usan [`config_path`] y el módulo /// [`toolkit`] para ubicar `gtk-3.0/gtk.css` y `gtk-4.0/gtk.css`. pub(crate) fn config_home() -> Option { std::env::var("XDG_CONFIG_HOME") .ok() .filter(|s| !s.is_empty()) .map(PathBuf::from) .or_else(|| { std::env::var("HOME") .ok() .filter(|s| !s.is_empty()) .map(|h| PathBuf::from(h).join(".config")) }) } /// Lee el theme persistido. `None` si: no hay config dir, file no /// existe, lectura falla, o el name guardado no matchea ningún /// preset (ej. tema renombrado entre versiones). pub fn load_persisted() -> Option { let path = config_path()?; load_from_path(&path) } /// Persiste el name del theme al config file. Crea el dir parent si /// no existe. Devuelve el `io::Error` para que tests puedan /// asertar; los call sites de producción pueden ignorarlo /// (best-effort persistence). pub fn persist(theme: &Theme) -> std::io::Result<()> { let path = config_path().ok_or_else(|| { std::io::Error::new( std::io::ErrorKind::NotFound, "no se pudo determinar config dir (HOME/XDG_CONFIG_HOME no set)", ) })?; persist_to_path(theme, &path) } /// Variante de [`load_persisted`] que toma un path explícito. Útil /// para tests + para apps que quieren su propio path /// (ej. multi-user single-machine). pub fn load_from_path(path: &Path) -> Option { let raw = std::fs::read_to_string(path).ok()?; let name = raw.trim(); Theme::by_name(name) } /// Variante de [`persist`] que toma un path explícito. pub fn persist_to_path(theme: &Theme, path: &Path) -> std::io::Result<()> { if let Some(parent) = path.parent() { std::fs::create_dir_all(parent)?; } std::fs::write(path, theme.name) } #[cfg(test)] mod persistence_tests { use super::*; fn unique_path(label: &str) -> PathBuf { let mut p = std::env::temp_dir(); p.push(format!( "nahual-theme-test-{}-{}-{}", std::process::id(), std::time::SystemTime::now() .duration_since(std::time::UNIX_EPOCH) .map(|d| d.as_nanos()) .unwrap_or(0), label )); p } #[test] fn persist_then_load_round_trip() { let path = unique_path("round-trip"); let theme = Theme::aurora(); persist_to_path(&theme, &path).unwrap(); let loaded = load_from_path(&path).expect("load"); assert_eq!(loaded.name, "Aurora"); let _ = std::fs::remove_file(&path); let _ = std::fs::remove_dir(path.parent().unwrap()); } #[test] fn load_from_missing_file_returns_none() { let path = unique_path("missing"); // path NO existe. assert!(load_from_path(&path).is_none()); } #[test] fn load_from_unknown_name_returns_none() { let path = unique_path("unknown"); std::fs::create_dir_all(path.parent().unwrap()).unwrap(); std::fs::write(&path, "DefinitelyNotARealTheme").unwrap(); assert!(load_from_path(&path).is_none()); let _ = std::fs::remove_file(&path); let _ = std::fs::remove_dir(path.parent().unwrap()); } #[test] fn persist_creates_parent_dir_if_missing() { let path = unique_path("nested-create"); // Aseguramos que el parent NO existe antes. let _ = std::fs::remove_dir_all(path.parent().unwrap()); persist_to_path(&Theme::sunset(), &path).unwrap(); assert!(path.exists()); let loaded = load_from_path(&path).unwrap(); assert_eq!(loaded.name, "Sunset"); let _ = std::fs::remove_file(&path); let _ = std::fs::remove_dir(path.parent().unwrap()); } #[test] fn config_path_uses_xdg_config_home_when_set() { // Snapshot del env, mutación local, restauración. let prev = std::env::var("XDG_CONFIG_HOME").ok(); // SAFETY: tests del crate single-thread por default; este // env mutation no impacta otros tests del mismo proceso. unsafe { std::env::set_var("XDG_CONFIG_HOME", "/custom/xdg"); } let p = config_path().unwrap(); unsafe { match prev { Some(v) => std::env::set_var("XDG_CONFIG_HOME", v), None => std::env::remove_var("XDG_CONFIG_HOME"), } } assert_eq!(p, PathBuf::from("/custom/xdg/nahual/theme")); } }