hni 0.0.3

ni-compatible package manager command router with node shim
Documentation
use std::{
    env, fs, io,
    path::{Path, PathBuf},
};

use anyhow::{Result, anyhow};
use configparser::ini::Ini;

use super::types::PackageManager;

#[derive(Debug, Clone)]
pub struct HniConfig {
    pub default_package_manager: Option<PackageManager>,
    pub global_package_manager: PackageManager,
    pub fast_mode: bool,
    pub config_path: Option<PathBuf>,
}

impl Default for HniConfig {
    fn default() -> Self {
        Self {
            default_package_manager: None,
            global_package_manager: PackageManager::Npm,
            fast_mode: true,
            config_path: None,
        }
    }
}

impl HniConfig {
    pub fn load() -> Result<Self> {
        let mut cfg = Self::default();

        let explicit_path = env::var("HNI_CONFIG_FILE").ok().map(PathBuf::from);

        if let Some(path) = explicit_path {
            let loaded = parse_hnirc_file(&path, &mut cfg, true)
                .map_err(|error| anyhow!("failed to load {}: {error}", path.display()))?;
            if loaded {
                cfg.config_path = Some(path);
            }
        } else if let Some(path) = default_config_path() {
            let loaded = parse_hnirc_file(&path, &mut cfg, false)
                .map_err(|error| anyhow!("failed to load {}: {error}", path.display()))?;
            if loaded {
                cfg.config_path = Some(path);
            }
        }

        if let Ok(v) = env::var("HNI_DEFAULT_PACKAGE_MANAGER") {
            cfg.default_package_manager = Some(parse_pm(&v)?);
        }

        if let Ok(v) = env::var("HNI_GLOBAL_PACKAGE_MANAGER") {
            cfg.global_package_manager = parse_pm(&v)?;
        }

        if let Ok(v) = env::var("HNI_FAST") {
            cfg.fast_mode = parse_bool(&v)?;
        }

        Ok(cfg)
    }
}

fn default_config_path() -> Option<PathBuf> {
    let home = dirs::home_dir()?;
    let hni = home.join(".hnirc");
    hni.exists().then_some(hni)
}

fn parse_hnirc_file(path: &Path, config: &mut HniConfig, required: bool) -> Result<bool> {
    let raw = match fs::read_to_string(path) {
        Ok(content) => content,
        Err(error) if error.kind() == io::ErrorKind::NotFound && !required => return Ok(false),
        Err(error) if error.kind() == io::ErrorKind::NotFound && required => {
            return Err(anyhow!(
                "config error: config file not found: {}",
                path.display()
            ));
        }
        Err(error) => {
            return Err(anyhow!(
                "config error: failed to read config file {}: {error}",
                path.display()
            ));
        }
    };

    let mut ini = Ini::new();
    ini.read(raw)
        .map_err(|error| anyhow!("config error: failed to parse {}: {error}", path.display()))?;

    if let Some(v) = ini.get("default", "defaultpackagemanager") {
        config.default_package_manager = Some(parse_pm(v.trim())?);
    }
    if let Some(v) = ini.get("default", "globalpackagemanager") {
        config.global_package_manager = parse_pm(v.trim())?;
    }
    if let Some(v) = ini.get("default", "fastmode") {
        config.fast_mode = parse_bool(v.trim())?;
    }

    Ok(true)
}

fn parse_pm(value: &str) -> Result<PackageManager> {
    PackageManager::from_name(&value.to_ascii_lowercase())
        .ok_or_else(|| anyhow!("config error: unsupported package manager: {value}"))
}

fn parse_bool(value: &str) -> Result<bool> {
    match value.trim().to_ascii_lowercase().as_str() {
        "1" | "true" | "yes" | "on" => Ok(true),
        "0" | "false" | "no" | "off" => Ok(false),
        _ => Err(anyhow!("config error: invalid boolean: {value}")),
    }
}

#[cfg(test)]
fn default_config_path_with_home(home: &Path) -> Option<PathBuf> {
    let hni = home.join(".hnirc");
    hni.exists().then_some(hni)
}

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

    #[test]
    fn parses_hnirc_values() {
        let dir = tempdir().unwrap();
        let path = dir.path().join(".hnirc");
        fs::write(
            &path,
            "defaultPackageManager=pnpm\nglobalPackageManager=yarn\nfastMode=false\n",
        )
        .unwrap();

        let mut cfg = HniConfig::default();
        parse_hnirc_file(&path, &mut cfg, true).unwrap();

        assert_eq!(cfg.default_package_manager, Some(PackageManager::Pnpm));
        assert_eq!(cfg.global_package_manager, PackageManager::Yarn);
        assert!(!cfg.fast_mode);
    }

    #[test]
    fn explicit_missing_config_is_error() {
        let dir = tempdir().unwrap();
        let path = dir.path().join("missing-hnirc");
        let mut cfg = HniConfig::default();
        let err = parse_hnirc_file(&path, &mut cfg, true).unwrap_err();
        assert!(err.to_string().contains("config file not found"));
    }

    #[test]
    fn default_config_path_only_considers_hnirc() {
        let dir = tempdir().unwrap();
        let home = dir.path();
        fs::write(home.join(".nirc"), "defaultPackageManager=pnpm\n").unwrap();
        fs::write(home.join(".hnirc"), "defaultPackageManager=bun\n").unwrap();

        let resolved = default_config_path_with_home(home).unwrap();
        assert_eq!(resolved, home.join(".hnirc"));
    }

    #[test]
    fn default_config_path_ignores_nirc_when_hnirc_missing() {
        let dir = tempdir().unwrap();
        let home = dir.path();
        fs::write(home.join(".nirc"), "defaultPackageManager=pnpm\n").unwrap();

        assert_eq!(default_config_path_with_home(home), None);
    }
}