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
//! §4 `bl conf` writes — scope-keyed CRUD on each key's canonical home.
//!
//! `set` replaces (a scalar, or a hooks key's whole list); `append`/`prepend`/
//! `remove` compose a list — the §4 directive vocabulary APPLIED AT WRITE TIME
//! to the canonical bare list, never stored as `_append`/`_prepend`/`_ban` keys
//! beside it (one fact, one home; the directive keys remain the cross-LAYER
//! compose for a hand-written XDG overlay). Compose converges (§13 idempotence):
//! appending a present name or removing an absent one is a no-op, and a list
//! emptied by `remove` drops its key (absent/empty = run nothing, §4/§6).
//!
//! Homes: `task-remote` routes by VALUE — a URL ⇒ this clone's `binding.toml` (a
//! plain per-checkout local-state file edit, clearing any landing stealth
//! sentinel so the set changes what the ladder resolves — never the machine-wide
//! XDG file that silently shadowed every other repo's store, bl-d081), the
//! sentinel `none` ⇒ the landing
//! `task_remote` policy rung (§12, bl-9df0); `clock-provider` ⇒ this clone's
//! `binding.toml` too (§8, bl-cfe3 — a box-local op-clock value, never a landing
//! field, never travels on `install`); `task-branch`/`log-level` ⇒ the
//! landing `balls.toml` and the hooks
//! keys ⇒ the landing `plugins.toml`, each an ordinary commit on `balls/config`
//! (`balls: conf <op> <key> …`, checkout-scoped — §5). A write that changes
//! nothing seals nothing: git's own empty-diff check is the change detector,
//! the same trick as the §8 no-op seal. Foreign tables in either TOML file
//! round-trip untouched — this edits ONE key, never re-shapes the document.
use super::Key;
use crate::edge::Edge;
use crate::git;
use crate::hooks::Hooks;
use crate::layout::CloneDir;
use crate::log::Level;
use crate::message::Message;
use crate::verb::Verb;
use std::fs;
use std::io;
use std::path::Path;
use toml::value::{Table, Value};
/// Dispatch one write: `bl conf <op> <key> <value...>`. The key implies its
/// home (§4); a list op on a scalar key is refused, naming the split.
pub(super) fn run(edge: &Edge, clone: &CloneDir, op: &str, rest: &[String]) -> io::Result<()> {
let Some((token, values)) = rest.split_first() else {
return Err(crate::usage(format!("conf {op}: needs <key> <value...>")));
};
let landing = clone.landing();
let actor = &edge.default_actor;
let present = Hooks::effective(&landing, &edge.xdg.user_config(), &edge.machine_dirs())?;
match (Key::parse(token, &present)?, op) {
(Key::Hook(k), _) => hooks_edit(&landing, actor, op, &k, values),
(Key::TaskRemote, "set") => match one(op, token, values)? {
crate::config::STEALTH_REMOTE => declare_stealth(&landing, actor),
url => bind_task_remote(clone, &landing, actor, url),
},
(Key::LogLevel, "set") => {
let value = one(op, token, values)?;
Level::parse(value)?; // refuse a level the ladder won't speak
landing_set(&landing, actor, token, "log_level", value)
}
(Key::TaskBranch, "set") => {
let value = one(op, token, values)?;
crate::config::forbid_landing(value)?; // the coincident name is refused at the front door (bl-ac89)
landing_set(&landing, actor, token, "tasks_branch", value)
}
(Key::ClockProvider, "set") => {
// A DIRECTLY-SET LOCAL value (bl-cfe3): an absolute path or a
// PATH-resolved name, written to THIS clone's `binding.toml` — the
// per-machine LOCAL-TRUST layer that never travels on `install` (§4).
// No install, no `bin/<name>` symlink: the clock is box-local,
// cosmetic, fail-open (§1/§8). The value is resolved + fail-open at
// op-start ([`crate::clock`]), not validated here.
binding_set(clone, "clock_provider", one(op, token, values)?)
}
_ => Err(crate::usage(format!(
"conf {op}: '{token}' is a scalar — append/prepend/remove compose the [hooks] list keys"
))),
}?;
eprintln!("conf {op} {token}");
Ok(())
}
/// The single value a scalar `set` takes.
fn one<'a>(op: &str, key: &str, values: &'a [String]) -> io::Result<&'a str> {
match values {
[only] => Ok(only),
_ => Err(crate::usage(format!("conf {op}: '{key}' takes exactly one value"))),
}
}
/// Bind THIS checkout to a store-remote `url` (the §12 durable per-clone tier):
/// clear any declared stealth first — the landing rung outranks the binding one,
/// so leaving the sentinel would make this set change nothing the ladder resolves
/// (the bl-d234 trap, inverted) — then write `url` into the clone's `binding.toml`.
/// `bl conf set task-remote <url>` IS this write, and `bl prime --center <url>`
/// reuses it as enrollment's durable half (bl-35e5): one composition, one home,
/// so a durable bind can never drift between the two spellings.
pub(crate) fn bind_task_remote(clone: &CloneDir, landing: &Path, actor: &str, url: &str) -> io::Result<()> {
clear_stealth(landing, actor, url)?;
binding_set(clone, "remote", url)
}
/// Declare stealth: write the §12 sentinel into the landing's per-checkout
/// `task_remote` policy rung, sealed like any landing config edit. `bl prime
/// --stealth` is sugar for exactly this write (bl-9df0) — the opt-out is a
/// durable config fact every later op's bind derives, never a per-invocation
/// flag.
pub(crate) fn declare_stealth(landing: &Path, actor: &str) -> io::Result<()> {
landing_set(landing, actor, "task-remote", "task_remote", crate::config::STEALTH_REMOTE)
}
/// Drop the landing stealth sentinel (a durable URL set supersedes the declared
/// opt-out). Removing an absent key is the convergent no-op — nothing commits.
fn clear_stealth(landing: &Path, actor: &str, url: &str) -> io::Result<()> {
edit_landing_toml(landing, actor, "balls.toml", &format!("balls: conf set task-remote {url}"), |table| {
table.remove("task_remote");
Ok(())
})
}
/// Set one string `field` in THIS checkout's `binding.toml` — a plain local-state
/// file edit, every other key in it untouched. The binding is the per-machine
/// LOCAL-TRUST layer (§4/§12): the store `remote` (which center this clone tracks)
/// and the §8 `clock_provider` (this box's op-clock) both live here, never in the
/// landing config, so neither travels on `install` and neither can shadow another
/// repo the way the machine-wide XDG file once did (bl-d081/bl-cfe3). Local state:
/// never committed — it lives beside the landing/store checkouts in the clone
/// bundle.
///
/// The binding is the ONE balls-owned mutable fact outside git, so it has no CAS
/// commit point to seal against; the replace is therefore made ATOMIC by hand
/// (bl-ffbf): the new document goes to a private temp file beside the target and
/// is `rename`d over it — git's own `index.lock` discipline. A reader (every
/// op's §12 ladder resolution) sees the whole old file or the whole new one,
/// never a truncated prefix, and a crash mid-write leaves the established
/// binding standing. What remains — and is ACCEPTED — is the lost update: two
/// concurrent `bl conf set` in one clone read the same table and the later
/// rename wins whole, dropping the other's field. A lock would trade that for a
/// stale lockfile bricking every later write, the same true-forever debris the
/// founding predicate had to shed; the fix, if this ever bites, is to move the
/// binding under a ref, not to add a lock.
fn binding_set(clone: &CloneDir, field: &str, value: &str) -> io::Result<()> {
let path = clone.binding();
let mut table = read_table(&path)?;
table.insert(field.into(), Value::String(value.to_string()));
let body = toml::to_string(&Value::Table(table)).expect("a string field always serializes");
fs::create_dir_all(path.parent().expect("the clone binding always has a parent"))?;
// The pid keeps two concurrent writers in their own temp, so neither can
// interleave bytes into the other's — the failure they share is losing a
// field, never a corrupt document.
let temp = path.with_file_name(format!("binding.toml.{}.tmp", std::process::id()));
let replaced = fs::write(&temp, body).and_then(|()| fs::rename(&temp, &path));
if replaced.is_err() {
let _ = fs::remove_file(&temp); // a half-written temp is never left behind
}
replaced
}
/// Set a landing `balls.toml` scalar and seal it on `balls/config` (§4).
fn landing_set(landing: &Path, actor: &str, token: &str, field: &str, value: &str) -> io::Result<()> {
edit_landing_toml(landing, actor, "balls.toml", &format!("balls: conf set {token} {value}"), |table| {
table.insert(field.into(), Value::String(value.to_string()));
Ok(())
})
}
/// Apply one §4 list op to a `[hooks]` key on the landing `plugins.toml`:
/// `set` bare-replaces with `values`; `append`/`prepend` insert ONE name iff
/// absent (convergent); `remove` prunes it, dropping the key when emptied.
fn hooks_edit(landing: &Path, actor: &str, op: &str, key: &str, values: &[String]) -> io::Result<()> {
if values.iter().any(String::is_empty) {
// bl-bee0: "" is not a plugin name — it would resolve bin/ itself at
// dispatch. Clearing already has a spelling: `set <key>` with no values.
return Err(crate::usage(format!(
"conf {op}: a plugin name must be non-empty — `conf set {key}` clears the list"
)));
}
if op != "set" {
one(op, key, values)?; // compose moves exactly one name
}
let subject = format!("balls: conf {op} {key} {}", values.join(" "));
edit_landing_toml(landing, actor, "plugins.toml", &subject, |root| {
let Value::Table(hooks) = root.entry("hooks").or_insert_with(|| Value::Table(Table::new())) else {
return Err(io::Error::other("plugins.toml: [hooks] is not a table"));
};
let mut names: Vec<String> = hooks
.get(key)
.and_then(Value::as_array)
.into_iter()
.flatten()
.filter_map(|v| v.as_str().map(str::to_string))
.collect();
match op {
"set" => names = values.to_vec(),
"append" if !names.contains(&values[0]) => names.push(values[0].clone()),
"prepend" if !names.contains(&values[0]) => names.insert(0, values[0].clone()),
"remove" => names.retain(|n| n != &values[0]),
_ => {} // append/prepend of a present name — the convergent no-op
}
if names.is_empty() {
hooks.remove(key); // absent/empty = run nothing (§4) — drop, don't store []
} else {
let list = names.into_iter().map(Value::String).collect();
hooks.insert(key.to_string(), Value::Array(list));
}
Ok(())
})
}
/// Read-edit-write one landing `config/<file>` TOML document, then seal it as
/// an ordinary commit on `balls/config` carrying the §5 checkout-scoped
/// trailer block (bl-1d9b). The edit touches one key; everything else in the
/// document round-trips. A no-change edit commits nothing — git's empty-diff
/// check is the §13 convergence test. `pub(crate)` so [`crate::converge`]
/// rewrites retired plugin names through the SAME raw-`toml::Table` seal (a raw
/// closure, not [`Hooks::to_toml`], which drops a team's foreign tables —
/// bl-18bf §12.1); every other key round-trips exactly as here.
pub(crate) fn edit_landing_toml(
landing: &Path,
actor: &str,
file: &str,
subject: &str,
edit: impl FnOnce(&mut Table) -> io::Result<()>,
) -> io::Result<()> {
let path = landing.join("config").join(file);
let mut table = read_table(&path)?;
edit(&mut table)?;
fs::write(&path, toml::to_string(&Value::Table(table)).expect("a hooks/scalar table always serializes"))?;
git::run(landing, &["add", "-A", "config"], None)?;
if git::run(landing, &["diff", "--cached", "--quiet"], None).is_ok() {
return Ok(()); // the value already held — converge, no empty commit
}
let message = Message::checkout(Verb::Conf, actor, subject.to_string()).render()?;
git::run(landing, &["commit", "-q", "-F", "-"], Some(&message))?;
Ok(())
}
/// One TOML document as a table: absent ⇒ empty (the un-configured case),
/// malformed ⇒ an error naming the file ([`crate::config::read_layer`]).
fn read_table(path: &Path) -> io::Result<Table> {
Ok(crate::config::read_layer(path)?.unwrap_or_default())
}
#[cfg(test)]
#[path = "conf_write_tests.rs"]
mod tests;