yog 0.0.1

yog: a balls-oriented session manager for lernie loops (egui frontend)
Documentation
//! The start-flow's hand-off from [`AppModel`] (DESIGN §3.4, §3.5, §8.1): the
//! instance's start axes resolved into the [`StartInputs`] the composer's ▶ Start,
//! ▶ Continue, new-ball, and bare Enter entry points hand [`crate::start::prepare`].
//! Split from [`super`] per §12's 300-line budget — the ball fetch + join live
//! there, the start-input construction here. All pure over the pre-fetched caches.

use super::AppModel;
use crate::projects::balls::Ball;
use crate::projects::join::JoinRow;
use crate::start::{self, BallSpec, Payload, StartInputs, Target};
use std::path::{Path, PathBuf};

impl AppModel {
    /// The live ball with `id` in `project`, from the cached projection (§5.1 #2).
    fn ball_of(&self, project: &Path, id: &str) -> Option<&Ball> {
        self.balls_by_project
            .get(project)?
            .iter()
            .find(|b| b.id == id)
    }

    /// The **where** axis for a start from this instance (§3.4): the focused
    /// workspace verbatim — [`Target::Existing`] at its path — whenever one is
    /// focused (named **or foreign**: a foreign workspace is a real lernie
    /// workspace, so §3.4's "prompt into the focused workspace" is unconditional
    /// and never a silent mint), else [`Target::Mint`] — the bootstrap / no-focus
    /// empty general path. Carrying the path (not a names-root-relative leaf) is
    /// what lets a foreign focus, which lives outside yog's flat names root,
    /// resolve correctly. Path-derived: no re-scan of the workspace set.
    fn start_target(&self) -> Target {
        match self.focus.ws.as_deref() {
            Some(ws) => Target::Existing {
                workspace: ws.to_path_buf(),
            },
            None => Target::Mint,
        }
    }

    /// The mint's occupied-set claimants (§3.1): every claimant `bl list` shows
    /// across the fetched projects, so a fresh name collides with no live claim.
    pub fn claimants(&self) -> Vec<String> {
        self.balls_by_project
            .values()
            .flatten()
            .filter_map(|b| b.claimant.clone())
            .collect()
    }

    /// The common [`StartInputs`] shape at an **explicit** target (§3.4): the
    /// roots, `~`, and the mint claimants around `target` + `payload`. The bare /
    /// ball / new-ball entries resolve the instance target ([`start_inputs`]); the
    /// resume entry pins the ball's own claimant workspace ([`resumable`]).
    fn start_inputs_at(&self, target: Target, payload: Payload) -> StartInputs {
        StartInputs {
            target,
            payload,
            home: self.roots.home.clone(),
            yog_data_root: self.roots.yog_data.clone(),
            balls_state_root: self.balls_state_root(),
            claimants: self.claimants(),
        }
    }

    /// A [`StartInputs`] carrying `payload` on this instance's resolved target
    /// (§3.4) — the focused workspace or the bootstrap mint.
    fn start_inputs(&self, payload: Payload) -> StartInputs {
        self.start_inputs_at(self.start_target(), payload)
    }

    /// The bare rung (§3.4): a new root in the focused workspace, or the bootstrap
    /// mint when none is focused — the composer's everyday Enter.
    pub fn start_bare_inputs(&self) -> StartInputs {
        self.start_inputs(Payload::Bare)
    }

    /// The + **New workspace** verb (§3.4/§11): a bare start that **always mints**
    /// a fresh name — even with a workspace focused — raising a new sphere wall
    /// deliberately (contrast [`start_bare_inputs`], which prompts into the focused
    /// one). Reuses the [`Target::Mint`] planner path (§3.4); the occupied set
    /// ([`claimants`] + the names-root readdir) is respected at fire.
    pub fn new_workspace_inputs(&self) -> StartInputs {
        self.start_inputs_at(Target::Mint, Payload::Bare)
    }

    /// The path rung (§3.4, STORIES S2): a [`Payload::Path`] at `dir` on the
    /// instance's resolved target — the composer's optional work-directory field.
    /// The dispatch already composes the target preamble + directory cwd (Z3); this
    /// is the hand-off.
    pub fn start_path_inputs(&self, dir: &Path) -> StartInputs {
        self.start_inputs(Payload::Path {
            dir: dir.to_path_buf(),
        })
    }

    /// The ▶ Start entry points (§3.5, §8.1): one [`StartInputs`] per start-eligible
    /// join row ([`start::is_start_eligible`] — a ready ball) targeting the focused
    /// workspace/mint. The claim lands `--as` the resolved target name.
    pub fn startable(&self) -> Vec<StartInputs> {
        self.join_rows
            .iter()
            .filter(|r| start::is_start_eligible(r.state))
            .filter_map(|row| Some(self.start_inputs(self.ball_payload(row)?)))
            .collect()
    }

    /// The ▶ Continue entry points (§8.1 resume, addendum): one [`StartInputs`] per
    /// **bound** join row ([`start::is_resume_eligible`]) targeting the ball's *own*
    /// claimant workspace — not the focused one — so the re-plan is prompt-only (no
    /// mint, no second claim). Reaches a ball stranded between claim and prompt by a
    /// crash/Cancel, which ▶ Start (ready-only) can never re-enter.
    pub fn resumable(&self) -> Vec<StartInputs> {
        self.join_rows
            .iter()
            .filter(|r| start::is_resume_eligible(r.state))
            .filter_map(|row| {
                let workspace = row.workspace.clone()?;
                Some(self.start_inputs_at(Target::Existing { workspace }, self.ball_payload(row)?))
            })
            .collect()
    }

    /// The existing-ball [`Payload`] for a live join row (§3.5): its id/title/body/
    /// join state. `None` when the row's ball is not in the live projection (a
    /// broken row drops rather than panics — both callers filter it out).
    fn ball_payload(&self, row: &JoinRow) -> Option<Payload> {
        let ball = self.ball_of(&row.project, &row.ball_id)?;
        Some(Payload::Ball {
            project: row.project.clone(),
            ball: BallSpec::Existing {
                id: row.ball_id.clone(),
                title: ball.title.clone(),
                body: ball.body.clone(),
                join: row.state,
            },
        })
    }

    /// A new-ball start input (§8.1): a [`BallSpec::New`] in `project` with the
    /// operator's RAM-drafted title/body — the new-ball entry's hand-off.
    pub fn new_ball_inputs(&self, project: &Path, title: &str, body: &str) -> StartInputs {
        self.start_inputs(Payload::Ball {
            project: project.to_path_buf(),
            ball: BallSpec::New {
                title: title.to_owned(),
                body: body.to_owned(),
            },
        })
    }

    /// The enumerated project paths (§5.1 #1), sorted — the new-ball entry offers a
    /// form per project, including those with no live balls yet.
    pub fn project_paths(&self) -> Vec<PathBuf> {
        let mut ps: Vec<PathBuf> = self.balls_by_project.keys().cloned().collect();
        ps.sort();
        ps
    }
}