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
//! §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 gets one stderr line (bl-5b09: the org opted into loudness by authoring
/// the hint); 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<()> {
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)?;
for name in hooks.referenced().keys().filter(|n| !present.contains(*n)) {
if let Some(hint) = hooks.source(name) {
eprintln!("seed: pruned {name} (no binary beside bl) — source: {hint} — re-add with bl conf after acquiring");
}
}
hooks.retain(|name| present.contains(name));
fs::write(config.join("plugins.toml"), hooks.to_toml())?;
Ok(())
}
/// 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).
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;