hni 0.0.2

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

use configparser::ini::Ini;

use super::{
    error::{HniError, HniResult},
    types::PackageManager,
};

#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum DefaultAgent {
    Prompt,
    Agent(PackageManager),
}

#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum RunAgent {
    PackageManager,
    Node,
}

#[derive(Debug, Clone)]
pub struct HniConfig {
    pub default_agent: DefaultAgent,
    pub global_agent: PackageManager,
    pub run_agent: RunAgent,
    pub use_sfw: bool,
    pub auto_install: bool,
    pub config_path: Option<PathBuf>,
}

impl Default for HniConfig {
    fn default() -> Self {
        Self {
            default_agent: DefaultAgent::Prompt,
            global_agent: PackageManager::Npm,
            run_agent: RunAgent::PackageManager,
            use_sfw: false,
            auto_install: false,
            config_path: None,
        }
    }
}

impl HniConfig {
    pub fn load() -> HniResult<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| {
                error.with_context(format!("failed to load {}", 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| {
                error.with_context(format!("failed to load {}", path.display()))
            })?;
            if loaded {
                cfg.config_path = Some(path);
            }
        }

        if let Ok(v) = env::var("HNI_DEFAULT_AGENT") {
            cfg.default_agent = parse_default_agent(&v)?;
        }

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

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

        if let Ok(v) = env::var("HNI_AUTO_INSTALL") {
            cfg.auto_install = 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) -> HniResult<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(HniError::config(format!(
                "config file not found: {}",
                path.display()
            )));
        }
        Err(error) => {
            return Err(HniError::config(format!(
                "failed to read config file {}: {error}",
                path.display()
            )));
        }
    };

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

    if let Some(v) = ini.get("default", "defaultagent") {
        config.default_agent = parse_default_agent(v.trim())?;
    }
    if let Some(v) = ini.get("default", "globalagent") {
        config.global_agent = parse_pm(v.trim())?;
    }
    if let Some(v) = ini.get("default", "runagent") {
        config.run_agent = parse_run_agent(v.trim())?;
    }
    if let Some(v) = ini.get("default", "usesfw") {
        config.use_sfw = parse_bool(v.trim())?;
    }

    Ok(true)
}

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

fn parse_default_agent(value: &str) -> HniResult<DefaultAgent> {
    if value.eq_ignore_ascii_case("prompt") {
        return Ok(DefaultAgent::Prompt);
    }
    Ok(DefaultAgent::Agent(parse_pm(value)?))
}

fn parse_run_agent(value: &str) -> HniResult<RunAgent> {
    let normalized = value.trim().to_ascii_lowercase();
    match normalized.as_str() {
        "node" => Ok(RunAgent::Node),
        _ if PackageManager::from_name(&normalized).is_some() => Ok(RunAgent::PackageManager),
        _ => Err(HniError::config(format!("invalid runAgent value: {value}"))),
    }
}

fn parse_bool(value: &str) -> HniResult<bool> {
    match value.trim().to_ascii_lowercase().as_str() {
        "1" | "true" | "yes" | "on" => Ok(true),
        "0" | "false" | "no" | "off" => Ok(false),
        _ => Err(HniError::config(format!("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,
            "defaultAgent=pnpm\nglobalAgent=yarn\nrunAgent=node\nuseSfw=true\n",
        )
        .unwrap();

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

        assert_eq!(cfg.default_agent, DefaultAgent::Agent(PackageManager::Pnpm));
        assert_eq!(cfg.global_agent, PackageManager::Yarn);
        assert_eq!(cfg.run_agent, RunAgent::Node);
        assert!(cfg.use_sfw);
        assert!(!cfg.auto_install);
    }

    #[test]
    fn parse_default_prompt() {
        let parsed = parse_default_agent("prompt").unwrap();
        assert_eq!(parsed, DefaultAgent::Prompt);
    }

    #[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"), "defaultAgent=pnpm\n").unwrap();
        fs::write(home.join(".hnirc"), "defaultAgent=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"), "defaultAgent=pnpm\n").unwrap();

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