yog 0.0.1

yog: a balls-oriented session manager for lernie loops (egui frontend)
Documentation
//! The piped + opslog'd executors and the non-spawn aborts: `bl create`, `bl
//! claim` (+ cross-check), the idempotent `lernie new` ensure, the mint mapping
//! ([`on_mint`]), and name resolution — each leaving a durable ops row, a
//! `["yog-step",…]` line for the non-spawn failures (§4.2, Z5).

use super::{World, ball, fake_bl, fake_fail, fake_lernie};
use crate::binding::{work_worktree_path, workspace_path};
use crate::cli_outbound::Cli;
use crate::names::MintError;
use crate::opslog::{SYNTHETIC_EXIT, YOG_STEP};
use crate::projects::join::JoinState;
use crate::start::{
    Payload, StartError, Target, cross_check_claim, execute_claim, execute_create,
    execute_ensure_workspace, on_mint, resolve_name, resolve_worktree,
};
use crate::test_support::spawn_guard;
use std::path::PathBuf;

#[test]
fn create_stamps_the_workspace_name_and_captures_the_id() {
    let _g = spawn_guard();
    let w = World::new();
    let bl = Cli::new(fake_bl(w.bin.path(), "bl-new9", w.balls.path()));
    let id = execute_create(
        &bl,
        w.state.path(),
        "TS",
        w.project.path(),
        "Wire",
        "the body",
        "cobalt-gecko",
    )
    .unwrap();
    assert_eq!(id, "bl-new9");
    assert_eq!(
        &w.ops()[0].argv[1..],
        &[
            "create",
            "Wire",
            "--as",
            "cobalt-gecko",
            "--body",
            "the body"
        ]
    );
}

#[test]
fn create_elides_an_empty_body() {
    let _g = spawn_guard();
    let w = World::new();
    let bl = Cli::new(fake_bl(w.bin.path(), "bl-1", w.balls.path()));
    execute_create(&bl, w.state.path(), "TS", w.project.path(), "T", "", "n").unwrap();
    assert_eq!(&w.ops()[0].argv[1..], &["create", "T", "--as", "n"]);
}

#[test]
fn create_errors_on_a_nonzero_exit() {
    let _g = spawn_guard();
    let w = World::new();
    let bl = Cli::new(fake_fail(w.bin.path(), "bl", "boom"));
    let err =
        execute_create(&bl, w.state.path(), "TS", w.project.path(), "T", "", "n").unwrap_err();
    assert!(matches!(err, StartError::VerbFailed { verb: "create", .. }));
}

#[test]
fn claim_stamps_the_name_and_cross_checks_the_canonical_worktree() {
    let _g = spawn_guard();
    let w = World::new();
    let canonical = work_worktree_path(w.balls.path(), w.project.path(), "bl-7", None);
    let bl = Cli::new(fake_bl(w.bin.path(), "x", &canonical));
    let r = execute_claim(
        &bl,
        w.state.path(),
        "TS",
        w.project.path(),
        "bl-7",
        "cobalt-gecko",
        w.balls.path(),
    )
    .unwrap();
    assert_eq!(r.worktree, canonical);
    assert!(!r.suffixed);
    assert_eq!(
        &w.ops()[0].argv[1..],
        &["claim", "bl-7", "--as", "cobalt-gecko"]
    );
}

#[test]
fn claim_errors_on_a_nonzero_exit() {
    let _g = spawn_guard();
    let w = World::new();
    let bl = Cli::new(fake_fail(w.bin.path(), "bl", "blocked"));
    let err = execute_claim(
        &bl,
        w.state.path(),
        "TS",
        w.project.path(),
        "bl-7",
        "n",
        w.balls.path(),
    )
    .unwrap_err();
    let StartError::VerbFailed { verb, outcome } = err else {
        panic!("expected VerbFailed, got {err:?}");
    };
    assert_eq!(verb, "claim");
    assert_eq!(outcome.stderr, "blocked\n");
}

#[test]
fn cross_check_accepts_the_claimant_suffix_variant() {
    let w = World::new();
    let suffixed = work_worktree_path(w.balls.path(), w.project.path(), "bl-9", Some("n"));
    let r = cross_check_claim(
        &suffixed.display().to_string(),
        w.balls.path(),
        w.project.path(),
        "bl-9",
        "n",
        w.state.path(),
        "TS",
    )
    .unwrap();
    assert!(r.suffixed);
    assert_eq!(r.worktree, suffixed);
    assert!(w.ops().is_empty(), "a matched claim logs no step-failure");
}

#[test]
fn cross_check_drift_is_surfaced_loudly_and_logged() {
    let w = World::new();
    let err = cross_check_claim(
        "/somewhere/unexpected",
        w.balls.path(),
        w.project.path(),
        "bl-9",
        "n",
        w.state.path(),
        "TS",
    )
    .unwrap_err();
    assert!(matches!(err, StartError::Drift { .. }));
    // The Drift is a rendered fact: a `["yog-step","cross-check"]` row (Z5).
    let e = &w.ops()[0];
    assert_eq!(e.argv, [YOG_STEP, "cross-check"]);
    assert_eq!(e.exit, SYNTHETIC_EXIT);
    assert!(e.stderr.contains("drift"));
}

#[test]
fn ensure_skips_when_the_workspace_already_exists() {
    let w = World::new();
    let ws = workspace_path(w.yog.path(), "n");
    std::fs::create_dir_all(ws.join("repo.git")).unwrap();
    let lernie = Cli::new("/definitely/not/a/real/lernie");
    let created = execute_ensure_workspace(&lernie, w.state.path(), "TS", &ws).unwrap();
    assert!(!created, "existing workspace skipped");
    assert!(w.ops().is_empty(), "skip runs and logs nothing");
}

#[test]
fn ensure_creates_the_workspace_and_logs() {
    let _g = spawn_guard();
    let w = World::new();
    let ws = workspace_path(w.yog.path(), "cobalt-gecko");
    let lernie = Cli::new(fake_lernie(w.bin.path()));
    assert!(execute_ensure_workspace(&lernie, w.state.path(), "TS", &ws).unwrap());
    assert!(ws.parent().unwrap().is_dir(), "parent chain mkdir -p'd");
    assert_eq!(
        &w.ops()[0].argv[1..],
        &["new", ws.to_string_lossy().as_ref()]
    );
}

#[test]
fn ensure_errors_and_logs_on_a_nonzero_new() {
    let _g = spawn_guard();
    let w = World::new();
    let ws = workspace_path(w.yog.path(), "n");
    let lernie = Cli::new(fake_fail(w.bin.path(), "lernie", "disk full"));
    let err = execute_ensure_workspace(&lernie, w.state.path(), "TS", &ws).unwrap_err();
    assert!(matches!(err, StartError::VerbFailed { verb: "new", .. }));
}

#[test]
fn ensure_logs_a_mkdir_step_failure() {
    // The parent chain cannot be created (a file sits where a dir must go) → a
    // `["yog-step","mkdir"]` row before the Io error (§4.2, Z5), no `lernie` spawn.
    let w = World::new();
    let blocker = w.yog.path().join("blocked");
    std::fs::write(&blocker, b"x").unwrap();
    let ws = blocker.join("workspaces").join("n");
    let lernie = Cli::new("/definitely/not/a/real/lernie");
    let err = execute_ensure_workspace(&lernie, w.state.path(), "TS", &ws).unwrap_err();
    assert!(matches!(err, StartError::Io(_)));
    assert_eq!(w.ops()[0].argv, [YOG_STEP, "mkdir"]);
}

#[test]
fn on_mint_passes_a_name_through() {
    let w = World::new();
    let name = on_mint(
        Ok("cobalt-gecko".to_owned()),
        w.state.path(),
        "TS",
        w.home.path(),
    )
    .unwrap();
    assert_eq!(name, "cobalt-gecko");
    assert!(w.ops().is_empty(), "a clean mint logs nothing");
}

#[test]
fn on_mint_logs_an_exhausted_pool() {
    // Pool exhaustion is a non-spawn abort: a `["yog-step","mint"]` row (Z5) then
    // the error — the empty-world bootstrap's one non-spawn failure, made visible.
    let w = World::new();
    let err = on_mint(
        Err(MintError::Exhausted(6)),
        w.state.path(),
        "TS",
        w.home.path(),
    )
    .unwrap_err();
    assert!(matches!(err, StartError::Mint(MintError::Exhausted(6))));
    let e = &w.ops()[0];
    assert_eq!(e.argv, [YOG_STEP, "mint"]);
    assert_eq!(e.exit, SYNTHETIC_EXIT);
    assert!(e.stderr.contains("pool exhausted"));
}

#[test]
fn resolve_name_returns_existing_and_mints() {
    let w = World::new();
    let existing = w.inputs(
        Target::Existing {
            workspace: workspace_path(w.yog.path(), "alpha"),
        },
        Payload::Bare,
    );
    assert_eq!(
        resolve_name(&existing, &mut super::rng(), w.state.path(), "TS").unwrap(),
        "alpha"
    );
    let mint = w.inputs(Target::Mint, Payload::Bare);
    let name = resolve_name(&mint, &mut super::rng(), w.state.path(), "TS").unwrap();
    // The resolved value is a legal claimant leaf (`^[a-z-]+$`, §3.1) — the claim
    // it stamps (§3.2) can never be mistaken for a human.
    assert!(name.contains('-'));
    assert!(name.bytes().all(|b| b.is_ascii_lowercase() || b == b'-'));
}

#[test]
fn resolve_worktree_prefers_the_claim_then_disk_then_canonical() {
    // Addendum: the composer's ball worktree must be the path bl actually minted,
    // never a hardcoded canonical guess. Four cases, one function.
    let w = World::new();
    let (balls, project, name) = (w.balls.path(), w.project.path(), "cobalt-gecko");
    let existing = |id: &str| ball(project, id, JoinState::Bound);
    // A non-ball rung names no worktree.
    assert_eq!(resolve_worktree(&Payload::Bare, balls, name, None), None);
    // The claim's cross-checked worktree wins verbatim (the `<id>-<claimant>`
    // variant when bl minted it — threaded from `ClaimResolved`).
    let claimed = PathBuf::from("/claimed/wt-suffixed");
    assert_eq!(
        resolve_worktree(&existing("bl-1"), balls, name, Some(claimed.clone())),
        Some(claimed),
    );
    // Resume (no claim), neither variant on disk → the canonical `<id>` formula.
    assert_eq!(
        resolve_worktree(&existing("bl-2"), balls, name, None),
        Some(work_worktree_path(balls, project, "bl-2", None)),
    );
    // Resume where only the `<id>-<claimant>` worktree exists → that suffixed path.
    let suffixed = work_worktree_path(balls, project, "bl-3", Some(name));
    std::fs::create_dir_all(&suffixed).unwrap();
    assert_eq!(
        resolve_worktree(&existing("bl-3"), balls, name, None),
        Some(suffixed),
    );
}