repoke 0.3.0

Finds the package managers on your system and keeps them up to date, interactively or unattended
use std::collections::BTreeMap;
use std::path::Path;

use serde::Deserialize;

use crate::manager::{Installed, Manager, Package, Step, capture, writable};

pub struct Npm;

impl Manager for Npm {
    fn id(&self) -> &'static str {
        "npm"
    }

    fn name(&self) -> &'static str {
        "npm (global)"
    }

    fn binary(&self) -> &'static str {
        "npm"
    }

    fn outdated(&self, bin: &Path) -> Result<Vec<Package>, String> {
        let json = capture(bin, ["outdated", "--global", "--json"])?.expect(&[0, 1])?;
        parse_outdated(&json)
    }

    fn upgrade_steps(&self, _bin: &Path, packages: &[Package]) -> Vec<Step> {
        vec![install("install", packages)]
    }

    /// A system Node (e.g. /usr/local) keeps globals in a root-owned prefix; fnm and nvm don't.
    fn needs_root(&self, bin: &Path) -> bool {
        if cfg!(windows) {
            return false;
        }
        match capture(bin, ["prefix", "--global"]).and_then(|o| o.expect(&[0])) {
            Ok(prefix) => !writable(&Path::new(prefix.trim()).join("lib/node_modules")),
            Err(_) => false,
        }
    }

    fn incompatible(&self, bin: &Path, packages: &[Package]) -> Vec<(Package, String)> {
        super::engines::incompatible(bin, packages)
    }

    fn installed(&self, bin: &Path) -> Result<Vec<Installed>, String> {
        let json = capture(bin, ["ls", "--global", "--depth=0", "--json"])?.expect(&[0])?;
        parse_globals(&json)
    }

    fn install_steps(&self, _bin: &Path, packages: &[Installed]) -> Vec<Step> {
        Step::install_all(&["install", "--global"], packages)
    }

    fn config_files(&self) -> &'static [&'static str] {
        &[".npmrc"]
    }
}

/// `update -g` stays within the current major, so globals are reinstalled at the version
/// `outdated` showed. `@latest` could resolve to a newer release that the running Node rejects.
pub(super) fn install(verb: &str, packages: &[Package]) -> Step {
    let specs = packages.iter().map(|p| match p.latest.as_str() {
        "" => format!("{}@latest", p.name),
        version => format!("{}@{version}", p.name),
    });
    Step::new(
        [verb.to_string(), "--global".to_string()]
            .into_iter()
            .chain(specs),
        packages,
    )
}

#[derive(Deserialize)]
struct Entry {
    current: Option<String>,
    latest: Option<String>,
}

/// pnpm prints the same shape, keyed by package name.
pub(super) fn parse_outdated(json: &str) -> Result<Vec<Package>, String> {
    if json.trim().is_empty() {
        return Ok(Vec::new());
    }
    let entries: BTreeMap<String, Entry> =
        serde_json::from_str(json).map_err(|e| format!("unexpected output: {e}"))?;
    Ok(entries
        .into_iter()
        .map(|(name, entry)| Package {
            name,
            current: entry.current.unwrap_or_default(),
            latest: entry.latest.unwrap_or_default(),
        })
        .collect())
}

#[derive(Deserialize)]
pub(super) struct Globals {
    #[serde(default)]
    dependencies: BTreeMap<String, Dependency>,
}

#[derive(Deserialize)]
struct Dependency {
    version: Option<String>,
}

impl Globals {
    /// npm and corepack ship with Node itself; pnpm lists itself when it was added globally.
    pub(super) fn packages(self) -> Vec<Installed> {
        self.dependencies
            .into_iter()
            .filter(|(name, _)| !matches!(name.as_str(), "npm" | "corepack" | "pnpm"))
            .map(|(name, dep)| Installed::new(name).version(dep.version.unwrap_or_default()))
            .collect()
    }
}

pub(super) fn parse_globals(json: &str) -> Result<Vec<Installed>, String> {
    serde_json::from_str::<Globals>(json)
        .map(Globals::packages)
        .map_err(|e| format!("unexpected npm output: {e}"))
}

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

    #[test]
    fn parses_outdated_globals() {
        let json = r#"{
            "corepack": {"current": "0.30.0", "wanted": "0.34.7", "latest": "0.34.7", "dependent": "global"},
            "eslint": {"current": "9.20.0", "wanted": "10.13.0", "latest": "10.13.0", "dependent": "global"}
        }"#;
        let packages = parse_outdated(json).unwrap();
        assert_eq!(packages.len(), 2);
        assert_eq!(
            packages[0],
            Package {
                name: "corepack".into(),
                current: "0.30.0".into(),
                latest: "0.34.7".into()
            }
        );
    }

    #[test]
    fn empty_output_means_up_to_date() {
        assert!(parse_outdated("").unwrap().is_empty());
        assert!(parse_outdated("{}").unwrap().is_empty());
    }

    fn package(name: &str, latest: &str) -> Package {
        Package {
            name: name.into(),
            current: "0.0.1".into(),
            latest: latest.into(),
        }
    }

    #[test]
    fn installs_the_reported_version() {
        let step = install(
            "install",
            &[
                package("@google/gemini-cli", "0.66.0"),
                package("eslint", ""),
            ],
        );
        assert_eq!(
            step.commands[0],
            [
                "install",
                "--global",
                "@google/gemini-cli@0.66.0",
                "eslint@latest"
            ]
        );
        assert_eq!(step.packages.len(), 2);
    }

    #[test]
    fn rejects_error_payload() {
        assert!(parse_outdated(r#"{"error": "boom"}"#).is_err());
    }
}