hni 0.0.2

ni-compatible package manager command router with node shim
Documentation
use dialoguer::{FuzzySelect, Input, Select, theme::ColorfulTheme};
use reqwest::blocking::Client;
use serde::Deserialize;
use std::time::Duration;

use crate::core::{
    error::{HniError, HniResult},
    resolve::ResolveContext,
    types::PackageManager,
};

#[derive(Debug, Deserialize)]
struct NpmResponse {
    objects: Vec<NpmEntry>,
}

#[derive(Debug, Deserialize)]
struct NpmEntry {
    package: NpmPackage,
}

#[derive(Debug, Deserialize)]
struct NpmPackage {
    name: String,
    version: String,
    description: Option<String>,
}

pub fn augment_ni_args_interactive(
    args: Vec<String>,
    ctx: &ResolveContext,
) -> HniResult<Vec<String>> {
    let is_interactive = interactive_requested(&args);
    if !is_interactive {
        return Ok(args);
    }

    let args = strip_interactive_flags(args);

    let pattern = args
        .iter()
        .find(|v| !v.starts_with('-'))
        .cloned()
        .map_or_else(prompt_pattern, Ok)?;

    if pattern.trim().is_empty() {
        return Err(HniError::interactive(
            "interactive install requires a package search pattern",
        ));
    }

    let packages = fetch_npm_packages(&pattern)?;
    if packages.is_empty() {
        return Err(HniError::interactive(format!(
            "no npm packages found for pattern '{pattern}'"
        )));
    }

    let labels: Vec<String> = packages
        .iter()
        .map(|pkg| {
            let description = pkg.description.as_deref().unwrap_or("");
            format!("{}  v{}  {}", pkg.name, pkg.version, description)
        })
        .collect();

    let idx = FuzzySelect::with_theme(&ColorfulTheme::default())
        .with_prompt("Choose package to install")
        .items(&labels)
        .default(0)
        .interact_opt()
        .map_err(|error| {
            HniError::interactive(format!("failed to read package selection: {error}"))
        })?
        .ok_or_else(|| HniError::interactive("package selection canceled"))?;

    let chosen = &packages[idx].name;

    let agent = crate::core::resolve::detected_package_manager(ctx)?;
    let mode_labels = mode_labels_for(agent);
    let mode_idx = Select::with_theme(&ColorfulTheme::default())
        .with_prompt(format!("Install {chosen} as"))
        .items(&mode_labels)
        .default(0)
        .interact()
        .map_err(|error| HniError::interactive(format!("failed to read install mode: {error}")))?;

    Ok(apply_selected_package(args, chosen, mode_labels[mode_idx]))
}

fn interactive_requested(args: &[String]) -> bool {
    args.iter()
        .any(|arg| matches!(arg.as_str(), "-i" | "--interactive"))
}

fn strip_interactive_flags(args: Vec<String>) -> Vec<String> {
    args.into_iter()
        .filter(|arg| !matches!(arg.as_str(), "-i" | "--interactive"))
        .collect()
}

fn mode_labels_for(agent: PackageManager) -> Vec<&'static str> {
    if matches!(agent, PackageManager::Npm | PackageManager::Pnpm) {
        vec!["prod", "dev", "peer"]
    } else {
        vec!["prod", "dev"]
    }
}

fn apply_selected_package(mut args: Vec<String>, chosen: &str, mode: &str) -> Vec<String> {
    let mut removed_search_query = false;
    args.retain(|arg| {
        if !removed_search_query && !arg.starts_with('-') {
            removed_search_query = true;
            return false;
        }
        !matches!(
            arg.as_str(),
            "-d" | "-D" | "-p" | "--dev" | "--peer" | "--save-dev" | "--save-peer"
        )
    });
    args.push(chosen.to_string());

    match mode {
        "dev" => args.push("-D".to_string()),
        "peer" => args.push("--save-peer".to_string()),
        _ => {}
    }

    args
}

fn prompt_pattern() -> HniResult<String> {
    Input::with_theme(&ColorfulTheme::default())
        .with_prompt("search for package")
        .interact_text()
        .map_err(|error| {
            HniError::interactive(format!(
                "failed to read interactive search pattern: {error}"
            ))
        })
}

fn fetch_npm_packages(pattern: &str) -> HniResult<Vec<NpmPackage>> {
    let client = Client::builder()
        .connect_timeout(Duration::from_secs(5))
        .timeout(Duration::from_secs(10))
        .build()
        .map_err(|error| {
            HniError::network(format!("failed to create npm registry client: {error}"))
        })?;

    let url = npm_search_url(pattern)?;

    let response = client
        .get(url)
        .send()
        .map_err(|error| HniError::network(format!("failed to query npm registry: {error}")))?
        .error_for_status()
        .map_err(|error| HniError::network(format!("npm registry returned an error: {error}")))?;

    let parsed = response.json::<NpmResponse>().map_err(|error| {
        HniError::network(format!("failed to parse npm registry response: {error}"))
    })?;

    Ok(parsed.objects.into_iter().map(|obj| obj.package).collect())
}

fn npm_search_url(pattern: &str) -> HniResult<reqwest::Url> {
    reqwest::Url::parse_with_params(
        "https://registry.npmjs.com/-/v1/search",
        [("text", pattern), ("size", "35")],
    )
    .map_err(|error| HniError::network(format!("failed to build npm search URL: {error}")))
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::core::{config::HniConfig, resolve::ResolveContext};
    use std::path::PathBuf;

    #[test]
    fn detects_interactive_request_from_short_or_long_flag() {
        assert!(interactive_requested(&["-i".to_string()]));
        assert!(interactive_requested(&["--interactive".to_string()]));
        assert!(!interactive_requested(&["react".to_string()]));
    }

    #[test]
    fn strips_interactive_flags_only() {
        let out = strip_interactive_flags(vec![
            "react".to_string(),
            "-i".to_string(),
            "--interactive".to_string(),
            "-D".to_string(),
        ]);
        assert_eq!(out, vec!["react", "-D"]);
    }

    #[test]
    fn mode_labels_include_peer_only_for_npm_and_pnpm() {
        assert_eq!(
            mode_labels_for(PackageManager::Npm),
            vec!["prod", "dev", "peer"]
        );
        assert_eq!(
            mode_labels_for(PackageManager::Pnpm),
            vec!["prod", "dev", "peer"]
        );
        assert_eq!(mode_labels_for(PackageManager::Yarn), vec!["prod", "dev"]);
        assert_eq!(
            mode_labels_for(PackageManager::YarnBerry),
            vec!["prod", "dev"]
        );
        assert_eq!(mode_labels_for(PackageManager::Bun), vec!["prod", "dev"]);
        assert_eq!(mode_labels_for(PackageManager::Deno), vec!["prod", "dev"]);
    }

    #[test]
    fn apply_selected_package_replaces_search_query_and_dev_mode_flags() {
        let out = apply_selected_package(
            vec![
                "--frozen".to_string(),
                "react".to_string(),
                "--save-peer".to_string(),
                "-d".to_string(),
                "--peer".to_string(),
                "@types/react".to_string(),
            ],
            "vue",
            "prod",
        );
        assert_eq!(out, vec!["--frozen", "@types/react", "vue"]);
    }

    #[test]
    fn apply_selected_package_adds_dev_flag() {
        let out = apply_selected_package(vec!["react".to_string()], "vue", "dev");
        assert_eq!(out, vec!["vue", "-D"]);
    }

    #[test]
    fn apply_selected_package_adds_peer_flag() {
        let out = apply_selected_package(vec!["react".to_string()], "vue", "peer");
        assert_eq!(out, vec!["vue", "--save-peer"]);
    }

    #[test]
    fn non_interactive_call_is_passthrough() {
        let ctx = ResolveContext::new(PathBuf::from("."), HniConfig::default());
        let input = vec!["react".to_string(), "-D".to_string()];
        let out = augment_ni_args_interactive(input.clone(), &ctx).unwrap();
        assert_eq!(out, input);
    }

    #[test]
    fn npm_search_url_encodes_pattern() {
        let url = npm_search_url("react+dom @types").unwrap();
        assert_eq!(
            url.as_str(),
            "https://registry.npmjs.com/-/v1/search?text=react%2Bdom+%40types&size=35"
        );
    }
}