balls 0.5.9

Git-native task tracker for parallel agent workflows
Documentation
//! Tests for the §1/§12 seed — embedded bootstrap, the XDG override, the
//! bind-present + prune-absent, and the established-landing rebind. Each builds
//! throwaway XDG / landing / exe dirs in a tempdir; no real binary is run (a
//! bind is just a symlink — protocol validation is `install`'s job, §6).

use super::*;
use crate::hooks::Hooks;
use std::fs;
use std::path::Path;
use tempfile::TempDir;

/// An `Xdg` whose config-home is `root/cfg` (so `default-config/` lands there).
fn xdg_at(root: &Path) -> Xdg {
    Xdg::with(root, Some(&root.join("cfg").to_string_lossy()), Some(&root.join("state").to_string_lossy()))
}

/// A dir holding fake sibling binaries `names`, standing in for the dir `bl`
/// lives in.
fn exe_dir_with(root: &Path, names: &[&str]) -> std::path::PathBuf {
    let dir = root.join("bin");
    fs::create_dir_all(&dir).unwrap();
    for name in names {
        fs::write(dir.join(name), "#!/bin/sh\n").unwrap();
    }
    dir
}

fn bin_link(landing: &Path, name: &str) -> std::path::PathBuf {
    landing.join("config/plugins/bin").join(name)
}

#[test]
fn seed_uses_the_embedded_default_without_materializing_the_xdg_override() {
    let tmp = TempDir::new().unwrap();
    let xdg = xdg_at(tmp.path());
    let landing = tmp.path().join("landing");
    let exe = exe_dir_with(tmp.path(), &["bl-tracker", "bl-delivery"]);

    seed_landing(&xdg, &landing, Some(&exe)).unwrap();

    // The embedded default is used DIRECTLY: the XDG override slot is NEVER
    // auto-created, so it can't go stale and shadow a moved embedded default (bl-8088).
    assert!(!xdg.default_config().exists());
    // The landing's config/ carries the CURRENT embedded default verbatim.
    assert!(fs::read_to_string(landing.join("config/balls.toml")).unwrap().contains("balls/tasks"));
    // Both shipped plugins resolved → kept in the schedule and bound locally.
    let hooks = Hooks::load(&landing).unwrap();
    assert_eq!(hooks.names("close", "post"), ["bl-delivery", "bl-tracker"]);
    assert!(bin_link(&landing, "bl-tracker").symlink_metadata().is_ok());
    assert!(bin_link(&landing, "bl-delivery").symlink_metadata().is_ok());
    // The default wires no pre-seal staging (bl-0af4 — nothing is stored) but
    // does wire the `show` read-op under its bare key (§6 read dispatch).
    assert!(hooks.names("claim", "pre").is_empty());
    assert!(hooks.names("unclaim", "pre").is_empty());
    assert_eq!(hooks.resolve_read(&crate::registry::Registry::at(&landing), "show").len(), 1);
}

#[test]
fn seed_prunes_a_plugin_whose_binary_is_absent_here() {
    let tmp = TempDir::new().unwrap();
    let xdg = xdg_at(tmp.path());
    let landing = tmp.path().join("landing");
    // Only bl-tracker is installed beside bl — bl-delivery must prune.
    let exe = exe_dir_with(tmp.path(), &["bl-tracker"]);

    seed_landing(&xdg, &landing, Some(&exe)).unwrap();

    let hooks = Hooks::load(&landing).unwrap();
    assert_eq!(hooks.names("close", "post"), ["bl-tracker"]); // bl-delivery dropped
    assert!(hooks.names("close", "pre").is_empty()); // was [bl-delivery] → emptied
    assert!(bin_link(&landing, "bl-tracker").symlink_metadata().is_ok());
    assert!(bin_link(&landing, "bl-delivery").symlink_metadata().is_err());
}

#[test]
fn seed_with_no_exe_dir_prunes_every_plugin() {
    let tmp = TempDir::new().unwrap();
    let xdg = xdg_at(tmp.path());
    let landing = tmp.path().join("landing");

    seed_landing(&xdg, &landing, None).unwrap();

    // A tracker-less box: every default entry prunes, the chain runs empty.
    let hooks = Hooks::load(&landing).unwrap();
    assert!(hooks.names("prime", "pre").is_empty());
    assert!(!landing.join("config/plugins/bin").exists());
    // balls.toml still seeded — config defaults stand.
    assert!(landing.join("config/balls.toml").is_file());
}

#[test]
fn an_xdg_override_wins_over_the_embedded_default() {
    let tmp = TempDir::new().unwrap();
    let xdg = xdg_at(tmp.path());
    // Pre-populate the override folder with a custom schedule (only a tracker).
    let dc = xdg.default_config();
    fs::create_dir_all(&dc).unwrap();
    fs::write(dc.join("balls.toml"), "tasks_branch = \"team/tasks\"\n").unwrap();
    fs::write(dc.join("plugins.toml"), "[hooks]\n\"close.post\" = [\"tracker\"]\n").unwrap();

    let landing = tmp.path().join("landing");
    let exe = exe_dir_with(tmp.path(), &["tracker", "bl-delivery"]);
    seed_landing(&xdg, &landing, Some(&exe)).unwrap();

    // The override's config travelled, not the embedded one.
    assert!(fs::read_to_string(landing.join("config/balls.toml")).unwrap().contains("team/tasks"));
    let hooks = Hooks::load(&landing).unwrap();
    assert_eq!(hooks.names("close", "post"), ["tracker"]); // override schedule
    assert!(hooks.names("prime", "pre").is_empty()); // override wired no prime hook
}

#[test]
fn a_pruned_hinted_plugin_is_noted_and_its_source_survives_the_rewrite() {
    // bl-5b09: the prune stays silent for a hintless absent name (the
    // shipped-sibling case) but a hinted one yields its rendered note — the org
    // opted into loudness by authoring the hint — RETURNED for prime to emit
    // through the op log (bl-b1be); and the [source] table round-trips WHOLE
    // through the seed's rewrite, so the hint is still readable for the re-add
    // after acquiring.
    let tmp = TempDir::new().unwrap();
    let xdg = xdg_at(tmp.path());
    let dc = xdg.default_config();
    fs::create_dir_all(&dc).unwrap();
    fs::write(
        dc.join("plugins.toml"),
        "[hooks]\n\"close.pre\" = [\"ghost\", \"mute\", \"tracker\"]\n[source]\nghost = \"cargo install ghost\"\n",
    )
    .unwrap();

    let landing = tmp.path().join("landing");
    let exe = exe_dir_with(tmp.path(), &["tracker"]);
    let notes = seed_landing(&xdg, &landing, Some(&exe)).unwrap();

    assert_eq!(
        notes,
        ["seed: pruned ghost (no binary beside bl) — source: cargo install ghost — re-add with bl conf after acquiring"],
        "one note for the hinted prune, none for the hintless one"
    );
    let seeded = Hooks::load(&landing).unwrap();
    assert_eq!(seeded.names("close", "pre"), ["tracker"], "ghost and mute pruned");
    assert_eq!(seeded.source("ghost").as_deref(), Some("cargo install ghost"), "the hint survived the prune");
}

#[test]
fn an_override_missing_a_file_falls_back_to_the_embedded_default() {
    let tmp = TempDir::new().unwrap();
    let xdg = xdg_at(tmp.path());
    // An override with a plugins.toml but no balls.toml.
    let dc = xdg.default_config();
    fs::create_dir_all(&dc).unwrap();
    fs::write(dc.join("plugins.toml"), "[hooks]\n\"create.post\" = [\"tracker\"]\n").unwrap();

    let landing = tmp.path().join("landing");
    let exe = exe_dir_with(tmp.path(), &["tracker"]);
    seed_landing(&xdg, &landing, Some(&exe)).unwrap();

    // The override's plugins.toml travels; the absent balls.toml falls back to the
    // CURRENT embedded default (per-file overlay, bl-8088) — not to nothing.
    assert!(fs::read_to_string(landing.join("config/balls.toml")).unwrap().contains("balls/tasks"));
    assert_eq!(Hooks::load(&landing).unwrap().names("create", "post"), ["tracker"]);
}

#[test]
fn rebind_reestablishes_present_bindings_without_touching_the_schedule() {
    let tmp = TempDir::new().unwrap();
    let landing = tmp.path().join("landing");
    let config = landing.join("config");
    fs::create_dir_all(&config).unwrap();
    let schedule = "[hooks]\n\"close.post\" = [\"bl-delivery\", \"tracker\"]\n";
    fs::write(config.join("plugins.toml"), schedule).unwrap();
    // A new machine has only the tracker installed.
    let exe = exe_dir_with(tmp.path(), &["tracker"]);

    rebind(&landing, Some(&exe)).unwrap();

    // The present binary is bound; the absent one stays dangling (no prune).
    assert!(bin_link(&landing, "tracker").symlink_metadata().is_ok());
    assert!(bin_link(&landing, "bl-delivery").symlink_metadata().is_err());
    // The committed schedule is untouched — capabilities change only by install.
    assert_eq!(fs::read_to_string(config.join("plugins.toml")).unwrap(), schedule);
}