yog 0.0.1

yog: a balls-oriented session manager for lernie loops (egui frontend)
Documentation
//! [`prepare`] end-to-end per rung (§8.1): the executor runs the planner's
//! output in the amended order (substrate → `bl` mutations → deferred prompt),
//! converging on skips and re-planning a new ball. The abort-before-claim proof
//! (a substrate failure precedes every `bl` mutation) is the load-bearing order.

use super::{World, ball, fake_bl, fake_fail, rng};
use crate::binding::{work_worktree_path, workspace_path};
use crate::cli_outbound::{Binary, Cli};
use crate::projects::join::JoinState;
use crate::start::{BallSpec, Deps, Payload, StartError, Target, prepare};
use crate::test_support::{OkProbe, spawn_guard};
use crate::world::toolgate::{self, ToolProbe, ToolState};
use std::path::Path;

fn deps(w: &World, bl: &Cli, lernie: &Cli) -> Deps {
    Deps {
        bl: bl.clone(),
        lernie: lernie.clone(),
        state_root: w.state.path().to_path_buf(),
        gate: toolgate::probe(&OkProbe),
    }
}

/// A probe failing exactly `<binary> <verb> --help`, so the gate classifies that
/// tool `Mismatch` and every other tool `Ok` (§16.6 W5).
struct Fail(Binary, &'static str);
impl ToolProbe for Fail {
    fn status(&self, binary: Binary, args: &[&str]) -> std::io::Result<i32> {
        Ok(i32::from(binary == self.0 && args.first() == Some(&self.1)) * 2)
    }
}

#[test]
fn prepare_refuses_a_bare_start_on_a_stale_lernie_before_any_spawn() {
    // The bare rung drives only lernie; a stale `prime` refuses it (§16.6 W5).
    let w = World::new();
    let deps = Deps {
        bl: Cli::new("/no/bl"),
        lernie: Cli::new("/no/lernie"),
        state_root: w.state.path().to_path_buf(),
        gate: toolgate::probe(&Fail(Binary::Lernie, "prime")),
    };
    let inputs = w.inputs(Target::Mint, Payload::Bare);
    let err = prepare(&deps, &inputs, &mut rng(), "TS").unwrap_err();
    let StartError::GateRefused(refusal) = err else {
        panic!("expected GateRefused, got {err:?}");
    };
    assert_eq!(refusal.tool, "lernie");
    assert!(matches!(&refusal.state, ToolState::Mismatch { verb, .. } if verb == "prime"));
    // The refusal precedes every mint/spawn: the only ops row is the gate step.
    assert_eq!(
        w.verbs(),
        vec!["gate"],
        "no lernie/bl spawn — just the gate row"
    );
}

#[test]
fn prepare_ball_rung_refuses_on_a_bad_bl_after_lernie_passes() {
    // The ball rung also needs bl (`create`/`claim`); lernie passes, bl refuses.
    let w = World::new();
    let deps = Deps {
        bl: Cli::new("/no/bl"),
        lernie: Cli::new("/no/lernie"),
        state_root: w.state.path().to_path_buf(),
        gate: toolgate::probe(&Fail(Binary::Bl, "create")),
    };
    let inputs = w.inputs(
        Target::Existing {
            workspace: workspace_path(w.yog.path(), "cobalt-gecko"),
        },
        ball(w.project.path(), "bl-r", JoinState::ReadyStartable),
    );
    let err = prepare(&deps, &inputs, &mut rng(), "TS").unwrap_err();
    let StartError::GateRefused(refusal) = err else {
        panic!("expected GateRefused, got {err:?}");
    };
    assert_eq!(refusal.tool, "bl");
    assert!(matches!(&refusal.state, ToolState::Mismatch { verb, .. } if verb == "create"));
    assert_eq!(w.verbs(), vec!["gate"]);
}

/// Materialize a seeded home (`models.yaml`) so `prime` skips.
fn mark_seeded(w: &World) {
    let lernie = crate::world::layout_under(w.yog.path()).lernie;
    std::fs::create_dir_all(&lernie).unwrap();
    std::fs::write(lernie.join("models.yaml"), b"models: {}\n").unwrap();
}

#[test]
fn prepare_bare_bootstrap_mints_seeds_and_news() {
    let _g = spawn_guard();
    let w = World::new();
    let lernie = w.lernie();
    let bl = Cli::new("/no/bl"); // no ball rung → bl never runs
    let inputs = w.inputs(Target::Mint, Payload::Bare);
    let p = prepare(&deps(&w, &bl, &lernie), &inputs, &mut rng(), "TS").unwrap();
    assert!(p.name.contains('-'), "a fresh name minted");
    assert_eq!(p.cwd, w.home.path(), "bare cwd is ~");
    assert_eq!(p.goal, "", "the operator types the bare payload");
    assert_eq!(p.workspace, workspace_path(w.yog.path(), &p.name));
    // Substrate only, in order — no `bl` mutation.
    assert_eq!(w.verbs(), vec!["prime", "new"]);
    assert_eq!(
        w.ops()[1].argv[2],
        p.workspace.to_string_lossy(),
        "`lernie new` targets the minted workspace"
    );
}

#[test]
fn prepare_prompt_into_existing_skips_prime_new_and_mint() {
    let _g = spawn_guard();
    let w = World::new();
    mark_seeded(&w);
    let name = "cobalt-gecko";
    std::fs::create_dir_all(workspace_path(w.yog.path(), name).join("repo.git")).unwrap();
    let lernie = Cli::new("/no/lernie"); // a spawn would surface as an error
    let bl = Cli::new("/no/bl");
    let inputs = w.inputs(
        Target::Existing {
            workspace: workspace_path(w.yog.path(), name),
        },
        Payload::Bare,
    );
    let p = prepare(&deps(&w, &bl, &lernie), &inputs, &mut rng(), "TS").unwrap();
    assert_eq!(p.name, name);
    assert!(
        w.ops().is_empty(),
        "seeded + existing → nothing spawns (S1-T1)"
    );
}

#[test]
fn prepare_path_rung_composes_the_target_and_runs_no_bl() {
    let _g = spawn_guard();
    let w = World::new();
    let lernie = w.lernie();
    let bl = Cli::new("/no/bl");
    let dir = w.home.path().join("work");
    let inputs = w.inputs(
        Target::Existing {
            workspace: workspace_path(w.yog.path(), "cobalt-gecko"),
        },
        Payload::Path { dir: dir.clone() },
    );
    let p = prepare(&deps(&w, &bl, &lernie), &inputs, &mut rng(), "TS").unwrap();
    assert_eq!(p.cwd, dir, "driver cwd is the directory");
    assert!(p.goal.contains(&dir.display().to_string()));
    assert!(!w.verbs().iter().any(|v| v == "claim" || v == "create"));
}

#[test]
fn prepare_ball_ready_claims_after_new() {
    let _g = spawn_guard();
    let w = World::new();
    let canonical = work_worktree_path(w.balls.path(), w.project.path(), "bl-r", None);
    let bl = Cli::new(fake_bl(w.bin.path(), "x", &canonical));
    let lernie = w.lernie();
    let inputs = w.inputs(
        Target::Existing {
            workspace: workspace_path(w.yog.path(), "cobalt-gecko"),
        },
        ball(w.project.path(), "bl-r", JoinState::ReadyStartable),
    );
    let p = prepare(&deps(&w, &bl, &lernie), &inputs, &mut rng(), "TS").unwrap();
    assert_eq!(p.cwd, canonical, "ball cwd is the worktree");
    assert!(p.goal.contains("Ball bl-r: T"));
    // The amended §8.1 order: seed → new → claim.
    assert_eq!(w.verbs(), vec!["prime", "new", "claim"]);
    assert_eq!(
        &w.ops()[2].argv[1..],
        &["claim", "bl-r", "--as", "cobalt-gecko"],
        "claim stamped with the workspace name"
    );
}

#[test]
fn prepare_new_ball_creates_then_converges_to_one_claim() {
    let _g = spawn_guard();
    let w = World::new();
    let canonical = work_worktree_path(w.balls.path(), w.project.path(), "bl-mint", None);
    let bl = Cli::new(fake_bl(w.bin.path(), "bl-mint", &canonical));
    let lernie = w.lernie();
    let payload = Payload::Ball {
        project: w.project.path().to_path_buf(),
        ball: BallSpec::New {
            title: "Fresh".to_owned(),
            body: "New body".to_owned(),
        },
    };
    let inputs = w.inputs(
        Target::Existing {
            workspace: workspace_path(w.yog.path(), "cobalt-gecko"),
        },
        payload,
    );
    let p = prepare(&deps(&w, &bl, &lernie), &inputs, &mut rng(), "TS").unwrap();
    assert!(
        p.goal.contains("Ball bl-mint: Fresh"),
        "re-planned as existing"
    );
    // Substrate before every `bl`, one claim (the re-plan's seed/new skip).
    assert_eq!(w.verbs(), vec!["prime", "new", "create", "claim"]);
}

#[test]
fn prepare_bound_ball_resumes_without_a_claim_or_mint() {
    let _g = spawn_guard();
    let w = World::new();
    mark_seeded(&w);
    std::fs::create_dir_all(workspace_path(w.yog.path(), "cobalt-gecko").join("repo.git")).unwrap();
    // A bl that would *fail* if a claim ran — proof the claim is skipped.
    let bl = Cli::new(fake_fail(w.bin.path(), "bl", "should not run"));
    let lernie = Cli::new("/no/lernie");
    let inputs = w.inputs(
        Target::Existing {
            workspace: workspace_path(w.yog.path(), "cobalt-gecko"),
        },
        ball(w.project.path(), "bl-c", JoinState::Bound),
    );
    let p = prepare(&deps(&w, &bl, &lernie), &inputs, &mut rng(), "TS").unwrap();
    assert_eq!(
        p.cwd,
        work_worktree_path(w.balls.path(), w.project.path(), "bl-c", None)
    );
    assert!(w.ops().is_empty(), "resume: no claim, no mint, no re-seed");
}

#[test]
fn prepare_ball_aborts_before_any_bl_on_a_substrate_failure() {
    // S3-T6 load-bearing order: a failed `prime` precedes every `bl` mutation, so
    // no `bl create`/`bl claim` is recorded — the orphaned-claim wound is closed.
    let _g = spawn_guard();
    let w = World::new();
    let bl = Cli::new(fake_bl(w.bin.path(), "x", Path::new("/wt")));
    let lernie = Cli::new(fake_fail(w.bin.path(), "lernie", "no seed"));
    let inputs = w.inputs(
        Target::Existing {
            workspace: workspace_path(w.yog.path(), "cobalt-gecko"),
        },
        ball(w.project.path(), "bl-r", JoinState::ReadyStartable),
    );
    assert!(prepare(&deps(&w, &bl, &lernie), &inputs, &mut rng(), "TS").is_err());
    assert_eq!(
        w.verbs(),
        vec!["prime"],
        "aborted at the seed — no bl mutation"
    );
}