yog 0.0.1

yog: a balls-oriented session manager for lernie loops (egui frontend)
Documentation
//! Ops-pane and surface-failure view-models (DESIGN §4.2, §7.3, §11).
//!
//! [`OpRow`] carries the **full** `ops.jsonl` entry (argv, cwd, exit, stdout,
//! stderr) so the ops pane can expand a row to the whole record — "a trail that
//! hides *why* is not a trail" (§7.3). [`OpRow::failed`] classifies an attempted
//! action as a rendered failure (the §7.3 failed-action row); [`SurfaceFailure`]
//! is what the originating surface (start pane, input bar) holds as its §5.3
//! RAM item and paints in ichor red — argv + a stderr tail. Both are pure
//! projections of the durable ops line, so the surface's RAM item and the ops
//! pane never diverge (§5.3: "renders the durable fact from the pane").

use super::{DETACHED_EXIT, OpEntry, PIPED_UNOBSERVED};

/// How many trailing stderr lines the compact surface-failure view keeps: the
/// error's tail is where the cause lands; the ops pane expands to the full text.
const SURFACE_STDERR_LINES: usize = 3;

/// One ops-pane row — the whole `ops.jsonl` entry, argv pre-joined for display.
/// Collapsed the pane shows `ts`/`argv`/`exit`; expanded it shows `cwd`,
/// `stdout`, and `stderr` (§11). No egui here — the shell paints these.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct OpRow {
    pub ts: String,
    pub argv: String,
    pub cwd: String,
    pub exit: i32,
    pub stdout: String,
    pub stderr: String,
}

impl OpRow {
    /// Whether either captured stream carried bytes — the pane's "expandable"
    /// hint (a row with output is worth opening).
    pub fn has_output(&self) -> bool {
        !self.stdout.is_empty() || !self.stderr.is_empty()
    }

    /// Whether this attempted action is a **failure** to render (§7.3). A clean
    /// exit (0), a ran-but-unobservable piped status ([`PIPED_UNOBSERVED`]), and
    /// a cleanly-launched, *silent* detached spawn ([`DETACHED_EXIT`] with empty
    /// `stderr`) are not failures; a real non-zero code, a signal (`128+n`), a
    /// synthetic failure line ([`super::SYNTHETIC_EXIT`]), and a detached spawn
    /// that carries stderr are. The detached arm reads two facts through one
    /// field: the spawn error logged at append time, and — folded in at read time
    /// from the per-spawn sink ([`super::detached`]) — whatever a child that died
    /// *after* launching said on its way out. Both are the operator's only
    /// evidence that a fired prompt is not running.
    pub fn failed(&self) -> bool {
        match self.exit {
            0 | PIPED_UNOBSERVED => false,
            DETACHED_EXIT => !self.stderr.is_empty(),
            _ => true,
        }
    }
}

impl From<&OpEntry> for OpRow {
    fn from(entry: &OpEntry) -> Self {
        Self {
            ts: entry.ts.clone(),
            argv: entry.argv.join(" "),
            cwd: entry.cwd.clone(),
            exit: entry.exit,
            stdout: entry.stdout.clone(),
            stderr: entry.stderr.clone(),
        }
    }
}

/// The failure a surface holds as its §5.3 RAM item and paints in ichor red
/// (§7.3): the attempted `argv` and the tail of its `stderr`. Built from the
/// same ops line the pane renders, so the two never diverge.
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct SurfaceFailure {
    pub argv: String,
    pub stderr_tail: String,
}

impl From<&OpRow> for SurfaceFailure {
    fn from(row: &OpRow) -> Self {
        Self {
            argv: row.argv.clone(),
            stderr_tail: stderr_tail(&row.stderr),
        }
    }
}

/// The last [`SURFACE_STDERR_LINES`] non-empty-trailing lines of `stderr`, the
/// cause the surface shows compactly. Empty stderr yields an empty tail.
fn stderr_tail(stderr: &str) -> String {
    let lines: Vec<&str> = stderr.trim_end_matches('\n').lines().collect();
    let start = lines.len().saturating_sub(SURFACE_STDERR_LINES);
    lines.get(start..).unwrap_or_default().join("\n")
}

#[cfg(test)]
mod tests {
    use super::super::SYNTHETIC_EXIT;
    use super::*;

    fn entry(exit: i32, stderr: &str) -> OpEntry {
        OpEntry {
            ts: "TS".into(),
            argv: vec!["bl".into(), "close".into(), "bl-4db6".into()],
            cwd: "/proj".into(),
            exit,
            stdout: "out".into(),
            stderr: stderr.into(),
        }
    }

    #[test]
    fn op_row_carries_the_full_entry_and_joins_argv() {
        let row = OpRow::from(&entry(0, ""));
        assert_eq!(row.ts, "TS");
        assert_eq!(row.argv, "bl close bl-4db6");
        assert_eq!(row.cwd, "/proj");
        assert_eq!(row.exit, 0);
        assert_eq!(row.stdout, "out");
        assert_eq!(row.stderr, "");
    }

    #[test]
    fn has_output_reflects_either_stream() {
        assert!(OpRow::from(&entry(0, "")).has_output()); // stdout "out"
        let neither = OpEntry {
            stdout: String::new(),
            ..entry(0, "")
        };
        assert!(!OpRow::from(&neither).has_output());
        let only_err = OpEntry {
            stdout: String::new(),
            ..entry(1, "boom")
        };
        assert!(OpRow::from(&only_err).has_output());
    }

    #[test]
    fn failed_classifies_every_exit_class() {
        assert!(!OpRow::from(&entry(0, "")).failed()); // clean
        assert!(!OpRow::from(&entry(PIPED_UNOBSERVED, "")).failed()); // ran, unobservable
        assert!(!OpRow::from(&entry(DETACHED_EXIT, "")).failed()); // launched clean
        assert!(OpRow::from(&entry(DETACHED_EXIT, "no such file")).failed()); // spawn failed
        assert!(OpRow::from(&entry(1, "gate")).failed()); // real non-zero
        assert!(OpRow::from(&entry(SYNTHETIC_EXIT, "missing binary")).failed()); // synthetic
    }

    #[test]
    fn surface_failure_carries_argv_and_stderr_tail() {
        let row = OpRow::from(&entry(2, "line1\nline2"));
        let f = SurfaceFailure::from(&row);
        assert_eq!(f.argv, "bl close bl-4db6");
        assert_eq!(f.stderr_tail, "line1\nline2");
    }

    #[test]
    fn stderr_tail_keeps_only_the_last_lines() {
        assert_eq!(stderr_tail(""), "");
        assert_eq!(stderr_tail("only\n"), "only");
        assert_eq!(stderr_tail("a\nb\nc\nd\ne\n"), "c\nd\ne");
    }
}