1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
//! §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 (`bl-adversary = "cargo install balls-adversary"`)
//! 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).
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),
})
.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;