8000 GitHub - ynishi/cfggraft: Inject declared items into config files owned by another program. Applies what it can prove it wrote, reports what it cannot, and never overwrites a disagreement. · GitHub
[go: up one dir, main page]

Skip to content
ynishiPublic

About

Inject declared items into config files owned by another program. Applies what it can prove it wrote, reports what it cannot, and never overwrites a disagreement.

Resources

Stars

0 stars

Watchers

0 watching

Forks

Latest commit

 

History

7 Commits

Folders and files

Repository files navigation

cfggraft

Inject declared items into config files that belong to another program.

One rule: apply what is provably ours, report what is not, never overwrite a disagreement.

cfggraft merge   --key .env --fragment env.json --file ~/.config/app/settings.json --in-place
cfggraft region  --marker PROFILE --body generated.md --file ~/.config/app/notes.md --in-place

Why this exists

A config file you want to manage from a repo is often not yours alone. The application writes to it too — a model choice, a remembered permission, a UI toggle. Owning the whole file (symlink, template render, cp) throws those away. Owning nothing means the repo can't configure anything.

So you inject items and leave the rest. The awkward part is what to do when both sides have something to say about the same item, and no standard format expresses the answer:

approach what it does with an existing item
RFC 7396 JSON Merge Patch overwrites, unconditionally
RFC 6902 JSON Patch test + add can guard it, but re-applying add to an array duplicates
ansible blockinfile text lines only, no structure
chezmoi modify_ gives you a stdin→stdout hook; the policy is still yours to write (#219 is open)

The model

The unit is an item, not a file format. Managing config from above means declaring, per item, what its value should be. Whether that item is a key path in a JSON document or a marker-delimited span of a text file only changes how it is addressed, read, and written.

Deciding needs three values, not two:

declared what the repo says the value should be
prior what cfggraft wrote on its last run
observed what is in the target right now

Without prior there is no way to tell a value cfggraft wrote itself from a value the other program wrote, so every change to a declaration looks like a conflict and nothing can ever be updated or withdrawn. With it:

declared ownership observed
yes unknown absent add
yes unknown equal skip — and adopt, recording ownership
yes unknown differs refuse — not ours to claim
yes ours equal skip
yes ours differs update — the old value is provably ours
yes diverged any refuse — someone changed what we wrote
yes removed absent refuse — the deletion was deliberate
no ours present retract — ours, and no longer declared
no other any skip

Two properties follow: nothing is written over a value unless that value is ours, and re-running an unchanged declaration produces only skips.

Where ownership is recorded

Ownership needs a record of what was written. Where it lives depends on what the target can carry.

region keeps it inline, as a digest in the begin marker — text can hold a comment, so the whole contract stays visible in the file being managed:

hand-written text, yours

<!-- ===PROFILE=== generated by cfggraft; edit config/profile.md instead; sha256=9c91… -->
generated body
<!-- ===/PROFILE=== -->

more hand-written text, also yours

merge keeps it in $XDG_STATE_HOME/cfggraft/priors.json (--prior-store< 8000 /code> overrides). JSON has no comments, and adding a reserved key to another program's document would be a change it never asked for and might reject.

The record is local to the machine holding the target, never to the repo holding the declaration. Lose it and every item reads as unowned, so the run degrades to add-only — a loss of capability, not of data.

Provenance is written only when the target is: with --in-place, and without --check.

merge — JSON documents

A declaration expands into one item per leaf, so each key is decided separately and a disagreement about one never blocks the rest.

Arrays are sets: list-valued settings are combined across config layers by the programs that read them, so an element is a membership statement rather than a position. Elements are addressed by value, never by index — which is what makes a withdrawn element findable later instead of accumulating forever.

$ cfggraft merge --key .permissions.deny --fragment deny.json \
             --file ~/.config/app/settings.json --in-place --retract
add         permissions.deny[#8dd3b1a5] "Bash(rm -rf:*)"
retract     permissions.deny[#7c9f1e95] "Bash(rm:*)"
merge: added=1 updated=0 skipped=1 retracted=1 refused=0

Anything the other program added itself is not in the record, so it is never walked and never touched.

A run decides only on items under its --key. Several declarations can manage one file at different key paths: each leaves the others' items, and their records, alone, even with --retract.

--fragment-key reads from a different path inside the fragment; --fragment-root uses the whole document. The target's key order is preserved — reordering someone else's file turns a one-line injection into a whole-file diff.

region — a marker-delimited span

For a file a human also writes. The span is a single item, so the run is all-or-nothing; everything outside the markers is theirs.

--init appends the region when the marker is absent. --comment hash uses # instead of <!-- --> for shell, toml and yaml.

A marker is a whole line in that comment style: optional indentation, the comment opener, ===NAME=== or ===/NAME===, and for html the --> closing the same line. Text after the token inside the comment is fine — that is where the digest lives. A line that merely mentions the marker, in prose, in backticks or mid-line, is not a marker, and neither is a marker in the other comment style. Fenced code blocks are not tracked, so an example region in a file that also holds the real one needs a different name; a file holding the same begin marker twice is refused. Names are ASCII letters, digits, _, - and ..

--retract does not apply to region: the span is always declared, so there is nothing for it to remove. Passing it is an error.

The marker name is a long-lived contract. Renaming it orphans every file carrying the old name: the span stops being found and a second region gets appended below the first.

A span with no recorded digest whose body differs from the declaration is refused, because nothing shows the body was ever cfggraft's. Adopt it deliberately with one --force, after which ordinary updates apply.

Exit codes

code meaning
0 applied, or already up to date
1 failure — bad input, unreadable file, missing marker without --init
2 drift — something cfggraft declined to overwrite. Nothing was written.

2 is reserved for drift alone. A check that cannot tell the generator crashed from there is no difference is a green light that checks nothing.

Flags shared by both

flag
--file PATH target; defaults to stdin. A missing file is an empty document, so the first run can create it.
--in-place write back atomically (temp file in the same dir, then rename, original mode carried over). Skipped when nothing changed, so mtime stays put.
--check report only; writes nothing anywhere. This is the drift check.
--verbose also list what was already in place.
--force overwrite values cfggraft cannot claim, discarding them.
--retract remove items cfggraft wrote that are no longer declared. merge only.
--prior-store PATH where to keep the record of what cfggraft has written.

Without --in-place, the result goes to stdout — on drift, byte for byte the input, so a caller that writes it anyway still cannot destroy anything.

Crates

crate what it is dependencies
cfggraft the library: the model, the decision, the adapters 17
cfggraft-cli argument parsing, file I/O, reporting, exit status 35

The library is the product and keeps the name; the command line is one of its callers. Splitting them keeps clap and its proc-macro chain — syn, quote, proc-macro2, clap_derive among the 18 crates of difference — out of the build of anything that only wants the library.

cargo install cfggraft-cli is what installs it. The binary is still cfggraft.

Design

The whole design is in the library's documentation, including the decision table, what a representation has to implement, and why values cross the port as text rather than through a shared structural type.

cargo doc -p cfggraft --open

policy decides, port states the interface, adapter implements it, engine walks the items, prior persists provenance. Adding a representation means adding an adapter; no other module changes.

Install

cargo install cfggraft-cli

From a checkout, cargo install --path crates/cfggraft-cli. Either way the binary is cfggraft.

Development

cargo test -p cfggraft
cargo test -p cfggraft-cli
cargo clippy --all-targets -- -D warnings
cargo fmt --check

Status

Early. Published on crates.io as cfggraft and cfggraft-cli, versioned and released together; CHANGELOG.md says what each version changed. The interface may still move before 1.0.

About

Inject declared items into config files owned by another program. Applies what it can prove it wrote, reports what it cannot, and never overwrites a disagreement.

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages

0