gha-cache-proof 1.0.1

GitHub Actions cache compatibility checker and local cache-store receipt tool for offline CI
Documentation
use assert_cmd::Command;
use predicates::prelude::*;
use serde_json::Value;
use std::fs;
use tempfile::tempdir;

#[test]
fn save_then_restore_exact_cache_hit() {
    let dir = tempdir().unwrap();
    let workspace = dir.path().join("workspace");
    fs::create_dir_all(workspace.join("deps")).unwrap();
    fs::write(workspace.join("deps/lock.txt"), "cache me").unwrap();
    let store = dir.path().join("store");

    Command::cargo_bin("gha-cache-proof")
        .unwrap()
        .args([
            "save",
            "--store",
            store.to_str().unwrap(),
            "--workspace",
            workspace.to_str().unwrap(),
            "--key",
            "Linux-deps-123",
            "--path",
            "deps",
        ])
        .assert()
        .success();

    fs::remove_dir_all(workspace.join("deps")).unwrap();
    let output = Command::cargo_bin("gha-cache-proof")
        .unwrap()
        .args([
            "restore",
            "--store",
            store.to_str().unwrap(),
            "--workspace",
            workspace.to_str().unwrap(),
            "--key",
            "Linux-deps-123",
            "--path",
            "deps",
            "--format",
            "json",
        ])
        .assert()
        .success()
        .get_output()
        .stdout
        .clone();

    assert_eq!(
        fs::read_to_string(workspace.join("deps/lock.txt")).unwrap(),
        "cache me"
    );
    let receipt: Value = serde_json::from_slice(&output).unwrap();
    assert_eq!(receipt["operations"][0]["cache_hit"], "true");
    assert_eq!(
        receipt["operations"][0]["matched"]["match_kind"],
        "exact-key"
    );
}

#[test]
fn restore_key_falls_back_to_default_branch_prefix() {
    let dir = tempdir().unwrap();
    let main_workspace = dir.path().join("main");
    fs::create_dir_all(main_workspace.join("deps")).unwrap();
    fs::write(main_workspace.join("deps/lock.txt"), "from main").unwrap();
    let store = dir.path().join("store");

    Command::cargo_bin("gha-cache-proof")
        .unwrap()
        .args([
            "save",
            "--store",
            store.to_str().unwrap(),
            "--workspace",
            main_workspace.to_str().unwrap(),
            "--reference",
            "refs/heads/main",
            "--key",
            "Linux-deps-abc",
            "--path",
            "deps",
        ])
        .assert()
        .success();

    let feature_workspace = dir.path().join("feature");
    fs::create_dir_all(&feature_workspace).unwrap();
    let output = Command::cargo_bin("gha-cache-proof")
        .unwrap()
        .args([
            "restore",
            "--store",
            store.to_str().unwrap(),
            "--workspace",
            feature_workspace.to_str().unwrap(),
            "--reference",
            "refs/heads/feature",
            "--default-branch",
            "main",
            "--key",
            "Linux-deps-new",
            "--restore-key",
            "Linux-deps-",
            "--path",
            "deps",
            "--format",
            "json",
        ])
        .assert()
        .success()
        .get_output()
        .stdout
        .clone();

    let receipt: Value = serde_json::from_slice(&output).unwrap();
    assert_eq!(receipt["operations"][0]["cache_hit"], "false");
    assert_eq!(
        receipt["operations"][0]["matched"]["scope"],
        "refs/heads/main"
    );
    assert_eq!(
        receipt["operations"][0]["matched"]["match_kind"],
        "restore-prefix"
    );
}

#[test]
fn fail_on_cache_miss_exits_nonzero() {
    let dir = tempdir().unwrap();
    fs::create_dir_all(dir.path().join("workspace")).unwrap();

    Command::cargo_bin("gha-cache-proof")
        .unwrap()
        .args([
            "restore",
            "--store",
            dir.path().join("store").to_str().unwrap(),
            "--workspace",
            dir.path().join("workspace").to_str().unwrap(),
            "--key",
            "missing",
            "--path",
            "deps",
            "--fail-on-cache-miss",
        ])
        .assert()
        .failure()
        .stdout(predicate::str::contains("cache miss"));
}

#[test]
fn workflow_check_evaluates_cache_templates() {
    let dir = tempdir().unwrap();
    let repo = dir.path().join("repo");
    fs::create_dir_all(repo.join(".github/workflows")).unwrap();
    fs::create_dir_all(repo.join("node_modules")).unwrap();
    fs::write(repo.join("package-lock.json"), "{\"lockfileVersion\":3}").unwrap();
    fs::write(repo.join("node_modules/dep.txt"), "dep").unwrap();
    fs::write(
        repo.join(".github/workflows/ci.yml"),
        r#"
name: CI
on: push
jobs:
  test:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/cache@v5
        id: npm-cache
        with:
          path: node_modules
          key: ${{ runner.os }}-npm-${{ hashFiles('package-lock.json') }}
          restore-keys: |
            ${{ runner.os }}-npm-
"#,
    )
    .unwrap();

    let output = Command::cargo_bin("gha-cache-proof")
        .unwrap()
        .args([
            "check-workflow",
            "--repo",
            repo.to_str().unwrap(),
            "--workspace",
            repo.to_str().unwrap(),
            "--store",
            dir.path().join("store").to_str().unwrap(),
            "--format",
            "json",
        ])
        .assert()
        .success()
        .get_output()
        .stdout
        .clone();

    let receipt: Value = serde_json::from_slice(&output).unwrap();
    let step = &receipt["workflows"][0]["cache_steps"][0];
    assert_eq!(step["uses"], "actions/cache@v5");
    assert!(step["key"].as_str().unwrap().starts_with("Linux-npm-"));
    assert_eq!(step["restore_keys"][0], "Linux-npm-");
    assert!(step["expression_receipts"].as_array().unwrap().len() >= 2);
}

#[test]
fn restore_skips_absolute_path_entries_and_records_count() {
    // Saving an out-of-workspace path encodes the file under an `absolute/`
    // sentinel inside the cache entry. On restore the host has no clean way
    // to push that file back into the right container-side absolute path
    // (the runner only bind-mounts the workspace), so the restore must skip
    // those entries — both to avoid polluting `<workspace>/absolute/...` with
    // bogus files that the build can't find, and to avoid overwriting the
    // user's real `~/.cargo` on Windows. The receipt surfaces the skipped
    // count so the runner can show why fewer files were restored than saved.
    let dir = tempdir().unwrap();
    let workspace = dir.path().join("workspace");
    let outside = dir.path().join("outside-of-workspace");
    fs::create_dir_all(workspace.join("deps")).unwrap();
    fs::create_dir_all(&outside).unwrap();
    fs::write(workspace.join("deps/in-ws.txt"), "in workspace").unwrap();
    fs::write(outside.join("abs.txt"), "outside workspace").unwrap();
    let store = dir.path().join("store");

    Command::cargo_bin("gha-cache-proof")
        .unwrap()
        .args([
            "save",
            "--store",
            store.to_str().unwrap(),
            "--workspace",
            workspace.to_str().unwrap(),
            "--key",
            "Linux-mixed-1",
            "--path",
            "deps",
            "--path",
            outside.join("abs.txt").to_str().unwrap(),
        ])
        .assert()
        .success();

    fs::remove_dir_all(workspace.join("deps")).unwrap();
    let output = Command::cargo_bin("gha-cache-proof")
        .unwrap()
        .args([
            "restore",
            "--store",
            store.to_str().unwrap(),
            "--workspace",
            workspace.to_str().unwrap(),
            "--key",
            "Linux-mixed-1",
            "--path",
            "deps",
            "--path",
            outside.join("abs.txt").to_str().unwrap(),
            "--format",
            "json",
        ])
        .assert()
        .success()
        .get_output()
        .stdout
        .clone();

    assert_eq!(
        fs::read_to_string(workspace.join("deps/in-ws.txt")).unwrap(),
        "in workspace",
        "workspace-relative file should be restored normally"
    );
    assert!(
        !workspace.join("absolute").exists(),
        "absolute/ sentinel directory must not be created inside the workspace"
    );
    let receipt: Value = serde_json::from_slice(&output).unwrap();
    let op = &receipt["operations"][0];
    assert_eq!(op["cache_hit"], "true");
    assert_eq!(
        op["restored_files"], 1,
        "only the in-workspace file restores"
    );
    assert_eq!(
        op["skipped_absolute_files"], 1,
        "the out-of-workspace file should be counted as skipped"
    );
    let warn = op["checks"]
        .as_array()
        .unwrap()
        .iter()
        .find(|c| c["id"] == "cache.restore.absolute_skipped");
    assert!(warn.is_some(), "expected an absolute_skipped warning check");
}

#[test]
fn markdown_output_has_receipt_heading() {
    let dir = tempdir().unwrap();
    fs::create_dir_all(dir.path().join("workspace")).unwrap();

    Command::cargo_bin("gha-cache-proof")
        .unwrap()
        .args([
            "restore",
            "--store",
            dir.path().join("store").to_str().unwrap(),
            "--workspace",
            dir.path().join("workspace").to_str().unwrap(),
            "--key",
            "missing",
            "--path",
            "deps",
            "--format",
            "markdown",
        ])
        .assert()
        .success()
        .stdout(predicate::str::contains("# gha-cache-proof Receipt"));
}