mkit-core 0.3.0

Content-addressed VCS primitives for mkit: BLAKE3 hashing, canonical objects, refs, packs, and transport traits
Documentation
//! Integration tests for Phase 4 — refs + index + worktree + ignore +
//! `repo_lock`.
//!
//! Loads the golden byte vectors under `rust/tests/golden/phase4/` and
//! checks that the parsers in `mkit-core` accept them and round-trip
//! cleanly. Also exercises every CAS variant of `update_ref` against a
//! temp `ObjectStore`-rooted repo, which is the bit Phases 5+ depend
//! on.
//!
//! The goldens themselves are generated by the example
//! `examples/generate_phase4_goldens.rs`; re-running the generator
//! must produce byte-identical files.

use std::fs;
use std::path::{Path, PathBuf};

use mkit_core::hash;
use mkit_core::index::{self, EntryStatus, Index, IndexEntry};
use mkit_core::refs::{
    self, Head, REFS_DIR, Ref, RefError, RefWriteCondition, decode_ref_wire, validate_ref_name,
};
use mkit_core::repo_lock;
use mkit_core::store::ObjectStore;

fn goldens_root() -> PathBuf {
    // CARGO_MANIFEST_DIR points at crates/mkit-core/.
    let manifest_dir = PathBuf::from(env!("CARGO_MANIFEST_DIR"));
    manifest_dir
        .parent()
        .and_then(Path::parent)
        .map(|workspace| workspace.join("tests").join("golden").join("phase4"))
        .expect("workspace layout: crates/mkit-core/.. → workspace root")
}

fn read_golden(name: &str) -> Vec<u8> {
    let path = goldens_root().join(name);
    fs::read(&path).unwrap_or_else(|e| {
        panic!(
            "failed to read golden vector {}: {e}. Run `cargo run -p mkit-core --example generate_phase4_goldens`.",
            path.display()
        )
    })
}

#[test]
fn manifest_blake3_digests_match_bin_files() {
    let manifest_path = goldens_root().join("MANIFEST.txt");
    let manifest = fs::read_to_string(&manifest_path).unwrap_or_else(|e| {
        panic!(
            "failed to read MANIFEST.txt at {}: {e}",
            manifest_path.display()
        )
    });
    let mut checked = 0usize;
    for line in manifest.lines() {
        if line.starts_with('#') || line.is_empty() {
            continue;
        }
        let mut parts = line.split_whitespace();
        let name = parts.next().expect("manifest line missing name");
        let expected = parts
            .next()
            .expect("manifest line missing blake3")
            .to_string();
        let bytes = read_golden(&format!("{name}.bin"));
        let actual = hash::to_hex(&hash::hash(&bytes));
        assert_eq!(
            actual, expected,
            "BLAKE3 mismatch for {name}: regenerate goldens"
        );
        checked += 1;
    }
    assert!(checked >= 4, "expected ≥ 4 vectors, got {checked}");
}

#[test]
fn golden_index_empty_round_trips() {
    // The phase-4 golden is a v1 stream: it must still PARSE (read
    // compat is the v1→v2 migration path, SPEC-INDEX §"versioning"),
    // and re-serialisation upgrades it to the current v2 layout.
    let bytes = read_golden("index_empty.bin");
    assert_eq!(bytes.len(), 9);
    assert_eq!(&bytes[..4], b"MKIX");
    let idx = index::deserialize(&bytes).unwrap();
    assert!(idx.entries.is_empty());
    let upgraded = idx.serialize();
    assert_eq!(upgraded[4], index::FORMAT_VERSION, "writer emits v2");
    assert_eq!(index::deserialize(&upgraded).unwrap(), idx);
}

#[test]
fn golden_index_3entries_round_trips() {
    let bytes = read_golden("index_3entries.bin");
    let idx = index::deserialize(&bytes).unwrap();
    assert_eq!(idx.entries.len(), 3);
    let by_path: std::collections::HashMap<_, _> = idx
        .entries
        .iter()
        .map(|e| (e.path.as_str(), e.status))
        .collect();
    assert_eq!(by_path["README.md"], EntryStatus::Blob);
    assert_eq!(by_path["src"], EntryStatus::Tree);
    assert_eq!(by_path["scripts/build"], EntryStatus::Executable);
    // v1 entries parse with an empty stat cache and upgrade to v2 on
    // write, preserving every entry.
    assert!(idx.entries.iter().all(|e| e.mtime_ns == 0 && e.size == 0));
    let upgraded = idx.serialize();
    assert_eq!(upgraded[4], index::FORMAT_VERSION, "writer emits v2");
    assert_eq!(index::deserialize(&upgraded).unwrap(), idx);
}

#[test]
fn golden_ref_detached_decodes() {
    let bytes = read_golden("ref_detached.bin");
    assert_eq!(bytes.len(), 65);
    assert_eq!(*bytes.last().unwrap(), b'\n');
    // The hex must be lowercase per SPEC-REFS §1.
    assert!(bytes[..64].iter().all(|b| !b.is_ascii_uppercase()));
    let h = decode_ref_wire(&bytes).unwrap();
    // Reconstruct via the encoder and confirm it round-trips.
    assert_eq!(refs::encode_ref_wire(&h).as_slice(), bytes.as_slice());
}

#[test]
fn golden_head_symbolic_parses() {
    let bytes = read_golden("head_symbolic.bin");
    let s = std::str::from_utf8(&bytes).unwrap();
    assert!(s.starts_with("ref: refs/heads/"));
    assert!(s.ends_with('\n'));
    // Use the actual reader to confirm parse semantics.
    let dir = tempfile::TempDir::new().unwrap();
    let mkit = dir.path().join(".mkit");
    fs::create_dir_all(&mkit).unwrap();
    fs::write(mkit.join("HEAD"), &bytes).unwrap();
    let head = refs::read_head(&mkit).unwrap();
    assert_eq!(head, Head::Branch("main".to_string()));
}

#[test]
fn cas_any_works_against_object_store_repo() {
    // Repo built atop the ObjectStore (which owns .mkit/objects/);
    // the refs subsystem co-tenants that .mkit/ root.
    let dir = tempfile::TempDir::new().unwrap();
    let _store = ObjectStore::init(dir.path()).expect("init store");
    let mkit = dir.path().join(".mkit");
    refs::init(&mkit).unwrap();

    let h1 = hash::hash(b"any-1");
    refs::update_ref(&mkit, "main", RefWriteCondition::Any, &h1).unwrap();
    assert_eq!(refs::read_ref(&mkit, "main").unwrap(), Some(h1));

    let h2 = hash::hash(b"any-2");
    refs::update_ref(&mkit, "main", RefWriteCondition::Any, &h2).unwrap();
    assert_eq!(refs::read_ref(&mkit, "main").unwrap(), Some(h2));
}

#[test]
fn cas_missing_then_match_then_conflict() {
    let dir = tempfile::TempDir::new().unwrap();
    let _store = ObjectStore::init(dir.path()).expect("init store");
    let mkit = dir.path().join(".mkit");
    refs::init(&mkit).unwrap();

    let v1 = hash::hash(b"v1");
    let v2 = hash::hash(b"v2");

    // Missing succeeds when the ref is absent.
    refs::update_ref(&mkit, "main", RefWriteCondition::Missing, &v1).unwrap();

    // A second Missing fails.
    let err = refs::update_ref(&mkit, "main", RefWriteCondition::Missing, &v2).unwrap_err();
    assert!(matches!(err, RefError::Conflict(_)));

    // Match against the correct prior hash succeeds.
    refs::update_ref(&mkit, "main", RefWriteCondition::Match(v1), &v2).unwrap();
    assert_eq!(refs::read_ref(&mkit, "main").unwrap(), Some(v2));

    // Match against the wrong hash fails.
    let bogus = hash::hash(b"bogus");
    let err = refs::update_ref(&mkit, "main", RefWriteCondition::Match(bogus), &v1).unwrap_err();
    assert!(matches!(err, RefError::Conflict(_)));
    assert_eq!(refs::read_ref(&mkit, "main").unwrap(), Some(v2));
}

#[test]
fn list_refs_returns_sorted_unique_entries() {
    let dir = tempfile::TempDir::new().unwrap();
    let _store = ObjectStore::init(dir.path()).expect("init store");
    let mkit = dir.path().join(".mkit");
    refs::init(&mkit).unwrap();
    refs::write_ref(&mkit, "main", &hash::hash(b"m")).unwrap();
    refs::write_ref(&mkit, "feature/topic", &hash::hash(b"ft")).unwrap();
    refs::write_ref(&mkit, "feature/another", &hash::hash(b"fa")).unwrap();
    let listed: Vec<Ref> = refs::list_refs(&mkit).unwrap();
    let names: Vec<&str> = listed.iter().map(|r| r.name.as_str()).collect();
    assert_eq!(names, vec!["feature/another", "feature/topic", "main"]);
    // No duplicates.
    let mut sorted = names.clone();
    sorted.sort_unstable();
    sorted.dedup();
    assert_eq!(sorted.len(), names.len());
}

#[test]
fn refs_init_creates_layout() {
    let dir = tempfile::TempDir::new().unwrap();
    let mkit = dir.path().join(".mkit");
    fs::create_dir_all(&mkit).unwrap();
    refs::init(&mkit).unwrap();
    assert!(mkit.join(REFS_DIR).is_dir());
    assert!(mkit.join("refs/heads").is_dir());
    assert!(mkit.join("refs/tags").is_dir());
    let head = refs::read_head(&mkit).unwrap();
    assert_eq!(head, Head::Branch("main".to_string()));
}

#[test]
fn validate_ref_name_examples_from_spec() {
    // Positive cases from SPEC-REFS §3 / §7.6.
    assert!(validate_ref_name("main"));
    assert!(validate_ref_name("feat/v1.0-beta"));
    assert!(validate_ref_name("release/2024_09"));
    // Negative cases from the same section.
    assert!(!validate_ref_name("feat/.."));
    assert!(!validate_ref_name("/main"));
    assert!(!validate_ref_name("main@v1"));
    assert!(!validate_ref_name("feat\\branch"));
    assert!(!validate_ref_name(""));
}

#[test]
fn repo_lock_blocks_concurrent_acquire() {
    let dir = tempfile::TempDir::new().unwrap();
    let mkit = dir.path().join(".mkit");
    fs::create_dir_all(&mkit).unwrap();
    let _held = repo_lock::acquire_default(&mkit, "index.lock").unwrap();
    let err =
        repo_lock::acquire(&mkit, "index.lock", std::time::Duration::from_millis(100)).unwrap_err();
    assert!(matches!(err, repo_lock::LockError::Busy(_)));
}

#[test]
fn index_round_trip_via_disk() {
    let dir = tempfile::TempDir::new().unwrap();
    fs::create_dir_all(dir.path().join(".mkit")).unwrap();
    let mut idx = Index::new();
    idx.entries.push(IndexEntry {
        path: "README.md".into(),
        status: EntryStatus::Blob,
        object_hash: hash::hash(b"r"),
        mtime_ns: 0,
        size: 0,
        ino: 0,
        ctime_ns: 0,
    });
    idx.entries.push(IndexEntry {
        path: "old.txt".into(),
        status: EntryStatus::Removed,
        object_hash: [0u8; 32],
        mtime_ns: 0,
        size: 0,
        ino: 0,
        ctime_ns: 0,
    });
    index::write_index(dir.path(), &idx).unwrap();
    let read = index::read_index(dir.path()).unwrap();
    assert_eq!(read, idx);
}

#[test]
fn worktree_build_tree_against_object_store() {
    let dir = tempfile::TempDir::new().unwrap();
    let store = ObjectStore::init(dir.path()).unwrap();
    let work = tempfile::TempDir::new().unwrap();
    fs::write(work.path().join("a.txt"), b"a").unwrap();
    fs::write(work.path().join("b.txt"), b"b").unwrap();
    let h1 = mkit_core::worktree::build_tree(&store, work.path()).unwrap();
    let h2 = mkit_core::worktree::build_tree(&store, work.path()).unwrap();
    assert_eq!(h1, h2, "build_tree must be deterministic");
    let obj = store.read_object(&h1).unwrap();
    if let mkit_core::object::Object::Tree(t) = obj {
        assert_eq!(t.entries.len(), 2);
    } else {
        panic!("expected tree object");
    }
}