yog 0.0.1

yog: a balls-oriented session manager for lernie loops (egui frontend)
Documentation
//! The per-project no-marks knob (DESIGN §16.3, §16.6 W4): yog's project setting
//! that DRIVES `bl conf` — never a yog config file, so the policy lives in
//! balls' own capability, not yog's core (severability, §16.3).
//!
//! The nested balls clone tracks the project's **shared `balls/tasks` store
//! branch by default** — the coordination point with the ambient `bl`, so a
//! claim or close yog makes is visible to `bl list`, and vice versa. A
//! per-project knob repoints it, through bl's OWN config surface, into one of
//! three mutually-exclusive [`Mode`]s:
//!
//! - **[`Shared`](Mode::Shared)** — `task-remote origin` + `task-branch
//!   balls/tasks`: the coordinating default.
//! - **[`Stealth`](Mode::Stealth)** — `task-remote none`: a local-only store, so
//!   yog's claims are INVISIBLE to the ambient `bl list` (coordination traded
//!   for invisibility).
//! - **[`CustomBranch`](Mode::CustomBranch)** — `task-branch <name>` on the
//!   shared remote: a parallel task universe.
//!
//! [`Mode`] is the single source of truth: [`plan`] maps it to the `bl conf
//! set …` writes and [`classify`] is the inverse read. Reads keep the raw
//! provenance layer (`conf: <key> from <layer>` on stderr) so the operator sees
//! WHERE the setting lives (landing / binding / stealth).
//!
//! **The effect seam ([`ConfRunner`]) runs in the world (§16.6 W2).** The VM
//! drives `bl conf` through an injected runner whose production impl ([`BlConf`])
//! holds a world [`Cli`] ([`resolve`](BlConf::resolve)), so every `bl conf` read
//! and write inherits the standing world overrides (§16.2) and drives the
//! **nested** clone yog watches — a stealth/branch repoint yog makes and the
//! store yog reads are the same clone.

use std::io;
use std::path::Path;

use crate::actions::verbs::{Outcome, collect};
use crate::cli_outbound::{Binary, Cli};
use crate::opslog::{self, OpEntry};
use crate::xdg::Env;

/// The logical binary name logged to `ops.jsonl` — stable across a `BL_BINARY`
/// override, since the log records the gesture, not the resolved path.
const BL: &str = "bl";
const CONF: &str = "conf";
const SET: &str = "set";
const TASK_REMOTE: &str = "task-remote";
const TASK_BRANCH: &str = "task-branch";
/// The shared coordination remote — bl's resolution-ladder default.
const SHARED_REMOTE: &str = "origin";
/// The stealth write value: `bl conf set task-remote none`.
const STEALTH_REMOTE: &str = "none";
/// bl's default store branch.
const SHARED_BRANCH: &str = "balls/tasks";
/// The stdout sentinel `bl conf task-remote` prints for a stealth/no-remote clone.
const NONE_STDOUT: &str = "(none)";

/// One of three mutually-exclusive project marks modes (§16.3) — yog's UX
/// collapse of bl's two orthogonal knobs (task-remote + task-branch).
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum Mode {
    /// Track the shared `origin` / `balls/tasks` store (coordinates with `bl`).
    Shared,
    /// Local-only store: claims invisible to the ambient `bl list`.
    Stealth,
    /// A custom `task-branch`: a parallel task universe (still shared).
    CustomBranch(String),
}

impl Mode {
    /// The custom-branch mode for `name`, or `None` when unusable — empty, or the
    /// default `balls/tasks` (that is [`Shared`](Mode::Shared), not a parallel
    /// universe). The pane applies only a `Some`.
    pub fn custom_branch(name: &str) -> Option<Mode> {
        let name = name.trim();
        if name.is_empty() || name == SHARED_BRANCH {
            None
        } else {
            Some(Mode::CustomBranch(name.to_owned()))
        }
    }
}

/// The knob state read from bl for one project: the classified [`Mode`] plus the
/// raw provenance layer (`landing` / `binding` / `stealth` / …).
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct MarksState {
    pub mode: Mode,
    pub provenance: String,
}

/// The `bl conf` effect seam (arch §14, the
/// [`BlRunner`](crate::projects::runner::BlRunner) template): run `bl conf
/// <args>` in cwd = `project`, capturing value (stdout), provenance (stderr) and
/// exit as one [`Outcome`]. Env-carrying — see the module doc for the W2 story.
pub trait ConfRunner {
    fn conf(&self, project: &Path, args: &[&str]) -> io::Result<Outcome>;
}

/// Production [`ConfRunner`] over [`Cli`]. The held `bl` carries the standing
/// world overrides (§16.6 W2), so `bl conf` drives the nested clone (cwd =
/// project via [`Cli::run_in`], world env via the standing overrides).
pub struct BlConf {
    cli: Cli,
}

impl BlConf {
    pub fn new(cli: Cli) -> Self {
        Self { cli }
    }

    /// Resolve `bl` via `BL_BINARY` / PATH (§8) in the composed `world` (§16.6
    /// W2), so `bl conf` reads/writes the nested clone yog watches (§16.2).
    pub fn resolve(world: &Env) -> Self {
        Self::new(Cli::resolve_in_world(
            Binary::Bl,
            &crate::world::overrides(world),
        ))
    }
}

impl ConfRunner for BlConf {
    fn conf(&self, project: &Path, args: &[&str]) -> io::Result<Outcome> {
        collect(self.cli.run_in(project, args))
    }
}

/// The `bl conf set <key> <value>` argv (owned — nothing borrows out of [`plan`],
/// so no named lifetime is introduced, rule 1).
fn conf_set(key: &str, value: &str) -> Vec<String> {
    vec![CONF.into(), SET.into(), key.into(), value.into()]
}

/// The `bl conf set …` write sequence that ESTABLISHES `mode` (§16.3),
/// independent of the current state — the single source of truth for
/// mode → knobs, with [`classify`] its inverse. Stealth asserts only the remote
/// (the branch axis is orthogonal, §16.3 "and/or"); a custom branch also clears
/// stealth, so it reads back as a parallel universe rather than as stealth.
pub fn plan(mode: &Mode) -> Vec<Vec<String>> {
    match mode {
        Mode::Shared => vec![
            conf_set(TASK_REMOTE, SHARED_REMOTE),
            conf_set(TASK_BRANCH, SHARED_BRANCH),
        ],
        Mode::Stealth => vec![conf_set(TASK_REMOTE, STEALTH_REMOTE)],
        Mode::CustomBranch(name) => vec![
            conf_set(TASK_BRANCH, name),
            conf_set(TASK_REMOTE, SHARED_REMOTE),
        ],
    }
}

/// Classify the two knob VALUES into a [`Mode`] — the inverse of [`plan`].
/// Stealth takes precedence: a no-remote clone pushes nothing, the dominant
/// no-marks property, so it reads as Stealth whatever the branch.
fn classify(remote: &str, branch: &str) -> Mode {
    if is_stealth(remote) {
        Mode::Stealth
    } else if branch == SHARED_BRANCH {
        Mode::Shared
    } else {
        Mode::CustomBranch(branch.to_owned())
    }
}

/// stdout `(none)` (or empty) ⇒ a stealth / no-remote clone.
fn is_stealth(remote: &str) -> bool {
    remote.is_empty() || remote == NONE_STDOUT
}

/// The provenance LAYER from a `bl conf <key>` stderr line
/// (`conf: <key> from <layer>`): the text after `from`. Defensive — an
/// unexpected shape falls back to the whole trimmed line, never panics.
fn provenance(stderr: &str) -> String {
    let line = stderr.trim();
    match line.rsplit_once(" from ") {
        Some((_, layer)) => layer.trim().to_owned(),
        None => line.to_owned(),
    }
}

/// Read the knob for `project` (§16.3): `bl conf task-remote` + `bl conf
/// task-branch`, classified with the remote's provenance kept. `Ok(None)` when
/// the project has no balls checkout (never primed — the reads exit non-zero);
/// yog renders that as "not a balls project", never an invented mode. A spawn
/// failure (missing `bl`) is the one hard error.
pub fn read(runner: &dyn ConfRunner, project: &Path) -> io::Result<Option<MarksState>> {
    let remote = runner.conf(project, &[CONF, TASK_REMOTE])?;
    if !remote.ok() {
        return Ok(None);
    }
    let branch = runner.conf(project, &[CONF, TASK_BRANCH])?;
    Ok(Some(MarksState {
        mode: classify(remote.stdout.trim(), branch.stdout.trim()),
        provenance: provenance(&remote.stderr),
    }))
}

/// Apply `mode`: run [`plan`]'s writes in `project`, appending each outcome to
/// `ops.jsonl` (§4.2, the mutation-logging discipline), then re-[`read`] so the
/// pane reflects the landed state. `ts` is the shell's wall-clock stamp. The
/// first failing write short-circuits the rest (its outcome is already logged).
pub fn apply(
    runner: &dyn ConfRunner,
    state_root: &Path,
    ts: &str,
    project: &Path,
    mode: &Mode,
) -> io::Result<Option<MarksState>> {
    for argv in plan(mode) {
        let refs: Vec<&str> = argv.iter().map(String::as_str).collect();
        let out = runner.conf(project, &refs)?;
        log_op(state_root, ts, project, &argv, &out)?;
        if !out.ok() {
            break;
        }
    }
    read(runner, project)
}

/// Append one `bl conf set …` outcome to `ops.jsonl` (§4.2). The logged argv
/// carries the logical `bl` name (stable across a `BL_BINARY` override).
fn log_op(
    state_root: &Path,
    ts: &str,
    project: &Path,
    argv: &[String],
    out: &Outcome,
) -> io::Result<()> {
    let mut full = Vec::with_capacity(argv.len() + 1);
    full.push(BL.to_owned());
    full.extend(argv.iter().cloned());
    opslog::append(
        state_root,
        &OpEntry {
            ts: ts.to_owned(),
            argv: full,
            cwd: project.display().to_string(),
            exit: out.exit,
            stdout: out.stdout.clone(),
            stderr: out.stderr.clone(),
        },
    )
}

#[cfg(test)]
mod tests;