yog 0.0.1

yog: a balls-oriented session manager for lernie loops (egui frontend)
Documentation
//! The [`prepare`] orchestrator (DESIGN §8.1, §15 M6 Z3): it **runs
//! [`plan`](super::plan)'s output step-by-step** — the order lives only in the
//! planner, never duplicated here. [`prepare`] resolves the target name (the
//! mint, §3.4), then [`run`] iterates the plan: the substrate + `bl` mutations
//! run in place, a new ball's [`Create`](super::Step::Create) re-plans the minted
//! ball (the new→existing convergence), and the detached
//! [`Prompt`](super::Step::Prompt) is **deferred** to the composer — its
//! fire-time params are composed purely from the same source
//! ([`goal::compose_prepared`](super::goal)).

use super::exec::{execute_claim, execute_create, execute_ensure_workspace, resolve_name};
use super::goal::compose_prepared;
use super::{BallSpec, Deps, Payload, PlanInputs, Prepared, StartError, StartInputs, Step, Target};
use crate::actions::verbs::log_step_failure;
use crate::binding::{work_worktree_path, workspace_path};
use crate::cli_outbound::Binary;
use crate::names::Rng;
use crate::projects::join::JoinState;
use crate::world::toolgate::ToolchainState;
use crate::world::{layout_under, seed};
use std::path::{Path, PathBuf};

/// The `["yog-step",<step>]` name for a start gate refusal's ops row (§4.2).
const GATE_STEP: &str = "gate";

/// The resolved ball worktree for the composer (§3.3, addendum): the claim's
/// cross-checked worktree when a claim ran (canonical or `<id>-<claimant>`, from
/// [`ClaimResolved`](super::ClaimResolved)), else — the resume path, no claim —
/// the variant that exists on disk (`<id>-<claimant>` only when it alone is
/// present), else the canonical formula. `None` for non-existing-ball rungs
/// (bare/path/new name no worktree), so the composer's driver cwd falls back to
/// `~` (§3.4). `pub`: the story fixtures assert the resolution directly.
pub fn resolve_worktree(
    payload: &Payload,
    balls_state_root: &Path,
    name: &str,
    claimed: Option<PathBuf>,
) -> Option<PathBuf> {
    let Payload::Ball {
        project,
        ball: BallSpec::Existing { id, .. },
    } = payload
    else {
        return None;
    };
    Some(claimed.unwrap_or_else(|| existing_worktree(balls_state_root, project, id, name)))
}

/// The on-disk worktree of an already-claimed ball for the resume path (§8.1): the
/// `<id>-<claimant>` variant when only it exists, else the canonical `<id>` — a
/// pure disk read (`Path::exists`), never a mutation (I7). The resume path has no
/// `bl claim` stdout to cross-check, so disk is the ground truth here.
fn existing_worktree(balls_state_root: &Path, project: &Path, id: &str, name: &str) -> PathBuf {
    let canonical = work_worktree_path(balls_state_root, project, id, None);
    let suffixed = work_worktree_path(balls_state_root, project, id, Some(name));
    if !canonical.exists() && suffixed.exists() {
        suffixed
    } else {
        canonical
    }
}

/// Consult the capability gate before any spawn or mint (§16.4 / §16.6 W5), then
/// resolve the target name and its workspace path (mint or existing, §3.4) and run
/// every mutating step [`plan`](super::plan) emits, returning the composer's
/// [`Prepared`]. The detached prompt fires later, on confirm
/// ([`super::execute_prompt`]).
pub fn prepare(
    deps: &Deps,
    inputs: &StartInputs,
    rng: &mut dyn Rng,
    ts: &str,
) -> Result<Prepared, StartError> {
    require_tools(
        &deps.gate,
        &inputs.payload,
        &deps.state_root,
        ts,
        &inputs.home,
    )?;
    let name = resolve_name(inputs, rng, &deps.state_root, ts)?;
    let workspace = resolve_workspace(&inputs.target, &inputs.yog_data_root, &name);
    run(deps, &resolved(inputs, name, workspace), ts)
}

/// The target's workspace path (§3.4): the focused workspace verbatim for an
/// existing target (named or foreign — its path, not a names-root guess), else
/// `<names-root>/<minted-name>` for a mint.
fn resolve_workspace(target: &Target, yog_data_root: &Path, name: &str) -> PathBuf {
    match target {
        Target::Existing { workspace } => workspace.clone(),
        Target::Mint => workspace_path(yog_data_root, name),
    }
}

/// The dispatch-layer gate precondition (§16.6 W5): refuse the whole start unless
/// every tool the payload drives is `Ok` — `lernie` always (seed / `lernie new` /
/// prompt), `bl` for the ball rung (`bl create` / `claim`). A refusal appends a
/// `["yog-step","gate"]` ops row (Z5's idiom, mirroring the mint/mkdir/drift
/// aborts) before returning the typed verdict, so a stale-tool refusal is a
/// rendered fact — never the invisible-failure wound presence-gating left.
fn require_tools(
    gate: &ToolchainState,
    payload: &Payload,
    state_root: &Path,
    ts: &str,
    cwd: &Path,
) -> Result<(), StartError> {
    let mut needed = vec![Binary::Lernie];
    if matches!(payload, Payload::Ball { .. }) {
        needed.push(Binary::Bl);
    }
    for binary in needed {
        if let Err(refusal) = gate.require(binary) {
            log_step_failure(state_root, ts, cwd, GATE_STEP, &refusal.to_string())?;
            return Err(StartError::GateRefused(refusal));
        }
    }
    Ok(())
}

/// Iterate the plan (§8.1: "the executor runs it step-by-step"). The planned
/// `Prompt` is deferred (a no-op) and composed after the loop with the **claim's
/// cross-checked worktree** (addendum: never the canonical guess), so the
/// preamble + driver cwd name the path bl actually minted. A new ball's plan ends
/// at `Create`, which re-plans; every other plan reaches the after-loop return.
fn run(deps: &Deps, inputs: &PlanInputs, ts: &str) -> Result<Prepared, StartError> {
    let mut claimed: Option<PathBuf> = None;
    for step in super::plan(inputs) {
        match step {
            Step::EnsureSeeded => {
                let layout = layout_under(&inputs.yog_data_root);
                seed::ensure_seeded(&deps.lernie, &deps.state_root, ts, &layout)?;
            }
            Step::EnsureWorkspace { workspace } => {
                execute_ensure_workspace(&deps.lernie, &deps.state_root, ts, &workspace)?;
            }
            Step::Create {
                project,
                title,
                body,
            } => {
                let id = execute_create(
                    &deps.bl,
                    &deps.state_root,
                    ts,
                    &project,
                    &title,
                    &body,
                    &inputs.name,
                )?;
                return run(deps, &with_minted(inputs, &project, id, title, body), ts);
            }
            Step::Claim { project, id, name } => {
                claimed = Some(
                    execute_claim(
                        &deps.bl,
                        &deps.state_root,
                        ts,
                        &project,
                        &id,
                        &name,
                        &inputs.balls_state_root,
                    )?
                    .worktree,
                );
            }
            Step::Prompt { .. } => {}
        }
    }
    let worktree = resolve_worktree(
        &inputs.payload,
        &inputs.balls_state_root,
        &inputs.name,
        claimed,
    );
    Ok(compose_prepared(inputs, worktree.as_deref()))
}

/// [`StartInputs`] + a resolved name and workspace path → the [`PlanInputs`]
/// [`plan`](super::plan) is pure over.
fn resolved(inputs: &StartInputs, name: String, workspace: PathBuf) -> PlanInputs {
    PlanInputs {
        name,
        workspace,
        payload: inputs.payload.clone(),
        home: inputs.home.clone(),
        yog_data_root: inputs.yog_data_root.clone(),
        balls_state_root: inputs.balls_state_root.clone(),
    }
}

/// Re-plan a new ball as its freshly-minted existing self (§8.1): the Ready,
/// unclaimed ball whose id `bl create` just returned — the convergence.
fn with_minted(
    inputs: &PlanInputs,
    project: &Path,
    id: String,
    title: String,
    body: String,
) -> PlanInputs {
    PlanInputs {
        payload: Payload::Ball {
            project: project.to_path_buf(),
            ball: BallSpec::Existing {
                id,
                title,
                body,
                join: JoinState::ReadyStartable,
            },
        },
        ..inputs.clone()
    }
}