aura-gui 0.1.0

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 buscar 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: aquí se completa.

use std::ffi::OsString;
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()
}

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

    #[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");
    }
}