cleanlib-cli 0.1.0

Terminal interface to CleanLibrary — query dependency verdicts and scan package manifests for ALLOW / DENY / WARN signals from the terminal or CI pipelines.
//! `npm install` / `npm i` / `npm add` arg parser.
//!
//! Extracts `WrappedPackage{ecosystem: "npm", name, version}` triples from
//! the positional args after the install verb. Flag-shaped args (`--save`,
//! `--global`, `-D` etc.) are skipped. Scoped names (`@org/pkg`) are
//! preserved verbatim — `cleanlib-client::transport` already handles the
//! `@scope/name` URL composition.

use super::WrappedPackage;

const ECO: &str = "npm";
const VERBS: &[&str] = &["install", "i", "add"];

/// Parse `argv` (the full `npm install ...` command, including the `npm`
/// binary name as `argv[0]`). Returns the set of `WrappedPackage`s the
/// install would target, or an empty `Vec` if no install verb is present
/// (in which case the wrapper should pass through to the unwrapped binary).
pub fn parse(argv: &[&str]) -> Vec<WrappedPackage> {
    let Some(verb_pos) = find_verb_pos(argv) else {
        return Vec::new();
    };
    let mut packages = Vec::new();
    let mut iter = argv.iter().skip(verb_pos + 1);
    while let Some(arg) = iter.next() {
        // Skip flag-shaped args; a small subset of flags take a value
        // (--registry=URL, --tag=TAG, --workspace=NAME).
        if let Some(stripped) = arg.strip_prefix("--") {
            // long flag — may consume next arg if no `=` (value-bearing flag)
            if !stripped.contains('=') && VALUE_FLAGS_LONG.contains(&stripped) {
                let _ = iter.next();
            }
            continue;
        }
        if arg.starts_with('-') {
            // short flag cluster (`-D`, `-g`, `-S`) — none of npm's short
            // flags take a separate value today (`-w` does; left as a known
            // edge case that ping-conditions to v0.1.1 per dispatch §10.4).
            continue;
        }
        // Empty arg guard (defensive against fuzz-style invocations).
        if arg.is_empty() {
            continue;
        }
        let (name, version) = split_pkg_spec(arg);
        packages.push(WrappedPackage::new(ECO, name, version));
    }
    packages
}

/// Long flags that take a separate-arg value (rather than `--key=value`).
/// Conservative list — superset is fine (just skips an extra arg), false
/// negatives drop a flag-value into the package list (worse — surface this
/// as a known v0.1.0 limitation per dispatch §10.4).
const VALUE_FLAGS_LONG: &[&str] = &[
    "registry",
    "tag",
    "workspace",
    "workspaces",
    "prefix",
    "userconfig",
    "globalconfig",
];

fn find_verb_pos(argv: &[&str]) -> Option<usize> {
    argv.iter().position(|a| VERBS.contains(a))
}

/// Split an npm package spec into (name, version). Scoped names are tricky
/// because `@scope/name@version` has *two* `@` signs — the version is what
/// follows the LAST `@` provided that `@` isn't the very first character.
pub fn split_pkg_spec(spec: &str) -> (String, String) {
    // Scoped name: `@scope/name` (no version) → ("@scope/name", "latest").
    // Scoped pinned: `@scope/name@1.2.3` → ("@scope/name", "1.2.3").
    // Bare: `lodash` → ("lodash", "latest").
    // Bare pinned: `lodash@4.17.21` → ("lodash", "4.17.21").
    if let Some(rest) = spec.strip_prefix('@') {
        // Scoped — look for `@` after the slash.
        match rest.split_once('/') {
            Some((scope, after_slash)) => match after_slash.split_once('@') {
                Some((name, ver)) => (format!("@{}/{}", scope, name), ver.to_string()),
                None => (format!("@{}/{}", scope, after_slash), "latest".to_string()),
            },
            // Malformed `@foo` (no slash) — pass through as-is; let the
            // verdict endpoint return an error to the user.
            None => (spec.to_string(), "latest".to_string()),
        }
    } else {
        match spec.rsplit_once('@') {
            Some((name, ver)) if !name.is_empty() && !ver.is_empty() => {
                (name.to_string(), ver.to_string())
            }
            _ => (spec.to_string(), "latest".to_string()),
        }
    }
}

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

    fn argv<'a>(args: &[&'a str]) -> Vec<&'a str> {
        let mut v: Vec<&'a str> = vec!["npm"];
        v.extend_from_slice(args);
        v
    }

    #[test]
    fn parses_install_single() {
        let a = argv(&["install", "lodash"]);
        let pkgs = parse(&a);
        assert_eq!(pkgs.len(), 1);
        assert_eq!(pkgs[0], WrappedPackage::new("npm", "lodash", "latest"));
    }

    #[test]
    fn parses_install_pinned() {
        let a = argv(&["install", "lodash@4.17.21"]);
        let pkgs = parse(&a);
        assert_eq!(pkgs.len(), 1);
        assert_eq!(pkgs[0], WrappedPackage::new("npm", "lodash", "4.17.21"));
    }

    #[test]
    fn parses_install_scoped_unpinned() {
        let a = argv(&["install", "@my-org/pkg"]);
        let pkgs = parse(&a);
        assert_eq!(pkgs[0], WrappedPackage::new("npm", "@my-org/pkg", "latest"));
    }

    #[test]
    fn parses_install_scoped_pinned() {
        let a = argv(&["install", "@my-org/pkg@1.2.3"]);
        let pkgs = parse(&a);
        assert_eq!(pkgs[0], WrappedPackage::new("npm", "@my-org/pkg", "1.2.3"));
    }

    #[test]
    fn parses_multiple_with_flags() {
        let a = argv(&["install", "--save", "-D", "cors", "express@4.18.0"]);
        let pkgs = parse(&a);
        assert_eq!(pkgs.len(), 2);
        assert_eq!(pkgs[0].name, "cors");
        assert_eq!(pkgs[1], WrappedPackage::new("npm", "express", "4.18.0"));
    }

    #[test]
    fn i_alias_works() {
        let a = argv(&["i", "left-pad"]);
        assert_eq!(parse(&a)[0].name, "left-pad");
    }

    #[test]
    fn add_alias_works() {
        let a = argv(&["add", "left-pad"]);
        assert_eq!(parse(&a)[0].name, "left-pad");
    }

    #[test]
    fn skips_value_bearing_long_flag() {
        let a = argv(&["install", "--registry", "https://registry.npmjs.org", "cors"]);
        let pkgs = parse(&a);
        assert_eq!(pkgs.len(), 1);
        assert_eq!(pkgs[0].name, "cors");
    }

    #[test]
    fn no_install_verb_returns_empty() {
        let a = argv(&["test"]);
        assert!(parse(&a).is_empty());
    }

    #[test]
    fn split_handles_bare() {
        assert_eq!(split_pkg_spec("lodash"), ("lodash".to_string(), "latest".to_string()));
    }
}