yog 0.0.1

yog: a balls-oriented session manager for lernie loops (egui frontend)
Documentation
//! CLI outbound: the frontend's sole command surface to the harness. Every user
//! action is an `exec(<binary>, args)` and nothing else (ARCH §3.4/§3.5;
//! "resume" is no longer user-facing per the §2.9 amendment, bl-abf3).
//!
//! Three spawn shapes over one binary abstraction (DESIGN §8):
//! - [`Cli::run`] / [`Cli::run_in`] stream stdout/stderr with terminal exit
//!   reporting and aggressive SIGTERM-then-SIGKILL cleanup on [`Stream`] drop;
//!   `run_in` sets the child's `current_dir` (bl verbs run cwd = project, §8.2).
//! - [`Cli::spawn_detached`] fires a child in its own process group
//!   (`process_group(0)`, safe std — a new group, not a session; enough, since
//!   terminal signals hit the foreground group), stdin/stdout null, stderr to a
//!   caller-named per-spawn sink file, no retained handle — long-lived drivers
//!   (§8.1) that yog's exit can't kill.
//! - [`Streamed`] consumes a [`Cli::run`] child non-blocking and line-buffered,
//!   live (§8's streamed-piped class: `bz --login`, [`crate::login`]).
//!
//! The crate's one remaining `unsafe` is the SIGTERM in [`sys`]. The binary is
//! named by [`Binary`]: one env-var override per name (`LERNIE_BINARY`,
//! `BL_BINARY`, `BZ_BINARY`) with a PATH-name default. Pure Rust — no egui — so a
//! future `lernie-ui-web` crate reuses it unchanged; the caller supplies argv.

use std::ffi::OsString;
use std::io::Read;
use std::path::{Path, PathBuf};
use std::process::{Command, Stdio};
use std::sync::mpsc::{self, Sender};
use std::thread;

const READ_BUF: usize = 4096;

mod stream;
pub use stream::{Stream, StreamPoll};

mod streamed;
pub use streamed::{Streamed, StreamedOutcome, StreamedPoll};

/// The `yog exec` world escape hatch spawn (§8.4): [`Cli::exec_in_world`].
mod exec;

/// The fire-and-forget detached spawn (§8.1): [`Cli::spawn_detached`] and its
/// per-spawn stderr sink. Split out to hold [`self`] under the 300-line cap.
mod detach;

#[derive(Debug, thiserror::Error)]
pub enum CliError {
    #[error("failed to spawn {binary}: {source}")]
    Spawn {
        binary: PathBuf,
        source: std::io::Error,
    },
    #[error("child {0} stream was not captured")]
    Stdio(&'static str),
}

/// A harness binary yog drives (DESIGN §8: "binary resolution parametric
/// over env var — `LERNIE_BINARY`, `BL_BINARY`, `BZ_BINARY`, default PATH
/// names"). This enum is the one place the `(env var, default)` pairs
/// live; [`Cli::resolve`] reads them.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Binary {
    Lernie,
    Bl,
    Bz,
}

impl Binary {
    /// The environment-variable override and the PATH-name default.
    const fn env_and_default(self) -> (&'static str, &'static str) {
        match self {
            Binary::Lernie => ("LERNIE_BINARY", "lernie"),
            Binary::Bl => ("BL_BINARY", "bl"),
            Binary::Bz => ("BZ_BINARY", "bz"),
        }
    }
}

/// One piece of output from a running `lernie` subprocess. The final
/// chunk in any stream is always `Exited`.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum Chunk {
    Stdout(Vec<u8>),
    Stderr(Vec<u8>),
    Exited(ExitInfo),
}

#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ExitInfo {
    Code(i32),
    Signal(i32),
    Unknown,
}

impl ExitInfo {
    /// Collapse to the shell-convention process exit integer: a plain code
    /// passes through, a terminating signal is `128 + signum`, an unobservable
    /// status is `-1`. The single home for the mapping — `yog exec`'s faithful
    /// exit propagation (§8.4) and the `ops.jsonl` `exit` field
    /// ([`crate::actions`], §8.2) both collapse an [`ExitInfo`] through here.
    pub fn shell_code(self) -> i32 {
        match self {
            ExitInfo::Code(c) => c,
            ExitInfo::Signal(s) => 128 + s,
            ExitInfo::Unknown => -1,
        }
    }
}

#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Cli {
    binary: PathBuf,
    /// The **standing** env overrides layered over the inherited environment on
    /// every spawn (§16.6 W2): the composed world's nesting set (`LERNIE_HOME`/
    /// `XDG_STATE_HOME`, §16.2) when built through
    /// [`resolve_in_world`](Self::resolve_in_world), empty otherwise. Carrying it
    /// at construction makes nesting impossible to forget at a new call site — a
    /// world `Cli` already nests every `run`/`run_in`/`run_env`/`spawn_detached`.
    /// This crate stays generic: it layers whatever pairs it is handed, knowing
    /// nothing of *which* vars nest (that fact lives in [`crate::world`]).
    env: Vec<(String, String)>,
}

impl Cli {
    pub fn new(binary: impl Into<PathBuf>) -> Self {
        Self {
            binary: binary.into(),
            env: Vec::new(),
        }
    }

    /// Resolve `binary`'s path from the environment (override var if set and
    /// non-empty, else PATH-name default); wraps [`resolve_with`](Self::resolve_with).
    /// No standing env — [`resolve_in_world`](Self::resolve_in_world) adds it.
    pub fn resolve(binary: Binary) -> Self {
        Self::resolve_with(binary, |k| std::env::var_os(k))
    }

    /// Resolve `binary` and stand the world's nesting `overrides` (§16.2,
    /// [`world::overrides`](crate::world::overrides)) on every spawn, so each
    /// child nests in yog's world (§16.6 W2 / §16.4 phase-1 correctness: an agent
    /// closing a ball hits the world's clones/worktrees, not ambient ones) with
    /// no per-call-site opt-in. The overrides are opaque pairs — this crate stays
    /// generic, knowing nothing of *which* vars nest (that lives in [`crate::world`]).
    pub fn resolve_in_world(binary: Binary, overrides: &[(String, String)]) -> Self {
        Self::resolve(binary).with_env(overrides.to_vec())
    }

    /// Stand `env` on every spawn (builder). `pub(crate)` — the world-`Cli`
    /// seam for [`resolve_in_world`](Self::resolve_in_world) and for tests that
    /// spawn a recorder in the world without PATH resolution.
    pub(crate) fn with_env(mut self, env: Vec<(String, String)>) -> Self {
        self.env = env;
        self
    }

    /// A clone with `extra` appended to the standing env — the workspace-scoped
    /// `YOG_NAME=<name>` layer (§8, §3.3). `pub(crate)`, an internal spawn seam.
    pub(crate) fn and_env(&self, extra: Vec<(String, String)>) -> Self {
        let mut cli = self.clone();
        cli.env.extend(extra);
        cli
    }

    /// Resolve `binary` against an injected `lookup` (name → value) — the
    /// seam that tests the resolution branches without `std::env::set_var`
    /// (`unsafe` in edition 2024); [`resolve`](Self::resolve) wires `std::env`.
    fn resolve_with(binary: Binary, lookup: impl Fn(&str) -> Option<OsString>) -> Self {
        let (env_var, default) = binary.env_and_default();
        match lookup(env_var) {
            Some(v) if !v.is_empty() => Self::new(PathBuf::from(v)),
            _ => Self::new(default),
        }
    }

    pub(crate) fn binary(&self) -> &Path {
        &self.binary
    }

    /// Spawn `<binary> <args...>` and return a streaming handle: stdout and
    /// stderr piped, stdin closed. Dropping the `Stream` terminates the
    /// child (SIGTERM, then SIGKILL after a short grace).
    pub fn run(&self, args: &[&str]) -> Result<Stream, CliError> {
        self.run_streaming(None, &[], args)
    }

    /// Like [`run`](Self::run) but with the child's working directory set
    /// to `dir` — bl verbs run cwd = project (§8.2).
    pub fn run_in(&self, dir: &Path, args: &[&str]) -> Result<Stream, CliError> {
        self.run_streaming(Some(dir), &[], args)
    }

    /// Like [`run`](Self::run) but layering explicit env vars over the
    /// inherited environment — the config-edit drive's `EDITOR` (the
    /// `--editor-apply` shim re-entry) and `YOG_EDIT_SRC` (the staging dir),
    /// §9.3. Nothing is scrubbed; the child otherwise inherits yog's env.
    pub fn run_env(&self, env: &[(&str, &str)], args: &[&str]) -> Result<Stream, CliError> {
        self.run_streaming(None, env, args)
    }

    fn run_streaming(
        &self,
        cwd: Option<&Path>,
        env: &[(&str, &str)],
        args: &[&str],
    ) -> Result<Stream, CliError> {
        let mut cmd = Command::new(&self.binary);
        cmd.args(args)
            .envs(self.standing_env())
            .envs(env.iter().copied())
            .stdin(Stdio::null())
            .stdout(Stdio::piped())
            .stderr(Stdio::piped());
        if let Some(dir) = cwd {
            cmd.current_dir(dir);
        }
        // Callers hold `SPAWN_LOCK` across the spawn so no fork lands while a peer holds a not-yet-closed write fd (ETXTBSY; test_support).
        let mut child = cmd.spawn().map_err(|e| CliError::Spawn {
            binary: self.binary.clone(),
            source: e,
        })?;
        let stdout = child.stdout.take().ok_or(CliError::Stdio("stdout"))?;
        let stderr = child.stderr.take().ok_or(CliError::Stdio("stderr"))?;
        let (tx, rx) = mpsc::channel();
        let tx_err = tx.clone();
        thread::spawn(move || pump(stdout, tx, Chunk::Stdout));
        thread::spawn(move || pump(stderr, tx_err, Chunk::Stderr));
        Ok(Stream::new(child, rx))
    }

    /// The standing world env (§16.6 W2) as `(&str, &str)` pairs for
    /// [`Command::envs`] — layered **first**, so an explicit per-call `env` (the
    /// `run_env` shim vars) wins on any key while the world overrides still beat
    /// the inherited environment. Empty for a non-world `Cli`.
    fn standing_env(&self) -> impl Iterator<Item = (&str, &str)> {
        self.env.iter().map(|(k, v)| (k.as_str(), v.as_str()))
    }
}

fn pump<R: Read>(mut reader: R, tx: Sender<Chunk>, wrap: fn(Vec<u8>) -> Chunk) {
    let mut buf = [0u8; READ_BUF];
    while pump_step(&mut reader, &tx, &mut buf, wrap) {}
}

fn pump_step<R: Read>(
    reader: &mut R,
    tx: &Sender<Chunk>,
    buf: &mut [u8],
    wrap: fn(Vec<u8>) -> Chunk,
) -> bool {
    let Ok(n @ 1..) = reader.read(buf) else {
        return false; // a 0-length read (EOF) or error ends the pump
    };
    let bytes = buf.get(..n).unwrap_or_default().to_vec(); // n <= buf.len() ⇒ Some
    tx.send(wrap(bytes)).is_ok()
}

/// The crate's one confined `unsafe`: raw `SIGTERM` in [`Stream`]'s drop.
mod sys;

#[cfg(test)]
mod tests;