cleanlib-cli 0.1.1

Terminal interface to CleanLibrary — query dependency verdicts and scan package manifests for ALLOW / DENY / WARN signals from the terminal or CI pipelines.
//! `cargo add` arg parser.
//!
//! cargo's add syntax: `cargo add <crate>[@version]` or `cargo add
//! <crate> --vers <version>`. We support the first (positional pinning) and
//! also `--version` / `-v` flags.

use super::WrappedPackage;

const ECO: &str = "crates";
const VERB: &str = "add";

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<WrappedPackage> = Vec::new();
    let mut overridden_version: Option<String> = None;
    let mut path_or_git_install = false;
    let mut iter = argv.iter().skip(verb_pos + 1);
    while let Some(arg) = iter.next() {
        // value-bearing flags — applies to the entire `cargo add` invocation
        // (cargo add takes ONE positional crate per call; `--version=X`
        // pins that crate regardless of whether the flag appears before or
        // after the positional).
        if matches!(*arg, "--version" | "-v" | "--vers") {
            if let Some(v) = iter.next() {
                overridden_version = Some((*v).to_string());
            }
            continue;
        }
        if let Some(rest) = arg.strip_prefix("--version=") {
            overridden_version = Some(rest.to_string());
            continue;
        }
        if matches!(
            *arg,
            "--path" | "--git" | "--branch" | "--tag" | "--rev"
        ) {
            // path/git installs are not registry packages — mark and skip.
            path_or_git_install = true;
            let _ = iter.next();
            continue;
        }
        if matches!(*arg, "--registry" | "--features" | "--rename") {
            // value-bearing flags we don't care about for verdict gating
            let _ = iter.next();
            continue;
        }
        if let Some(stripped) = arg.strip_prefix("--") {
            // boolean long flag (`--dev`, `--build`, `--optional`)
            let _ = stripped;
            continue;
        }
        if arg.starts_with('-') {
            continue;
        }
        if arg.is_empty() {
            continue;
        }
        let (name, version) = split_pkg_spec(arg);
        packages.push(WrappedPackage::new(ECO, name, version));
    }
    // If the `--version` flag appeared (in any position), override the
    // single resulting positional's version.
    if let Some(v) = overridden_version {
        if let Some(last) = packages.last_mut() {
            last.version = v;
        }
    }
    if path_or_git_install {
        // Path or git install — not a registry install; drop everything.
        packages.clear();
    }
    packages
}

pub fn split_pkg_spec(spec: &str) -> (String, String) {
    match spec.split_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!["cargo"];
        v.extend_from_slice(args);
        v
    }

    #[test]
    fn parses_add_unpinned() {
        let a = argv(&["add", "serde"]);
        let pkgs = parse(&a);
        assert_eq!(pkgs[0], WrappedPackage::new("crates", "serde", "latest"));
    }

    #[test]
    fn parses_add_at_version() {
        let a = argv(&["add", "serde@1.0.200"]);
        let pkgs = parse(&a);
        assert_eq!(pkgs[0], WrappedPackage::new("crates", "serde", "1.0.200"));
    }

    #[test]
    fn parses_add_version_flag() {
        let a = argv(&["add", "serde", "--version", "1.0.200"]);
        let pkgs = parse(&a);
        assert_eq!(pkgs[0], WrappedPackage::new("crates", "serde", "1.0.200"));
    }

    #[test]
    fn parses_add_version_equals_flag() {
        let a = argv(&["add", "serde", "--version=1.0.200"]);
        let pkgs = parse(&a);
        assert_eq!(pkgs[0].version, "1.0.200");
    }

    #[test]
    fn skips_path_add() {
        let a = argv(&["add", "--path", "./local-crate"]);
        assert!(parse(&a).is_empty());
    }

    #[test]
    fn skips_git_add() {
        let a = argv(&["add", "--git", "https://github.com/serde-rs/serde.git"]);
        assert!(parse(&a).is_empty());
    }

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