haz-cli 0.2.0

Command-line task runner: tracks task DAGs across a workspace of projects, executes them with caching.
Documentation
//! End-to-end tests for `haz cache info` against per-scenario
//! fixtures under `tests/fixtures/cache_info/`. Each fixture is a
//! minimal workspace; the cache state under `.haz/cache/` is
//! built at runtime (via `Cache::store` for valid entries plus
//! hand-rolled scaffolding for the stale shapes) so the entries
//! remain valid as `CHAPTER_REVISION` evolves.
//!
//! Test names follow the `<rule>_<description>` convention from
//! CLAUDE.md so the rules a regression touches are visible from
//! the test name alone.

use std::collections::BTreeMap;
use std::fs;
use std::path::Path;
use std::process::Command;

use haz_cache::CacheWriter;
use haz_cache::key::CacheKey;
use haz_cache::key::prefix::CHAPTER_REVISION;
use haz_cache::layout;
use haz_cache::manifest::{HashFunctionLabel, Manifest};
use haz_cache::store::{StoreInputs, StoredOutput};
use haz_domain::settings::cache::HashAlgo;
use haz_vfs::StdFilesystem;
use tempfile::TempDir;

const HAZ_BIN: &str = env!("CARGO_BIN_EXE_haz");
const HASH_ALGO: HashAlgo = HashAlgo::Blake3;

fn fixture_tempdir(name: &str) -> TempDir {
    let src = Path::new(env!("CARGO_MANIFEST_DIR"))
        .join("tests/fixtures/cache_info")
        .join(name);
    let dst = TempDir::new().expect("tempdir creation failed");
    copy_dir_recursive(&src, dst.path());
    dst
}

fn copy_dir_recursive(src: &Path, dst: &Path) {
    fs::create_dir_all(dst).unwrap();
    for entry in fs::read_dir(src).unwrap() {
        let entry = entry.unwrap();
        let src_path = entry.path();
        let dst_path = dst.join(entry.file_name());
        if entry.file_type().unwrap().is_dir() {
            copy_dir_recursive(&src_path, &dst_path);
        } else {
            fs::copy(&src_path, &dst_path).unwrap();
        }
    }
}

/// Run `haz cache info` against `workspace_root`. Returns the
/// captured `(stdout, stderr, exit_code)` tuple.
fn haz_cache_info(workspace_root: &Path) -> (String, String, Option<i32>) {
    let output = Command::new(HAZ_BIN)
        .args(["cache", "info"])
        .current_dir(workspace_root)
        .output()
        .expect("failed to spawn haz");
    (
        String::from_utf8(output.stdout).expect("haz stdout was not utf-8"),
        String::from_utf8(output.stderr).expect("haz stderr was not utf-8"),
        output.status.code(),
    )
}

fn build_cache(workspace_root: &Path) -> CacheWriter<StdFilesystem> {
    CacheWriter::new(StdFilesystem::new(), workspace_root, HASH_ALGO)
}

/// Construct a [`CacheKey`] whose first byte is `first` and the
/// rest zero. Two keys with distinct first bytes land under
/// distinct shards, which keeps the test's stale entry separable
/// from the well-formed ones.
fn key_with_first_byte(first: u8) -> CacheKey {
    let mut bytes = [0u8; 32];
    bytes[0] = first;
    CacheKey::from_bytes(bytes)
}

/// Store a single valid output blob under `key` in `cache`. Used
/// to populate well-formed entries from the test before invoking
/// `haz cache info`.
fn store_valid_entry(cache: &CacheWriter<StdFilesystem>, key: &CacheKey, rel: &str, bytes: &[u8]) {
    let workspace_root = cache.workspace_root().to_path_buf();
    let target = workspace_root.join(rel);
    let anchored = format!("/{rel}");
    fs::create_dir_all(target.parent().unwrap()).unwrap();
    fs::write(&target, bytes).unwrap();
    let outs = [StoredOutput {
        workspace_absolute_path: &anchored,
        on_disk_path: &target,
        mode: 0o644,
    }];
    cache
        .store(
            key,
            &StoreInputs {
                outputs: &outs,
                stdout: b"",
                stderr: b"",
                created_at_unix: 0,
            },
        )
        .expect("store should succeed for a fresh key");
}

/// Hand-write a manifest whose `chapter_revision` does NOT match
/// the current value. This places an "objectively stale"
/// (schema-mismatched) entry under the cache root that `info`
/// MUST classify as corrupt per AUX-019.
fn write_schema_stale_entry(cache: &CacheWriter<StdFilesystem>, key: &CacheKey) {
    let entry_dir = layout::entry_dir(cache.cache_root(), key);
    fs::create_dir_all(&entry_dir).unwrap();
    let manifest = Manifest {
        chapter_revision: CHAPTER_REVISION.saturating_add(1),
        hash_function: HashFunctionLabel::Blake3,
        key: *key,
        outputs: vec![],
        stdout_len: 0,
        stderr_len: 0,
        stdout_hash: [0u8; 32],
        stderr_hash: [0u8; 32],
        exit_status: 0,
        created_at_unix: 0,
    };
    fs::write(
        layout::manifest_path(cache.cache_root(), key),
        manifest.to_json_bytes(),
    )
    .unwrap();
}

/// Create an entry directory with no `manifest.json` (the
/// "incomplete entry" shape of CACHE-022 / AUX-019).
fn write_corrupt_entry_without_manifest(cache: &CacheWriter<StdFilesystem>, key: &CacheKey) {
    let entry_dir = layout::entry_dir(cache.cache_root(), key);
    fs::create_dir_all(&entry_dir).unwrap();
    fs::write(entry_dir.join("stdout"), b"").unwrap();
}

/// Create an orphan `<shard>/.tmp-<key>-<random>` directory, the
/// "incomplete two-phase store" shape of CACHE-017.
fn write_orphan_tmp_dir(cache: &CacheWriter<StdFilesystem>, key: &CacheKey, random: &str) {
    let tmp = layout::tmp_entry_dir(cache.cache_root(), key, random);
    fs::create_dir_all(&tmp).unwrap();
    fs::write(tmp.join("partial.bin"), b"scratch").unwrap();
}

/// Recursively snapshot every regular file under `root`,
/// returning a `BTreeMap<relative_path, bytes>`. Symlinks and
/// other non-file kinds are skipped.
fn snapshot_dir(root: &Path) -> BTreeMap<String, Vec<u8>> {
    let mut out = BTreeMap::new();
    snapshot_into(root, root, &mut out);
    out
}

fn snapshot_into(base: &Path, dir: &Path, out: &mut BTreeMap<String, Vec<u8>>) {
    let Ok(entries) = fs::read_dir(dir) else {
        return;
    };
    for entry in entries.flatten() {
        let path = entry.path();
        let Ok(ft) = entry.file_type() else { continue };
        if ft.is_file() {
            let rel = path
                .strip_prefix(base)
                .map_or_else(|_| path.display().to_string(), |p| p.display().to_string());
            let bytes = fs::read(&path).unwrap_or_default();
            out.insert(rel, bytes);
        } else if ft.is_dir() {
            snapshot_into(base, &path, out);
        }
    }
}

// --- fresh_workspace: AUX-019 missing-root branch ---

#[test]
fn aux_019_fresh_workspace_reports_zeros_and_exits_zero() {
    // Workspace loads cleanly but `.haz/cache/` has never been
    // created. AUX-019 prescribes a zero-count report rather than
    // an error.
    let tmp = fixture_tempdir("fresh_workspace");
    let (stdout, stderr, code) = haz_cache_info(tmp.path());
    assert_eq!(code, Some(0), "expected exit 0; stderr: {stderr}");
    assert!(
        stdout.contains("well-formed entries: 0"),
        "missing well-formed=0: {stdout}",
    );
    assert!(
        stdout.contains("corrupt entries: 0"),
        "missing corrupt=0: {stdout}",
    );
    assert!(
        stdout.contains("orphan tmp directories: 0"),
        "missing orphan-tmp=0: {stdout}",
    );
    assert!(
        stdout.contains("orphan restore directories: 0"),
        "missing orphan-restore=0: {stdout}",
    );
    assert!(
        stdout.contains("total bytes: 0"),
        "missing total-bytes=0: {stdout}",
    );
    // Missing root MUST NOT be an error; no schema breakdown is
    // emitted when no well-formed entries exist.
    assert!(
        !stdout.contains("by schema:"),
        "schema section should be absent: {stdout}",
    );
}

// --- populated_with_mixed_entries: AUX-019 full breakdown ---

/// Pre-populate `<workspace_root>/.haz/cache/` with:
///
/// - 2 well-formed entries under the current schema.
/// - 1 corrupt entry (directory present, no manifest).
/// - 1 schema-mismatched entry (manifest with a non-current
///   `chapter_revision`).
/// - 1 orphan `.tmp-` directory.
fn populate_mixed_cache(workspace_root: &Path) {
    let cache = build_cache(workspace_root);
    // Two well-formed entries on distinct shards.
    let key_a = key_with_first_byte(0xAA);
    store_valid_entry(&cache, &key_a, "lib/out_a", b"hello");
    let key_b = key_with_first_byte(0xBB);
    store_valid_entry(&cache, &key_b, "lib/out_b", b"world!");
    // Corrupt entry: directory exists but no manifest.
    let key_c = key_with_first_byte(0xCC);
    write_corrupt_entry_without_manifest(&cache, &key_c);
    // Schema-mismatched entry: manifest exists but chapter_revision
    // is bumped (an "objectively stale" entry per CACHE-022).
    let key_d = key_with_first_byte(0xDD);
    write_schema_stale_entry(&cache, &key_d);
    // Orphan `.tmp-` directory.
    let key_e = key_with_first_byte(0xEE);
    write_orphan_tmp_dir(&cache, &key_e, "tmpsuffix");
}

#[test]
fn aux_019_populated_cache_reports_all_categories() {
    let tmp = fixture_tempdir("populated_with_mixed_entries");
    populate_mixed_cache(tmp.path());

    let (stdout, stderr, code) = haz_cache_info(tmp.path());
    assert_eq!(code, Some(0), "expected exit 0; stderr: {stderr}");
    assert!(
        stdout.contains("well-formed entries: 2"),
        "expected 2 well-formed: {stdout}",
    );
    assert!(
        stdout.contains("corrupt entries: 2"),
        "expected 2 corrupt (no-manifest + schema-mismatched): {stdout}",
    );
    assert!(
        stdout.contains("orphan tmp directories: 1"),
        "expected 1 orphan tmp: {stdout}",
    );
    assert!(
        stdout.contains("orphan restore directories: 0"),
        "expected 0 orphan restore: {stdout}",
    );
    // The schema-breakdown section MUST list the current schema
    // for the two well-formed entries.
    assert!(
        stdout.contains("by schema:"),
        "missing schema section header: {stdout}",
    );
    assert!(
        stdout.contains(&format!("(revision={CHAPTER_REVISION}, hash=blake3): 2")),
        "missing schema breakdown for the two valid entries: {stdout}",
    );
}

// --- AUX-020: repeated invocation must not mutate the cache ---

#[test]
fn aux_020_repeated_invocation_does_not_alter_cache_root() {
    let tmp = fixture_tempdir("populated_with_mixed_entries");
    populate_mixed_cache(tmp.path());

    let cache_root = tmp.path().join(".haz/cache");
    let before = snapshot_dir(&cache_root);
    // Invoke twice. AUX-018 / AUX-020: read-only with respect to
    // the cache root; the second invocation must observe the
    // same on-disk state as the first.
    let (_, _, code1) = haz_cache_info(tmp.path());
    assert_eq!(code1, Some(0));
    let (_, _, code2) = haz_cache_info(tmp.path());
    assert_eq!(code2, Some(0));
    let after = snapshot_dir(&cache_root);
    assert_eq!(
        before, after,
        "cache root state must be byte-identical before and after two info invocations",
    );
}