cargo-ratchet 0.1.0

Prevent resolved versions in Cargo.lock from regressing below a committed, self-advancing baseline
Documentation
//! End-to-end behavior of the `cargo-ratchet` binary: bootstrap, advance,
//! baseline preservation on failure, `--fail-on-change`, `--json`, and path
//! overrides.

use std::fs;
use std::path::Path;
use std::process::{Command, Output};

const RATCHET_FILE_NAME: &str = ".Cargo.lock.ratchet";

const LOCK_OLD: &str = "version = 4\n\n[[package]]\nname = \"serde\"\nversion = \"1.0.100\"\nsource = \"registry+https://github.com/rust-lang/crates.io-index\"\n";
const LOCK_NEW: &str = "version = 4\n\n[[package]]\nname = \"serde\"\nversion = \"1.0.219\"\nsource = \"registry+https://github.com/rust-lang/crates.io-index\"\n";

fn write_project(dir: &Path, lockfile: &str) {
    fs::write(
        dir.join("Cargo.toml"),
        "[package]\nname = \"fixture\"\nversion = \"0.0.0\"\nedition = \"2021\"\n\n[workspace]\n",
    )
    .unwrap();
    fs::create_dir_all(dir.join("src")).unwrap();
    fs::write(dir.join("src/lib.rs"), "").unwrap();
    fs::write(dir.join("Cargo.lock"), lockfile).unwrap();
}

fn cargo_ratchet(project: &Path, extra_args: &[&str]) -> Output {
    Command::new(env!("CARGO_BIN_EXE_cargo-ratchet"))
        .arg("ratchet")
        .arg("--manifest-path")
        .arg(project.join("Cargo.toml"))
        .args(extra_args)
        .output()
        .expect("failed to run cargo-ratchet")
}

fn stderr(output: &Output) -> String {
    String::from_utf8(output.stderr.clone()).unwrap()
}

#[test]
fn bootstrap_creates_baseline() {
    let dir = tempfile::tempdir().unwrap();
    write_project(dir.path(), LOCK_NEW);

    let output = cargo_ratchet(dir.path(), &[]);
    assert!(output.status.success(), "stderr: {}", stderr(&output));
    let baseline = fs::read_to_string(dir.path().join(RATCHET_FILE_NAME)).unwrap();
    assert_eq!(baseline, LOCK_NEW);
}

#[test]
fn advance_on_pass() {
    let dir = tempfile::tempdir().unwrap();
    write_project(dir.path(), LOCK_NEW);
    fs::write(dir.path().join(RATCHET_FILE_NAME), LOCK_OLD).unwrap();

    let output = cargo_ratchet(dir.path(), &[]);
    assert!(output.status.success(), "stderr: {}", stderr(&output));
    let baseline = fs::read_to_string(dir.path().join(RATCHET_FILE_NAME)).unwrap();
    assert_eq!(baseline, LOCK_NEW);
}

#[test]
fn baseline_untouched_on_fail() {
    let dir = tempfile::tempdir().unwrap();
    write_project(dir.path(), LOCK_OLD);
    fs::write(dir.path().join(RATCHET_FILE_NAME), LOCK_NEW).unwrap();

    let output = cargo_ratchet(dir.path(), &[]);
    assert!(!output.status.success());
    assert!(
        stderr(&output).contains("package version regressed"),
        "stderr: {}",
        stderr(&output)
    );
    let baseline = fs::read_to_string(dir.path().join(RATCHET_FILE_NAME)).unwrap();
    assert_eq!(baseline, LOCK_NEW);
}

#[test]
fn fail_on_change_passes_when_baseline_matches() {
    let dir = tempfile::tempdir().unwrap();
    write_project(dir.path(), LOCK_NEW);
    fs::write(dir.path().join(RATCHET_FILE_NAME), LOCK_NEW).unwrap();

    let output = cargo_ratchet(dir.path(), &["--fail-on-change"]);
    assert!(output.status.success(), "stderr: {}", stderr(&output));
}

#[test]
fn fail_on_change_fails_on_stale_baseline() {
    // Check passes (upgrade), but the baseline lags the lockfile.
    let dir = tempfile::tempdir().unwrap();
    write_project(dir.path(), LOCK_NEW);
    fs::write(dir.path().join(RATCHET_FILE_NAME), LOCK_OLD).unwrap();

    let output = cargo_ratchet(dir.path(), &["--fail-on-change"]);
    assert!(!output.status.success());
    assert!(
        stderr(&output).contains("does not match"),
        "stderr: {}",
        stderr(&output)
    );
    // --fail-on-change never writes.
    let baseline = fs::read_to_string(dir.path().join(RATCHET_FILE_NAME)).unwrap();
    assert_eq!(baseline, LOCK_OLD);
}

#[test]
fn fail_on_change_fails_on_missing_baseline() {
    let dir = tempfile::tempdir().unwrap();
    write_project(dir.path(), LOCK_NEW);

    let output = cargo_ratchet(dir.path(), &["--fail-on-change"]);
    assert!(!output.status.success());
    assert!(!dir.path().join(RATCHET_FILE_NAME).exists());
}

#[test]
fn json_reports_violations_on_stdout() {
    let dir = tempfile::tempdir().unwrap();
    write_project(dir.path(), LOCK_OLD);
    fs::write(dir.path().join(RATCHET_FILE_NAME), LOCK_NEW).unwrap();

    let output = cargo_ratchet(dir.path(), &["--json"]);
    assert!(!output.status.success());
    let violations: serde_json::Value =
        serde_json::from_slice(&output.stdout).expect("stdout should be JSON");
    assert_eq!(violations[0]["kind"], "package_version_regressed");
    assert_eq!(violations[0]["name"], "serde");
    assert_eq!(violations[0]["baseline_version"], "1.0.219");
    assert_eq!(violations[0]["current_version"], "1.0.100");
}

#[test]
fn ratchet_file_override() {
    let dir = tempfile::tempdir().unwrap();
    write_project(dir.path(), LOCK_NEW);
    let custom = dir.path().join("custom.ratchet");

    let output = cargo_ratchet(dir.path(), &["--ratchet-file", custom.to_str().unwrap()]);
    assert!(output.status.success(), "stderr: {}", stderr(&output));
    assert_eq!(fs::read_to_string(&custom).unwrap(), LOCK_NEW);
    assert!(!dir.path().join(RATCHET_FILE_NAME).exists());
}

#[test]
fn lockfile_override_skips_discovery_and_derives_baseline_name() {
    // No Cargo.toml anywhere: --lockfile must work without `cargo metadata`,
    // and the default baseline name must derive from the lockfile's name.
    let dir = tempfile::tempdir().unwrap();
    let lockfile = dir.path().join("Custom.lock");
    fs::write(&lockfile, LOCK_NEW).unwrap();

    let output = Command::new(env!("CARGO_BIN_EXE_cargo-ratchet"))
        .arg("ratchet")
        .arg("--lockfile")
        .arg(&lockfile)
        .output()
        .expect("failed to run cargo-ratchet");
    assert!(output.status.success(), "stderr: {}", stderr(&output));
    let baseline = fs::read_to_string(dir.path().join(".Custom.lock.ratchet")).unwrap();
    assert_eq!(baseline, LOCK_NEW);
}