yog 0.0.1

yog: a balls-oriented session manager for lernie loops (egui frontend)
Documentation
//! Which failures are still **live** — the ambient error surface's retirement
//! rule (DESIGN §6, §4.2, §11).
//!
//! `ops.jsonl` is append-only and complete: a failure never leaves it. But a
//! failure a later clean run of the same verb has superseded is *history*, not
//! a live wound, and painting it with the same prominence sends diagnosis down
//! a false trail — the proven wound: a three-day-old `lernie prime` failure,
//! since fixed and re-run green, read as THE error when an unrelated action
//! failed.
//!
//! So prominence is a **projection over the tail at read time, never a stored
//! flag**: walking newest-first, a failed row is live unless a *later* row with
//! the same (`cwd`, verb) did not fail. The verb is the leading two argv tokens
//! — binary plus subcommand (`bl close`, `lernie prime`, `yog-step mint`) —
//! because the argv tail carries per-run operands (a ball id, a composed goal)
//! that never repeat, so keying on the whole argv would retire nothing; `cwd`
//! scopes it, so a clean `bl close` in one project leaves a failed one in
//! another alone. Success is the pane's own classifier negated
//! ([`OpRow::failed`]), so no second definition of success can drift from the
//! one the surface paints.
//!
//! A retired failure keeps its row and its ⚠ in the expanded accessory — it
//! loses only ichor and the chip's count. **Absence of a live failure is the
//! record; the log is the history.**

use std::collections::HashSet;

use super::OpRow;

/// The §11 activity-accessory summary — the demoted ops pane's collapsed chip:
/// how many ops the tail holds and how many are **live** failures (retired ones
/// excluded, per this module's rule). Pure over the rows the pane would paint,
/// so chip and expansion never diverge.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct Activity {
    pub total: usize,
    pub errors: usize,
}

/// Summarize the tail for the collapsed chip (§11): every op counted, only the
/// live failures ([`live_failures`]) flagged.
pub fn activity(rows: &[OpRow]) -> Activity {
    Activity {
        total: rows.len(),
        errors: live_failures(rows).into_iter().filter(|&live| live).count(),
    }
}

impl Activity {
    /// The chip label: `activity · N ops`, with `· M ⚠` appended when live
    /// failures are present (the shell paints that count in ichor).
    pub fn chip(&self) -> String {
        if self.errors > 0 {
            format!("activity · {} ops · {} ⚠", self.total, self.errors)
        } else {
            format!("activity · {} ops", self.total)
        }
    }
}

/// One flag per row, positionally aligned with `rows`: `true` where the row is a
/// **live** failure — it failed ([`OpRow::failed`]) and no later row with the
/// same [`verb_key`] ran clean. Everything else (a clean row, a retired failure)
/// is `false`. The whole retirement rule lives here; nothing is stored.
pub fn live_failures(rows: &[OpRow]) -> Vec<bool> {
    let mut retired: HashSet<(String, String)> = HashSet::new();
    let mut live: Vec<bool> = rows
        .iter()
        .rev()
        .map(|row| {
            if row.failed() {
                return !retired.contains(&verb_key(row));
            }
            retired.insert(verb_key(row));
            false
        })
        .collect();
    live.reverse();
    live
}

/// The identity a retirement keys on: the row's `cwd` and its **verb** — the
/// leading two tokens of the joined argv, i.e. binary plus subcommand. The
/// operand tail is deliberately dropped (see the module note).
fn verb_key(row: &OpRow) -> (String, String) {
    let verb = row
        .argv
        .split_whitespace()
        .take(2)
        .collect::<Vec<&str>>()
        .join(" ");
    (row.cwd.clone(), verb)
}

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

    /// One row: `argv` split on spaces, run in `cwd`, exiting `exit`.
    fn row(argv: &str, cwd: &str, exit: i32) -> OpRow {
        OpRow::from(&OpEntry {
            ts: "TS".into(),
            argv: argv.split(' ').map(String::from).collect(),
            cwd: cwd.into(),
            exit,
            stdout: String::new(),
            stderr: if exit == 0 {
                String::new()
            } else {
                "boom".into()
            },
        })
    }

    #[test]
    fn a_later_clean_run_of_the_same_verb_retires_the_failure() {
        let rows = [row("lernie prime", "/w", 2), row("lernie prime", "/w", 0)];
        assert_eq!(live_failures(&rows), vec![false, false]);
        assert_eq!(activity(&rows).errors, 0, "the chip counts no live failure");
    }

    #[test]
    fn a_failure_with_no_later_clean_run_stays_live() {
        let rows = [row("lernie prime", "/w", 0), row("lernie prime", "/w", 2)];
        assert_eq!(live_failures(&rows), vec![false, true]);
        assert_eq!(activity(&rows).errors, 1);
    }

    #[test]
    fn retirement_is_scoped_to_the_cwd_it_ran_in() {
        let rows = [row("lernie prime", "/a", 2), row("lernie prime", "/b", 0)];
        assert_eq!(
            live_failures(&rows),
            vec![true, false],
            "a clean run elsewhere leaves this project's failure live"
        );
    }

    #[test]
    fn the_verb_key_is_binary_plus_subcommand_not_the_operands() {
        // Same verb, different ball id → the later clean close retires the failure.
        let same = [row("bl close bl-1", "/p", 1), row("bl close bl-2", "/p", 0)];
        assert_eq!(live_failures(&same), vec![false, false]);
        // A different subcommand is a different verb → nothing is retired.
        let other = [
            row("bl close bl-1", "/p", 1),
            row("bl list --json", "/p", 0),
        ];
        assert_eq!(live_failures(&other), vec![true, false]);
        // A bare one-token argv keys on itself.
        let bare = [row("bz", "/p", 1), row("bz", "/p", 0)];
        assert_eq!(live_failures(&bare), vec![false, false]);
    }

    #[test]
    fn only_live_failures_reach_the_chip() {
        let rows = [
            row("lernie prime", "/w", 2), // retired below
            row("bl close bl-1", "/p", 1),
            row("lernie prime", "/w", 0),
        ];
        let a = activity(&rows);
        assert_eq!(
            a,
            Activity {
                total: 3,
                errors: 1
            }
        );
        assert_eq!(a.chip(), "activity · 3 ops · 1 ⚠");
        assert_eq!(activity(&rows[2..]).chip(), "activity · 1 ops");
        assert_eq!(activity(&[]).chip(), "activity · 0 ops");
        assert!(live_failures(&[]).is_empty());
    }
}