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
//! §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).
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).
#[derive(Debug, Default, Clone, PartialEq, Eq)]
pub struct Hooks {
table: BTreeMap<String, Vec<String>>,
}
impl Hooks {
/// Parse a `plugins.toml` body's `[hooks]` table. A missing `[hooks]` table,
/// or a value that is not a string array, contributes no entries; a malformed
/// TOML document is an error. balls reads only `[hooks]`; 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)?;
Ok(match root.get("hooks") {
Some(toml::Value::Table(hooks)) => Hooks::from_hooks_table(hooks),
_ => Hooks::default(),
})
}
/// 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 }
}
/// 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();
if let Some(hooks) = hooks_layer(&plugins_toml(landing))? {
crate::config::layer_over(&mut merged, hooks);
}
if let Some(hooks) = hooks_layer(&user_config.with_file_name("plugins.toml"))? {
crate::config::layer_over(&mut merged, hooks);
}
Ok(Hooks::from_hooks_table(&merged))
}
/// 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.
fn refs(registry: &Registry, names: &[String]) -> Vec<PluginRef> {
names
.iter()
.map(|name| PluginRef { name: name.clone(), bin: registry.resolve_bin(name) })
.collect()
}
/// 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 — a single `[hooks]` table with
/// the surviving entries (an emptied list is dropped: empty = run nothing).
/// The seed writes this after [`Hooks::retain`] prunes the absent binaries.
///
/// # Panics
/// Only if the `[hooks]` table fails to serialize to TOML, which a table of
/// string arrays never does.
#[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));
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")
}
/// Read one `plugins.toml` layer's `[hooks]` sub-table for [`Hooks::effective`].
/// An absent file ⇒ `None` (the layer contributes nothing); a present file with a
/// missing or non-table `[hooks]` ⇒ an empty table (also nothing, but distinct
/// from absent); a malformed document ⇒ an error naming the file.
fn hooks_layer(path: &Path) -> io::Result<Option<toml::Table>> {
match std::fs::read_to_string(path) {
Ok(body) => {
let root: toml::Table =
toml::from_str(&body).map_err(|e| io::Error::other(format!("{}: {e}", path.display())))?;
Ok(Some(match root.get("hooks") {
Some(toml::Value::Table(hooks)) => hooks.clone(),
_ => toml::Table::new(),
}))
}
Err(e) if e.kind() == io::ErrorKind::NotFound => Ok(None),
Err(e) => Err(e),
}
}
#[cfg(test)]
#[path = "hooks_tests.rs"]
mod tests;