hni 0.0.2

ni-compatible package manager command router with node shim
Documentation
use std::{
    env,
    path::Path,
    process::{Command, Stdio},
};

use semver::Version;

use super::{
    config::{DefaultAgent, HniConfig},
    error::{HniError, HniResult},
    pkg_json::read_package_json,
    types::{DetectionResult, DetectionSource, PackageManager},
};

const LOCKFILES: &[(&str, PackageManager)] = &[
    ("bun.lockb", PackageManager::Bun),
    ("bun.lock", PackageManager::Bun),
    ("pnpm-lock.yaml", PackageManager::Pnpm),
    ("yarn.lock", PackageManager::Yarn),
    ("package-lock.json", PackageManager::Npm),
    ("npm-shrinkwrap.json", PackageManager::Npm),
    ("deno.lock", PackageManager::Deno),
    ("deno.json", PackageManager::Deno),
    ("deno.jsonc", PackageManager::Deno),
];

pub fn detect(cwd: &Path, config: &HniConfig) -> HniResult<DetectionResult> {
    let ancestors = cwd.ancestors().collect::<Vec<_>>();
    let has_lock = ancestors
        .iter()
        .any(|dir| detect_lockfile_in_dir(dir).is_some());

    for dir in ancestors {
        let package_manager_hint = read_package_json(dir)?
            .and_then(|package_json| package_json.package_manager)
            .and_then(|raw| parse_package_manager_field(&raw));

        if let Some((pm, version_hint)) = package_manager_hint {
            return Ok(DetectionResult {
                agent: Some(pm),
                has_lock,
                version_hint,
                source: DetectionSource::PackageManagerField,
            });
        }

        if let Some(pm) = detect_lockfile_in_dir(dir) {
            return Ok(DetectionResult {
                agent: Some(pm),
                has_lock,
                version_hint: None,
                source: DetectionSource::Lockfile,
            });
        }
    }

    if let DefaultAgent::Agent(agent) = config.default_agent {
        return Ok(DetectionResult {
            agent: Some(agent),
            has_lock,
            version_hint: None,
            source: DetectionSource::Config,
        });
    }

    if which::which("npm").is_ok() {
        return Ok(DetectionResult {
            agent: Some(PackageManager::Npm),
            has_lock,
            version_hint: None,
            source: DetectionSource::Fallback,
        });
    }

    Ok(DetectionResult {
        agent: None,
        has_lock,
        version_hint: None,
        source: DetectionSource::None,
    })
}

fn detect_lockfile_in_dir(dir: &Path) -> Option<PackageManager> {
    LOCKFILES
        .iter()
        .find_map(|(lockfile, pm)| dir.join(lockfile).exists().then_some(*pm))
}

pub fn ensure_package_manager_available(
    pm: PackageManager,
    version_hint: Option<&str>,
    config: &HniConfig,
    cwd: &Path,
) -> HniResult<()> {
    if env::var_os("HNI_SKIP_PM_CHECK").is_some() {
        return Ok(());
    }

    if which::which(pm.bin()).is_ok() {
        return Ok(());
    }

    if !config.auto_install {
        let install_hint = format!("npm i -g {}", pm.global_package_name());
        return Err(HniError::detection(format!(
            "detected {} but it is not installed.\nTry: {install_hint}\nOr set HNI_AUTO_INSTALL=true",
            pm.display_name(),
        )));
    }

    if env::var_os("CI").is_some() {
        eprintln!("[hni] auto-installing {} in CI mode", pm.display_name());
    }

    let package = pm.global_package_name();
    if package == "npm" {
        return Err(HniError::detection(
            "npm is required for auto-install but was not found in PATH",
        ));
    }

    if matches!(pm, PackageManager::Deno) {
        return Err(HniError::detection(
            "auto-install for deno is not supported; install deno manually",
        ));
    }

    if which::which("npm").is_err() {
        return Err(HniError::detection(
            "auto-install requires npm in PATH, but npm is unavailable.\nInstall Node.js/npm first: https://nodejs.org/",
        ));
    }

    let target = match version_hint {
        Some(version) if !version.is_empty() => format!("{package}@{version}"),
        _ => package.to_string(),
    };

    let status = Command::new("npm")
        .args(["i", "-g", &target])
        .current_dir(cwd)
        .stdin(Stdio::inherit())
        .stdout(Stdio::inherit())
        .stderr(Stdio::inherit())
        .status()
        .map_err(|error| {
            HniError::detection(format!("failed to run npm for auto-install: {error}"))
        })?;

    if !status.success() {
        return Err(HniError::detection(format!(
            "auto-install failed for {} with exit code {:?}",
            pm.display_name(),
            status.code()
        )));
    }

    if which::which(pm.bin()).is_err() {
        return Err(HniError::detection(format!(
            "auto-install for {} completed but binary is still not in PATH",
            pm.display_name()
        )));
    }

    Ok(())
}

fn parse_package_manager_field(value: &str) -> Option<(PackageManager, Option<String>)> {
    let (name, version) = value.split_once('@')?;
    let lower = name.to_ascii_lowercase();

    let mut pm = PackageManager::from_name(&lower)?;
    if pm == PackageManager::Yarn && parse_major(version).is_some_and(|major| major >= 2) {
        pm = PackageManager::YarnBerry;
    }

    Some((pm, Some(version.to_string())))
}

fn parse_major(version: &str) -> Option<u64> {
    Version::parse(version)
        .map(|parsed| parsed.major)
        .ok()
        .or_else(|| {
            Version::parse(&format!("{version}.0.0"))
                .map(|parsed| parsed.major)
                .ok()
        })
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::core::config::HniConfig;
    use std::fs;
    use tempfile::tempdir;

    #[test]
    fn detects_package_manager_field_first() {
        let dir = tempdir().unwrap();
        fs::write(
            dir.path().join("package.json"),
            r#"{"packageManager":"pnpm@9.0.0"}"#,
        )
        .unwrap();

        let out = detect(dir.path(), &HniConfig::default()).unwrap();
        assert_eq!(out.agent, Some(PackageManager::Pnpm));
        assert_eq!(out.source, DetectionSource::PackageManagerField);
    }

    #[test]
    fn detects_lockfile_priority() {
        let dir = tempdir().unwrap();
        fs::write(dir.path().join("yarn.lock"), "x").unwrap();
        fs::write(dir.path().join("pnpm-lock.yaml"), "x").unwrap();

        let out = detect(dir.path(), &HniConfig::default()).unwrap();
        assert_eq!(out.agent, Some(PackageManager::Pnpm));
    }

    #[test]
    fn lockfile_priority_prefers_bun_when_multiple_lockfiles_exist() {
        let dir = tempdir().unwrap();
        fs::write(dir.path().join("package-lock.json"), "x").unwrap();
        fs::write(dir.path().join("pnpm-lock.yaml"), "x").unwrap();
        fs::write(dir.path().join("bun.lockb"), "x").unwrap();

        let out = detect(dir.path(), &HniConfig::default()).unwrap();
        assert_eq!(out.agent, Some(PackageManager::Bun));
    }

    #[test]
    fn package_manager_field_yarn_berry() {
        let parsed = parse_package_manager_field("yarn@4.2.1").unwrap();
        assert_eq!(parsed.0, PackageManager::YarnBerry);
    }

    #[test]
    fn package_manager_field_name_is_case_insensitive() {
        let parsed = parse_package_manager_field("PNPM@9.0.0").unwrap();
        assert_eq!(parsed.0, PackageManager::Pnpm);
        assert_eq!(parsed.1.as_deref(), Some("9.0.0"));
    }

    #[test]
    fn package_manager_field_short_major_yarn_is_berry() {
        let parsed = parse_package_manager_field("yarn@4").unwrap();
        assert_eq!(parsed.0, PackageManager::YarnBerry);
        assert_eq!(parsed.1.as_deref(), Some("4"));
    }

    #[test]
    fn package_manager_field_requires_version() {
        assert!(parse_package_manager_field("pnpm").is_none());
    }

    #[test]
    fn package_manager_field_unknown_manager_is_ignored() {
        assert!(parse_package_manager_field("foo@1.0.0").is_none());
    }

    #[test]
    fn yarn_lock_without_package_manager_stays_yarn_classic() {
        let dir = tempdir().unwrap();
        fs::write(dir.path().join("yarn.lock"), "lock").unwrap();
        fs::write(dir.path().join(".yarnrc.yml"), "nodeLinker: pnp\n").unwrap();

        let out = detect(dir.path(), &HniConfig::default()).unwrap();
        assert_eq!(out.agent, Some(PackageManager::Yarn));
    }

    #[test]
    fn detects_workspace_root_package_manager_from_subpackage() {
        let root = tempdir().unwrap();
        fs::write(
            root.path().join("package.json"),
            r#"{"packageManager":"pnpm@9.0.0","workspaces":["packages/*"]}"#,
        )
        .unwrap();
        fs::write(root.path().join("pnpm-lock.yaml"), "lock").unwrap();

        let pkg = root.path().join("packages").join("app");
        fs::create_dir_all(&pkg).unwrap();
        fs::write(pkg.join("package.json"), r#"{"name":"app"}"#).unwrap();

        let out = detect(&pkg, &HniConfig::default()).unwrap();
        assert_eq!(out.agent, Some(PackageManager::Pnpm));
        assert_eq!(out.source, DetectionSource::PackageManagerField);
        assert!(out.has_lock);
    }

    #[test]
    fn detects_workspace_lockfile_from_subpackage() {
        let root = tempdir().unwrap();
        fs::write(root.path().join("pnpm-lock.yaml"), "lock").unwrap();

        let pkg = root.path().join("packages").join("app");
        fs::create_dir_all(&pkg).unwrap();
        fs::write(pkg.join("package.json"), r#"{"name":"app"}"#).unwrap();

        let out = detect(&pkg, &HniConfig::default()).unwrap();
        assert_eq!(out.agent, Some(PackageManager::Pnpm));
        assert_eq!(out.source, DetectionSource::Lockfile);
        assert!(out.has_lock);
    }

    #[test]
    fn prefers_subpackage_lockfile_over_parent_package_manager() {
        let root = tempdir().unwrap();
        fs::write(
            root.path().join("package.json"),
            r#"{"packageManager":"pnpm@9.0.0"}"#,
        )
        .unwrap();

        let pkg = root.path().join("packages").join("app");
        fs::create_dir_all(&pkg).unwrap();
        fs::write(pkg.join("package.json"), r#"{"name":"app"}"#).unwrap();
        fs::write(pkg.join("package-lock.json"), "lock").unwrap();

        let out = detect(&pkg, &HniConfig::default()).unwrap();
        assert_eq!(out.agent, Some(PackageManager::Npm));
        assert_eq!(out.source, DetectionSource::Lockfile);
        assert!(out.has_lock);
    }

    #[test]
    fn has_lock_tracks_ancestor_lock_even_when_agent_is_from_subpackage_package_manager_field() {
        let root = tempdir().unwrap();
        fs::write(root.path().join("pnpm-lock.yaml"), "lock").unwrap();

        let pkg = root.path().join("packages").join("app");
        fs::create_dir_all(&pkg).unwrap();
        fs::write(
            pkg.join("package.json"),
            r#"{"name":"app","packageManager":"npm@10.0.0"}"#,
        )
        .unwrap();

        let out = detect(&pkg, &HniConfig::default()).unwrap();
        assert_eq!(out.agent, Some(PackageManager::Npm));
        assert_eq!(out.source, DetectionSource::PackageManagerField);
        assert!(out.has_lock);
    }

    #[test]
    fn detects_deno_from_deno_json() {
        let dir = tempdir().unwrap();
        fs::write(
            dir.path().join("deno.json"),
            r#"{"tasks":{"dev":"deno test"}}"#,
        )
        .unwrap();

        let out = detect(dir.path(), &HniConfig::default()).unwrap();
        assert_eq!(out.agent, Some(PackageManager::Deno));
        assert_eq!(out.source, DetectionSource::Lockfile);
        assert!(out.has_lock);
    }
}