aura-gui 0.2.2

UI nativa acelerada por GPU para escritorio, Android e iOS, en un solo crate: estilo tipo Tailwind, componentes, sombras, animaciones y agentes de IA. Reposo a cero. Licencia AGPL-3.0 o comercial.
//! Dónde están las cosas en disco. Por un lado, los programas externos (agentes ACP, servidores MCP, CLIs): una app
//! abierta desde el Finder o el menú de inicio hereda un `PATH` mínimo, sin `~/.local/bin`, Homebrew, nvm ni npm
//! global, y aquí se completa. Por otro, la carpeta de configuración de la app y su [`Store`] JSON.

use serde_json::{Map, Value};
use std::ffi::OsString;
use std::io;
use std::path::{Path, PathBuf};

/// Carpeta personal: `HOME`, o `USERPROFILE` en Windows.
pub fn home_dir() -> Option<PathBuf> {
    let var = |name: &str| std::env::var_os(name).filter(|value| !value.is_empty()).map(PathBuf::from);
    var("HOME").or_else(|| if cfg!(windows) { var("USERPROFILE") } else { None })
}

/// Nombre del ejecutable en este sistema: `tsgo` → `tsgo.exe` en Windows.
pub fn exe_name(program: &str) -> String {
    if cfg!(windows) && Path::new(program).extension().is_none() { format!("{program}.exe") } else { program.into() }
}

/// Versiones de Node instaladas con nvm, de la más reciente a la más antigua.
fn nvm_bins(home: &Path) -> Vec<PathBuf> {
    let Ok(entries) = std::fs::read_dir(home.join(".nvm/versions/node")) else { return Vec::new() };
    let mut versions: Vec<PathBuf> = entries.flatten().map(|entry| entry.path().join("bin")).collect();
    versions.sort_unstable_by(|a, b| b.cmp(a));
    versions
}

fn under_env(name: &str, relative: &str) -> Option<PathBuf> {
    std::env::var_os(name).map(|base| Path::new(&base).join(relative))
}

/// Lugares habituales de herramientas de usuario y del sistema, además del `PATH`.
fn usual_dirs() -> Vec<PathBuf> {
    let mut dirs = Vec::new();
    if let Some(home) = home_dir() {
        let relative = [".local/bin", ".cargo/bin", ".bun/bin", ".npm-global/bin", ".volta/bin", "bin"];
        dirs.extend(relative.map(|path| home.join(path)));
        dirs.extend(nvm_bins(&home));
    }
    if cfg!(windows) {
        dirs.extend(under_env("APPDATA", "npm"));
        dirs.extend(under_env("ProgramFiles", "nodejs"));
        dirs.extend(under_env("LOCALAPPDATA", "Microsoft/WinGet/Links"));
    } else if cfg!(target_os = "macos") {
        dirs.extend(["/opt/homebrew/bin", "/usr/local/bin", "/usr/bin"].map(PathBuf::from));
    } else {
        dirs.extend(["/usr/local/bin", "/usr/bin", "/snap/bin", "/home/linuxbrew/.linuxbrew/bin"].map(PathBuf::from));
    }
    dirs
}

/// Directorios de búsqueda sin repetir: el `PATH` heredado primero y después los lugares habituales.
pub fn tool_dirs() -> Vec<PathBuf> {
    let inherited: Vec<PathBuf> =
        std::env::var_os("PATH").map(|path| std::env::split_paths(&path).collect()).unwrap_or_default();
    let mut dirs: Vec<PathBuf> = Vec::new();
    for dir in inherited.into_iter().chain(usual_dirs()) {
        if !dirs.contains(&dir) {
            dirs.push(dir);
        }
    }
    dirs
}

/// Variantes del nombre que ejecuta el sistema: en Windows `npx` es `npx.cmd`, no `npx.exe`.
fn candidates(command: &str) -> Vec<String> {
    if !cfg!(windows) || Path::new(command).extension().is_some() {
        return vec![command.into()];
    }
    ["exe", "cmd", "bat"].iter().map(|extension| format!("{command}.{extension}")).collect()
}

/// Ruta completa del programa: tal cual si ya es una ruta; si no, el primero que exista en `tool_dirs`.
pub fn find_program(command: &str) -> PathBuf {
    if command.contains(['/', '\\']) {
        return PathBuf::from(command);
    }
    let names = candidates(command);
    tool_dirs()
        .iter()
        .flat_map(|dir| names.iter().map(move |name| dir.join(name)))
        .find(|path| path.is_file())
        .unwrap_or_else(|| command.into())
}

/// `PATH` para un proceso hijo, de modo que `npx` encuentre `node` aunque la app no heredara su directorio.
pub fn path_for_child() -> OsString {
    std::env::join_paths(tool_dirs()).unwrap_or_default()
}

/// Carpeta de configuración de una app: `~/.config/<app>` en macOS y Linux y `%APPDATA%\<app>` en Windows. `None`
/// si el sistema no da carpeta personal (móvil: usa [`Store::at`] con una ruta propia).
pub fn config_dir(app: &str) -> Option<PathBuf> {
    if cfg!(windows) {
        std::env::var_os("APPDATA").filter(|base| !base.is_empty()).map(|base| PathBuf::from(base).join(app))
    } else {
        home_dir().map(|home| home.join(".config").join(app))
    }
}

/// Escribe `bytes` sin dejar jamás el archivo a medias: temporal junto al destino y renombrado. Crea las
/// carpetas que falten. Un corte de luz a mitad de guardado deja el archivo anterior intacto.
pub fn write_atomic(path: &Path, bytes: &[u8]) -> io::Result<()> {
    if let Some(parent) = path.parent() {
        std::fs::create_dir_all(parent)?;
    }
    let mut temporary = path.as_os_str().to_owned();
    temporary.push(format!(".{}.tmp", std::process::id()));
    let temporary = PathBuf::from(temporary);
    let result = std::fs::write(&temporary, bytes).and_then(|()| std::fs::rename(&temporary, path));
    if result.is_err() {
        let _ = std::fs::remove_file(&temporary);
    }
    result
}

/// Configuración de la app en un JSON dentro de su carpeta:
///
/// ```ignore
/// let store = Store::new("miapp", "prefs.json").unwrap();
/// let theme = store.load().str_or("theme", "Aura Dark").to_owned();   // nunca falla: ausente o roto = {}
/// store.update(|json| json["theme"] = "Nord".into())?;                // conserva las claves desconocidas
/// ```
#[derive(Clone, Debug)]
pub struct Store {
    path: PathBuf,
}

impl Store {
    pub fn new(app: &str, file: &str) -> Option<Store> {
        config_dir(app).map(|dir| Store { path: dir.join(file) })
    }

    pub fn at(path: impl Into<PathBuf>) -> Store {
        Store { path: path.into() }
    }

    pub fn path(&self) -> &Path {
        &self.path
    }

    /// El JSON guardado; si falta, está roto o no es un objeto, un objeto vacío.
    pub fn load(&self) -> Value {
        std::fs::read(&self.path)
            .ok()
            .and_then(|bytes| serde_json::from_slice::<Value>(&bytes).ok())
            .filter(Value::is_object)
            .unwrap_or_else(|| Value::Object(Map::new()))
    }

    pub fn save(&self, value: &Value) -> io::Result<()> {
        write_atomic(&self.path, &serde_json::to_vec_pretty(value).map_err(io::Error::other)?)
    }

    /// Lee, aplica el cambio y guarda: conserva las claves que esta versión de la app no conoce.
    pub fn update(&self, change: impl FnOnce(&mut Value)) -> io::Result<()> {
        let mut value = self.load();
        change(&mut value);
        self.save(&value)
    }
}

/// Lecturas con valor por defecto, para no escribir `get(..).and_then(..).unwrap_or(..)` en cada ajuste.
pub trait JsonExt {
    fn str_or<'a>(&'a self, key: &str, default: &'a str) -> &'a str;
    fn bool_or(&self, key: &str, default: bool) -> bool;
    fn f64_or(&self, key: &str, default: f64) -> f64;
    fn u64_or(&self, key: &str, default: u64) -> u64;
}

impl JsonExt for Value {
    fn str_or<'a>(&'a self, key: &str, default: &'a str) -> &'a str {
        self.get(key).and_then(Value::as_str).unwrap_or(default)
    }

    fn bool_or(&self, key: &str, default: bool) -> bool {
        self.get(key).and_then(Value::as_bool).unwrap_or(default)
    }

    fn f64_or(&self, key: &str, default: f64) -> f64 {
        self.get(key).and_then(Value::as_f64).unwrap_or(default)
    }

    fn u64_or(&self, key: &str, default: u64) -> u64 {
        self.get(key).and_then(Value::as_u64).unwrap_or(default)
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    use serde_json::json;

    fn temp_file(name: &str) -> PathBuf {
        let dir = std::env::temp_dir().join(format!("aura-store-{}-{name}", std::process::id()));
        let _ = std::fs::remove_dir_all(&dir);
        dir.join("sub/prefs.json")
    }

    #[test]
    fn un_archivo_ausente_o_roto_se_carga_vacio() {
        let path = temp_file("roto");
        let store = Store::at(&path);
        assert_eq!(store.load(), json!({}));
        std::fs::create_dir_all(path.parent().unwrap()).unwrap();
        std::fs::write(&path, "{ no es json").unwrap();
        assert_eq!(store.load(), json!({}));
        std::fs::write(&path, "[1,2]").unwrap();
        assert_eq!(store.load(), json!({}), "una lista no es una configuración");
    }

    #[test]
    fn actualizar_conserva_las_claves_desconocidas_y_no_deja_temporales() {
        let path = temp_file("update");
        let store = Store::at(&path);
        store.save(&json!({ "futuro": 7, "theme": "Nord" })).unwrap();
        store.update(|json| json["theme"] = json!("Dracula")).unwrap();
        let loaded = store.load();
        assert_eq!((loaded.str_or("theme", ""), loaded.u64_or("futuro", 0)), ("Dracula", 7));
        assert_eq!(std::fs::read_dir(path.parent().unwrap()).unwrap().count(), 1, "solo queda prefs.json");
    }

    #[test]
    fn las_lecturas_tipadas_caen_al_valor_por_defecto() {
        let value = json!({ "on": true, "size": 14.5, "name": "x" });
        assert!(value.bool_or("on", false) && !value.bool_or("missing", false));
        assert_eq!((value.f64_or("size", 0.0), value.f64_or("name", 9.0)), (14.5, 9.0));
        assert_eq!(value.str_or("name", "-"), "x");
    }

    #[test]
    fn una_ruta_explicita_no_se_busca() {
        assert_eq!(find_program("/opt/mi/agente"), PathBuf::from("/opt/mi/agente"));
        assert_eq!(find_program(r"C:\mi\agente"), PathBuf::from(r"C:\mi\agente"));
    }

    #[test]
    fn un_programa_inexistente_se_devuelve_tal_cual() {
        assert_eq!(find_program("no-existe-aura-xyz"), PathBuf::from("no-existe-aura-xyz"));
    }

    #[test]
    fn un_programa_del_cargo_se_resuelve_a_ruta_completa() {
        let cargo = find_program("cargo");
        assert!(cargo.is_absolute() && cargo.is_file(), "{cargo:?}");
    }

    #[test]
    fn los_directorios_no_se_repiten() {
        let dirs = tool_dirs();
        let mut sorted = dirs.clone();
        sorted.sort();
        sorted.dedup();
        assert_eq!(sorted.len(), dirs.len());
        assert!(!path_for_child().is_empty());
    }

    #[test]
    fn el_nombre_del_ejecutable_lleva_exe_solo_en_windows() {
        assert_eq!(exe_name("tsgo"), if cfg!(windows) { "tsgo.exe" } else { "tsgo" });
        assert_eq!(exe_name("a.cmd"), "a.cmd");
    }
}