balls 0.5.13

Git-native task tracker for parallel agent workflows
Documentation
//! Tests for the recency walk over `balls/tasks` history — single-id
//! reconstruction ([`resolve_dead`]) and the dead-set enumeration
//! ([`dead_balls`]), exercised against a real throwaway git store.

use super::*;
use crate::reads::test_support::{git_store, task};

#[test]
fn resolve_dead_reconstructs_a_closed_ball_from_history() {
    let s = git_store();
    let mut t = task("Refactor", 100);
    t.body = "the plan".into();
    s.create("bl-1", &t, 100).retire("bl-1", "close", 500);

    let dead = resolve_dead(s.dir(), "bl-1").unwrap().unwrap();
    assert_eq!(dead.id, "bl-1");
    assert_eq!(dead.task.title, "Refactor");
    assert_eq!(dead.task.body, "the plan"); // frontmatter+body from the deletion's parent
    assert_eq!(dead.retired_at, 500); // the deletion commit's date
}

#[test]
fn resolve_dead_reconstructs_a_dropped_ball_the_same_as_a_closed_one() {
    // A `drop` retirement reconstructs identically — no distinct status survives
    // the collapse; the `bl-op: drop` trailer stays git bedrock, never a field.
    let s = git_store();
    s.create("bl-2", &task("Abandoned", 1), 1).retire("bl-2", "drop", 9);
    let dead = resolve_dead(s.dir(), "bl-2").unwrap().unwrap();
    assert_eq!(dead.task.title, "Abandoned");
    assert_eq!(dead.retired_at, 9);
}

#[test]
fn resolve_dead_is_none_when_the_id_was_never_deleted() {
    let s = git_store();
    s.create("bl-live", &task("Alive", 1), 1); // born, never retired
    // No deletion in history ⇒ no dead incarnation (the empty-log path).
    assert!(resolve_dead(s.dir(), "bl-live").unwrap().is_none());
    assert!(resolve_dead(s.dir(), "bl-never").unwrap().is_none());
}

#[test]
fn resolve_dead_takes_the_newest_incarnation_of_a_reused_id() {
    let s = git_store();
    // Same id lived twice: closed as "First", then re-created and dropped as "Second".
    s.create("bl-r", &task("First", 1), 1).retire("bl-r", "close", 2);
    s.create("bl-r", &task("Second", 3), 3).retire("bl-r", "drop", 4);

    let dead = resolve_dead(s.dir(), "bl-r").unwrap().unwrap();
    assert_eq!(dead.task.title, "Second"); // most-recent-down
    assert_eq!(dead.retired_at, 4);
}

#[test]
fn resolve_dead_sees_a_deletion_that_shares_its_commit_with_a_similar_add() {
    // `show`'s half of bl-ae74: ONE commit retires bl-gone while adding a
    // near-identical bl-new, which git's default rename detection reports as a
    // single `R`. The singular walk survives it even unflagged (its pathspec is
    // the one file, so the sibling add is filtered out before git can pair a
    // rename) — this locks the OUTCOME, so the day that stops being true the id
    // does not silently start naming nothing, live or dead.
    let s = git_store();
    let mut gone = task("Retired", 1);
    gone.body = "the plan\nstep one\nstep two\nstep three\n".into();
    let mut born = task("Successor", 2);
    born.body = gone.body.clone(); // self-similar enough to cross git's threshold
    s.create("bl-gone", &gone, 1).retire_and_create("bl-gone", "bl-new", &born, 50);

    let dead = resolve_dead(s.dir(), "bl-gone").unwrap().unwrap();
    assert_eq!(dead.task.title, "Retired"); // reconstructed from the deletion's parent
    assert_eq!(dead.retired_at, 50);
}

#[test]
fn resolve_dead_surfaces_a_corrupt_historical_file_as_an_error() {
    let s = git_store();
    s.create_raw("bl-bad", "not valid frontmatter", 1).retire("bl-bad", "close", 2);
    assert!(resolve_dead(s.dir(), "bl-bad").is_err());
}

#[test]
fn resolve_dead_errors_when_the_store_is_not_a_git_repo() {
    let tmp = tempfile::TempDir::new().unwrap();
    assert!(resolve_dead(&tmp.path().join("nope"), "bl-x").is_err());
}

#[test]
fn dead_balls_enumerates_every_dead_ball_newest_first() {
    let s = git_store();
    s.create("bl-a", &task("A", 1), 1).retire("bl-a", "close", 10);
    s.create("bl-b", &task("B", 2), 2).retire("bl-b", "drop", 20);

    let live = Catalog::load(s.dir()).unwrap();
    let dead = dead_balls(s.dir(), &live).unwrap();
    let ids: Vec<&str> = dead.iter().map(|d| d.id.as_str()).collect();
    assert_eq!(ids, ["bl-b", "bl-a"]); // newest deletion first
}

#[test]
fn dead_balls_excludes_an_id_that_is_live_again() {
    let s = git_store();
    s.create("bl-x", &task("X", 1), 1).retire("bl-x", "close", 2);
    s.create("bl-x", &task("X-again", 3), 3); // re-created, still live

    let live = Catalog::load(s.dir()).unwrap();
    let dead = dead_balls(s.dir(), &live).unwrap();
    assert!(dead.is_empty()); // resolves live, so not in the dead set
}

#[test]
fn dead_balls_attributes_every_path_in_one_commit_to_that_commit() {
    // An epic landing its children deletes several task files in ONE commit, so
    // the enumeration sees one `<sha>\x1f<ct>` header followed by several paths.
    // Every one of them must take that header's sha (for its content) and date.
    let s = git_store();
    let mut a = task("Child A", 1);
    a.body = "first".into();
    let mut b = task("Child B", 2);
    b.body = "second".into();
    s.create("bl-a", &a, 1).create("bl-b", &b, 2).retire_all(&["bl-a", "bl-b"], "close", 77);

    let live = Catalog::load(s.dir()).unwrap();
    let dead = dead_balls(s.dir(), &live).unwrap();
    let got: Vec<(&str, &str, i64)> =
        dead.iter().map(|d| (d.id.as_str(), d.task.body.as_str(), d.retired_at)).collect();
    // Bodies stay paired with their own ids: the batch replies zip positionally
    // with the enumeration, so a mis-paired stream would swap them.
    assert_eq!(got, [("bl-a", "first", 77), ("bl-b", "second", 77)]);
}

#[test]
fn dead_balls_frames_the_batch_on_bytes_not_characters() {
    // `cat-file --batch` delimits each reply by a BYTE count. A multi-byte body
    // makes byte length differ from character length, so a char-framed reader
    // would slide off the first reply and corrupt every one after it.
    let s = git_store();
    let mut wide = task("Accented", 1);
    wide.body = "café — naïve 日本語 🜂".into();
    let mut plain = task("Plain", 2);
    plain.body = "ascii".into();
    s.create("bl-wide", &wide, 1).retire("bl-wide", "close", 10);
    s.create("bl-plain", &plain, 2).retire("bl-plain", "close", 20);

    let live = Catalog::load(s.dir()).unwrap();
    let dead = dead_balls(s.dir(), &live).unwrap();
    assert_eq!(dead[0].task.body, "ascii"); // newest first
    assert_eq!(dead[1].task.body, "café — naïve 日本語 🜂");
    assert_eq!(dead[1].task.title, "Accented"); // frontmatter survived the split too
}

#[test]
fn dead_balls_surfaces_a_corrupt_historical_file_as_an_error() {
    // The batched path parses each reply itself, so it owns this refusal — the
    // whole listing fails rather than silently dropping the unreadable ball.
    let s = git_store();
    s.create_raw("bl-bad", "not valid frontmatter", 1).retire("bl-bad", "close", 2);
    let live = Catalog::load(s.dir()).unwrap();
    assert!(dead_balls(s.dir(), &live).is_err());
}

#[test]
fn dead_balls_is_empty_when_nothing_was_ever_deleted() {
    // The empty batch: no object names to feed, so git reads EOF and exits clean.
    let s = git_store();
    s.create("bl-live", &task("Alive", 1), 1);
    let live = Catalog::load(s.dir()).unwrap();
    assert!(dead_balls(s.dir(), &live).unwrap().is_empty());
}

#[test]
fn dead_balls_keeps_a_deletion_that_shares_its_commit_with_a_similar_add() {
    // The enumeration's half of the same hole: the rename-shaped commit would
    // drop bl-gone out of the dead set entirely, so `list -s closed` omits the
    // row (bl-ae74). The added ball is live, so only the retired one lists.
    let s = git_store();
    let mut gone = task("Retired", 1);
    gone.body = "the plan\nstep one\nstep two\nstep three\n".into();
    let mut born = task("Successor", 2);
    born.body = gone.body.clone();
    s.create("bl-gone", &gone, 1).retire_and_create("bl-gone", "bl-new", &born, 50);

    let live = Catalog::load(s.dir()).unwrap();
    let dead = dead_balls(s.dir(), &live).unwrap();
    let ids: Vec<&str> = dead.iter().map(|d| d.id.as_str()).collect();
    assert_eq!(ids, ["bl-gone"]);
    assert_eq!(dead[0].task.title, "Retired");
}

#[test]
fn dead_balls_dedupes_a_twice_deleted_id_to_its_newest() {
    let s = git_store();
    s.create("bl-d", &task("D1", 1), 1).retire("bl-d", "close", 2);
    s.create("bl-d", &task("D2", 3), 3).retire("bl-d", "drop", 4);

    let live = Catalog::load(s.dir()).unwrap();
    let dead = dead_balls(s.dir(), &live).unwrap();
    assert_eq!(dead.len(), 1); // one row, not two deletions
    assert_eq!(dead[0].task.title, "D2");
}