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
//! §1/§12 the seed — the app default-config, copied into a fresh landing.
//!
//! The trusted default capability set is config-time, not run-time: there is NO
//! runtime plugin magic, core only ever runs what the landing's `plugins.toml`
//! lists (§0). So the defaults live in a real folder a fresh `bl prime` copies
//! in — making the tracker + delivery plugins ordinary landing entries and the
//! default set swappable POLICY (an org ships its own seed) rather than core code
//! (severability, §1).
//!
//! **Bootstrap source.** The default-config is EMBEDDED in the binary
//! ([`include_str!`]) and used DIRECTLY as the seed — so a fresh `cargo install`
//! or a test binary run from `/tmp` always carries the CURRENT default (no "run a
//! script to get set up"). `$XDG_CONFIG_HOME/balls/default-config/` is a
//! DELIBERATE, never-auto-written override: present, its files win per-file (an
//! override that omits a file falls back to the embedded default for that file),
//! letting an org/user customize the default capability set without touching core
//! (§1). Core NEVER creates that folder, so a once-materialized copy can't go
//! stale and silently shadow a moved embedded default (bl-8088).
//!
//! **Bind + prune.** Seeding binds each plugin the schedule names to its sibling
//! binary beside `bl` ([`Registry::bind`]) and PRUNES the hook entries whose
//! binary is absent here, so a tracker-less or test box never aborts (§12).
//! [`rebind`] re-establishes those local `bin/<name>` symlinks on an established
//! landing (a new machine / clone re-deriving the gitignored half), without
//! re-seeding or pruning the committed schedule.
use std::collections::BTreeSet;
use std::fs;
use std::io;
use std::path::{Path, PathBuf};
use crate::hooks::Hooks;
use crate::layout::Xdg;
use crate::registry::Registry;
/// The embedded install-default `balls.toml` (§4) — the authoritative default a
/// fresh landing seeds from unless an XDG override `balls.toml` is present.
const EMBEDDED_BALLS: &str = include_str!("../default-config/balls.toml");
/// The embedded install-default `plugins.toml` (§6) — the `[hooks]` schedule
/// wiring the shipped `bl-tracker` + `bl-delivery` capabilities, used directly
/// unless an XDG override `plugins.toml` is present.
const EMBEDDED_PLUGINS: &str = include_str!("../default-config/plugins.toml");
/// Seed a fresh landing's `config/` from the default-config (§12). Writes
/// `balls.toml` verbatim, then writes `plugins.toml` with each named plugin bound
/// to its sibling binary beside `bl` and every absent-binary entry PRUNED. The
/// prune stays silent for a hintless name (the shipped-sibling case — a
/// tracker-less test box needs no advice) but a pruned name with a `[source]`
/// hint yields one rendered note (bl-5b09: the org opted into loudness by
/// authoring the hint), RETURNED to the caller — prime emits them through the
/// op log like install's dangling report, so the note persists and obeys the
/// threshold instead of being a bare eprintln (this layer stays log-free, like
/// the rest of the git-free seed). The hint table itself survives the rewrite
/// whole ([`Hooks::to_toml`]). Each file's content is the embedded
/// install-default unless an XDG override file is present ([`default_body`]).
/// `exe_dir` is the directory holding `bl` (where the shipped siblings live);
/// `None` ⇒ a tracker-less box (every entry prunes, the chain runs empty).
pub fn seed_landing(xdg: &Xdg, landing: &Path, exe_dir: Option<&Path>) -> io::Result<Vec<String>> {
let override_dir = xdg.default_config();
let config = landing.join("config");
fs::create_dir_all(&config)?;
fs::write(config.join("balls.toml"), default_body(&override_dir, "balls.toml", EMBEDDED_BALLS)?)?;
let mut hooks = Hooks::parse(&default_body(&override_dir, "plugins.toml", EMBEDDED_PLUGINS)?)?;
let present = bind_present(landing, exe_dir, &hooks)?;
let notes = hooks
.referenced()
.keys()
.filter(|n| !present.contains(*n))
.filter_map(|name| {
let hint = hooks.source(name)?;
Some(format!("seed: pruned {name} (no binary beside bl) — source: {hint} — re-add with bl conf after acquiring"))
})
.collect();
hooks.retain(|name| present.contains(name));
fs::write(config.join("plugins.toml"), hooks.to_toml())?;
Ok(notes)
}
/// Re-establish the local `bin/<name>` bindings for an established landing's
/// committed schedule (§12) — the gitignored half a new machine / clone must
/// re-derive. Idempotent; never prunes or rewrites the committed `plugins.toml`
/// (capabilities change only by `bl install`, never a re-prime).
pub fn rebind(landing: &Path, exe_dir: Option<&Path>) -> io::Result<()> {
let hooks = Hooks::load(landing)?;
bind_present(landing, exe_dir, &hooks)?;
Ok(())
}
/// Bind every plugin the `hooks` schedule names to its sibling binary beside
/// `bl`, when present, returning the set that resolved. Shared by [`seed_landing`]
/// (which prunes the rest) and [`rebind`] (which leaves the schedule untouched).
fn bind_present(landing: &Path, exe_dir: Option<&Path>, hooks: &Hooks) -> io::Result<BTreeSet<String>> {
let registry = Registry::at(landing);
let mut present = BTreeSet::new();
for name in hooks.referenced().keys() {
if let Some(bin) = sibling(exe_dir, name) {
registry.bind(name, &bin)?;
present.insert(name.clone());
}
}
Ok(present)
}
/// The path to a `name`d binary beside `bl` (in `exe_dir`), if it exists — how a
/// shipped sibling plugin is found (§6/§12). An absent `exe_dir` or missing
/// sibling ⇒ `None` (that plugin prunes from the seed; stays dangling on rebind).
/// Shared with [`crate::converge`], which binds a renamed name's current binary
/// by the SAME rule (the first-party rename ships beside `bl`, bl-18bf §12.1).
pub(crate) fn sibling(exe_dir: Option<&Path>, name: &str) -> Option<PathBuf> {
let path = exe_dir?.join(name);
path.exists().then_some(path)
}
/// The default-config content for `name` (§1): the XDG override file
/// `$XDG_CONFIG_HOME/balls/default-config/<name>` when a user/org has already
/// authored it, else the `embedded` install-default. The override folder is read
/// ONLY when present — never created — so the live embedded default can never be
/// shadowed by a stale once-materialized copy (bl-8088, "don't store what you can
/// compute").
fn default_body(override_dir: &Path, name: &str, embedded: &str) -> io::Result<String> {
let file = override_dir.join(name);
if file.is_file() {
fs::read_to_string(&file)
} else {
Ok(embedded.to_string())
}
}
#[cfg(test)]
#[path = "seed_tests.rs"]
mod tests;