yog 0.0.1

yog: a balls-oriented session manager for lernie loops (egui frontend)
Documentation
//! Ref-derived agent marks (§2.6, §6, ARCH §8 — all four `refs/lernie/*`).
//!
//! Four orthogonal marks, each a git ref keyed by agent id, read once per
//! `from_repo` tick and carried to the view-model as **ref oids** (not mere
//! membership booleans): the attention model (DESIGN §6) seen-gates each
//! signal on its evidence oid — a moved ref re-notifies (§4.1).
//!
//! - **conflicted** — `refs/lernie/conflicted/<id>`: a child's work-product
//!   transfer failed to apply and was declined loudly (§2.6).
//! - **budget-exhausted** — `refs/lernie/budget-exhausted/<id>`: the agent
//!   tree hit a spend ceiling (§6).
//! - **abandoned** — `refs/lernie/abandoned/<id>`: a policy assertion that a
//!   stopped branch will not be retried (ARCH §8 `mark_abandoned`); it
//!   suppresses the stop-attention signal (§6 rule 2).
//! - **notify** — `refs/lernie/notify/<id>`: the branch asked the UI to
//!   raise a user-facing notification (ARCH §8 `notify_ui`, §6 rule 1).
//!
//! Derived from `git for-each-ref`, never stored (PRINCIPLES "Single
//! source of truth"). Every namespace keys off the raw agent id (no
//! `agents/` prefix), matching the harness's ref writers.

use super::GitTreeError;
use super::cmd::for_each_ref_under;
use std::collections::HashMap;
use std::path::Path;

const CONFLICTED_PREFIX: &str = "refs/lernie/conflicted/";
const BUDGET_PREFIX: &str = "refs/lernie/budget-exhausted/";
const ABANDONED_PREFIX: &str = "refs/lernie/abandoned/";
const NOTIFY_PREFIX: &str = "refs/lernie/notify/";

/// The four mark maps for a workspace, each `agent-id -> ref oid`. A
/// membership query is `oid.is_some()`; the oid itself is the §6 watermark
/// evidence.
#[derive(Debug, Default)]
pub(super) struct Marks {
    conflicted: HashMap<String, String>,
    budget: HashMap<String, String>,
    abandoned: HashMap<String, String>,
    notify: HashMap<String, String>,
}

impl Marks {
    pub(super) fn from_repo(git_dir: &Path) -> Result<Self, GitTreeError> {
        Ok(Self {
            conflicted: oids_under(git_dir, CONFLICTED_PREFIX)?,
            budget: oids_under(git_dir, BUDGET_PREFIX)?,
            abandoned: oids_under(git_dir, ABANDONED_PREFIX)?,
            notify: oids_under(git_dir, NOTIFY_PREFIX)?,
        })
    }

    pub(super) fn conflicted_oid(&self, agent_id: &str) -> Option<String> {
        self.conflicted.get(agent_id).cloned()
    }

    pub(super) fn budget_oid(&self, agent_id: &str) -> Option<String> {
        self.budget.get(agent_id).cloned()
    }

    pub(super) fn abandoned_oid(&self, agent_id: &str) -> Option<String> {
        self.abandoned.get(agent_id).cloned()
    }

    pub(super) fn notify_oid(&self, agent_id: &str) -> Option<String> {
        self.notify.get(agent_id).cloned()
    }
}

fn oids_under(git_dir: &Path, prefix: &str) -> Result<HashMap<String, String>, GitTreeError> {
    let out = for_each_ref_under(git_dir, prefix)?;
    Ok(parse_oids(&out, prefix))
}

/// Strip `prefix` off each `<refname> <oid>` line, yielding the
/// `agent-id -> oid` map. Lines that don't match the namespace, carry no
/// oid, or strip to an empty id contribute nothing.
fn parse_oids(stdout: &[u8], prefix: &str) -> HashMap<String, String> {
    String::from_utf8_lossy(stdout)
        .lines()
        .filter_map(|line| {
            let (refname, oid) = line.trim().split_once(' ')?;
            let id = refname.strip_prefix(prefix)?;
            (!id.is_empty()).then(|| (id.to_string(), oid.to_string()))
        })
        .collect()
}

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

    #[test]
    fn parse_oids_strips_prefix_and_keeps_oid() {
        let out = b"refs/lernie/conflicted/a-b 1111111111111111111111111111111111111111\n\
                    refs/lernie/conflicted/c-d 2222222222222222222222222222222222222222\n";
        let oids = parse_oids(out, CONFLICTED_PREFIX);
        assert_eq!(oids.len(), 2);
        assert_eq!(
            oids.get("a-b").map(String::as_str),
            Some("1".repeat(40).as_str())
        );
        assert_eq!(
            oids.get("c-d").map(String::as_str),
            Some("2".repeat(40).as_str())
        );
    }

    #[test]
    fn parse_oids_ignores_nonmatching_and_malformed_lines() {
        // A ref outside the namespace, and a line with no oid field, both
        // contribute nothing.
        let out = b"refs/heads/agents/a-b 3333333333333333333333333333333333333333\n\
                    refs/lernie/budget-exhausted/x-y 4444444444444444444444444444444444444444\n\
                    nospaceline\n";
        let oids = parse_oids(out, BUDGET_PREFIX);
        assert_eq!(oids.len(), 1);
        assert_eq!(
            oids.get("x-y").map(String::as_str),
            Some("4".repeat(40).as_str())
        );
    }

    #[test]
    fn parse_oids_drops_empty_id() {
        // The bare prefix with no id after it strips to "" and is dropped.
        let out = b"refs/lernie/notify/ 5555555555555555555555555555555555555555\n";
        assert!(parse_oids(out, NOTIFY_PREFIX).is_empty());
    }

    #[test]
    fn parse_oids_empty_input_is_empty_map() {
        assert!(parse_oids(b"", CONFLICTED_PREFIX).is_empty());
    }

    #[test]
    fn getters_return_oid_or_none() {
        let marks = Marks {
            conflicted: HashMap::from([("a-b".to_string(), "aa".to_string())]),
            budget: HashMap::from([("c-d".to_string(), "bb".to_string())]),
            abandoned: HashMap::from([("e-f".to_string(), "cc".to_string())]),
            notify: HashMap::from([("g-h".to_string(), "dd".to_string())]),
        };
        assert_eq!(marks.conflicted_oid("a-b").as_deref(), Some("aa"));
        assert_eq!(marks.conflicted_oid("c-d"), None);
        assert_eq!(marks.budget_oid("c-d").as_deref(), Some("bb"));
        assert_eq!(marks.abandoned_oid("e-f").as_deref(), Some("cc"));
        assert_eq!(marks.notify_oid("g-h").as_deref(), Some("dd"));
        assert_eq!(marks.notify_oid("a-b"), None);
    }
}