balls 0.5.9

Git-native task tracker for parallel agent workflows
Documentation
//! §6 plugin wiring — the `config/plugins.toml` `[hooks]` schedule.
//!
//! The hook list is config: `config/plugins.toml`'s `[hooks]` table on the
//! landing (§2) is the SINGLE source of truth for which plugin runs in which
//! op-phase. A `<op>.<phase>` key maps to an ORDERED LIST of plugin names —
//! listed = run, list position = run order (the last name runs last). An absent
//! key or empty list = run nothing (the general path with no entries, §4). This
//! retires the filesystem `<op>/<phase>/NN-<name>` symlink registry: ordering is
//! a list property, not an `NN-` filename convention faking one.
//!
//! Names are committed text — portable verbatim, valid in stealth and federation
//! regardless of where the checkout sits. The LOCAL `config/plugins/bin/<name>`
//! symlink ([`crate::registry`]) resolves each name to this machine's binary;
//! [`Hooks::resolve`] stitches the two halves into the [`PluginRef`] sets the §8
//! engine runs, an absent `bin/<name>` surfacing as a dangling ref (a clean
//! "referenced but not installed here" at dispatch, never a silent skip).
//!
//! `plugins.toml` may also carry a `[source]` table (§6, bl-5b09): per-name
//! FREE-TEXT acquisition hints (`adversary = "git clone https://github.com/mudbungie/balls-adversary && make install"`)
//! the center owner authors beside the schedule that needs them. A hint is
//! display-only — never parsed, never executed; it decorates the refusal moments
//! core already emits (the dispatch unbound error, install's validation refusal
//! and dangling report, the seed prune) so the §6 recommendation an adopted
//! config ships is no longer mute. Layered like `[hooks]` (per-name scalar,
//! innermost wins) and round-tripped by [`Hooks::to_toml`] so a seed rewrite
//! never strips it. Severable: no `[source]` entries ⇒ bit-identical behavior
//! with terser errors.

use std::collections::{BTreeMap, BTreeSet};
use std::io;
use std::path::{Path, PathBuf};

use crate::registry::{PluginRef, Registry};

/// The parsed `[hooks]` table: `"<op>.<phase>"` → its ordered plugin-name list.
/// A [`BTreeMap`] so the schedule (and its [`Hooks::referenced`] projection) has
/// a deterministic order — the seed re-serializes it after pruning (§12).
/// Carries the sibling `[source]` hint table too (per-name free text, bl-5b09),
/// raw as authored — sanitized only at the [`Hooks::source`] display read.
#[derive(Debug, Default, Clone, PartialEq, Eq)]
pub struct Hooks {
    table: BTreeMap<String, Vec<String>>,
    source: BTreeMap<String, String>,
}

impl Hooks {
    /// Parse a `plugins.toml` body's `[hooks]` schedule and `[source]` hints. A
    /// missing table, or a value of the wrong shape (a non-string-array hook
    /// entry, a non-string hint), contributes no entries; a malformed TOML
    /// document is an error. balls reads only these two tables; any other table
    /// a team adds round-trips untouched on `install` (a file copy) but is
    /// ignored here.
    pub fn parse(body: &str) -> io::Result<Hooks> {
        let root: toml::Table = toml::from_str(body).map_err(io::Error::other)?;
        let mut hooks = match root.get("hooks") {
            Some(toml::Value::Table(hooks)) => Hooks::from_hooks_table(hooks),
            _ => Hooks::default(),
        };
        hooks.source = source_table(&root);
        Ok(hooks)
    }

    /// Build the schedule from an already-extracted `[hooks]` sub-table — the
    /// shared tail of [`Hooks::parse`] and the layered [`Hooks::effective`]. A
    /// value that is not a string array contributes no names.
    fn from_hooks_table(hooks: &toml::Table) -> Hooks {
        let mut table = BTreeMap::new();
        for (key, value) in hooks {
            let names = value
                .as_array()
                .into_iter()
                .flatten()
                .filter_map(|e| e.as_str().map(str::to_string))
                .collect();
            table.insert(key.clone(), names);
        }
        Hooks { table, source: BTreeMap::new() }
    }

    /// The EFFECTIVE dispatch schedule (§4/§6, bl-8540): the landing's `[hooks]`
    /// overlaid by the per-machine XDG `plugins.toml`'s `[hooks]`, merged like
    /// every other config list — a bare `<op>.<phase>` REPLACES, and
    /// `_prepend`/`_append`/`_ban` COMPOSE ([`crate::config::layer_over`]), XDG
    /// (innermost) winning. So a box composes a center's committed schedule with
    /// its own machine-local one, the §4 ONE-layering-mechanism rather than a
    /// second registry. An absent layer contributes nothing. This is the DISPATCH
    /// read; the seed and `install` read the committed landing schedule alone via
    /// [`Hooks::load`]/[`Hooks::load_from`] (the XDG overlay is dispatch-only — it
    /// must not redirect what the seed prunes or `install` binds).
    pub fn effective(landing: &Path, user_config: &Path) -> io::Result<Hooks> {
        let mut merged = toml::value::Table::new();
        let mut source = BTreeMap::new();
        for path in [plugins_toml(landing), user_config.with_file_name("plugins.toml")] {
            let Some(root) = crate::config::read_layer(&path)? else {
                continue; // absent layer contributes nothing
            };
            if let Some(toml::Value::Table(hooks)) = root.get("hooks") {
                crate::config::layer_over(&mut merged, hooks.clone());
            }
            // [source] layers as per-name scalars, innermost (XDG) winning —
            // plain replacement, no list directives (§4).
            source.extend(source_table(&root));
        }
        let mut hooks = Hooks::from_hooks_table(&merged);
        hooks.source = source;
        Ok(hooks)
    }

    /// Load the `[hooks]` schedule from `plugins.toml` at `path`. An absent file
    /// is the un-wired case — an empty schedule (run nothing), not an error.
    pub fn load_from(path: &Path) -> io::Result<Hooks> {
        match std::fs::read_to_string(path) {
            Ok(body) => Hooks::parse(&body),
            Err(e) if e.kind() == io::ErrorKind::NotFound => Ok(Hooks::default()),
            Err(e) => Err(e),
        }
    }

    /// Load the schedule from a landing's `config/plugins.toml` (§2). The
    /// dispatch + rebind entry point ([`crate::mutate`]/[`crate::checkout`]).
    pub fn load(landing: &Path) -> io::Result<Hooks> {
        Hooks::load_from(&plugins_toml(landing))
    }

    /// The ordered plugin names under one schedule `key` (empty when un-wired).
    fn key_names(&self, key: &str) -> &[String] {
        self.table.get(key).map_or(&[], Vec::as_slice)
    }

    /// Every `(key, names)` entry of the schedule, in [`BTreeMap`] order — the
    /// `bl conf` dump's iteration over the effective `[hooks]` table (§4).
    pub fn entries(&self) -> impl Iterator<Item = (&String, &Vec<String>)> {
        self.table.iter()
    }

    /// Is `key` wired in this schedule (even to an empty list)? Exactly the set
    /// the `bl conf` dump surfaces — so `conf`'s per-key ops accept it too, even
    /// when its op is a retired verb the strict slot-grammar no longer knows
    /// (bl-03a1: what the dump shows, you can read and remove).
    #[must_use]
    pub fn has(&self, key: &str) -> bool {
        self.table.contains_key(key)
    }

    /// The ordered plugin names wired for `<op>.<phase>` (empty when un-wired).
    #[must_use]
    pub fn names(&self, op: &str, phase: &str) -> &[String] {
        self.key_names(&format!("{op}.{phase}"))
    }

    /// Resolve `<op>.<phase>` into the engine's [`PluginRef`] set, in list order,
    /// each name stitched to its local `bin/<name>` via `registry` (`None` when
    /// not installed here — a dangling ref the dispatch rejects, §6).
    #[must_use]
    pub fn resolve(&self, registry: &Registry, op: &str, phase: &str) -> Vec<PluginRef> {
        self.refs(registry, self.names(op, phase))
    }

    /// Resolve a READ op's plugin set (§6 read dispatch): a read carries no seal
    /// and no `pre`/`post` split, so its hook key is the BARE `<op>` token — one
    /// key for the one phase it dispatches.
    #[must_use]
    pub fn resolve_read(&self, registry: &Registry, op: &str) -> Vec<PluginRef> {
        self.refs(registry, self.key_names(op))
    }

    /// Stitch `names` to their local `bin/<name>` bindings, in list order, each
    /// carrying its `[source]` hint (display-only, for the unbound refusal). A
    /// name with no hint of its own falls back to its RENAMED-to name's
    /// ([`crate::renames`], §15): the rename notice's remedy is a conf edit to
    /// the new name, and the new name's hint is where THAT binary comes from —
    /// so the one notice carries both halves of the fix.
    fn refs(&self, registry: &Registry, names: &[String]) -> Vec<PluginRef> {
        names
            .iter()
            .map(|name| PluginRef {
                name: name.clone(),
                bin: registry.resolve_bin(name),
                source: self
                    .source(name)
                    .or_else(|| crate::renames::renamed_to(name).and_then(|current| self.source(current))),
            })
            .collect()
    }

    /// The `[source]` acquisition hint for `name`, sanitized for display
    /// (bl-5b09): untrusted free text rendered as ONE terminal line — every
    /// control character (a newline, an escape) becomes a space, the same
    /// discipline as enveloped plugin stderr. Never parsed, never executed. A
    /// hint that sanitizes to nothing is no hint.
    #[must_use]
    pub fn source(&self, name: &str) -> Option<String> {
        let hint: String = self.source.get(name)?.chars().map(|c| if c.is_control() { ' ' } else { c }).collect();
        let hint = hint.trim().to_string();
        (!hint.is_empty()).then_some(hint)
    }

    /// Every plugin the schedule names, mapped to the op tokens it is wired into
    /// — the `<op>` half of each `<op>.<phase>` key. The seed binds each of these
    /// to its sibling binary (§12); `bl install` validates each against the local
    /// binary's self-description (§6).
    #[must_use]
    pub fn referenced(&self) -> BTreeMap<String, BTreeSet<String>> {
        let mut refs: BTreeMap<String, BTreeSet<String>> = BTreeMap::new();
        for (key, names) in &self.table {
            let op = key.split('.').next().unwrap_or(key);
            for name in names {
                refs.entry(name.clone()).or_default().insert(op.to_string());
            }
        }
        refs
    }

    /// Drop every name failing `keep` from every list — the seed's prune of
    /// entries whose binary is absent here, so a box missing a default plugin
    /// never aborts (§12).
    pub fn retain(&mut self, keep: impl Fn(&str) -> bool) {
        for names in self.table.values_mut() {
            names.retain(|name| keep(name));
        }
    }

    /// Serialize back to a `plugins.toml` body — the `[hooks]` table with the
    /// surviving entries (an emptied list is dropped: empty = run nothing) plus
    /// the `[source]` hints VERBATIM, whole (a pruned name keeps its hint — the
    /// owner's note survives for the re-add after acquiring, bl-5b09). The seed
    /// writes this after [`Hooks::retain`] prunes the absent binaries.
    ///
    /// # Panics
    /// Only if the tables fail to serialize to TOML, which tables of strings
    /// and string arrays never do.
    #[must_use]
    pub fn to_toml(&self) -> String {
        let mut hooks = toml::value::Table::new();
        for (key, names) in &self.table {
            if !names.is_empty() {
                let array = names.iter().cloned().map(toml::Value::String).collect();
                hooks.insert(key.clone(), toml::Value::Array(array));
            }
        }
        let mut root = toml::value::Table::new();
        root.insert("hooks".to_string(), toml::Value::Table(hooks));
        if !self.source.is_empty() {
            let hints = self.source.iter().map(|(name, hint)| (name.clone(), toml::Value::String(hint.clone())));
            root.insert("source".to_string(), toml::Value::Table(hints.collect()));
        }
        toml::to_string(&toml::Value::Table(root)).expect("a hooks table always serializes")
    }
}

/// The committed landing's plugin schedule, `config/plugins.toml` (§2) — the one
/// place that path is spelled (the seed prunes it, `install` binds it, dispatch
/// reads it through [`Hooks::load`]/[`Hooks::effective`]).
fn plugins_toml(landing: &Path) -> PathBuf {
    landing.join("config").join("plugins.toml")
}

/// Extract a `plugins.toml` root's `[source]` hint table (bl-5b09): per-name
/// free-text scalars, raw as authored. A missing or non-table `[source]`, or a
/// non-string hint value, contributes no entries.
fn source_table(root: &toml::Table) -> BTreeMap<String, String> {
    let Some(toml::Value::Table(entries)) = root.get("source") else {
        return BTreeMap::new();
    };
    entries
        .iter()
        .filter_map(|(name, hint)| hint.as_str().map(|h| (name.clone(), h.to_string())))
        .collect()
}

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