yog 0.0.1

yog: a balls-oriented session manager for lernie loops (egui frontend)
Documentation
//! Per-root-kind path allowlists (DESIGN §7.1).
//!
//! One `fs_watcher::Watcher` runs per watched root, and each root has a *kind*
//! that fixes which paths under it are worth surfacing. [`is_watched`] is the
//! single pure predicate generalizing the previously-hardcoded Workspace
//! allowlist over every kind in the §7.1 table. It is path-only (no stat, no
//! I/O), so it is exhaustively testable and shared unchanged by a future
//! `lernie-ui-web`. Event *kind* (create vs remove) and recursion *scope*
//! (top vs recursive) are the watcher's concern, never the allowlist's.

use std::path::Path;

/// Workspace-root paths (ARCH §2.2, §3.5): the shared `steps/` and `inbox/`
/// trees, outside every worktree, namespaced by agent id. Control files are
/// no longer loose here — they live in the config commit (§2.2), observed
/// through the refs below.
const ROOT_CONTROL_PREFIXES: &[&str] = &["steps", "inbox"];

/// Per-agent-worktree paths (ARCH §2.2 layout). Each agent occupies a worktree
/// at `agents/<agent-id>/`, with this set of files inside.
const WORKTREE_PREFIXES: &[&str] = &[
    "goal.md",
    "soul.md",
    "summary",
    "messages",
    "descriptions",
    "skills",
];

/// Refs and HEAD live in the bare workspace repository at `repo.git`
/// (ARCH §2.2). Branch existence is read from refs/ — no sidecar state file
/// (PRINCIPLES.md "Single source of truth").
const REFS_PREFIXES: &[&str] = &["repo.git/HEAD", "repo.git/refs"];

/// The brazen config basename (§9.1) — the ambient file, shared with the
/// user's own `bz` (§16.2). An ambient `$BRAZEN_CONFIG` with a non-standard
/// basename is supplied to the watcher registry when it wires the root (Y6);
/// the allowlist matches the canonical name.
const BRAZEN_CONFIG_FILE: &str = "config.toml";

/// The kind of root a [`Watcher`](super::Watcher) guards, selecting its
/// allowlist (DESIGN §7.1). `Hash` so `(root, kind)` keys a
/// [`WatchSet`](crate::watch::WatchSet) map (Y6).
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum RootKind {
    /// A lernie workspace dir: `steps/`, `inbox/`, per-agent worktree files,
    /// `repo.git/{HEAD,refs}` — the original hardcoded allowlist.
    Workspace,
    /// `$XDG_DATA_HOME/yog/workspaces/` (flat by construction, §3.1/§7.1): named
    /// workspaces appearing and being removed.
    NamesRoot,
    /// `<lernie-data>/workspaces/`, `replays/`: foreign workspaces and replays
    /// appearing and being removed.
    WorkspacesRoot,
    /// `$XDG_STATE_HOME/balls/clones/`: clone dirs, per-clone `tasks/tasks/*.md`
    /// and `config/config/**`. The churny per-clone `log` is filtered out.
    BallsClones,
    /// Dir of the resolved brazen `config.toml` — the ambient file, §16.2/§9.1:
    /// that one file.
    BrazenConfig,
    /// The lernie global config root: `models.yaml` and `workflows/` (§9.2).
    LernieConfig,
    /// `$XDG_STATE_HOME/yog/`: `ui.json` and `ops.jsonl` (§4.1, §4.2).
    YogState,
}

/// True when `path` (expected under `root`) falls in `kind`'s allowlist. Pure
/// and path-only: a rejected path is never surfaced by the watcher.
pub fn is_watched(kind: RootKind, root: &Path, path: &Path) -> bool {
    let Ok(rel) = path.strip_prefix(root) else {
        return false;
    };
    let rel = rel.to_string_lossy();
    match kind {
        RootKind::Workspace => workspace(&rel),
        // Coarse structural roots (§7.1: "dir create/remove"): any descendant
        // is a candidate — a named workspace's own git churn re-enumerates
        // idempotently, coalesced by the frame's debounce (§7.2).
        RootKind::NamesRoot | RootKind::WorkspacesRoot => !rel.is_empty(),
        RootKind::BallsClones => balls_clones(&rel),
        RootKind::BrazenConfig => rel == BRAZEN_CONFIG_FILE,
        RootKind::LernieConfig => rel == "models.yaml" || matches_any(&rel, &["workflows"]),
        RootKind::YogState => rel == "ui.json" || rel == "ops.jsonl",
    }
}

/// The original Workspace allowlist (ARCH §3.5), byte-for-byte.
fn workspace(rel: &str) -> bool {
    if matches_any(rel, ROOT_CONTROL_PREFIXES) || matches_any(rel, REFS_PREFIXES) {
        return true;
    }
    // Per-agent-worktree paths live under `agents/<agent-id>/…`. The watcher
    // does not enumerate agent ids — any id segment is admissible; the tail
    // determines the hit.
    matches!(
        rel.strip_prefix("agents/").and_then(|t| t.split_once('/')),
        Some((_agent_id, tail)) if matches_any(tail, WORKTREE_PREFIXES)
    )
}

/// The `$XDG_STATE_HOME/balls/clones/` allowlist (§7.1): each clone dir itself,
/// its `tasks/tasks/*.md` task files and `config/config/**` landing subtree;
/// the multi-MB unrotated per-clone `log` is filtered to avoid event storms.
fn balls_clones(rel: &str) -> bool {
    let Some((_clone, tail)) = rel.split_once('/') else {
        // A single non-empty segment is a clone dir itself (create/remove).
        return !rel.is_empty();
    };
    if matches_any(tail, &["log"]) {
        return false;
    }
    if let Some(name) = tail.strip_prefix("tasks/tasks/") {
        return !name.is_empty() && !name.contains('/') && name.ends_with(".md");
    }
    matches_any(tail, &["config/config"])
}

fn matches_any(rel: &str, prefixes: &[&str]) -> bool {
    prefixes
        .iter()
        .any(|prefix| rel == *prefix || rel.starts_with(&format!("{prefix}/")))
}

#[cfg(test)]
#[path = "roots_tests.rs"]
mod tests;