git-loom 0.25.0

A Git CLI tool that weaves together multiple feature branches into integration branches
use super::*;
use git2::Oid;

fn oid(byte: u8) -> Oid {
    let mut bytes = [0u8; 20];
    bytes[0] = byte;
    Oid::from_bytes(&bytes).unwrap()
}

#[test]
fn unstaged_is_always_zz() {
    let alloc = IdAllocator::new(vec![Entity::Unstaged]);
    assert_eq!(alloc.get_unstaged(), "zz");
}

#[test]
fn branch_word_initials() {
    let alloc = IdAllocator::new(vec![
        Entity::Branch("feature-a".to_string()),
        Entity::Branch("bugfix-123".to_string()),
    ]);
    // "feature-a" → words ["feature","a"] → first chars → "fa"
    assert_eq!(alloc.get_branch("feature-a"), "fa");
    // "bugfix-123" → words ["bugfix","123"] → first chars → "b1"
    assert_eq!(alloc.get_branch("bugfix-123"), "b1");
}

#[test]
fn single_word_branch_uses_first_two_chars() {
    let alloc = IdAllocator::new(vec![
        Entity::Branch("alpha".to_string()),
        Entity::Branch("beta".to_string()),
    ]);
    assert_eq!(alloc.get_branch("alpha"), "al");
    assert_eq!(alloc.get_branch("beta"), "be");
}

#[test]
fn commit_uses_first_two_hex_chars() {
    let alloc = IdAllocator::new(vec![Entity::commit(oid(0xAB))]);
    let id = alloc.get_commit(oid(0xAB));
    assert_eq!(id.len(), 2);
    assert_eq!(id, "ab");
}

#[test]
fn file_uses_stem_not_extension() {
    let alloc = IdAllocator::new(vec![
        Entity::File("src/main.rs".to_string()),
        Entity::File("tests/integration.rs".to_string()),
    ]);
    // stem "main" → pairs (0,1)=ma
    assert_eq!(alloc.get_file("src/main.rs"), "ma");
    // stem "integration" → pairs (0,1)=in
    assert_eq!(alloc.get_file("tests/integration.rs"), "in");
}

#[test]
fn common_prefix_resolved_at_two_chars() {
    // feature-a gets "fa", feature-b gets "fb" (same first letter is fine, full ID is unique)
    let alloc = IdAllocator::new(vec![
        Entity::Branch("feature-a".to_string()),
        Entity::Branch("feature-b".to_string()),
    ]);
    assert_eq!(alloc.get_branch("feature-a"), "fa");
    assert_eq!(alloc.get_branch("feature-b"), "fb");
}

#[test]
fn same_first_word_different_second_word_stays_two_chars() {
    // feature-alpha and feature-awesome: both start "fa",
    // second entity gets alternative 2-char like "fw"
    let alloc = IdAllocator::new(vec![
        Entity::Branch("feature-alpha".to_string()),
        Entity::Branch("feature-awesome".to_string()),
    ]);
    let id_alpha = alloc.get_branch("feature-alpha");
    let id_awesome = alloc.get_branch("feature-awesome");
    assert_eq!(id_alpha, "fa");
    assert_eq!(
        id_awesome.len(),
        2,
        "expected 2-char ID, got '{}'",
        id_awesome
    );
    assert_ne!(id_alpha, id_awesome);
}

#[test]
fn triple_collision_all_two_chars() {
    // main, master, maintenance — all single-word, start "ma"
    // main→ma, master→ms (next pair), maintenance→mi (next pair)
    let alloc = IdAllocator::new(vec![
        Entity::Branch("main".to_string()),
        Entity::Branch("master".to_string()),
        Entity::Branch("maintenance".to_string()),
    ]);
    let id_main = alloc.get_branch("main");
    let id_master = alloc.get_branch("master");
    let id_maint = alloc.get_branch("maintenance");

    assert_ne!(id_main, id_master);
    assert_ne!(id_main, id_maint);
    assert_ne!(id_master, id_maint);
    // All should stay at 2 chars
    assert_eq!(id_main.len(), 2, "main: '{}'", id_main);
    assert_eq!(id_master.len(), 2, "master: '{}'", id_master);
    assert_eq!(id_maint.len(), 2, "maintenance: '{}'", id_maint);
}

#[test]
fn cross_entity_collision_resolved() {
    // Branch "main" and file "src/main.rs" (stem "main") both start "ma"
    let alloc = IdAllocator::new(vec![
        Entity::Branch("main".to_string()),
        Entity::File("src/main.rs".to_string()),
    ]);
    let branch_id = alloc.get_branch("main");
    let file_id = alloc.get_file("src/main.rs");
    assert_ne!(branch_id, file_id);
    // Both should stay at 2 chars
    assert_eq!(branch_id.len(), 2);
    assert_eq!(file_id.len(), 2);
}

#[test]
fn no_collision_stays_two_chars() {
    let alloc = IdAllocator::new(vec![
        Entity::Branch("alpha".to_string()),
        Entity::Branch("beta".to_string()),
        Entity::File("src/config.rs".to_string()),
        Entity::commit(oid(0xFF)),
    ]);
    assert_eq!(alloc.get_branch("alpha"), "al");
    assert_eq!(alloc.get_branch("beta"), "be");
    assert_eq!(alloc.get_file("src/config.rs"), "co");
    assert_eq!(alloc.get_commit(oid(0xFF)).len(), 2);
}

#[test]
fn exhausted_source_uses_numeric_suffix() {
    let alloc = IdAllocator::new(vec![
        Entity::Branch("ab".to_string()),
        Entity::File("ab".to_string()),
    ]);
    let id_branch = alloc.get_branch("ab");
    let id_file = alloc.get_file("ab");
    assert_ne!(id_branch, id_file);
}

#[test]
fn short_source_does_not_hang() {
    let alloc = IdAllocator::new(vec![
        Entity::Branch("a".to_string()),
        Entity::File("a".to_string()),
    ]);
    let id_branch = alloc.get_branch("a");
    let id_file = alloc.get_file("a");
    assert_ne!(id_branch, id_file);
    // Single-char names must still produce 2+ char IDs
    assert!(id_branch.len() >= 2, "branch 'a': got '{}'", id_branch);
    assert!(id_file.len() >= 2, "file 'a': got '{}'", id_file);
}

#[test]
fn single_char_file_gets_two_char_id() {
    let alloc = IdAllocator::new(vec![Entity::File("a".to_string())]);
    let id = alloc.get_file("a");
    assert_eq!(id, "aa");
}

#[test]
fn commits_allocated_before_branches() {
    // Commit eaec… naturally wants "ea". Branch "feat2" also generates "ea"
    // as a candidate (f+e, e+a, …). Commits should get priority.
    let commit_oid = Oid::from_str("eaec409000000000000000000000000000000000").unwrap();
    let alloc = IdAllocator::new(vec![
        Entity::Branch("feat2".to_string()),
        Entity::commit(commit_oid),
    ]);
    // Commit keeps its natural 2-char hex prefix
    assert_eq!(alloc.get_commit(commit_oid), "ea");
    // Branch gets an alternative that doesn't conflict
    let branch_id = alloc.get_branch("feat2");
    assert_ne!(branch_id, "ea");
    assert_eq!(branch_id.len(), 2, "feat2: got '{}'", branch_id);
}

#[test]
fn slash_separator_works() {
    let alloc = IdAllocator::new(vec![
        Entity::Branch("feature/login".to_string()),
        Entity::Branch("feature/logout".to_string()),
    ]);
    let id_login = alloc.get_branch("feature/login");
    let id_logout = alloc.get_branch("feature/logout");
    // Both primary "fl", second gets alternative 2-char
    assert_ne!(id_login, id_logout);
    assert_eq!(id_login.len(), 2);
    assert_eq!(id_logout.len(), 2);
}

#[test]
fn underscore_separator_works() {
    let alloc = IdAllocator::new(vec![
        Entity::Branch("my_feature".to_string()),
        Entity::Branch("my_fix".to_string()),
    ]);
    let id_feat = alloc.get_branch("my_feature");
    let id_fix = alloc.get_branch("my_fix");
    assert_ne!(id_feat, id_fix);
    assert_eq!(id_feat.len(), 2);
    assert_eq!(id_fix.len(), 2);
}

#[test]
fn file_with_underscores_uses_word_initials() {
    let alloc = IdAllocator::new(vec![Entity::File("new_file.txt".to_string())]);
    // stem "new_file" → words ["new","file"] → "nf"
    assert_eq!(alloc.get_file("new_file.txt"), "nf");
}

#[test]
fn collision_shifts_to_second_letter_multi_word() {
    // feature-a and feature-b: first gets "fa", second gets "fb" (full ID is unique)
    let alloc = IdAllocator::new(vec![
        Entity::Branch("feature-a".to_string()),
        Entity::Branch("feature-b".to_string()),
    ]);
    assert_eq!(alloc.get_branch("feature-a"), "fa");
    assert_eq!(alloc.get_branch("feature-b"), "fb");
}

#[test]
fn collision_shifts_forward_single_word() {
    // main and mainstream: first gets "ma", second gets "mi" (next unused 2-char candidate)
    let alloc = IdAllocator::new(vec![
        Entity::Branch("main".to_string()),
        Entity::Branch("mainstream".to_string()),
    ]);
    assert_eq!(alloc.get_branch("main"), "ma");
    assert_eq!(alloc.get_branch("mainstream"), "mi");
}

#[test]
fn zz_is_never_generated_for_other_entities() {
    // "zoo-zulu" would naturally want "zz", and "zz.rs" has no other candidate.
    let alloc = IdAllocator::new(vec![
        Entity::Branch("zoo-zulu".to_string()),
        Entity::File("zz.rs".to_string()),
    ]);
    assert_ne!(alloc.get_branch("zoo-zulu"), "zz");
    // "zz.rs" exhausts its candidates, so it falls back to the reserved escape.
    assert_eq!(alloc.get_file("zz.rs"), "zz1");
}

// ── Persistent commit IDs (Change-Id letters) ────────────────────────────

/// `3ac7…` encodes to `wpns…`, `3ac8…` to `wpnr…`, `b71…` to `osy…`.
const ID_A: &str = "I3ac7000000000000000000000000000000000000";
const ID_B: &str = "I3ac8000000000000000000000000000000000000";
const ID_C: &str = "Ib710000000000000000000000000000000000000";

fn persistent(byte: u8, change_id: &str) -> Entity {
    Entity::Commit {
        oid: oid(byte),
        change_id: Some(change_id.to_string()),
    }
}

#[test]
fn commit_with_change_id_gets_three_letters() {
    let alloc = IdAllocator::new(vec![persistent(1, ID_A), persistent(2, ID_C)]);
    assert_eq!(alloc.get_commit(oid(1)), "wpn");
    assert_eq!(alloc.get_commit(oid(2)), "osy");
}

/// A newcomer sharing a prefix must never take an existing commit's ID: both
/// grow, so a stale `wpn` matches neither instead of the wrong one.
#[test]
fn a_colliding_newcomer_lengthens_both_ids() {
    let before = IdAllocator::new(vec![persistent(1, ID_A)]);
    assert_eq!(before.get_commit(oid(1)), "wpn");

    // Newest first, as the graph lists commits.
    let after = IdAllocator::new(vec![persistent(2, ID_B), persistent(1, ID_A)]);
    assert_eq!(after.get_commit(oid(1)), "wpns");
    assert_eq!(after.get_commit(oid(2)), "wpnr");
}

#[test]
fn persistent_ids_do_not_depend_on_commit_order() {
    let one = IdAllocator::new(vec![persistent(1, ID_A), persistent(2, ID_B)]);
    let two = IdAllocator::new(vec![persistent(2, ID_B), persistent(1, ID_A)]);
    assert_eq!(one.get_commit(oid(1)), two.get_commit(oid(1)));
    assert_eq!(one.get_commit(oid(2)), two.get_commit(oid(2)));
}

#[test]
fn twins_sharing_a_change_id_fall_back_to_hash_ids() {
    let alloc = IdAllocator::new(vec![persistent(0x11, ID_A), persistent(0x22, ID_A)]);
    assert_eq!(alloc.get_commit(oid(0x11)), "11");
    assert_eq!(alloc.get_commit(oid(0x22)), "22");
}

#[test]
fn letter_and_hex_commits_coexist_with_a_branch() {
    let alloc = IdAllocator::new(vec![
        Entity::commit(oid(0xAB)),
        persistent(1, ID_A),
        Entity::Branch("wp-n".to_string()),
    ]);
    assert_eq!(alloc.get_commit(oid(0xAB)), "ab");
    assert_eq!(alloc.get_commit(oid(1)), "wpn");
    assert_eq!(alloc.get_branch("wp-n"), "wn");
}

#[test]
fn find_persistent_by_prefix_or_literal() {
    let alloc = IdAllocator::new(vec![
        persistent(2, ID_B),
        persistent(1, ID_A),
        Entity::commit(oid(0xAB)),
    ]);
    assert_eq!(alloc.find_persistent("wpns"), vec![oid(1)]);
    assert_eq!(alloc.find_persistent("wpnszzz"), vec![oid(1)]);
    assert_eq!(alloc.find_persistent(ID_B), vec![oid(2)]);
    assert_eq!(alloc.find_persistent(&ID_B.to_uppercase()), vec![oid(2)]);
    assert_eq!(sorted(alloc.find_persistent("wpn")), vec![oid(1), oid(2)]);
    assert!(
        alloc.find_persistent("wp").is_empty(),
        "shorter than MIN_LEN"
    );
    assert!(
        alloc.find_persistent("ab").is_empty(),
        "hex ids are not letters"
    );
    assert!(alloc.find_persistent("osy").is_empty());
}

#[test]
fn find_persistent_returns_both_twins() {
    let alloc = IdAllocator::new(vec![persistent(0x11, ID_A), persistent(0x22, ID_A)]);
    assert_eq!(
        sorted(alloc.find_persistent(ID_A)),
        vec![oid(0x11), oid(0x22)]
    );
    assert_eq!(
        sorted(alloc.find_persistent("wpn")),
        vec![oid(0x11), oid(0x22)]
    );
}

fn sorted(mut oids: Vec<git2::Oid>) -> Vec<git2::Oid> {
    oids.sort();
    oids
}