feat: mirada standalone — compositor Wayland + WM sobre Llimphi (build magro)

Stack de display extraído del monorepo: compositor teselante (Cuerpo smithay
+ Cerebro WM agnóstico), greeter PAM, portal XDG, CLI de control. Llimphi se
consume por git desde su repo publicado; las hojas compartidas (format,
auth-core, rimay-localize, wawa-config, app-bus) y el widget menubar van
vendorizados. Sin el asistente IA (pluma-llm) ni la barra web wasm — el
compositor no los necesita. cargo check --workspace pasa (18 crates, 0 warn).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-06-04 11:01:49 +00:00
commit 3dc85ebdcd
116 changed files with 31060 additions and 0 deletions
+762
View File
@@ -0,0 +1,762 @@
//! `wawa-config` — bus de configuración del SO wawa.
//!
//! Dos capas de archivos JSON canónicos actúan como medio:
//!
//! 1. **Sistema** — `/etc/wawa/config.json` (Linux). Defaults
//! machine-wide; lo escribe el admin con `wawactl --system set …`
//! (requiere root) o un instalador.
//! 2. **Usuario** — `$XDG_CONFIG_HOME/wawa/config.json` (Linux:
//! `~/.config/wawa/config.json`). Lo que escribe el panel y las
//! apps; **sobreescribe** campo por campo a la capa de sistema.
//!
//! El panel de control y los daemons escriben; las apps Llimphi leen y
//! se suscriben a cambios vía [`notify::RecommendedWatcher`] sobre
//! **ambos** paths.
//!
//! ## Nota sobre `/etc/wawa`
//!
//! `/etc/` es una convención de Unix/Linux. Cuando wawa sea su propio
//! SO (no un userland sobre Linux), esta capa se reemplazará por el
//! mecanismo nativo de "config de sistema" que defina arje — la API
//! pública (`load`, `system_path`, `user_path`) se mantiene; sólo
//! cambia lo que devuelve `system_path()` adentro.
//!
//! Por qué archivo + `notify` y no un daemon pub-sub:
//!
//! * **Cero dependencias en runtime**: ninguna app necesita que un
//! daemon esté arrancado para leer la config; basta con que el
//! archivo exista (y si no existe, devuelve defaults).
//! * **Auditable y editable a mano**: el archivo es JSON con `pretty`,
//! el admin lo abre con cualquier editor o lo edita por sed/jq.
//! * **Atomicidad simple**: `save()` escribe a `config.json.tmp` y
//! `rename()` — los watchers ven un único evento de creación que
//! contiene la versión completa.
//! * **Compatible con apps existentes**: el modelo de Llimphi ya
//! reentra al `update` vía `Handle::dispatch`; el watcher dispara
//! un Msg del consumidor cuando llega el evento.
//!
//! ## Forma del archivo
//!
//! ```json
//! {
//! "theme_variant": "dark",
//! "accent": "default",
//! "lang": "es-PE",
//! "timefmt_24h": true,
//! "modules": {
//! "mirada": true,
//! "shuma": true,
//! "chasqui": true,
//! "akasha": true,
//! "minga": true,
//! "agora": true
//! }
//! }
//! ```
//!
//! Campos desconocidos se ignoran al deserializar; campos ausentes
//! caen al default. Esto permite agregar nuevas keys sin romper
//! consumidores antiguos.
//!
//! ## Productor
//!
//! ```ignore
//! use wawa_config::WawaConfig;
//!
//! let mut cfg = WawaConfig::load();
//! cfg.theme_variant = "aurora".into();
//! cfg.save()?;
//! ```
//!
//! ## Consumidor (app Llimphi)
//!
//! ```ignore
//! use wawa_config::{WawaConfig, ConfigWatcher};
//!
//! // En `App::init`:
//! let handle = handle.clone();
//! let watcher = ConfigWatcher::spawn(move |cfg| {
//! handle.dispatch(Msg::ConfigChanged(cfg));
//! })?;
//! // Guardar `watcher` en el Model para que viva todo lo que vive la app.
//! ```
#![forbid(unsafe_code)]
use std::collections::BTreeMap;
use std::path::{Path, PathBuf};
use std::sync::mpsc;
use std::thread;
use std::time::Duration;
use notify::{EventKind, RecommendedWatcher, RecursiveMode, Watcher};
use serde::{Deserialize, Serialize};
use thiserror::Error;
use tracing::warn;
/// Nombre del subdirectorio dentro de XDG_CONFIG_HOME donde vive el
/// archivo. Exporto la constante para que tests y herramientas
/// externas lo puedan inspeccionar.
pub const CONFIG_DIR: &str = "wawa";
/// Nombre del archivo canónico.
pub const CONFIG_FILE: &str = "config.json";
/// Directorio de la capa de sistema en Linux. Cuando wawa sea su
/// propio SO esta ruta se reemplaza por lo que defina arje; la API
/// pública (`system_config_path`) se mantiene.
pub const SYSTEM_CONFIG_DIR_LINUX: &str = "/etc/wawa";
/// Capa de la cual se cargó/escribió una config. Útil para herramientas
/// que necesiten distinguir explícitamente entre sistema y usuario.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum Layer {
/// `/etc/wawa/config.json` — defaults machine-wide, requiere root.
System,
/// `$XDG_CONFIG_HOME/wawa/config.json` — override por usuario.
User,
}
/// Mapea el `theme_variant` de la config (lowercase, libre) al nombre
/// canónico que reconoce `llimphi_theme::Theme::by_name` (capitalizado).
/// Devuelve `None` si el variant no es uno de los presets conocidos —
/// el consumidor decide qué hacer (fallback a dark, error, etc.).
///
/// Los presets de Llimphi tienen `name: &'static str` capitalizado;
/// los users del CLI y el panel escriben en lowercase. Este shim
/// mantiene a `wawa-config` UI-agnóstico (no depende de
/// `llimphi-theme`) y a la vez evita que cada consumidor reimplemente
/// el casing.
pub fn canonical_theme_name(variant: &str) -> Option<&'static str> {
match variant.to_ascii_lowercase().as_str() {
"dark" => Some("Dark"),
"light" => Some("Light"),
"aurora" => Some("Aurora"),
"sunset" => Some("Sunset"),
_ => None,
}
}
/// Devuelve el color RGB de un acento por id. `default` retorna `None`
/// para que el consumidor no toque el accent del theme base. La paleta
/// es la misma del web (`gioser-web/styles.css`): tinte por cuadrante
/// + accent gioser por default.
///
/// Es un trio RGB (no un tipo de `peniko`) para no obligar a depender
/// de `llimphi-raster` desde acá. Los consumidores Llimphi hacen:
///
/// ```ignore
/// if let Some([r,g,b]) = wawa_config::accent_rgb(&cfg.accent) {
/// let c = llimphi_theme::Color::from_rgba8(r, g, b, 255);
/// theme.accent = c;
/// theme.border_focus = c;
/// }
/// ```
pub fn accent_rgb(accent: &str) -> Option<[u8; 3]> {
match accent {
"default" => None,
"gioser" => Some([0x6E, 0x8C, 0xDC]),
"unanchay" => Some([0xB9, 0xC9, 0xE8]),
"yachay" => Some([0xE8, 0xC9, 0x7A]),
"ruway" => Some([0xE8, 0x9B, 0x6E]),
"ukupacha" => Some([0x8F, 0xB5, 0x8C]),
_ => None,
}
}
/// Lista de variants de theme reconocidas — útil para validadores y
/// generadores de docs/UI. Orden estable.
pub const THEME_VARIANTS: &[&str] = &["dark", "light", "aurora", "sunset"];
/// Lista de acentos reconocidos. `"default"` significa "no override".
pub const ACCENTS: &[&str] = &["default", "gioser", "unanchay", "yachay", "ruway", "ukupacha"];
/// Identificadores estables de los módulos del SO conocidos. Las apps
/// son libres de leer/escribir otros, pero estos son los que el panel
/// expone por default — mantenerlos como `const` ayuda a no escribir
/// el string mal en sitios distintos.
pub mod modules {
pub const MIRADA: &str = "mirada";
pub const SHUMA: &str = "shuma";
pub const CHASQUI: &str = "chasqui";
pub const AKASHA: &str = "akasha";
pub const MINGA: &str = "minga";
pub const AGORA: &str = "agora";
pub fn defaults() -> [(&'static str, bool); 6] {
[
(MIRADA, true),
(SHUMA, true),
(CHASQUI, true),
(AKASHA, true),
(MINGA, true),
(AGORA, true),
]
}
}
/// Configuración del sistema operativo wawa. Serializada como el JSON
/// del módulo. Campos nuevos se agregan con `#[serde(default = "…")]`
/// para preservar compatibilidad hacia atrás.
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
pub struct WawaConfig {
/// Variante del theme global. Coincide con
/// `llimphi_theme::Theme::name`: `"dark"`, `"light"`, `"aurora"`,
/// `"sunset"`.
#[serde(default = "default_theme_variant")]
pub theme_variant: String,
/// Acento. `"default"` deja el accent del theme; cualquier otro
/// id (gioser/unanchay/yachay/ruway/ukupacha) lo sobreescribe.
#[serde(default = "default_accent")]
pub accent: String,
/// Locale activo. Acepta lo mismo que `rimay_localize::set_locale`:
/// `"es-PE"`, `"en-US"`, `"qu-PE"`.
#[serde(default = "default_lang")]
pub lang: String,
/// Formato del reloj (true = 24h, false = 12h con am/pm).
#[serde(default = "default_timefmt")]
pub timefmt_24h: bool,
/// Estado on/off de los módulos del SO. Usa los ids de
/// [`modules`]. BTreeMap → serializa con orden estable y diffs
/// limpios en git.
#[serde(default = "default_modules")]
pub modules: BTreeMap<String, bool>,
}
fn default_theme_variant() -> String {
"dark".into()
}
fn default_accent() -> String {
"default".into()
}
fn default_lang() -> String {
"es-PE".into()
}
fn default_timefmt() -> bool {
true
}
fn default_modules() -> BTreeMap<String, bool> {
modules::defaults()
.into_iter()
.map(|(k, v)| (k.to_string(), v))
.collect()
}
impl Default for WawaConfig {
fn default() -> Self {
Self {
theme_variant: default_theme_variant(),
accent: default_accent(),
lang: default_lang(),
timefmt_24h: default_timefmt(),
modules: default_modules(),
}
}
}
impl WawaConfig {
/// `true` si el módulo `id` está activo (default: activo si no se
/// conoce — convención conservadora).
pub fn module_enabled(&self, id: &str) -> bool {
self.modules.get(id).copied().unwrap_or(true)
}
/// Conmuta el módulo `id`. Si no existía, lo agrega con `false`.
pub fn toggle_module(&mut self, id: &str) {
let v = self.modules.entry(id.to_string()).or_insert(true);
*v = !*v;
}
/// Path canónico del archivo de usuario (alias de [`user_config_path`]).
/// `None` si la plataforma no expone un config dir (extremadamente
/// raro fuera de embebidos).
pub fn path() -> Option<PathBuf> {
user_config_path()
}
/// Path canónico del archivo de la capa indicada. `None` si la
/// capa no aplica en esta plataforma (p. ej. `Layer::System` fuera
/// de Linux).
pub fn path_for(layer: Layer) -> Option<PathBuf> {
match layer {
Layer::System => system_config_path(),
Layer::User => user_config_path(),
}
}
/// Carga la config efectiva: defaults → capa de sistema → capa de
/// usuario. Cada capa **sobreescribe campo por campo** lo que
/// definió la anterior; campos ausentes preservan el valor
/// previo. Para `modules`, el merge es key-by-key (no reemplazo
/// total del mapa).
///
/// Si ningún archivo existe, o están corruptos, devuelve defaults
/// — nunca falla. Los errores se loggean a `tracing::warn`.
pub fn load() -> Self {
let mut acc = serde_json::to_value(Self::default())
.expect("WawaConfig::default siempre serializa");
for layer in [Layer::System, Layer::User] {
if let Some(v) = load_layer_value(layer) {
merge_json(&mut acc, v);
}
}
serde_json::from_value(acc).unwrap_or_default()
}
/// Carga **sólo** la capa indicada, sin mergear con la otra. Útil
/// para herramientas como `wawactl --system show` que necesitan
/// inspeccionar una capa concreta. Si el archivo no existe,
/// devuelve `None` (no defaults — distingue "ausente" de
/// "presente con defaults"). Errores de parseo loggean warn y
/// también devuelven `None`.
pub fn load_layer(layer: Layer) -> Option<Self> {
let path = Self::path_for(layer)?;
let bytes = match std::fs::read(&path) {
Ok(b) => b,
Err(e) if e.kind() == std::io::ErrorKind::NotFound => return None,
Err(e) => {
warn!(?path, error = %e, "wawa-config: read failed");
return None;
}
};
match serde_json::from_slice::<WawaConfig>(&bytes) {
Ok(c) => Some(c),
Err(e) => {
warn!(?path, error = %e, "wawa-config: parse failed");
None
}
}
}
/// Persiste atómicamente en la capa de **usuario** (compat con
/// callers existentes): serializa a `config.json.tmp` y renombra
/// sobre `config.json`. Crea el directorio padre si no existe.
pub fn save(&self) -> Result<PathBuf, ConfigError> {
self.save_to(Layer::User)
}
/// Persiste en la capa indicada. `Layer::System` apunta a
/// `/etc/wawa/config.json` y típicamente requiere root — devuelve
/// `ConfigError::Io` con `PermissionDenied` si no.
pub fn save_to(&self, layer: Layer) -> Result<PathBuf, ConfigError> {
let path = Self::path_for(layer).ok_or(ConfigError::NoProjectDirs)?;
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent)?;
}
let json = serde_json::to_string_pretty(self)?;
let tmp = path.with_extension("json.tmp");
std::fs::write(&tmp, json)?;
std::fs::rename(&tmp, &path)?;
Ok(path)
}
}
/// Lee y parsea una capa como `serde_json::Value`. Devuelve `None`
/// (no defaults) si el archivo no existe o falla el parse — esto es
/// distinto del `WawaConfig::load_layer` que también devuelve Option,
/// pero acá trabajamos con Value para mergear sin perder "campo
/// ausente vs explícito".
fn load_layer_value(layer: Layer) -> Option<serde_json::Value> {
let path = WawaConfig::path_for(layer)?;
let bytes = match std::fs::read(&path) {
Ok(b) => b,
Err(e) if e.kind() == std::io::ErrorKind::NotFound => return None,
Err(e) => {
warn!(?path, error = %e, "wawa-config: read failed");
return None;
}
};
match serde_json::from_slice::<serde_json::Value>(&bytes) {
Ok(v) => Some(v),
Err(e) => {
warn!(?path, error = %e, "wawa-config: parse failed");
None
}
}
}
/// Merge profundo: `over` sobreescribe `base` hoja por hoja, recursivo
/// sobre objetos JSON. Para arrays y escalares, `over` reemplaza
/// completamente. Esto preserva la semántica "campo ausente → no
/// modifica la capa inferior" y permite que un user override sólo
/// algunas keys de `modules`.
fn merge_json(base: &mut serde_json::Value, over: serde_json::Value) {
use serde_json::Value;
match (base, over) {
(Value::Object(b), Value::Object(o)) => {
for (k, v) in o {
match b.get_mut(&k) {
Some(existing) => merge_json(existing, v),
None => {
b.insert(k, v);
}
}
}
}
(slot, v) => *slot = v,
}
}
/// Errores de IO o serialización al persistir la config. La carga
/// nunca falla — devuelve defaults en su lugar.
#[derive(Debug, Error)]
pub enum ConfigError {
#[error("no hay ProjectDirs en esta plataforma")]
NoProjectDirs,
#[error("io: {0}")]
Io(#[from] std::io::Error),
#[error("serde: {0}")]
Serde(#[from] serde_json::Error),
#[error("notify: {0}")]
Notify(#[from] notify::Error),
}
/// Path del archivo de **usuario**. El qualifier "" + organization ""
/// se mapea a `$XDG_CONFIG_HOME/wawa/` en Linux (típicamente
/// `~/.config/wawa/`), `~/Library/Application Support/wawa/` en
/// macOS, `%APPDATA%/wawa/` en Windows. `None` si la plataforma no
/// expone un config dir.
pub fn user_config_path() -> Option<PathBuf> {
directories::ProjectDirs::from("", "", CONFIG_DIR)
.map(|d| d.config_dir().join(CONFIG_FILE))
}
/// Path del archivo de **sistema**. `Some("/etc/wawa/config.json")`
/// en Linux; `None` en otras plataformas (no hay convención
/// equivalente y no vale la pena inventarla). Cuando wawa sea su
/// propio SO, esta función devolverá el equivalente nativo y la API
/// pública no cambia.
pub fn system_config_path() -> Option<PathBuf> {
#[cfg(target_os = "linux")]
{
Some(PathBuf::from(SYSTEM_CONFIG_DIR_LINUX).join(CONFIG_FILE))
}
#[cfg(not(target_os = "linux"))]
{
None
}
}
// =====================================================================
// Watcher
// =====================================================================
/// Suscripción al bus. Mantenelo vivo (guardalo en el Model de tu app)
/// para seguir recibiendo notificaciones; al dropearlo, los callbacks
/// dejan de dispararse.
///
/// Observa **ambas capas** (sistema y usuario): un cambio en
/// cualquiera dispara `on_change` con la config efectiva ya mergeada.
/// Cada capa escucha el directorio padre con
/// `RecursiveMode::NonRecursive` y filtra por `config.json` — así
/// detecta tanto modificaciones in-place como reemplazos atómicos por
/// `rename`. Si la capa de sistema no aplica en la plataforma (no
/// Linux), o no se puede crear/observar (p. ej. `/etc/wawa` sin
/// permisos de lectura — improbable porque `/etc/` es world-readable
/// por convención), se ignora con un warn y el watcher sigue activo
/// sólo sobre la capa de usuario.
///
/// Para evitar disparar dos veces seguidas cuando un editor escribe
/// con la secuencia `truncate → write → close`, el watcher debouncea
/// internamente con un timeout de ~200 ms: agrupa eventos consecutivos
/// y emite un único callback con la última versión leída.
pub struct ConfigWatcher {
_watchers: Vec<RecommendedWatcher>,
_debounce_thread: Option<thread::JoinHandle<()>>,
}
impl ConfigWatcher {
/// Arranca el watcher. `on_change` se llama cada vez que **alguna**
/// de las capas cambia, ya con la nueva config efectiva mergeada
/// (sistema ← usuario). Si el parseo falla, no se invoca (se
/// loggea como warn y se ignora hasta el próximo cambio).
///
/// `on_change` corre en un thread propio del watcher — para
/// reentrar al loop de Llimphi, capturá un `Handle<Msg>` clonado
/// y llamá `handle.dispatch(...)` dentro de la closure.
pub fn spawn<F>(on_change: F) -> Result<Self, ConfigError>
where
F: FnMut(WawaConfig) + Send + 'static,
{
let (tx, rx) = mpsc::channel::<()>();
let mut watchers = Vec::with_capacity(2);
// Capa de usuario es obligatoria; si no hay ProjectDirs es un
// entorno raro y devolvemos error como antes.
let user_path = user_config_path().ok_or(ConfigError::NoProjectDirs)?;
watchers.push(spawn_layer_watcher(&user_path, tx.clone(), /*must_exist=*/ true)?);
// Capa de sistema es best-effort: si no aplica (no Linux), o
// no se puede observar (sin permiso de lectura de `/etc/wawa`,
// que es muy raro pero posible si el admin la chmodea), no
// rompe — sólo no se entera de cambios de sistema.
if let Some(sys_path) = system_config_path() {
match spawn_layer_watcher(&sys_path, tx.clone(), /*must_exist=*/ false) {
Ok(w) => watchers.push(w),
Err(e) => warn!(?sys_path, error = %e, "wawa-config: system layer watch skipped"),
}
}
// Debounce: junta señales durante ~200 ms y al cierre llama
// `on_change` con la lectura más reciente (mergeada). Acepta
// que perdamos ráfagas intermedias — solo importa el estado
// final.
let debounce = thread::Builder::new()
.name("wawa-config-debounce".into())
.spawn(move || debounce_loop(rx, Box::new(on_change)))
.ok();
Ok(Self {
_watchers: watchers,
_debounce_thread: debounce,
})
}
}
fn spawn_layer_watcher(
path: &Path,
tx: mpsc::Sender<()>,
must_exist: bool,
) -> Result<RecommendedWatcher, ConfigError> {
let parent = path
.parent()
.ok_or_else(|| {
ConfigError::Io(std::io::Error::new(
std::io::ErrorKind::Other,
"config path sin parent",
))
})?
.to_path_buf();
// Para la capa de usuario creamos el dir si falta — notify puede
// watchear un dir vacío. Para la de sistema no lo creamos: si
// `/etc/wawa` no existe, probablemente esta máquina no usa la capa
// de sistema y mejor no requerir permisos de root para correr una
// app de usuario.
if must_exist {
std::fs::create_dir_all(&parent)?;
} else if !parent.exists() {
return Err(ConfigError::Io(std::io::Error::new(
std::io::ErrorKind::NotFound,
"directorio de capa de sistema ausente",
)));
}
let target_name = path.file_name().map(|n| n.to_owned());
let mut watcher =
notify::recommended_watcher(move |res: notify::Result<notify::Event>| {
let event = match res {
Ok(e) => e,
Err(e) => {
warn!(error = %e, "wawa-config: watcher error");
return;
}
};
let is_target = match &target_name {
Some(name) => event
.paths
.iter()
.any(|p| p.file_name().map(|f| f == name.as_os_str()).unwrap_or(false)),
None => true,
};
if !is_target {
return;
}
if !matches!(
event.kind,
EventKind::Modify(_) | EventKind::Create(_) | EventKind::Remove(_)
) {
return;
}
let _ = tx.send(());
})?;
watcher.watch(&parent, RecursiveMode::NonRecursive)?;
Ok(watcher)
}
fn debounce_loop(rx: mpsc::Receiver<()>, mut on_change: Box<dyn FnMut(WawaConfig) + Send>) {
const QUIET: Duration = Duration::from_millis(200);
loop {
// Esperar al primer evento sin timeout.
if rx.recv().is_err() {
return;
}
// Drenar lo que se acumule en la ventana de quiet.
loop {
match rx.recv_timeout(QUIET) {
Ok(()) => continue,
Err(mpsc::RecvTimeoutError::Timeout) => break,
Err(mpsc::RecvTimeoutError::Disconnected) => return,
}
}
let cfg = WawaConfig::load();
on_change(cfg);
}
}
// =====================================================================
// Tests
// =====================================================================
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn default_round_trip() {
let c = WawaConfig::default();
let s = serde_json::to_string(&c).unwrap();
let back: WawaConfig = serde_json::from_str(&s).unwrap();
assert_eq!(c, back);
}
#[test]
fn partial_json_uses_defaults() {
// Sólo se aporta theme; el resto debe caer al default.
let s = r#"{"theme_variant":"aurora"}"#;
let c: WawaConfig = serde_json::from_str(s).unwrap();
assert_eq!(c.theme_variant, "aurora");
assert_eq!(c.accent, "default");
assert_eq!(c.lang, "es-PE");
assert!(c.timefmt_24h);
assert!(c.module_enabled(modules::MIRADA));
}
#[test]
fn unknown_fields_ignored() {
// Un campo extra no rompe la deserialización.
let s = r#"{"theme_variant":"dark","unknown":42}"#;
let _c: WawaConfig = serde_json::from_str(s).unwrap();
}
#[test]
fn toggle_module_persists_value() {
let mut c = WawaConfig::default();
assert!(c.module_enabled(modules::MIRADA));
c.toggle_module(modules::MIRADA);
assert!(!c.module_enabled(modules::MIRADA));
c.toggle_module("inexistente");
assert!(!c.module_enabled("inexistente"));
}
#[test]
fn canonical_theme_maps_variants() {
assert_eq!(canonical_theme_name("dark"), Some("Dark"));
assert_eq!(canonical_theme_name("LIGHT"), Some("Light"));
assert_eq!(canonical_theme_name("Aurora"), Some("Aurora"));
assert_eq!(canonical_theme_name("sunset"), Some("Sunset"));
assert_eq!(canonical_theme_name("hyperdark"), None);
}
#[test]
fn accent_rgb_default_is_none() {
assert_eq!(accent_rgb("default"), None);
assert_eq!(accent_rgb("gioser"), Some([0x6E, 0x8C, 0xDC]));
assert_eq!(accent_rgb("ukupacha"), Some([0x8F, 0xB5, 0x8C]));
assert_eq!(accent_rgb("desconocido"), None);
}
#[test]
fn merge_user_over_system_overrides_field_by_field() {
// Sistema define theme aurora y lang qu-PE; usuario sólo
// sobreescribe lang. Resultado: theme aurora (de sistema),
// lang en-US (de usuario).
let mut base = serde_json::to_value(WawaConfig::default()).unwrap();
let system: serde_json::Value =
serde_json::from_str(r#"{"theme_variant":"aurora","lang":"qu-PE"}"#).unwrap();
let user: serde_json::Value =
serde_json::from_str(r#"{"lang":"en-US"}"#).unwrap();
merge_json(&mut base, system);
merge_json(&mut base, user);
let final_cfg: WawaConfig = serde_json::from_value(base).unwrap();
assert_eq!(final_cfg.theme_variant, "aurora");
assert_eq!(final_cfg.lang, "en-US");
// El resto cae al default.
assert_eq!(final_cfg.accent, "default");
}
#[test]
fn merge_modules_is_deep_per_key() {
// Sistema apaga mirada; usuario apaga shuma. Esperado: ambos
// off, el resto en su default true.
let mut base = serde_json::to_value(WawaConfig::default()).unwrap();
let system: serde_json::Value =
serde_json::from_str(r#"{"modules":{"mirada":false}}"#).unwrap();
let user: serde_json::Value =
serde_json::from_str(r#"{"modules":{"shuma":false}}"#).unwrap();
merge_json(&mut base, system);
merge_json(&mut base, user);
let final_cfg: WawaConfig = serde_json::from_value(base).unwrap();
assert!(!final_cfg.module_enabled(modules::MIRADA));
assert!(!final_cfg.module_enabled(modules::SHUMA));
assert!(final_cfg.module_enabled(modules::CHASQUI));
}
#[test]
fn system_path_only_on_linux() {
let p = system_config_path();
if cfg!(target_os = "linux") {
assert_eq!(p, Some(PathBuf::from("/etc/wawa/config.json")));
} else {
assert!(p.is_none());
}
}
#[test]
fn constants_match_helpers() {
// THEME_VARIANTS y ACCENTS deben coincidir con lo que aceptan
// los helpers — guarda contra agregar uno y olvidar el otro.
for v in THEME_VARIANTS {
assert!(canonical_theme_name(v).is_some(), "variant {v} sin mapeo");
}
for a in ACCENTS {
// accent_rgb("default") es None por diseño; el resto debe
// tener color asignado.
if *a == "default" {
assert_eq!(accent_rgb(a), None);
} else {
assert!(accent_rgb(a).is_some(), "accent {a} sin color");
}
}
}
}
/// Path absoluto del config dir de **usuario** — alias por compat.
/// `None` si no hay ProjectDirs disponibles.
pub fn config_dir() -> Option<PathBuf> {
directories::ProjectDirs::from("", "", CONFIG_DIR).map(|d| d.config_dir().to_path_buf())
}
/// Path absoluto del config dir de **sistema** (`/etc/wawa` en Linux).
/// `None` en otras plataformas.
pub fn system_config_dir() -> Option<PathBuf> {
#[cfg(target_os = "linux")]
{
Some(PathBuf::from(SYSTEM_CONFIG_DIR_LINUX))
}
#[cfg(not(target_os = "linux"))]
{
None
}
}
/// Helper opcional: agrega el `path` provisto a una lista de
/// watchers. No es parte del flujo normal — está expuesto para
/// herramientas que quieran observar un directorio externo (p. ej.
/// `/etc/wawa/` para configuración del sistema vs el del usuario).
/// El default (`spawn`) ya cubre el caso típico.
pub fn watch_path(
p: &Path,
on_event: impl FnMut(notify::Event) + Send + 'static,
) -> Result<RecommendedWatcher, ConfigError> {
let mut on_event = on_event;
let mut w =
notify::recommended_watcher(move |res: notify::Result<notify::Event>| match res {
Ok(ev) => on_event(ev),
Err(e) => warn!(error = %e, "wawa-config: external watch error"),
})?;
w.watch(p, RecursiveMode::NonRecursive)?;
Ok(w)
}