use serde_json::{Map, Value};
use std::ffi::OsString;
use std::io;
use std::path::{Path, PathBuf};
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 })
}
pub fn exe_name(program: &str) -> String {
if cfg!(windows) && Path::new(program).extension().is_none() { format!("{program}.exe") } else { program.into() }
}
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))
}
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
}
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
}
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()
}
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())
}
pub fn path_for_child() -> OsString {
std::env::join_paths(tool_dirs()).unwrap_or_default()
}
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))
}
}
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
}
#[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
}
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)?)
}
pub fn update(&self, change: impl FnOnce(&mut Value)) -> io::Result<()> {
let mut value = self.load();
change(&mut value);
self.save(&value)
}
}
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");
}
}