yog 0.0.1

yog: a balls-oriented session manager for lernie loops (egui frontend)
Documentation
//! Inbox deposit view-model (DESIGN §11 Inbox tab; ARCH §2.11 deposit).
//!
//! An agent's inbox is `<workspace>/inbox/<agent-id>/<sender>-<NNN>.md`
//! (§2.11): the path carries framing (the sender), the frontmatter carries
//! asserted facts (`from:` / `deposited_at:`, plus `epitaph:` /
//! `terminal_ref:` on a result message, §2.6), and the body is the content
//! verbatim. Yog is a pure reader (§3.5): this module parses one deposit
//! file and enumerates a listing, both pure over injected paths, deriving
//! nothing it can read.
//!
//! Parsing is **forgiving** (brazen's forgiving-read stance): a file
//! without a well-formed `---` frontmatter block renders as a raw body with
//! every field absent, so a half-written or hand-edited deposit never
//! becomes an error.

use std::path::Path;

mod render;
pub use render::render;

/// Workspace subdir holding per-agent inboxes (ARCH §2.11).
const INBOX_DIR: &str = "inbox";
/// Extension of a deposited message file — the atomic-rename temp files
/// are `.<name>.tmp` dotfiles, excluded by this suffix.
const MESSAGE_EXT: &str = ".md";

/// The pinned manner of an agent's ending (ARCH §2.6), from a result
/// message's `epitaph:` frontmatter. `Unknown` preserves a forward-compat
/// value verbatim (the `v=1` tolerate-unknown stance, §3.2).
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum Epitaph {
    FinalResponse,
    Stopped,
    BudgetExhausted,
    Died,
    Unknown(String),
}

impl Epitaph {
    /// The on-disk `epitaph:` value → typed variant (ARCH §2.6 hyphenated
    /// spellings); anything else rides through as [`Epitaph::Unknown`].
    fn parse(value: &str) -> Epitaph {
        match value {
            "final-response" => Epitaph::FinalResponse,
            "stopped" => Epitaph::Stopped,
            "budget-exhausted" => Epitaph::BudgetExhausted,
            "died" => Epitaph::Died,
            other => Epitaph::Unknown(other.to_string()),
        }
    }
}

/// One parsed inbox deposit (ARCH §2.11). Frontmatter fields are `Option`
/// — a malformed file yields all-`None` with the whole content as
/// [`body`](Deposit::body). `epitaph` / `terminal_ref` are present only on
/// a result message (§2.6); an empty `body` is a result whose agent never
/// spoke.
#[derive(Debug, Clone, PartialEq, Eq, Default)]
pub struct Deposit {
    pub sender: Option<String>,
    pub deposited_at: Option<String>,
    pub epitaph: Option<Epitaph>,
    pub terminal_ref: Option<String>,
    pub body: String,
}

/// Parse one deposit file's bytes into a [`Deposit`]. Forgiving: content
/// without a leading `---\n … \n---\n` frontmatter block is taken as a raw
/// body with every field absent.
pub fn parse_deposit(bytes: &[u8]) -> Deposit {
    let text = String::from_utf8_lossy(bytes);
    match split_frontmatter(&text) {
        Some((frontmatter, body)) => Deposit {
            sender: field(frontmatter, "from"),
            deposited_at: field(frontmatter, "deposited_at"),
            epitaph: field(frontmatter, "epitaph").map(|v| Epitaph::parse(&v)),
            terminal_ref: field(frontmatter, "terminal_ref"),
            body: body.to_string(),
        },
        None => Deposit {
            body: text.into_owned(),
            ..Deposit::default()
        },
    }
}

/// Enumerate an agent's inbox (`<workspace>/inbox/<agent-id>/*.md`),
/// oldest-first by filename (the sender-namespaced `<sender>-<NNN>.md`
/// sequence, ARCH §2.11). Atomic-rename temp dotfiles and non-`.md`
/// entries are excluded; an unreadable entry is skipped; a missing inbox
/// is an empty listing. Pure over the injected workspace path.
pub fn list_inbox(workspace: &Path, agent_id: &str) -> Vec<Deposit> {
    let dir = workspace.join(INBOX_DIR).join(agent_id);
    let Ok(entries) = std::fs::read_dir(&dir) else {
        return Vec::new();
    };
    let mut files: Vec<std::path::PathBuf> = entries
        .flatten()
        .map(|e| e.path())
        .filter(|p| {
            p.file_name()
                .and_then(|n| n.to_str())
                .is_some_and(|n| n.ends_with(MESSAGE_EXT))
        })
        .collect();
    files.sort();
    files
        .iter()
        .filter_map(|p| std::fs::read(p).ok())
        .map(|bytes| parse_deposit(&bytes))
        .collect()
}

/// Split `---\n<frontmatter>\n---\n<body>` into `(frontmatter, body)`, or
/// `None` when the leading delimiter or the closing `\n---\n` is absent.
/// The first `\n---\n` closes the block (frontmatter precedes any body), so
/// a body that itself contains the delimiter is safe.
fn split_frontmatter(text: &str) -> Option<(&str, &str)> {
    text.strip_prefix("---\n")?.split_once("\n---\n")
}

/// The value of frontmatter line `<key>: <value>`, or `None`. A line with
/// no `": "` separator, or a different key, is skipped.
fn field(frontmatter: &str, key: &str) -> Option<String> {
    frontmatter
        .lines()
        .filter_map(|line| line.split_once(": "))
        .find(|(k, _)| *k == key)
        .map(|(_, v)| v.to_string())
}

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

    #[test]
    fn parses_ordinary_deposit() {
        let d =
            parse_deposit(b"---\nfrom: user\ndeposited_at: 2026-07-17T12:00:00Z\n---\nhello there");
        assert_eq!(d.sender.as_deref(), Some("user"));
        assert_eq!(d.deposited_at.as_deref(), Some("2026-07-17T12:00:00Z"));
        assert_eq!(d.epitaph, None);
        assert_eq!(d.terminal_ref, None);
        assert_eq!(d.body, "hello there");
    }

    #[test]
    fn epitaph_values_map_to_typed_variants() {
        let cases = [
            ("final-response", Epitaph::FinalResponse),
            ("stopped", Epitaph::Stopped),
            ("budget-exhausted", Epitaph::BudgetExhausted),
            ("died", Epitaph::Died),
            ("wat", Epitaph::Unknown("wat".to_string())),
        ];
        for (raw, want) in cases {
            let file = format!(
                "---\nfrom: c\ndeposited_at: t\nepitaph: {raw}\nterminal_ref: sha\n---\nbody"
            );
            let d = parse_deposit(file.as_bytes());
            assert_eq!(d.epitaph, Some(want));
            assert_eq!(d.terminal_ref.as_deref(), Some("sha"));
        }
    }

    #[test]
    fn result_message_without_body_has_empty_body() {
        let d = parse_deposit(
            b"---\nfrom: c\ndeposited_at: t\nepitaph: died\nterminal_ref: sha\n---\n",
        );
        assert_eq!(d.epitaph, Some(Epitaph::Died));
        assert_eq!(d.body, "");
    }

    #[test]
    fn malformed_file_is_raw_body_with_absent_fields() {
        let d = parse_deposit(b"no frontmatter at all");
        assert_eq!(
            d,
            Deposit {
                body: "no frontmatter at all".to_string(),
                ..Deposit::default()
            }
        );
    }

    #[test]
    fn opener_without_closer_is_raw_body() {
        let d = parse_deposit(b"---\nfrom: x but no closing fence");
        assert_eq!(d.sender, None);
        assert!(d.body.starts_with("---\n"));
    }

    #[test]
    fn frontmatter_line_without_separator_is_skipped() {
        let d = parse_deposit(b"---\nfrom: user\nbare line\n---\nbody");
        assert_eq!(d.sender.as_deref(), Some("user"));
        assert_eq!(d.body, "body");
    }

    #[test]
    fn list_inbox_orders_by_filename_skipping_temp_nonmd_and_unreadable() {
        let dir = tempdir().unwrap();
        let inbox = dir.path().join("inbox").join("a-1");
        std::fs::create_dir_all(&inbox).unwrap();
        std::fs::write(
            inbox.join("user-002.md"),
            b"---\nfrom: user\ndeposited_at: t2\n---\nsecond",
        )
        .unwrap();
        std::fs::write(
            inbox.join("user-001.md"),
            b"---\nfrom: user\ndeposited_at: t1\n---\nfirst",
        )
        .unwrap();
        // Atomic-rename temp dotfile, a non-`.md` file, and a directory
        // named like a deposit (its read fails) are all excluded.
        std::fs::write(inbox.join(".user-003.md.tmp"), b"partial").unwrap();
        std::fs::write(inbox.join("notes.txt"), b"not a deposit").unwrap();
        std::fs::create_dir_all(inbox.join("sub.md")).unwrap();
        let deposits = list_inbox(dir.path(), "a-1");
        let bodies: Vec<&str> = deposits.iter().map(|d| d.body.as_str()).collect();
        assert_eq!(bodies, vec!["first", "second"]);
    }

    #[test]
    fn list_inbox_missing_dir_is_empty() {
        let dir = tempdir().unwrap();
        assert!(list_inbox(dir.path(), "nobody").is_empty());
    }
}