cleanlib-cli 0.1.5

Terminal interface to CleanLibrary — query dependency verdicts and scan package manifests for ALLOW / DENY / WARN signals from the terminal or CI pipelines.
//! CLEANLIB-211..214 — `cleanlib fix` autonomous remediation.
//!
//! Behavioral tests exercise the real `cleanlib` binary at the process
//! boundary. Verdict resolution is mocked via `wiremock`; the test drives the
//! CLI with `CLEANLIBRARY_ENDPOINT` pointed at the mock so no live network
//! access is required.

use std::path::PathBuf;
use std::process::Command;

use wiremock::matchers::{method, path_regex};
use wiremock::{Mock, MockServer, ResponseTemplate};

fn cleanlib_bin() -> PathBuf {
    PathBuf::from(std::env!("CARGO_BIN_EXE_cleanlib"))
}

fn unique_tmp_dir(tag: &str) -> PathBuf {
    let ns = std::time::SystemTime::now()
        .duration_since(std::time::UNIX_EPOCH)
        .unwrap()
        .as_nanos();
    let dir = std::env::temp_dir().join(format!("cleanlib-fix-test-{}-{}", tag, ns));
    std::fs::create_dir_all(&dir).unwrap();
    dir
}

fn write_file(path: &std::path::Path, content: &str) {
    std::fs::write(path, content).unwrap();
}

async fn mock_verdicts(recipes: &[(&str, &str, &str, &str, &str)]) -> MockServer {
    let server = MockServer::start().await;
    for (eco, name, ver, decision, rec) in recipes {
        let resp_body = serde_json::json!({
            "verdict_id": format!("01T-{}-{}", name, ver),
            "verdict": if *decision == "ALLOW" { "ALLOWED_NO_FINDINGS" } else { "VECTOR_VERDICT" },
            "source": "test",
            "confidence": 0.99,
            "composite_score": if *decision == "ALLOW" { 5 } else { 95 },
            "reasoning": format!("fixture: {} {}", name, decision),
            "decision": decision,
            "suggested_actions": if rec.is_empty() {
                Vec::<String>::new()
            } else {
                vec![format!("recommended_version={}", rec)]
            },
            "computed_at": "2026-06-30T00:00:00Z"
        });
        let pattern = format!(
            r"^/v1/customer/verdicts/{}/{}/{}$",
            regex_escape(eco),
            regex_escape(name),
            regex_escape(ver)
        );
        Mock::given(method("GET"))
            .and(path_regex(&pattern))
            .respond_with(ResponseTemplate::new(200).set_body_json(resp_body))
            .mount(&server)
            .await;
    }
    server
}

fn regex_escape(s: &str) -> String {
    let mut out = String::with_capacity(s.len() + 8);
    for c in s.chars() {
        match c {
            '.' | '+' | '*' | '?' | '(' | ')' | '[' | ']' | '{' | '}' | '|' | '\\' | '^' | '$'
            | '/' => {
                out.push('\\');
                out.push(c);
            }
            _ => out.push(c),
        }
    }
    out
}

fn run_fix(endpoint: &str, lockfile: &std::path::Path, extra: &[&str]) -> std::process::Output {
    Command::new(cleanlib_bin())
        .env("CLEANLIBRARY_ENDPOINT", endpoint)
        .env("CLEANLIBRARY_API_KEY", "test-bearer")
        .args(["fix", "--lockfile", lockfile.to_str().unwrap()])
        .args(extra)
        .output()
        .expect("invoke cleanlib fix")
}

#[tokio::test]
async fn cleanlib_211_dry_run_reports_upgrade_and_does_not_write() {
    let dir = unique_tmp_dir("211-dry");
    let lockfile = dir.join("package-lock.json");
    write_file(
        &lockfile,
        r#"{
  "name": "app",
  "lockfileVersion": 3,
  "packages": {
    "": { "name": "app", "version": "1.0.0" },
    "node_modules/cors": { "version": "2.8.4" },
    "node_modules/lodash": { "version": "4.17.21" }
  }
}"#,
    );
    let original_content = std::fs::read_to_string(&lockfile).unwrap();

    let server = mock_verdicts(&[
        ("npm", "cors", "2.8.4", "DENY", "2.8.5"),
        ("npm", "lodash", "4.17.21", "ALLOW", ""),
    ])
    .await;

    let out = run_fix(&server.uri(), &lockfile, &[]);
    assert_eq!(
        out.status.code(),
        Some(4),
        "dry-run with pending upgrade must exit 4; stderr: {}",
        String::from_utf8_lossy(&out.stderr)
    );

    let stdout = String::from_utf8_lossy(&out.stdout);
    assert!(stdout.contains("DRY RUN"), "{}", stdout);
    assert!(stdout.contains("cors"), "{}", stdout);
    assert!(stdout.contains("2.8.4 → 2.8.5"), "{}", stdout);
    assert!(stdout.contains("blast radius") || stdout.contains("blast"), "{}", stdout);
    assert!(stdout.contains("verdict ALLOW"), "{}", stdout);

    let after = std::fs::read_to_string(&lockfile).unwrap();
    assert_eq!(after, original_content, "dry-run must not mutate lockfile");
}

#[tokio::test]
async fn cleanlib_214_apply_flag_mutates_lockfile() {
    let dir = unique_tmp_dir("214-apply");
    let lockfile = dir.join("package-lock.json");
    write_file(
        &lockfile,
        r#"{
  "name": "app",
  "lockfileVersion": 3,
  "packages": {
    "": { "name": "app", "version": "1.0.0" },
    "node_modules/cors": { "version": "2.8.4" }
  }
}"#,
    );

    let server = mock_verdicts(&[("npm", "cors", "2.8.4", "DENY", "2.8.5")]).await;

    let out = run_fix(&server.uri(), &lockfile, &["--apply"]);
    assert!(
        out.status.success(),
        "stderr: {}",
        String::from_utf8_lossy(&out.stderr)
    );

    let after = std::fs::read_to_string(&lockfile).unwrap();
    assert!(after.contains("\"2.8.5\""));
    assert!(!after.contains("\"2.8.4\""));

    let backup_present = std::fs::read_dir(&dir).unwrap().any(|e| {
        e.unwrap()
            .file_name()
            .to_string_lossy()
            .contains("cleanlib-backup-")
    });
    assert!(backup_present, "backup file must exist next to lockfile");
}

#[tokio::test]
async fn cleanlib_213_risk_accept_yaml_suppresses_matching_upgrade() {
    let dir = unique_tmp_dir("213-risk");
    let lockfile = dir.join("package-lock.json");
    write_file(
        &lockfile,
        r#"{
  "name": "app",
  "lockfileVersion": 3,
  "packages": {
    "": { "name": "app", "version": "1.0.0" },
    "node_modules/cors": { "version": "2.8.4" }
  }
}"#,
    );

    let risk_yaml = dir.join("risk.yaml");
    write_file(
        &risk_yaml,
        "risk_accepted:\n  - package: cors\n    version_range: \"<2.8.5\"\n    justification: \"waiver\"\n",
    );

    let server = mock_verdicts(&[("npm", "cors", "2.8.4", "DENY", "2.8.5")]).await;

    let out = run_fix(
        &server.uri(),
        &lockfile,
        &["--risk-accept", risk_yaml.to_str().unwrap()],
    );
    assert!(
        out.status.success(),
        "got {:?}, stderr: {}",
        out.status.code(),
        String::from_utf8_lossy(&out.stderr)
    );
    let stdout = String::from_utf8_lossy(&out.stdout);
    assert!(stdout.contains("risk-accepted"), "{}", stdout);
    assert!(
        stdout.contains("no upgrades") || stdout.contains("planned: 0"),
        "{}",
        stdout
    );
}

#[tokio::test]
async fn cleanlib_214_allow_list_filters_upgrades() {
    let dir = unique_tmp_dir("214-allow");
    let lockfile = dir.join("package-lock.json");
    write_file(
        &lockfile,
        r#"{
  "name": "app",
  "lockfileVersion": 3,
  "packages": {
    "": { "name": "app", "version": "1.0.0" },
    "node_modules/cors": { "version": "2.8.4" },
    "node_modules/lodash": { "version": "4.17.15" }
  }
}"#,
    );

    let allow_list = dir.join("allow.txt");
    write_file(&allow_list, "# only cors permitted\ncors\n");

    let server = mock_verdicts(&[
        ("npm", "cors", "2.8.4", "DENY", "2.8.5"),
        ("npm", "lodash", "4.17.15", "DENY", "4.17.21"),
    ])
    .await;

    let out = run_fix(
        &server.uri(),
        &lockfile,
        &["--allow-list", allow_list.to_str().unwrap()],
    );
    assert_eq!(out.status.code(), Some(4));
    let stdout = String::from_utf8_lossy(&out.stdout);
    assert!(stdout.contains("cors"), "{}", stdout);
    assert!(
        stdout.contains("not in --allow-list") || stdout.contains("allow-list"),
        "{}",
        stdout
    );
    assert!(stdout.contains("planned: 1"), "{}", stdout);
}

#[tokio::test]
async fn cleanlib_212_auto_pr_emits_bundle_without_touching_tree() {
    let dir = unique_tmp_dir("212-pr");
    let lockfile = dir.join("package-lock.json");
    write_file(
        &lockfile,
        r#"{
  "name": "app",
  "lockfileVersion": 3,
  "packages": {
    "": { "name": "app", "version": "1.0.0" },
    "node_modules/cors": { "version": "2.8.4" }
  }
}"#,
    );
    let original = std::fs::read_to_string(&lockfile).unwrap();
    let server = mock_verdicts(&[("npm", "cors", "2.8.4", "DENY", "2.8.5")]).await;

    let out = run_fix(
        &server.uri(),
        &lockfile,
        &[
            "--auto-pr",
            "--pr-remote",
            "git@github.com:acme/app.git",
            "--apply",
        ],
    );
    assert!(out.status.success());
    let stdout = String::from_utf8_lossy(&out.stdout);
    assert!(
        stdout.contains("auto-pr bundle") || stdout.contains("Title: cleanlib fix"),
        "{}",
        stdout
    );
    assert!(stdout.contains("git@github.com:acme/app.git"), "{}", stdout);
    assert!(stdout.contains("2.8.4 → 2.8.5"), "{}", stdout);

    let after = std::fs::read_to_string(&lockfile).unwrap();
    assert_eq!(after, original);
}

#[tokio::test]
async fn cleanlib_214_unknown_lockfile_kind_is_rejected() {
    let dir = unique_tmp_dir("214-unknown");
    let lockfile = dir.join("random.txt");
    write_file(&lockfile, "some content");
    let out = Command::new(cleanlib_bin())
        .env("CLEANLIBRARY_ENDPOINT", "https://cleanlibrary.clnstrt.dev")
        .env("CLEANLIBRARY_API_KEY", "test-bearer")
        .args(["fix", "--lockfile", lockfile.to_str().unwrap()])
        .output()
        .expect("invoke cleanlib fix");
    assert!(!out.status.success());
    let stderr = String::from_utf8_lossy(&out.stderr).to_lowercase();
    assert!(
        stderr.contains("unrecognized lockfile") || stderr.contains("supported"),
        "{}",
        stderr
    );
}