yog 0.0.1

yog: a balls-oriented session manager for lernie loops (egui frontend)
Documentation
//! The `bl` effect behind [`super::balls`] (DESIGN §5.1 #2/#4, §15 Y14).
//!
//! [`BlRunner`] is the one injected effect (arch §14, the LockProbe template):
//! `bl … --json` with cwd = the project path (§5.1 #2). A fake replays recorded
//! JSON in tests; [`BlCli`] is the production wrapper over [`Cli`]. The trait's
//! `live`/`closed`/`detail` default methods pair the primitives with the pure
//! parsers; ball detail and the closed listing are on demand, never bulk-loaded
//! (§5.1 #4). [`identity`] resolves the claimed-by-me axis (§4.1).

use super::balls::{Ball, parse_ball, parse_list};
use crate::cli_outbound::{Chunk, Cli, CliError, ExitInfo, Stream};
use std::io;
use std::path::Path;

/// The `bl` view surface: the three raw-stdout primitives plus their forgiving
/// projections (default methods, the single home for "run + parse"). Kept
/// object-safe so [`AppModel`](crate::AppModel) can hold a `Box<dyn BlRunner>`
/// (§15 Y16) — the default methods dispatch through the vtable unchanged.
pub trait BlRunner {
    /// `bl list --json` — the live balls of `project`.
    fn list(&self, project: &Path) -> io::Result<String>;
    /// `bl list -s closed --json` — the closed listing (on demand, §5.1 #4).
    fn list_closed(&self, project: &Path) -> io::Result<String>;
    /// `bl show <id> --json` — one ball's full bedrock record (on demand).
    fn show(&self, project: &Path, id: &str) -> io::Result<String>;

    /// The live balls of `project` (§5.1 #2). Forgiving: a runner error (project
    /// unreachable) yields no balls — the derive-from-disk default.
    fn live(&self, project: &Path) -> Vec<Ball> {
        self.list(project)
            .map(|j| parse_list(&j))
            .unwrap_or_default()
    }
    /// The closed listing on demand (§5.1 #4); forgiving like [`live`](Self::live).
    fn closed(&self, project: &Path) -> Vec<Ball> {
        self.list_closed(project)
            .map(|j| parse_list(&j))
            .unwrap_or_default()
    }
    /// One ball's full detail (frontmatter + body) on demand (§5.1 #4); `None` on
    /// a runner error or an unparseable body.
    fn detail(&self, project: &Path, id: &str) -> Option<Ball> {
        parse_ball(&self.show(project, id).ok()?)
    }
}

/// Production [`BlRunner`] over [`Cli`]. Construct in the shell with
/// `Cli::resolve_in_world(Binary::Bl, ..)` (arch §14, §16.6 W2 — the standing
/// world env nests the reads); the cwd = project convention (§5.1 #2) rides on
/// [`Cli::run_in`].
pub struct BlCli {
    cli: Cli,
}

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

/// Drain a `bl` stream to its stdout string, erroring on spawn failure or a
/// non-zero/signalled exit (a forgiving parser turns an odd-but-valid body into
/// an empty set; a failed *process* is a real error worth surfacing).
fn collect_stdout(stream: Result<Stream, CliError>) -> io::Result<String> {
    let stream = stream.map_err(io::Error::other)?;
    let mut out = Vec::new();
    let mut exit = ExitInfo::Unknown;
    for chunk in stream {
        match chunk {
            Chunk::Stdout(b) => out.extend(b),
            Chunk::Stderr(_) => {}
            Chunk::Exited(e) => exit = e,
        }
    }
    match exit {
        ExitInfo::Code(0) => Ok(String::from_utf8_lossy(&out).into_owned()),
        other => Err(io::Error::other(format!("bl exited: {other:?}"))),
    }
}

impl BlRunner for BlCli {
    fn list(&self, project: &Path) -> io::Result<String> {
        collect_stdout(self.cli.run_in(project, &["list", "--json"]))
    }
    fn list_closed(&self, project: &Path) -> io::Result<String> {
        collect_stdout(
            self.cli
                .run_in(project, &["list", "-s", "closed", "--json"]),
        )
    }
    fn show(&self, project: &Path, id: &str) -> io::Result<String> {
        collect_stdout(self.cli.run_in(project, &["show", id, "--json"]))
    }
}

/// The operator's claim identity for the claimed-by-me axis (§4.1): the recorded
/// `identity_last_used`, else the invoking `$USER`, else empty (nothing is
/// "mine" when the identity is unknown — the safe default).
pub fn identity(recorded: Option<String>, user: Option<String>) -> String {
    recorded.or(user).unwrap_or_default()
}

#[cfg(test)]
mod tests;