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.
//! `pip install` arg parser.
//!
//! pip's spec language uses PEP 440 version specifiers (`==`, `>=`, `<`,
//! `~=`, `!=`). For verdict gating we resolve a single concrete version per
//! spec — if the user pinned with `==`, we use that exact version; otherwise
//! the wrapper emits `"latest"` and the verdict endpoint resolves what that
//! means at App-side (per cycle-7 entry §2.6 — version-pinned else latest).

use super::WrappedPackage;

const ECO: &str = "pypi";
const VERB: &str = "install";

pub fn parse(argv: &[&str]) -> Vec<WrappedPackage> {
    let Some(verb_pos) = argv.iter().position(|a| *a == VERB) else {
        return Vec::new();
    };
    let mut packages = Vec::new();
    let mut iter = argv.iter().skip(verb_pos + 1);
    while let Some(arg) = iter.next() {
        // -e / --editable + path-or-url args we skip (not a registry install)
        if matches!(*arg, "-e" | "--editable") {
            let _ = iter.next();
            continue;
        }
        // -r / --requirement requirements.txt — beyond v0.1.0 scope
        if matches!(*arg, "-r" | "--requirement") {
            let _ = iter.next();
            continue;
        }
        // -c / --constraint
        if matches!(*arg, "-c" | "--constraint") {
            let _ = iter.next();
            continue;
        }
        // Other value-bearing pip flags
        if VALUE_FLAGS.contains(arg) {
            let _ = iter.next();
            continue;
        }
        if let Some(stripped) = arg.strip_prefix("--") {
            // long flag — may be a bool (`--upgrade`) or `--key=value`.
            // value-bearing-without-equals long flags are listed above; the
            // remaining long flags are skipped without consuming next arg.
            let _ = stripped;
            continue;
        }
        if arg.starts_with('-') {
            continue;
        }
        if arg.is_empty() {
            continue;
        }
        // Path-shaped args (`./local-pkg`, `/abs/path`) are not registry
        // installs — skip them.
        if arg.starts_with('.') || arg.starts_with('/') || arg.contains("://") {
            continue;
        }
        let (name, version) = split_pkg_spec(arg);
        packages.push(WrappedPackage::new(ECO, name, version));
    }
    packages
}

const VALUE_FLAGS: &[&str] = &[
    "-i",
    "--index-url",
    "--extra-index-url",
    "--find-links",
    "--target",
    "--platform",
    "--python-version",
    "--implementation",
    "--abi",
    "--prefix",
    "--user",
];

/// Split a PEP 508 / PEP 440-light spec into (name, version). Conservative:
/// only handles `==` exact pin; everything else falls back to `"latest"` and
/// the App-side verdict endpoint can resolve.
pub fn split_pkg_spec(spec: &str) -> (String, String) {
    // PEP 440 operators we'd recognize for pin extraction: `==`.
    if let Some((name, ver)) = spec.split_once("==") {
        // Strip optional whitespace pip allows (`requests == 2.32.5`).
        let name = name.trim();
        let ver = ver.trim();
        // Trim trailing extras like `[security]` from the name segment.
        let name = match name.split_once('[') {
            Some((bare, _)) => bare,
            None => name,
        };
        return (name.to_string(), ver.to_string());
    }
    // For other operators (`>=`, `~=`, `<`, `!=`, `>`) the spec isn't a
    // single concrete version — emit `latest` and document on the wrapper
    // dispatch path that the verdict applies to "the resolved version".
    // Strip any operator + version range to get the bare name.
    let mut name = spec.to_string();
    for op in ["~=", ">=", "<=", "!=", ">", "<"] {
        if let Some((n, _)) = name.split_once(op) {
            name = n.trim().to_string();
            break;
        }
    }
    // Strip extras `[security]`
    if let Some((bare, _)) = name.split_once('[') {
        name = bare.to_string();
    }
    (name, "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!["pip"];
        v.extend_from_slice(args);
        v
    }

    #[test]
    fn parses_exact_pin() {
        let a = argv(&["install", "requests==2.32.5"]);
        let pkgs = parse(&a);
        assert_eq!(pkgs[0], WrappedPackage::new("pypi", "requests", "2.32.5"));
    }

    #[test]
    fn parses_unpinned() {
        let a = argv(&["install", "django"]);
        let pkgs = parse(&a);
        assert_eq!(pkgs[0], WrappedPackage::new("pypi", "django", "latest"));
    }

    #[test]
    fn parses_range_returns_latest() {
        let a = argv(&["install", "django>=4.2"]);
        let pkgs = parse(&a);
        assert_eq!(pkgs[0], WrappedPackage::new("pypi", "django", "latest"));
    }

    #[test]
    fn skips_path_install() {
        let a = argv(&["install", "./my-local-pkg"]);
        assert!(parse(&a).is_empty());
    }

    #[test]
    fn skips_requirements_file() {
        let a = argv(&["install", "-r", "requirements.txt"]);
        assert!(parse(&a).is_empty());
    }

    #[test]
    fn strips_extras() {
        let a = argv(&["install", "requests[security]==2.32.5"]);
        let pkgs = parse(&a);
        assert_eq!(pkgs[0].name, "requests");
        assert_eq!(pkgs[0].version, "2.32.5");
    }

    #[test]
    fn skips_value_flag() {
        let a = argv(&["install", "--index-url", "https://pypi.org/simple", "requests==2.32.5"]);
        let pkgs = parse(&a);
        assert_eq!(pkgs.len(), 1);
        assert_eq!(pkgs[0].name, "requests");
    }

    #[test]
    fn multiple_packages() {
        let a = argv(&["install", "django>=4.2", "celery==5.3.0"]);
        let pkgs = parse(&a);
        assert_eq!(pkgs.len(), 2);
        assert_eq!(pkgs[0].name, "django");
        assert_eq!(pkgs[1], WrappedPackage::new("pypi", "celery", "5.3.0"));
    }

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