Agent-First Data v0.20: Editing, Unwelded From Files
v0.20 finishes what the source-preserving editor started: editing no longer requires a file. The set/unset/add/remove verbs now live on an in-memory Document, DocumentFile is a thin file adapter over it, and edits stage until a single atomic save() — so you can batch changes and validate the result before a byte is written. The editor also grows sparse and nested documents in place, and unset is now idempotent.
The last two releases gave afdata a source-preserving document editor: change
one value in a JSON/TOML/YAML file and everything else — comments, key order,
number spelling — survives untouched. It was good. It was also welded to
files.
If you held a config string and wanted to change one key while keeping its
formatting, you couldn’t. Editing lived only on the file-backed type; the
in-memory Document could parse and re-render, but re-rendering sorts keys and
drops comments. So to edit source-preservingly you either round-tripped a real
file or reached past the API into per-format internals. The editor had fused
three separate jobs — parsing, source-preserving editing, and
file-I/O-with-safety — into one file-bound object.
v0.20 takes them apart. It’s a breaking release; AFDATA is still pre-1.0 and carries no compatibility layer.
Editing lives on Document
The verbs moved down to where they belong. Document now owns the source text
and set / unset / add / remove:
let mut doc = Document::parse(source, Format::Json)?;
doc.set("imap.host", Value::from("mail.example.com"))?;
let edited = doc.source(); // source-preserving — no file, no I/O, no guards
DocumentFile is now just a Document plus a path. It Derefs to the document,
so every read and edit works exactly the same on it, and it adds only the file
boundary: open, an atomic symlink-guarded save, and an edit() closure. The
safety machinery — temp-file writes, fsync, permission-preserving rename,
symlink refusal — lives precisely where it’s needed, at the moment bytes hit
disk, instead of being unavoidable ceremony around every edit.
Stage, then commit
Editing and persisting used to be the same act: each verb wrote to disk. That made a common thing impossible — apply several edits, look at the result, and commit once, atomically.
Now the verbs stage in memory and a single public save() is the one commit
point:
let mut doc = DocumentFile::open(path, Some(Format::Json))?;
doc.set("imap.password_secret", secret)?; // staged
doc.unset("imap.password_secret_env")?; // staged
MailConfig::try_from(doc.value())?.validate()?; // check BEFORE writing
doc.save()?; // one atomic write — or none
If validation fails, nothing was written. If two edits both need to land, they
land together. And edit(|d| { … }) wraps stage-then-save in one call so the
commit can’t be forgotten. This is the shape a real config editor wants, and
it’s what let Agent-First Mail move its own config editing onto afdata without
a hand-written sparse writer sitting in the middle.
Grow the document in place
A source editor that can only touch keys already present isn’t much use for a
sparse config — the kind that stores only what you’ve overridden. Setting
imap.host when there’s no imap object yet should just work.
Now it does. The JSON and TOML backends create missing intermediate parents on the way to the leaf — matching what the in-memory setter has always done — and the JSON backend sets and replaces whole collection values, not just scalars:
{} → set imap.host x
{ "imap": { "host": "x" } }
New structure is spliced in with the surrounding indentation; everything that was already there keeps its exact bytes and order.
An unset that doesn’t fight you
Removing a key that isn’t there used to be an error. That forced anyone clearing
an optional field to look before they leaped. unset is now idempotent and
tells you what happened, the way HashSet::remove does:
doc.unset("audit.reason_mode")?; // Ok(true) if removed, Ok(false) if absent
The afdata unset command still treats a missing key as an error — a script
wants to know when its delete was a no-op — but the library primitive no longer
makes every caller guard.
While wiring afmail’s config editor onto all of this, one more thing fell out: a
JSON unset that, when removing the sole member of a nested object, could reach
back and delete a comma belonging to an enclosing container — producing
invalid JSON. It’s fixed, and it’s the kind of bug you only find by actually
using the thing you built.
The shape now
afdata’s document layer is finally honest about what it is: a pure,
source-preserving editor (Document) with a thin, safe file adapter
(DocumentFile) on top. Edit a string or edit a file with the same verbs; batch
your changes and validate before you commit; grow a sparse config without
special-casing the empty case. Editing is unwelded from files — and the tools
that build on afdata get simpler for it.