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
//! §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 ⇒ the XDG `config.toml` (a
//! plain per-machine file edit, clearing any landing stealth sentinel so the
//! set changes what the ladder resolves), the sentinel `none` ⇒ the landing
//! `task_remote` policy rung (§12, bl-9df0); `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::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(io::Error::other(format!("conf {op}: needs <key> <value...>")));
};
let landing = clone.landing();
let actor = &edge.default_actor;
match (Key::parse(token)?, 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 => {
// Clear a declared stealth first: the landing rung outranks the
// XDG one, so leaving the sentinel would make this set change
// nothing the ladder resolves — the bl-d234 trap, inverted.
clear_stealth(&landing, actor, url)?;
xdg_set(edge, 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)
}
_ => Err(io::Error::other(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(io::Error::other(format!("conf {op}: '{key}' takes exactly one value"))),
}
}
/// 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 the per-machine XDG `remote` (§12) — a plain file edit on the user
/// config, every other key in it untouched. A remote URL's only home: it must
/// not travel on `install` (§4).
fn xdg_set(edge: &Edge, url: &str) -> io::Result<()> {
let path = edge.xdg.user_config();
let mut table = read_table(&path)?;
table.insert("remote".into(), Value::String(url.to_string()));
fs::create_dir_all(path.parent().expect("the XDG user config always has a parent"))?;
fs::write(&path, toml::to_string(&Value::Table(table)).expect("a string field always serializes"))
}
/// 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(io::Error::other(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.
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;