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-179..184 — `cleanlib risk-accept` hygiene bundle (v0.1.3).
//!
//! Behavioral gates: each test invokes the real `cleanlib` binary at the
//! process boundary and asserts the observable outcome (exit code / stderr /
//! files on disk), never source-grep. A PASS means the bug is fixed on this
//! build.

use std::process::Command;

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

/// Baseline: a fully-specified risk-accept with no --write-to succeeds (exit 0)
/// and emits the YAML to stdout. Guards against the validation over-rejecting.
#[test]
fn valid_risk_accept_succeeds_and_emits_yaml() {
    let out = Command::new(cleanlib_bin())
        .args([
            "risk-accept",
            "--ecosystem",
            "npm",
            "--package",
            "cors",
            "--version",
            "2.8.4",
            "--justification",
            "Upstream patch pending; mitigated by input sanitization.",
        ])
        .output()
        .expect("invoke cleanlib");
    assert!(
        out.status.success(),
        "valid risk-accept should exit 0; stderr: {}",
        String::from_utf8_lossy(&out.stderr)
    );
    let stdout = String::from_utf8_lossy(&out.stdout);
    assert!(
        stdout.contains("cors"),
        "YAML should carry the package; got: {stdout}"
    );
    // CLEANLIB-80: ecosystem field must appear in emitted YAML.
    assert!(
        stdout.contains("ecosystem: npm"),
        "YAML must carry the ecosystem so uploaded rules can be disambiguated \
         across registries; got: {stdout}"
    );
}

/// CLEANLIB-80: omitting --ecosystem is now a clap-level error (required
/// argument missing), not a silent emit of an ecosystem-less rule.
#[test]
fn cleanlib_80_missing_ecosystem_is_rejected() {
    let out = Command::new(cleanlib_bin())
        .args([
            "risk-accept",
            "--package",
            "cors",
            "--version",
            "2.8.4",
            "--justification",
            "j",
        ])
        .output()
        .expect("invoke cleanlib");
    assert!(
        !out.status.success(),
        "risk-accept without --ecosystem must exit non-zero; stdout: {}",
        String::from_utf8_lossy(&out.stdout)
    );
    // No silent emit — nothing useful on stdout.
    assert!(
        String::from_utf8_lossy(&out.stdout).trim().is_empty(),
        "missing --ecosystem must NOT emit a YAML record to stdout"
    );
    let stderr = String::from_utf8_lossy(&out.stderr).to_lowercase();
    assert!(
        stderr.contains("ecosystem"),
        "error message should name --ecosystem; got: {stderr}"
    );
}

/// CLEANLIB-80: an empty (whitespace-only) --ecosystem is rejected by the
/// same non-blank guard that protects --package / --version, not silently
/// accepted.
#[test]
fn cleanlib_80_empty_ecosystem_rejected() {
    assert_blank_rejected(
        "ecosystem",
        &[
            "risk-accept",
            "--ecosystem",
            "",
            "--package",
            "cors",
            "--version",
            "2.8.4",
            "--justification",
            "j",
        ],
        "ecosystem",
    );
}

// --- CLEANLIB-179/180/181: blank required fields rejected, not silently accepted ---

fn assert_blank_rejected(flag: &str, args: &[&str], needle: &str) {
    let out = Command::new(cleanlib_bin())
        .args(args)
        .output()
        .expect("invoke cleanlib");
    assert!(
        !out.status.success(),
        "empty --{flag} must exit non-zero (was silently accepted); stdout: {}",
        String::from_utf8_lossy(&out.stdout)
    );
    let stderr = String::from_utf8_lossy(&out.stderr).to_lowercase();
    assert!(
        stderr.contains(needle),
        "error should name --{flag}; got stderr: {stderr}"
    );
    // No silent accept: nothing useful should be emitted to stdout.
    assert!(
        String::from_utf8_lossy(&out.stdout).trim().is_empty(),
        "empty --{flag} must NOT emit a YAML record to stdout"
    );
}

#[test]
fn cleanlib_179_empty_justification_rejected() {
    assert_blank_rejected(
        "justification",
        &[
            "risk-accept",
            "--ecosystem",
            "npm",
            "--package",
            "cors",
            "--version",
            "2.8.4",
            "--justification",
            "",
        ],
        "justification",
    );
}

#[test]
fn cleanlib_180_empty_package_rejected() {
    assert_blank_rejected(
        "package",
        &[
            "risk-accept",
            "--ecosystem",
            "npm",
            "--package",
            "",
            "--version",
            "2.8.4",
            "--justification",
            "j",
        ],
        "package",
    );
}

#[test]
fn cleanlib_181_empty_version_rejected() {
    assert_blank_rejected(
        "version",
        &[
            "risk-accept",
            "--ecosystem",
            "npm",
            "--package",
            "cors",
            "--version",
            "",
            "--justification",
            "j",
        ],
        "version",
    );
}

// --- CLEANLIB-182: help text documents the bash `!` / single-quote workaround ---

#[test]
fn cleanlib_182_help_documents_single_quote_workaround() {
    let out = Command::new(cleanlib_bin())
        .args(["risk-accept", "--help"])
        .output()
        .expect("invoke cleanlib risk-accept --help");
    assert!(out.status.success(), "--help should exit 0");
    let help = String::from_utf8_lossy(&out.stdout);
    assert!(
        help.contains("single quote")
            || help.contains("SINGLE quote")
            || help.contains("single-quote"),
        "justification help should mention the single-quote workaround; got: {help}"
    );
    assert!(
        help.contains('!'),
        "justification help should mention the `!` history-expansion gotcha; got: {help}"
    );
}

// --- CLEANLIB-183: existing --write-to target backed up before overwrite ---

#[test]
fn cleanlib_183_overwrite_creates_timestamped_backup() {
    let dir = tempfile::tempdir().expect("tempdir");
    let target = dir.path().join("risk.yaml");

    let run = |just: &str| {
        Command::new(cleanlib_bin())
            .args([
                "risk-accept",
                "--ecosystem",
                "npm",
                "--package",
                "cors",
                "--version",
                "2.8.4",
                "--justification",
                just,
                "--write-to",
                target.to_str().unwrap(),
            ])
            .output()
            .expect("invoke cleanlib")
    };

    assert!(
        run("first rationale").status.success(),
        "first write should succeed"
    );
    assert!(
        run("second rationale").status.success(),
        "second write should succeed"
    );

    let backups: Vec<_> = std::fs::read_dir(dir.path())
        .unwrap()
        .filter_map(|e| e.ok())
        .map(|e| e.file_name().to_string_lossy().into_owned())
        .filter(|n| n.contains("cleanlib-backup"))
        .collect();
    assert_eq!(
        backups.len(),
        1,
        "second write must leave exactly one timestamped backup; dir had: {backups:?}"
    );
    // The backup preserves the FIRST write's content (not silently lost).
    let backup_path = dir.path().join(&backups[0]);
    let backup_body = std::fs::read_to_string(&backup_path).unwrap();
    assert!(
        backup_body.contains("first rationale"),
        "backup should preserve the prior record's content"
    );
    // The live file now holds the second write.
    let live = std::fs::read_to_string(&target).unwrap();
    assert!(
        live.contains("second rationale"),
        "live file holds the latest write"
    );
}

#[test]
fn cleanlib_183_force_skips_backup() {
    let dir = tempfile::tempdir().expect("tempdir");
    let target = dir.path().join("risk.yaml");
    let run = |force: bool| {
        let mut args = vec![
            "risk-accept",
            "--ecosystem",
            "npm",
            "--package",
            "cors",
            "--version",
            "2.8.4",
            "--justification",
            "rationale",
            "--write-to",
            target.to_str().unwrap(),
        ];
        if force {
            args.push("--force");
        }
        Command::new(cleanlib_bin())
            .args(args)
            .output()
            .expect("invoke cleanlib")
    };
    assert!(run(false).status.success());
    assert!(run(true).status.success(), "force overwrite should succeed");
    let backups = std::fs::read_dir(dir.path())
        .unwrap()
        .filter_map(|e| e.ok())
        .filter(|e| e.file_name().to_string_lossy().contains("cleanlib-backup"))
        .count();
    assert_eq!(backups, 0, "--force must NOT create a backup");
}

// --- CLEANLIB-184: missing target directory → clear error, not "Permission denied" ---

#[test]
fn cleanlib_184_missing_dir_reports_clearly_not_permission_denied() {
    let dir = tempfile::tempdir().expect("tempdir");
    let missing = dir
        .path()
        .join("does")
        .join("not")
        .join("exist")
        .join("risk.yaml");
    let out = Command::new(cleanlib_bin())
        .args([
            "risk-accept",
            "--ecosystem",
            "npm",
            "--package",
            "cors",
            "--version",
            "2.8.4",
            "--justification",
            "rationale",
            "--write-to",
            missing.to_str().unwrap(),
        ])
        .output()
        .expect("invoke cleanlib");
    assert!(
        !out.status.success(),
        "missing parent dir must exit non-zero"
    );
    let stderr = String::from_utf8_lossy(&out.stderr).to_lowercase();
    assert!(
        stderr.contains("does not exist"),
        "error should say the directory does not exist; got: {stderr}"
    );
    assert!(
        !stderr.contains("permission denied"),
        "error must NOT mislead with 'Permission denied'; got: {stderr}"
    );
}