yog 0.0.1

yog: a balls-oriented session manager for lernie loops (egui frontend)
Documentation
//! Ball fetching, the §3.5 join, and the ops tail — the live `bl` projection
//! wiring of [`AppModel`] (DESIGN §5.1 #2/#7, §7.2, §4.2, §15 Y16).
//!
//! Y14 built the pure projection ([`crate::projects`]); Y16 holds it live. The
//! injected [`BlRunner`] drives `bl list --json` per project on the **fetch
//! cadence** (§7.2: "`bl list --json` per project runs on its root's dirtiness
//! or the 15 s sweep, never per frame") — never in the render path. The cached
//! balls feed the join, whose rows supply the roster's bound balls ([`ws_balls`])
//! and the Close/Release/Move target ([`focused_join`]). The `ops.jsonl` tail
//! (§4.2) is cached the same way and re-read after any dispatched verb, so the
//! operator sees the outcome without waiting for the watch.

use super::AppModel;
use crate::opslog::{self, OpRow, SurfaceFailure};
use crate::projects;
use crate::projects::balls::parse_list;
use crate::projects::join::{self, JoinRow};
use crate::projects::runner;
use std::path::{Path, PathBuf};

/// How many `ops.jsonl` lines the ops pane tails (§4.2, §11 accessory).
const OPS_TAIL: usize = 256;

impl AppModel {
    /// Re-fetch every *visible* project's live ball list (§5.1 #2) and rebuild
    /// the §3.5 join. The fetch-cadence entry point: run on the clones-root
    /// dirtiness or the 15 s full sweep (§7.2), never per frame. Nested-delivery
    /// clones are filtered out unless the internal toggle is on ([`visible`]).
    pub(super) fn refresh_balls(&mut self) {
        let all = projects::enumerate(&self.roots.balls_clones);
        let visible: Vec<PathBuf> = projects::visible(&all, self.show_internal())
            .into_iter()
            .map(|p| p.path.clone())
            .collect();
        // Key only the projects that list cleanly; a cloned project absent from
        // the map is unlistable → an orphaned row in the join (§3.5).
        self.balls_by_project = visible
            .iter()
            .filter_map(|p| Some((p.clone(), parse_list(&self.balls.list(p).ok()?))))
            .collect();
        self.rebuild_join(&visible);
    }

    /// Whether nested-delivery ("internal") clones are shown (§5.1 #1). Default
    /// hidden: the dedicated `ui.json` `show_internal` boolean (§4.1) reads
    /// `false` when absent.
    pub fn show_internal(&self) -> bool {
        self.ui.show_internal()
    }

    /// Set the internal-clone toggle (§5.1 #1) and re-fetch at once, so the
    /// roster's project surface — new-ball forms, ▶ Start, badges — reflects it
    /// without waiting for the sweep. The persisted view converges via `ui.json`
    /// like any view kept for convenience (§4.1, §13.0).
    pub fn set_show_internal(&mut self, show: bool) {
        self.ui.set_show_internal(show);
        self.refresh_balls();
    }

    /// Rebuild the join rows from the cached live + closed balls and the
    /// enumerated workspaces (§3.5). The binding is claimant = workspace name
    /// (§3.2); no operator identity enters. Pure over the pre-fetched caches.
    /// `pub(super)`: [`super::derive`]'s `reconcile` re-binds on a workspace-set
    /// change so a fresh mint never leaves a claimed ball claimed-elsewhere.
    pub(super) fn rebuild_join(&mut self, cloned: &[PathBuf]) {
        self.join_rows = join::join(
            cloned,
            &self.balls_by_project,
            &self.closed_by_project,
            &self.workspaces,
        );
    }

    /// The operator identity (§4.1): recorded `identity_last_used` else `$USER`
    /// else empty. **Not** a claim stamp — Z3's start flow and Z4's close/release/
    /// assign/move all stamp `--as <workspace name>` (§3.2 ownership line), never
    /// the operator. Retained as the *author* identity for the standalone `bl
    /// create`/`bl update` verbs (§8.2 New ball / Update ball), where the operator
    /// — not a workspace — is the reporter.
    pub fn identity(&self) -> String {
        runner::identity(self.ui.identity_last_used(), self.identity_user.clone())
    }

    /// Re-read the `ops.jsonl` tail (§4.2) into the render cache. Run on the
    /// yog-state dirtiness, the full sweep, and after any dispatched verb.
    ///
    /// Each line is projected through [`opslog::detached::fold`] first, so a
    /// detached driver's captured stderr — the only evidence a fired prompt died
    /// after launch (§8.1, §13.3) — reaches the row from its sink file on *this*
    /// sweep. The fold is read-time by construction: the sink stays the
    /// authority, `ops.jsonl` is never rewritten, and a driver still writing
    /// surfaces more on the next pass.
    pub(super) fn refresh_ops(&mut self) {
        let root = self.roots.yog_state.clone();
        self.ops = opslog::tail(&root, OPS_TAIL)
            .iter()
            .map(|entry| OpRow::from(&opslog::detached::fold(&root, entry)))
            .collect();
    }

    /// The ops-pane rows, oldest-first (§11 accessory; the shell renders these).
    pub(crate) fn ops_rows(&self) -> &[OpRow] {
        &self.ops
    }

    /// The surface's last-failure view-model (§5.3, §7.3): the most recent ops
    /// row *iff* it is a rendered failure ([`OpRow::failed`]), projected to its
    /// argv and stderr tail. `None` when the last attempted action succeeded, so
    /// the surface clears its ichor-red banner. Reads the already-refreshed tail,
    /// so the surface's §5.3 RAM item and the ops pane never diverge (both
    /// project the same durable ops line, §4.2). The shell holds and paints it.
    pub fn last_failure(&self) -> Option<SurfaceFailure> {
        self.ops
            .last()
            .filter(|r| r.failed())
            .map(SurfaceFailure::from)
    }

    /// The yog state root — where `ops.jsonl` lives, the verb-log target the
    /// shell passes to [`crate::actions::verbs`] (§4.2).
    pub(crate) fn state_root(&self) -> &Path {
        &self.roots.yog_state
    }

    /// **All** the bound balls a workspace renders (§3.5, §11 balls section):
    /// every join row whose workspace is `ws` and which carries a ball, each
    /// projected to its id + [`join::badge`]. A workspace with N bound balls
    /// shows all N (the wave-1 review fix — the old first-row `row_for` showed
    /// one arbitrary badge and let a Delivered row shadow a Bound one); an
    /// unassigned workspace yields an empty list (its UnassignedWorkspace row
    /// has no ball id).
    pub fn ws_balls(&self, ws: &Path) -> Vec<crate::nav::BoundBall> {
        self.join_rows
            .iter()
            .filter(|r| r.workspace.as_deref() == Some(ws) && !r.ball_id.is_empty())
            .map(|r| crate::nav::BoundBall {
                id: r.ball_id.clone(),
                badge: join::badge(r.state, r.claimant.as_deref()),
            })
            .collect()
    }

    /// The §11 grouped-by-ball conversation view: the flat conversation list
    /// ([`Self::conversations`]) partitioned so each start-flow ball heads its
    /// conversations, unassociated last (§3.5, §15 Z9). The shell's grouping
    /// toggle picks this or the flat list.
    pub fn conversation_groups(&self, now_unix: i64) -> Vec<crate::nav::convs::group::ConvGroup> {
        crate::nav::convs::group::group_by_ball(self.conversations(now_unix))
    }

    /// The ball a conversation `root_id` stamped in its `goal.md` (§3.3), resolved
    /// through the §3.5 join — the header's ball (title/status, a link to ball
    /// detail). `None` for a root with no stamp (bare/path) or one absent from the
    /// focused tree. The covered derivation the header paints.
    pub fn conversation_ball(&self, root_id: &str) -> Option<crate::nav::convs::ConvBall> {
        let ws = self.focus.ws.as_deref()?;
        let tree = self.trees.get(ws)?;
        let id = tree
            .agents
            .iter()
            .find(|a| a.agent_id == root_id)?
            .goal_ball
            .as_deref()?;
        Some(self.resolve_conv_ball(id))
    }

    /// Resolve a conversation's goal-stamp ball `id` to its render facts (§3.3,
    /// §3.5): the id always renders (source 1 — the stamp); the §3.5 join supplies
    /// status/title/badge when a live or closed ball matches it here, else those
    /// stay `None` (the project may be unfetched, or the id a stray). A pure read
    /// over the cached join, so a per-conversation badge never re-lists `bl`.
    /// `pub(crate)`: [`super::focus`]'s `conversations` closure resolves each row.
    pub(crate) fn resolve_conv_ball(&self, id: &str) -> crate::nav::convs::ConvBall {
        match self.join_rows.iter().find(|r| r.ball_id == id) {
            Some(r) => crate::nav::convs::ConvBall {
                id: id.to_owned(),
                state: Some(r.state),
                title: r.title.clone(),
                badge: join::badge(r.state, r.claimant.as_deref()),
            },
            None => crate::nav::convs::ConvBall {
                id: id.to_owned(),
                state: None,
                title: None,
                badge: None,
            },
        }
    }

    /// The join row of the focused workspace, if bound and matched — the
    /// (project, ball, state) the Close/Release/Move actions target (§8.2). The
    /// first bound row wins; the live-ball loop emits Bound before Delivered, so a
    /// closed ball never shadows an active one here.
    pub fn focused_join(&self) -> Option<&JoinRow> {
        self.row_for(self.focus.ws.as_deref()?)
    }

    /// The join row whose bound workspace is `ws` (the first — see [`focused_join`]).
    fn row_for(&self, ws: &Path) -> Option<&JoinRow> {
        self.join_rows
            .iter()
            .find(|r| r.workspace.as_deref() == Some(ws))
    }

    /// The focused workspace's name — its path leaf (§3.1): the **target** an
    /// Assign (`bl claim <id> --as <name>`) or a Move's claim stamps (§8.2/§3.2).
    /// `None` when no workspace is focused (the affordance is then withheld). The
    /// covered derivation the shell paints, never a name it decides.
    pub fn focused_ws_name(&self) -> Option<String> {
        self.focus
            .ws
            .as_deref()
            .and_then(Path::file_name)
            .map(|s| s.to_string_lossy().into_owned())
    }

    /// The local **named** workspaces' names (§3.1), the Move affordance's target
    /// picker (§8.2): where a bound ball can be re-homed. Foreign/replay workspaces
    /// carry no yog identity, so they are not move targets.
    pub fn workspace_names(&self) -> Vec<String> {
        self.workspaces
            .iter()
            .filter_map(|w| match &w.kind {
                crate::binding::WorkspaceKind::Named { name } => Some(name.clone()),
                _ => None,
            })
            .collect()
    }

    /// The empty-project roster hint (STORIES S3-T5): with **zero projects** in the
    /// world — no clone lists cleanly and none is orphaned — the roster shows the
    /// paved interim for entering one, `yog exec bl prime` in a repo (v1 keeps `bl
    /// prime` out of the UI, §8.3). `None` once any project is present (clean or
    /// orphaned) — a project surface then exists to work with.
    pub fn empty_project_hint(&self) -> Option<String> {
        let has_project = !self.balls_by_project.is_empty()
            || self
                .join_rows
                .iter()
                .any(|r| r.state == join::JoinState::OrphanedProject);
        (!has_project).then(|| "yog exec bl prime".to_owned())
    }

    /// On-demand refresh after a dispatched `bl` verb (§15 Y16): re-fetch the
    /// affected project's live **and** closed balls (the delivered-row source,
    /// §5.1 #4 — closed is fetched only here, never on the cadence), rebuild the
    /// join, and re-read the ops tail — the immediate convergence the operator
    /// sees, ahead of the watch/sweep.
    pub fn after_bl_verb(&mut self, project: &Path) {
        self.balls_by_project
            .insert(project.to_path_buf(), self.balls.live(project));
        self.closed_by_project
            .insert(project.to_path_buf(), self.balls.closed(project));
        let cloned: Vec<PathBuf> = self.balls_by_project.keys().cloned().collect();
        self.rebuild_join(&cloned);
        self.refresh_ops();
    }

    /// On-demand refresh after a dispatched `lernie` verb (message/stop/scan):
    /// those touch no ball, so only the ops tail changes.
    pub fn after_lernie_verb(&mut self) {
        self.refresh_ops();
    }

    /// The yog data root — where bound workspaces live (§3.1). Test-only
    /// reader; the start flow reads `roots.yog_data` through `PlanInputs`.
    #[cfg(test)]
    pub(crate) fn yog_data_root(&self) -> &Path {
        &self.roots.yog_data
    }

    /// The balls state root — the parent of the per-project clones dir (balls
    /// arch §1: `clones/` always lives under it); the start flow's
    /// `work_worktree_path` derives from it (§3.3).
    pub fn balls_state_root(&self) -> PathBuf {
        let clones = &self.roots.balls_clones;
        // `clones` is always nested under the state root, so it has a parent;
        // the fallback (the clones dir itself) keeps this panic-free.
        clones.parent().unwrap_or(clones).to_path_buf()
    }
}

/// The start-flow's hand-off (`startable`/`resumable`/`start_bare_inputs`/…) —
/// split out per §12's 300-line budget.
mod starts;

#[cfg(test)]
mod tests;