hni 0.0.2

ni-compatible package manager command router with node shim
Documentation
use std::{ffi::OsStr, path::Path};

use crate::{
    core::{
        config::{DefaultAgent, HniConfig, RunAgent},
        detect::detect,
        types::DetectionSource,
    },
    platform::node::resolve_real_node_path,
};

pub fn print_doctor(cwd: &Path, config: &HniConfig) {
    let current_hni = std::env::current_exe()
        .ok()
        .map(|path| path.canonicalize().unwrap_or(path));
    let path_node = which::which("node").ok();
    let resolved_real_node = resolve_real_node_path().ok();

    println!("hni doctor");
    println!();
    println!("cwd: {}", cwd.display());
    println!(
        "current_hni: {}",
        current_hni
            .as_ref()
            .map_or_else(|| "unavailable".to_string(), |p| p.display().to_string())
    );
    println!(
        "path_node: {}",
        path_node
            .as_ref()
            .map_or_else(|| "missing".to_string(), |p| p.display().to_string())
    );
    println!(
        "real_node: {}",
        resolved_real_node
            .as_ref()
            .map_or_else(|| "unavailable".to_string(), |p| p.display().to_string())
    );
    println!(
        "shim_precedence_active: {}",
        shim_precedence_active(current_hni.as_deref(), path_node.as_deref())
    );
    println!();
    println!(
        "config_file: {}",
        config
            .config_path
            .as_ref()
            .map_or_else(|| "none".to_string(), |p| p.display().to_string())
    );
    println!(
        "defaultAgent: {}",
        format_default_agent(config.default_agent)
    );
    println!("globalAgent: {}", config.global_agent.display_name());
    println!("runAgent: {}", format_run_agent(config.run_agent));
    println!("useSfw: {}", config.use_sfw);
    println!("autoInstall(env): {}", config.auto_install);
    println!();

    match detect(cwd, config) {
        Ok(detection) => {
            println!(
                "detected_agent: {}",
                detection
                    .agent
                    .map_or_else(|| "none".to_string(), |pm| pm.display_name().to_string())
            );
            println!(
                "detection_source: {}",
                detection_source_label(detection.source)
            );
            println!("has_lockfile: {}", detection.has_lock);
            if let Some(version_hint) = detection.version_hint {
                println!("version_hint: {version_hint}");
            }
        }
        Err(err) => {
            println!("detection_error: {err}");
        }
    }

    println!();
    println!("package_manager_binaries:");
    for (label, bin) in [
        ("npm", "npm"),
        ("yarn", "yarn"),
        ("pnpm", "pnpm"),
        ("bun", "bun"),
        ("deno", "deno"),
    ] {
        let state = if which::which(bin).is_ok() {
            "ok"
        } else {
            "missing"
        };
        println!("  {label:<5} {state}");
    }
}

fn shim_precedence_active(current_hni: Option<&Path>, path_node: Option<&Path>) -> bool {
    let (Some(current_hni), Some(path_node)) = (current_hni, path_node) else {
        return false;
    };

    let Some(node_dir) = path_node.parent() else {
        return false;
    };
    let Some(hni_dir) = current_hni.parent() else {
        return false;
    };
    if !paths_equal(node_dir, hni_dir) {
        return false;
    }

    matches!(
        path_node.file_name().and_then(OsStr::to_str),
        Some("node") | Some("node.exe")
    )
}

fn paths_equal(a: &Path, b: &Path) -> bool {
    match (a.canonicalize(), b.canonicalize()) {
        (Ok(a), Ok(b)) => a == b,
        _ => a == b,
    }
}

fn format_default_agent(value: DefaultAgent) -> &'static str {
    match value {
        DefaultAgent::Prompt => "prompt",
        DefaultAgent::Agent(pm) => pm.display_name(),
    }
}

fn format_run_agent(value: RunAgent) -> &'static str {
    match value {
        RunAgent::PackageManager => "package-manager",
        RunAgent::Node => "node",
    }
}

fn detection_source_label(value: DetectionSource) -> &'static str {
    match value {
        DetectionSource::PackageManagerField => "packageManager field",
        DetectionSource::Lockfile => "lockfile",
        DetectionSource::Config => "config defaultAgent",
        DetectionSource::Fallback => "fallback (npm in PATH)",
        DetectionSource::None => "none",
    }
}