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-157 — `cleanlib config init` multi-ecosystem `--write-to` hygiene.
//!
//! When several ecosystems share one `--write-to` path, every snippet must land
//! in the file (truncate once, then append). A pre-existing target gets a
//! single timestamped backup for the whole run, not one per ecosystem.

use std::process::Command;

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

#[test]
fn cleanlib_157_shared_write_to_accumulates_all_ecosystems() {
    let dir = tempfile::tempdir().expect("tempdir");
    let target = dir.path().join("proxy.conf");

    let out = Command::new(cleanlib_bin())
        .args([
            "config",
            "init",
            "--ecosystem",
            "npm,pypi",
            "--write-to",
            target.to_str().unwrap(),
        ])
        .output()
        .expect("invoke cleanlib");

    assert!(
        out.status.success(),
        "config init should exit 0; stderr: {}",
        String::from_utf8_lossy(&out.stderr)
    );

    let body = std::fs::read_to_string(&target).unwrap();
    assert!(
        body.contains("registry=") && body.contains("always-auth=true"),
        "npm snippet must survive in shared file; got: {body}"
    );
    assert!(
        body.contains("[global]") && body.contains("index-url"),
        "pypi snippet must be appended after npm; got: {body}"
    );
    assert!(
        body.find("registry=").unwrap() < body.find("[global]").unwrap(),
        "npm block should precede pypi block (truncate-then-append order); got: {body}"
    );
}

// ── CLEANLIB-373 + CLEANLIB-374 — crates + maven accepted at CLI layer ────
//
// Regression guard: before this change, `cleanlib config init --ecosystem crates`
// and `--ecosystem maven` exited non-zero with `cleanlib config init does not
// yet support ecosystem 'crates'` even though the App backend and the
// per-ecosystem crates (`cleanlib-ecosystem-crates`, `cleanlib-ecosystem-maven`)
// were already live. These tests pin the CLI to accept the newly-allowed set
// end-to-end (parse → emit → stdout) so a regression in `Ecosystem::parse` or
// `emit()`'s match arms fails at test time, not on customer laptops.

#[test]
fn cleanlib_373_config_init_accepts_crates() {
    let out = Command::new(cleanlib_bin())
        .args(["config", "init", "--ecosystem", "crates"])
        .output()
        .expect("invoke cleanlib");

    assert!(
        out.status.success(),
        "config init --ecosystem crates must exit 0; stderr: {}",
        String::from_utf8_lossy(&out.stderr)
    );
    let stdout = String::from_utf8_lossy(&out.stdout);
    // Assert the emitted snippet carries the crates-specific signature so a
    // future refactor that silently drops the emit body still fails loudly.
    assert!(
        stdout.contains("[registries.cleanlibrary]"),
        "crates snippet must carry the [registries.cleanlibrary] block; stdout:\n{stdout}"
    );
    assert!(
        stdout.contains("sparse+"),
        "crates snippet must use the sparse+ HTTP registry index; stdout:\n{stdout}"
    );
}

#[test]
fn cleanlib_374_config_init_accepts_maven() {
    let out = Command::new(cleanlib_bin())
        .args(["config", "init", "--ecosystem", "maven"])
        .output()
        .expect("invoke cleanlib");

    assert!(
        out.status.success(),
        "config init --ecosystem maven must exit 0; stderr: {}",
        String::from_utf8_lossy(&out.stderr)
    );
    let stdout = String::from_utf8_lossy(&out.stdout);
    assert!(
        stdout.contains("<mirror>") && stdout.contains("<mirrorOf>*</mirrorOf>"),
        "maven snippet must carry the <mirror> + <mirrorOf>*</mirrorOf> block; stdout:\n{stdout}"
    );
    assert!(
        stdout.contains("Authorization"),
        "maven snippet must carry the Authorization httpHeader property; stdout:\n{stdout}"
    );
}

#[test]
fn cleanlib_373_374_config_init_accepts_mixed_5_ecosystem_batch() {
    // Regression guard for validate-all-first (CLEANLIB-132): a mixed batch
    // with the newly-accepted ecosystems must not partial-fail — every
    // ecosystem prints, none is silently dropped.
    let out = Command::new(cleanlib_bin())
        .args(["config", "init", "--ecosystem", "npm,pypi,go,crates,maven"])
        .output()
        .expect("invoke cleanlib");

    assert!(
        out.status.success(),
        "config init must accept the full 5-ecosystem batch; stderr: {}",
        String::from_utf8_lossy(&out.stderr)
    );
    let stdout = String::from_utf8_lossy(&out.stdout);
    for token in [
        "=== npm",
        "=== pypi",
        "=== go",
        "=== crates",
        "=== maven",
    ] {
        assert!(
            stdout.contains(token),
            "expected header `{token}` in stdout; got:\n{stdout}"
        );
    }
}

#[test]
fn cleanlib_373_374_config_init_error_message_lists_new_ecosystems() {
    // Sister of the CLEANLIB-365 generic error message. When an unknown
    // ecosystem is passed, the error message must advertise the full
    // accepted set — including the newly-added `crates` + `maven` — so a
    // customer copy-pasting the error line into a correction gets a hint
    // that both are valid.
    let out = Command::new(cleanlib_bin())
        .args(["config", "init", "--ecosystem", "nonexistent-ecosystem"])
        .output()
        .expect("invoke cleanlib");

    assert!(
        !out.status.success(),
        "unknown ecosystem must fail; stdout: {}",
        String::from_utf8_lossy(&out.stdout)
    );
    let stderr = String::from_utf8_lossy(&out.stderr);
    for expected in &["npm", "pypi", "go", "crates", "maven"] {
        assert!(
            stderr.contains(expected),
            "error message must advertise supported ecosystem `{}`; got:\n{}",
            expected,
            stderr
        );
    }
}

#[test]
fn cleanlib_157_shared_write_to_creates_single_backup() {
    let dir = tempfile::tempdir().expect("tempdir");
    let target = dir.path().join("proxy.conf");
    std::fs::write(&target, "prior proxy config\n").unwrap();

    let out = Command::new(cleanlib_bin())
        .args([
            "config",
            "init",
            "--ecosystem",
            "npm,pypi",
            "--write-to",
            target.to_str().unwrap(),
        ])
        .output()
        .expect("invoke cleanlib");

    assert!(
        out.status.success(),
        "config init should exit 0; stderr: {}",
        String::from_utf8_lossy(&out.stderr)
    );

    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,
        "multi-ecosystem write must create exactly one backup; dir had: {backups:?}"
    );

    let backup_body = std::fs::read_to_string(dir.path().join(&backups[0])).unwrap();
    assert!(
        backup_body.contains("prior proxy config"),
        "backup should preserve pre-existing content; got: {backup_body}"
    );

    let live = std::fs::read_to_string(&target).unwrap();
    assert!(
        live.contains("registry=") && live.contains("[global]"),
        "live file must hold every ecosystem snippet; got: {live}"
    );
}