cordance-cli 0.1.2

Cordance CLI — installs the `cordance` binary. The umbrella package `cordance` re-exports this entry; either install command works.
Documentation
use std::collections::HashSet;

use cordance_core::lock::SourceLock;

fn fixture(name: &str) -> SourceLock {
    let manifest = env!("CARGO_MANIFEST_DIR");
    let path = format!("{manifest}/../../fixtures/check/{name}");
    let json = std::fs::read_to_string(&path)
        .unwrap_or_else(|e| panic!("failed to read fixture {name}: {e}"));
    serde_json::from_str(&json).unwrap_or_else(|e| panic!("failed to parse fixture {name}: {e}"))
}

/// Diff a clean lock against itself — no drift at all.
#[test]
fn clean_diff_is_clean() {
    let lock = fixture("clean.lock.json");
    let fenced: HashSet<String> = HashSet::new();
    let report = lock.diff(&lock, &fenced);
    assert!(report.is_clean(), "self-diff must be clean");
    assert_eq!(report.exit_code(), 0);
}

/// The source-drifted fixture has README.md sha256 changed to "xxxx".
/// When treated as "current" and diffed against the clean lock (previous),
/// we expect exactly one source drift entry.
#[test]
fn source_drift_detected() {
    let prev = fixture("clean.lock.json");
    let curr = fixture("source-drifted.lock.json");
    let fenced: HashSet<String> = HashSet::new();
    let report = curr.diff(&prev, &fenced);
    assert_eq!(
        report.source_drifts.len(),
        1,
        "expected one source drift entry"
    );
    assert_eq!(report.source_drifts[0].id, "project_readme:README.md");
    assert_eq!(report.exit_code(), 1);
}

/// Computing a lock from the same empty pack twice must yield the same `pack_id`.
#[test]
fn compute_from_empty_pack_stable_id() {
    use cordance_core::advise::AdviseReport;
    use cordance_core::pack::{CordancePack, PackTargets, ProjectIdentity};
    use cordance_core::schema;

    let pack = CordancePack {
        schema: schema::CORDANCE_PACK_V1.into(),
        project: ProjectIdentity {
            name: "t".into(),
            repo_root: ".".into(),
            kind: "test".into(),
            host_os: "linux".into(),
            axiom_pin: None,
        },
        sources: vec![],
        doctrine_pins: vec![],
        targets: PackTargets::default(),
        outputs: vec![],
        source_lock: SourceLock::empty(),
        advise: AdviseReport::empty(),
        residual_risk: vec!["x".into()],
    };
    let lock1 = SourceLock::compute_from_pack(&pack);
    let lock2 = SourceLock::compute_from_pack(&pack);
    assert_eq!(
        lock1.pack_id, lock2.pack_id,
        "pack_id must be deterministic"
    );
}

/// `cordance check` against a fresh `cordance pack`'s lock must report clean,
/// because the fresh rescan and the saved lock describe the same on-disk state.
#[test]
fn check_after_pack_reports_clean() {
    use assert_cmd::prelude::*;
    use std::process::Command;

    let dir = tempfile::tempdir().expect("tempdir");
    let project = dir.path();
    std::fs::write(
        project.join("README.md"),
        "# initial cordance check fixture\n",
    )
    .expect("write README");

    let mut pack_cmd = Command::cargo_bin("cordance").expect("bin");
    pack_cmd
        .arg("--target")
        .arg(project)
        .arg("--allow-outside-cwd")
        .arg("pack")
        .arg("--output-mode")
        .arg("write");
    pack_cmd.assert().success();

    assert!(
        project.join(".cordance").join("sources.lock").exists(),
        "pack must produce .cordance/sources.lock"
    );

    let mut check_cmd = Command::cargo_bin("cordance").expect("bin");
    check_cmd
        .arg("--target")
        .arg(project)
        .arg("--allow-outside-cwd")
        .arg("check");
    check_cmd.assert().code(0);
}

/// Modifying a tracked source between `pack` and `check` must produce a
/// non-zero exit code — the CRITICAL #4 regression from the round-1 review.
#[test]
fn check_detects_modified_source_file() {
    use assert_cmd::prelude::*;
    use std::process::Command;

    let dir = tempfile::tempdir().expect("tempdir");
    let project = dir.path();
    std::fs::write(project.join("README.md"), "# Initial\n").expect("write README");

    let mut pack_cmd = Command::cargo_bin("cordance").expect("bin");
    pack_cmd
        .arg("--target")
        .arg(project)
        .arg("--allow-outside-cwd")
        .arg("pack")
        .arg("--output-mode")
        .arg("write");
    pack_cmd.assert().success();

    // Mutate the source the lock now remembers.
    std::fs::write(project.join("README.md"), "# Modified\n").expect("mutate README");

    let mut check_cmd = Command::cargo_bin("cordance").expect("bin");
    check_cmd
        .arg("--target")
        .arg(project)
        .arg("--allow-outside-cwd")
        .arg("check");
    // Exit code is 1 (source drift only) or 3 (source + fenced output drift,
    // if a generated AGENTS.md happens to drift too because README.md fed into
    // it). Both signal "drift detected" — what matters is non-zero.
    check_cmd.assert().failure();
}

/// `cordance pack` followed by `cordance check` on a target whose
/// `cordance.toml` pins a custom axiom version must round-trip the pin into
/// the lock — guarding CRITICAL #5 from the round-1 review.
#[test]
fn pack_propagates_axiom_pin_into_lock() {
    use assert_cmd::prelude::*;
    use std::process::Command;

    let dir = tempfile::tempdir().expect("tempdir");
    let project = dir.path();
    std::fs::write(
        project.join("cordance.toml"),
        "[axiom]\nsource = \"./pai-axiom\"\nalgorithm_latest = \"v9.9.9-axiom-test\"\n",
    )
    .expect("write cordance.toml");
    std::fs::write(project.join("README.md"), "# axiom pin test\n").expect("write README");

    let mut pack_cmd = Command::cargo_bin("cordance").expect("bin");
    pack_cmd
        .arg("--target")
        .arg(project)
        .arg("--allow-outside-cwd")
        .arg("pack")
        .arg("--output-mode")
        .arg("write");
    pack_cmd.assert().success();

    let lock_path = project.join(".cordance").join("sources.lock");
    let lock_json = std::fs::read_to_string(&lock_path).expect("read lock");
    let lock: SourceLock = serde_json::from_str(&lock_json).expect("parse lock");
    assert_eq!(
        lock.axiom_algorithm_pin.as_deref(),
        Some("v9.9.9-axiom-test"),
        "axiom pin from cordance.toml must round-trip into the lock"
    );
}