Skip to main content

max / alloy

config: bind a form to a TOML file through the seam the System tab fills Step 2 of the build order, with the `Bind` seam carved now rather than later. [[alloy-settings]] puts two tabs over one form engine, and the only thing that differs between them is where a value comes from and what committing one does; a trait shaped around FileBind while it is the sole implementor makes the command front a fill-in rather than a refactor. commit and save return Effects instead of performing them, which is the pattern every backend in the console already follows, and it settles the dirty/apply question the design note left open without either tab knowing how the other behaves: a file holds its edits and emits one write at save, a command front will emit its setter at commit and have nothing to save. The form asks whether anything is pending. Edits go into a DocumentMut in place, so comments, key order and keys no field declares survive untouched — the unknown_keys = "preserve" promise is structural rather than remembered. Reading and defaulting stay separate calls, which is what keeps a displayed default off the disk. Tested against the shipped rio schema and the shipped rio config together: every key is declared, the roundtrip is byte-identical, and an edit moves one value and leaves its trailing comment where it was. Nothing else would have noticed those two drifting apart, and renderer.backend already did.
Author: Max Johnson <me@maxj.phd> · 2026-07-24 18:07 UTC
Signed with PGP, not checked
Commit: 01a103b57ab90a4cde4d4585e6fdb5429be4a4ad
Parent: 1a81e4a
5 files changed, +542 insertions, -0 deletions
M Cargo.lock +17
@@ -31,6 +31,7 @@
31 31 "serde_json",
32 32 "sha-crypt",
33 33 "toml",
34 + "toml_edit",
34 35 ]
35 36
36 37 [[package]]
@@ -1650,6 +1651,19 @@
1650 1651 "serde_core",
1651 1652 ]
1652 1653
1654 + [[package]]
1655 + name = "toml_edit"
1656 + version = "0.25.13+spec-1.1.0"
1657 + source = "registry+https://github.com/rust-lang/crates.io-index"
1658 + checksum = "6975367e4d2ef766d86af01ffad14b622fecc8d4357a998fbc4deb6e9bacaf9b"
1659 + dependencies = [
1660 + "indexmap",
1661 + "toml_datetime",
1662 + "toml_parser",
1663 + "toml_writer",
1664 + "winnow",
1665 + ]
1666 +
1653 1667 [[package]]
1654 1668 name = "toml_parser"
1655 1669 version = "1.1.2+spec-1.1.0"
@@ -1913,6 +1927,9 @@
1913 1927 version = "1.0.4"
1914 1928 source = "registry+https://github.com/rust-lang/crates.io-index"
1915 1929 checksum = "23b97319f7b8343df12cc98938e5c3eb436064524c8d2b4e30a1d3a36eecdf81"
1930 + dependencies = [
1931 + "memchr",
1932 + ]
1916 1933
1917 1934 [[package]]
1918 1935 name = "wit-bindgen"
@@ -23,6 +23,7 @@
23 23 makeover.workspace = true
24 24 sha-crypt = "0.6.0"
25 25 getrandom = "0.4.3"
26 + toml_edit = "0.25.13"
26 27
27 28 [lints]
28 29 workspace = true
@@ -7,6 +7,7 @@
7 7 //! <!-- wiki: alloy-console -->
8 8
9 9 mod audio;
10 + mod bind;
10 11 mod cli;
11 12 mod field;
12 13 mod install;
@@ -92,6 +92,29 @@
92 92 pub(crate) kind: FieldKind,
93 93 }
94 94
95 + impl Field {
96 + /// The schema `default`, as a value.
97 + ///
98 + /// What a row shows when the target has no such key. Deliberately not the
99 + /// same call as reading the target: docs/CONSOLE.md wants the default
100 + /// displayed without being materialized on disk, so a minimal config stays
101 + /// minimal, and keeping the two apart is what makes that structural.
102 + pub(crate) fn default_value(&self) -> Option<Value> {
103 + match &self.kind {
104 + FieldKind::Bool { default } => default.map(Value::Boolean),
105 + FieldKind::Int { default, .. } => default.map(Value::Integer),
106 + FieldKind::Float { default, .. } => default.map(Value::Float),
107 + FieldKind::Str { default, .. }
108 + | FieldKind::Color { default, .. }
109 + | FieldKind::Path { default, .. }
110 + | FieldKind::Enum { default, .. } => default.clone().map(Value::String),
111 + // A v1 list's only default is the empty one, which is also what an
112 + // absent key reads as, so there is nothing to show.
113 + FieldKind::List { .. } => None,
114 + }
115 + }
116 + }
117 +
95 118 /// The value half of a field: its type, its constraints, and its default.
96 119 ///
97 120 /// One enum rather than one struct per type, for the reason recorded in
@@ -1,0 +1,765 @@
1 + //! The seam a form edits through, and its file implementation.
2 + //!
3 + //! [[alloy-settings]] settled `alloy settings` as two tabs over one form
4 + //! engine: System reads and writes live state through `timedatectl` and friends,
5 + //! Applications reads and writes a config file. The rows, the chrome, the modal
6 + //! edit and the validation are the same on both sides; the only thing that
7 + //! differs is where a value comes from and what committing one does. That
8 + //! difference is [`Bind`].
9 + //!
10 + //! The seam is extracted here, while [`FileBind`] is its only implementor,
11 + //! because a trait carved around one implementation before the second exists is
12 + //! a fill-in rather than a refactor. The System side is the next one in.
13 + //!
14 + //! ## Committing returns effects, it does not perform them
15 + //!
16 + //! [`Bind::commit`] and [`Bind::save`] return [`Effect`]s for the caller to
17 + //! apply through the [`CommandLog`](crate::cli::CommandLog), which is the
18 + //! pattern every backend in the console already follows: it keeps the log's
19 + //! coverage structural and it keeps the layer testable on a machine with none
20 + //! of the tools installed.
21 + //!
22 + //! It also settles the dirty/apply question [[alloy-settings]] left open,
23 + //! without either tab having to know how the other behaves. A file bind holds
24 + //! its edits in a document and emits one `Effect::Write` at save; a command
25 + //! front will emit its setter at commit and have nothing to save. The form asks
26 + //! [`Bind::dirty`] whether there is anything pending and shows the save
27 + //! affordance accordingly, rather than being told which kind of tab it is on.
28 + //!
29 + //! ## Roundtrip safety
30 + //!
31 + //! Edits go into a `toml_edit::DocumentMut` in place, so comments, key order,
32 + //! whitespace and keys no field declares survive an edit untouched. That is
33 + //! what makes docs/CONSOLE.md's `unknown_keys = "preserve"` promise structural
34 + //! rather than remembered.
35 + //!
36 + //! <!-- wiki: alloy-console -->
37 +
38 + #![allow(dead_code)]
39 +
40 + use std::path::{Path, PathBuf};
41 +
42 + use anyhow::{Context, Result, ensure};
43 + use toml::Value;
44 + use toml_edit::{DocumentMut, Item, TableLike};
45 +
46 + use crate::cli::Effect;
47 + use crate::schema::{Field, Schema, Section, UnknownKeys, check_value};
48 +
49 + /// Permissions for a written config file. Not a secret and not executable.
50 + const CONFIG_MODE: u32 = 0o644;
51 +
52 + /// What a form reads from and writes to.
53 + pub(crate) trait Bind {
54 + /// Where the values come from, for the form header. A file path or the name
55 + /// of the front.
56 + fn origin(&self) -> String;
57 +
58 + /// The panes, in the order they are shown.
59 + fn sections(&self) -> &[Section];
60 +
61 + /// Every row, in the order they are shown.
62 + fn fields(&self) -> &[Field];
63 +
64 + /// The value the source of truth currently holds.
65 + ///
66 + /// `None` means it holds none, which for a file is a key that is simply not
67 + /// there. What a row displays in that case is the field's
68 + /// [`default_value`](Field::default_value) — reading and defaulting are
69 + /// kept apart so that showing a default cannot write one.
70 + fn read(&self, path: &str) -> Option<Value>;
71 +
72 + /// Commit a value. See the module docs for why this returns effects.
73 + fn commit(&mut self, path: &str, value: Value) -> Result<Vec<Effect>>;
74 +
75 + /// Whether there are edits that [`save`](Bind::save) has not emitted yet.
76 + fn dirty(&self) -> bool;
77 +
78 + /// Emit the effects that make the accumulated edits durable.
79 + fn save(&mut self) -> Result<Vec<Effect>>;
80 +
81 + /// The field at a path, if this bind has one.
82 + fn field(&self, path: &str) -> Option<&Field> {
83 + self.fields().iter().find(|field| field.path == path)
84 + }
85 +
86 + /// Whether a value can be committed at a path.
87 + ///
88 + /// Schema constraints by default, which is the whole of it for a file. A
89 + /// front whose vocabulary lives in a command (`timedatectl list-timezones`)
90 + /// overrides this.
91 + fn validate(&self, path: &str, value: &Value) -> Result<()> {
92 + let field = self
93 + .field(path)
94 + .with_context(|| format!("`{path}` is not a field of {}", self.origin()))?;
95 + check_value(&field.kind, value).with_context(|| format!("`{path}`"))
96 + }
97 + }
98 +
99 + /// A form over a TOML file, bound through its schema.
100 + pub(crate) struct FileBind {
101 + schema: Schema,
102 + path: PathBuf,
103 + document: DocumentMut,
104 + /// Edits since the last save. A count and not a flag because a preset is
105 + /// one edit however many values it carries, and the count is what says so.
106 + edits: usize,
107 + }
108 +
109 + impl FileBind {
110 + /// Open the file the schema targets.
111 + ///
112 + /// A missing file is not an error: an adopted tool the user has never
113 + /// configured has nothing on disk, and the right form for it is every field
114 + /// showing its default. The file appears at the first save.
115 + pub(crate) fn open(schema: Schema, path: &Path) -> Result<Self> {
116 + let text = match std::fs::read_to_string(path) {
117 + Ok(text) => text,
118 + Err(error) if error.kind() == std::io::ErrorKind::NotFound => String::new(),
119 + Err(error) => {
120 + return Err(error).with_context(|| format!("reading {}", path.display()));
121 + }
122 + };
123 + Self::new(schema, path.to_path_buf(), &text)
124 + .with_context(|| format!("in {}", path.display()))
125 + }
126 +
127 + pub(crate) fn new(schema: Schema, path: PathBuf, text: &str) -> Result<Self> {
128 + let document: DocumentMut = text.parse().context("the file is not valid TOML")?;
129 + let bind = Self {
130 + schema,
131 + path,
132 + document,
133 + edits: 0,
134 + };
135 +
136 + // `unknown_keys = "error"` is a schema saying it describes the whole
137 + // file, so an undeclared key is a schema that has fallen behind its
138 + // tool and the form would be editing a file it does not understand.
139 + // Under `preserve`, the same keys are an info diagnostic the view
140 + // shows, and they survive every edit untouched.
141 + if bind.schema.header.unknown_keys == UnknownKeys::Error {
142 + let unknown = bind.unknown_keys();
143 + ensure!(
144 + unknown.is_empty(),
145 + "the schema declares no field for {} (unknown_keys = \"error\")",
146 + unknown.join(", "),
147 + );
148 + }
149 + Ok(bind)
150 + }
151 +
152 + /// The file as it would be written right now.
153 + pub(crate) fn text(&self) -> String {
154 + self.document.to_string()
155 + }
156 +
157 + /// Keys present in the file that no field declares.
158 + ///
159 + /// Leaf paths only, and the walk stops at any declared field, so the rows
160 + /// of a `[[bindings.keys]]` table are the list field's business rather than
161 + /// two dozen unknown keys.
162 + pub(crate) fn unknown_keys(&self) -> Vec<String> {
163 + let mut found = Vec::new();
164 + self.walk(self.document.as_table(), "", &mut found);
165 + found
166 + }
167 +
168 + fn walk(&self, table: &dyn TableLike, prefix: &str, found: &mut Vec<String>) {
169 + for (key, item) in table.iter() {
170 + let path = if prefix.is_empty() {
171 + key.to_string()
172 + } else {
173 + format!("{prefix}.{key}")
174 + };
175 + if self.schema.field(&path).is_some() {
176 + continue;
177 + }
178 + match item.as_table_like() {
179 + Some(child) => self.walk(child, &path, found),
180 + // An array of tables is a leaf here: if it were declared, the
181 + // branch above took it, and if it is not, naming the array is
182 + // more use than naming every key inside it.
183 + None => found.push(path),
184 + }
185 + }
186 + }
187 +
188 + /// Apply a preset's whole values map as one edit.
189 + ///
190 + /// One increment, so quitting after applying one asks once and undoing it
191 + /// later is one step. The values were checked against their fields when the
192 + /// schema was parsed, so this cannot half-apply.
193 + pub(crate) fn apply_preset(&mut self, name: &str) -> Result<()> {
194 + let preset = self
195 + .schema
196 + .presets
197 + .iter()
198 + .find(|preset| preset.name == name)
199 + .with_context(|| format!("no preset named \"{name}\""))?;
200 +
201 + // Collected first: the write borrows the document mutably and the
202 + // preset borrows the schema, which the same `self` owns.
203 + let values: Vec<(String, Value)> = preset
204 + .values
205 + .iter()
206 + .map(|(path, value)| (path.clone(), value.clone()))
207 + .collect();
208 +
209 + for (path, value) in values {
210 + write(&mut self.document, &path, &value)?;
211 + }
212 + self.edits += 1;
213 + Ok(())
214 + }
215 + }
216 +
217 + impl Bind for FileBind {
218 + fn origin(&self) -> String {
219 + self.path.display().to_string()
220 + }
221 +
222 + fn sections(&self) -> &[Section] {
223 + &self.schema.sections
224 + }
225 +
226 + fn fields(&self) -> &[Field] {
227 + &self.schema.fields
228 + }
229 +
230 + fn read(&self, path: &str) -> Option<Value> {
231 + let item = lookup(self.document.as_table(), path)?;
232 + from_edit(item)
233 + }
234 +
235 + fn commit(&mut self, path: &str, value: Value) -> Result<Vec<Effect>> {
236 + self.validate(path, &value)?;
237 + write(&mut self.document, path, &value)?;
238 + self.edits += 1;
239 + // Nothing to run: the edit lives in the document until it is saved,
240 + // which is what Ctrl-S is for and why a half-typed TOML never reaches
241 + // disk.
242 + Ok(Vec::new())
243 + }
244 +
245 + fn dirty(&self) -> bool {
246 + self.edits > 0
247 + }
248 +
249 + fn save(&mut self) -> Result<Vec<Effect>> {
250 + // The count clears on emitting the effect rather than on its success:
251 + // the log is what reports a failed write, and a form that stayed dirty
252 + // after a write it can see in the log would be reporting it twice.
253 + self.edits = 0;
254 + Ok(vec![Effect::Write {
255 + path: self.path.clone(),
256 + contents: self.document.to_string(),
257 + mode: CONFIG_MODE,
258 + }])
259 + }
260 + }
261 +
262 + /// Follow a dotted path to the item it names.
263 + fn lookup<'a>(table: &'a dyn TableLike, path: &str) -> Option<&'a Item> {
264 + let mut segments = path.split('.');
265 + let mut item = table.get(segments.next()?)?;
266 + for segment in segments {
267 + item = item.as_table_like()?.get(segment)?;
268 + }
269 + Some(item)
270 + }
271 +
272 + /// Write a value at a dotted path, creating the tables above it.
273 + fn write(document: &mut DocumentMut, path: &str, value: &Value) -> Result<()> {
274 + let mut segments: Vec<&str> = path.split('.').collect();
275 + let leaf = segments.pop().expect("split yields at least one segment");
276 +
277 + let mut table: &mut dyn TableLike = document.as_table_mut();
278 + for segment in segments {
279 + if table.get(segment).is_none() {
280 + // Implicit, so that writing `fonts.regular.family` into an empty
281 + // file emits `[fonts.regular]` and not an empty `[fonts]` header
282 + // above it. Only ever set on a table this call created; an existing
283 + // one keeps whatever form the author wrote it in.
284 + let mut created = toml_edit::Table::new();
285 + created.set_implicit(true);
286 + table.insert(segment, Item::Table(created));
287 + }
288 + table = table
289 + .get_mut(segment)
290 + .and_then(Item::as_table_like_mut)
291 + .with_context(|| format!("`{segment}` in `{path}` is not a table"))?;
292 + }
293 +
294 + match table.get_mut(leaf) {
295 + // Assigning into the existing item keeps its decor: the comment after
296 + // a value, and the spacing around it, are the user's and not ours.
297 + Some(existing) => {
298 + let decor = existing
299 + .as_value()
300 + .map(|value| value.decor().clone())
301 + .unwrap_or_default();
302 + let mut replacement = to_edit(value);
303 + *replacement.decor_mut() = decor;
304 + *existing = Item::Value(replacement);
305 + }
306 + None => {
307 + table.insert(leaf, Item::Value(to_edit(value)));
308 + }
309 + }
310 + Ok(())
311 + }
312 +
313 + /// A schema-side value as an editable one.
314 + fn to_edit(value: &Value) -> toml_edit::Value {
315 + match value {
316 + Value::String(text) => text.as_str().into(),
317 + Value::Integer(number) => (*number).into(),
318 + Value::Float(number) => (*number).into(),
319 + Value::Boolean(flag) => (*flag).into(),
320 + Value::Datetime(stamp) => toml_edit::Value::from(stamp.to_string()),
321 + Value::Array(items) => items
322 + .iter()
323 + .map(to_edit)
324 + .collect::<toml_edit::Array>()
325 + .into(),
326 + Value::Table(table) => table
327 + .iter()
328 + .map(|(key, item)| (key.as_str(), to_edit(item)))
329 + .collect::<toml_edit::InlineTable>()
330 + .into(),
331 + }
332 + }
333 +
334 + /// An item read out of the document, as a schema-side value.
335 + ///
336 + /// `None` for anything with no value shape at all, which in practice is an
337 + /// empty `[table]` header standing over nothing.
338 + fn from_edit(item: &Item) -> Option<Value> {
339 + match item {
340 + Item::Value(value) => value_from_edit(value),
341 + Item::Table(table) => Some(Value::Table(
342 + table
343 + .iter()
344 + .filter_map(|(key, item)| Some((key.to_string(), from_edit(item)?)))
345 + .collect(),
346 + )),
347 + // `[[bindings.keys]]`, which v1 renders read-only.
348 + Item::ArrayOfTables(tables) => Some(Value::Array(
349 + tables
350 + .iter()
351 + .map(|table| {
352 + Value::Table(
353 + table
354 + .iter()
355 + .filter_map(|(key, item)| Some((key.to_string(), from_edit(item)?)))
356 + .collect(),
357 + )
358 + })
359 + .collect(),
360 + )),
361 + Item::None => None,
362 + }
363 + }
364 +
365 + fn value_from_edit(value: &toml_edit::Value) -> Option<Value> {
366 + Some(match value {
367 + toml_edit::Value::String(text) => Value::String(text.value().clone()),
368 + toml_edit::Value::Integer(number) => Value::Integer(*number.value()),
369 + toml_edit::Value::Float(number) => Value::Float(*number.value()),
370 + toml_edit::Value::Boolean(flag) => Value::Boolean(*flag.value()),
371 + // Held as its written form. Nothing in schema-DSL v1 is a datetime, so
372 + // this only ever comes back out of an unknown key on its way through an
373 + // edit untouched.
374 + toml_edit::Value::Datetime(stamp) => Value::String(stamp.value().to_string()),
375 + toml_edit::Value::Array(items) => {
376 + Value::Array(items.iter().filter_map(value_from_edit).collect())
377 + }
378 + toml_edit::Value::InlineTable(table) => Value::Table(
379 + table
380 + .iter()
381 + .filter_map(|(key, item)| Some((key.to_string(), value_from_edit(item)?)))
382 + .collect(),
383 + ),
384 + })
385 + }
386 +
387 + #[cfg(test)]
388 + mod tests {
389 + use super::*;
390 +
391 + const SCHEMA: &str = "\
392 + [schema]
393 + target = \"rio.toml\"
394 + target_tool = \"rio\"
395 + schema_version = \"1\"
396 +
397 + [[section]]
398 + path = \"window\"
399 +
400 + [[field]]
401 + path = \"window.background-opacity\"
402 + type = \"float\"
403 + range = [0.0, 1.0]
404 + default = 1.0
405 +
406 + [[field]]
407 + path = \"cursor.blinking\"
408 + type = \"bool\"
409 + default = false
410 +
411 + [[field]]
412 + path = \"fonts.regular.family\"
413 + type = \"string\"
414 + default = \"IosevkaTerm Nerd Font\"
415 +
416 + [[field]]
417 + path = \"fonts.regular.weight\"
418 + type = \"int\"
419 + range = [100, 900]
420 + default = 400
421 +
422 + [[field]]
423 + path = \"cursor.shape\"
424 + type = \"enum\"
425 + values = [\"block\", \"underline\", \"beam\"]
426 + default = \"block\"
427 +
428 + [[group]]
429 + path = \"colors\"
430 + type = \"color\"
431 + entries = [
432 + { key = \"background\", default = \"#e4ded6\" },
433 + { key = \"foreground\", default = \"#1a1816\" },
434 + ]
435 +
436 + [[preset]]
437 + name = \"Akari Night\"
438 + values = { \"colors.background\" = \"#1a1816\", \"colors.foreground\" = \"#e4ded6\" }
439 + ";
440 +
441 + fn bind(text: &str) -> FileBind {
442 + let schema = Schema::parse(SCHEMA).expect("the test schema parses");
443 + FileBind::new(
444 + schema,
445 + PathBuf::from("/home/max/.config/rio/config.toml"),
446 + text,
447 + )
448 + .expect("the test document parses")
449 + }
450 +
451 + /// The whole context chain, which is what a bind error says. `Display` on
452 + /// its own gives only the outermost layer; the reason is underneath it.
453 + fn error<T>(result: Result<T>) -> String {
454 + match result {
455 + Ok(_) => panic!("expected an error"),
456 + Err(error) => format!("{error:#}"),
457 + }
458 + }
459 +
460 + #[test]
461 + fn a_key_the_file_holds_reads_back() {
462 + let bind = bind("[cursor]\nshape = \"beam\"\n");
463 + assert_eq!(
464 + bind.read("cursor.shape"),
465 + Some(Value::String("beam".into())),
466 + );
467 + }
468 +
469 + // The distinction the minimal-config promise rests on: reading is what the
470 + // file says, defaulting is the schema's, and the row shows the second only
471 + // because the first came back empty.
472 + #[test]
473 + fn a_key_the_file_omits_reads_as_nothing_not_as_its_default() {
474 + let bind = bind("");
475 + assert_eq!(bind.read("cursor.shape"), None);
476 + assert_eq!(
477 + bind.field("cursor.shape").unwrap().default_value(),
478 + Some(Value::String("block".into())),
479 + );
480 + }
481 +
482 + #[test]
483 + fn a_dotted_path_walks_nested_tables() {
484 + let bind = bind("[fonts.regular]\nfamily = \"Departure Mono\"\nweight = 700\n");
485 + assert_eq!(
486 + bind.read("fonts.regular.family"),
487 + Some(Value::String("Departure Mono".into())),
488 + );
489 + assert_eq!(bind.read("fonts.regular.weight"), Some(Value::Integer(700)));
490 + }
491 +
492 + // The whole reason the layer is toml_edit and not a parse-and-reserialize.
493 + #[test]
494 + fn an_edit_leaves_comments_order_and_undeclared_keys_alone() {
495 + let mut bind = bind(
496 + "# rio, configured by Alloy\n\
497 + [cursor]\n\
498 + blinking = false # steady\n\
499 + shape = \"block\"\n\
500 + \n\
Lines truncated