dotcli: add freeform mechanism to dot kde save/apply/diff

Read/write schema-less KDE settings (e.g. kxkbrc's Options= line)
directly via kreadconfig6/kwriteconfig6, with default defined as the
key being absent. diff only checks freeform entries already declared
in the manifest, never via undeclared broad scan.

Brings the machine's caps-lock/Escape swap under tracking as the
real-world validation case.
This commit is contained in:
2026-07-05 20:41:15 -04:00
parent 8570826927
commit 00da6f1466
5 changed files with 96 additions and 26 deletions

View File

@@ -24,9 +24,18 @@ correctly against it.
## Acceptance criteria
- [ ] An identifier whose `(rcfile, group, key)` has no schema match is treated as freeform rather than erroring
- [ ] `dot kde save <identifier>` and `dot kde save` (refresh) work for freeform entries
- [ ] `dot kde apply` writes freeform entries via `kwriteconfig6`, idempotently
- [ ] `dot kde diff` reports a freeform mismatch when its identifier is already declared in the manifest, and never surfaces an undeclared freeform setting via broad scan
- [ ] Tests run against a scratch `$HOME`, covering freeform save/apply/diff using a fixture rc file with no corresponding schema
- [ ] The live `kxkbrc` caps-lock/Escape swap is tracked via `dot kde save` and the manifest committed to the dotfiles repo
- [x] An identifier whose `(rcfile, group, key)` has no schema match is treated as freeform rather than erroring
- [x] `dot kde save <identifier>` and `dot kde save` (refresh) work for freeform entries
- [x] `dot kde apply` writes freeform entries via `kwriteconfig6`, idempotently
- [x] `dot kde diff` reports a freeform mismatch when its identifier is already declared in the manifest, and never surfaces an undeclared freeform setting via broad scan
- [x] Tests run against a scratch `$HOME`, covering freeform save/apply/diff using a fixture rc file with no corresponding schema
- [x] The live `kxkbrc` caps-lock/Escape swap is tracked via `dot kde save` and the manifest committed to the dotfiles repo
## Implementation Notes
- `save_one`/`apply_one`'s gate changed from `mechanism != "schema"` (reject everything but schema) to `mechanism == "shortcuts"` (reject only shortcuts) — freeform now flows through the same `read_live_value`/`write_live_value` calls schema-backed settings already use, since both mechanisms only differ in what "default" means, not in how the read/write itself happens.
- `cmd_diff` gained a second pass after the existing schema broad-scan: it walks the manifest (not the kcfg mapping table, which freeform settings are absent from by definition), resolves each identifier's mechanism, and reports only those that resolve to `freeform` and whose live value is non-empty — structurally guaranteeing freeform can never surface via undeclared broad scan, since the loop never sees anything outside the manifest.
- **Real-world validation surfaced a stale premise**: the task assumed the caps-lock/Escape swap was "already hand-set" and live, but the machine had no `kxkbrc` file and no active XKB option at all. Confirmed with the user before proceeding; with their approval, wrote the option live via `kwriteconfig6 --file kxkbrc --group Layout --key Options -- caps:escape_shifted_capslock` and applied it immediately via a live KWin reconfigure (`busctl --user call org.kde.KWin /KWin org.kde.KWin reconfigure`), then ran `dot kde save kxkbrc.Layout.Options` to bring it under tracking. `dot kde apply`/`dot kde diff` were both verified against the real entry (idempotent apply; diff reports `declared kxkbrc.Layout.Options = caps:escape_shifted_capslock (default: )`).
- Added a `.github/keybindings.md` row for the swap (`CapsLock``Esc`, `Shift`+`CapsLock` → real Caps Lock toggle), per the project's cross-cutting keybindings convention.
- Existing tests that previously asserted freeform saves/applies were *rejected* (written when freeform was still unimplemented, per task 0001/0002's "not yet supported" stopgap) were updated to assert success instead, using a new `somefreeform` fixture rc file with no corresponding `.kcfg` schema. Coverage for the still-unimplemented shortcuts mechanism (task 0005) was added in the same spots to keep the "not yet supported" rejection path tested now that freeform no longer exercises it.
- `/review-uncommitted`'s Spec pass caught that `cmd_diff`'s new freeform loop called `parse_identifier` on raw manifest keys with no exception guard, unlike the rest of the function — a hand-edited manifest with a malformed identifier would have crashed the whole scan instead of reporting a clean per-identifier error. Fixed: the loop body is now wrapped in `try/except (ValueError, RuntimeError)`, matching the file's established per-identifier-failure-tolerant convention. The Standards pass also flagged threading a hardcoded `None`/blank literal through the freeform loop instead of the real `default` value returned by `resolve_mechanism`; fixed by reusing that variable directly (`default or ''` for display, since freeform's default is always `None`).

View File

@@ -31,8 +31,10 @@ DIFF_USAGE = """usage: dot kde diff
Scans every schema-backed setting reachable through the kcfg mapping
table and reports each one whose live value differs from its
schema-declared default, tagged declared (present in the manifest)
or undeclared. Read-only -- never writes the manifest or the live
system.
or undeclared. Also reports already-declared freeform settings whose
live value is present (freeform has no schema to scan, so it is only
checked when already declared). Read-only -- never writes the
manifest or the live system.
help show this message"""
Setting = namedtuple("Setting", ["file", "group", "key"])
@@ -171,7 +173,7 @@ def write_live_value(setting, value):
def save_one(identifier, kcfg_map):
setting = parse_identifier(identifier)
mechanism, default = resolve_mechanism(setting, kcfg_map)
if mechanism != "schema":
if mechanism == "shortcuts":
raise RuntimeError(f"{identifier}: {mechanism} settings are not yet supported")
return read_live_value(setting, default)
@@ -179,7 +181,7 @@ def save_one(identifier, kcfg_map):
def apply_one(identifier, value, kcfg_map):
setting = parse_identifier(identifier)
mechanism, _default = resolve_mechanism(setting, kcfg_map)
if mechanism != "schema":
if mechanism == "shortcuts":
raise RuntimeError(f"{identifier}: {mechanism} settings are not yet supported")
write_live_value(setting, value)
@@ -259,6 +261,25 @@ def cmd_diff(args, manifest_path, schema_dir):
tag = "declared" if identifier in manifest else "undeclared"
print(f"{tag} {identifier} = {live} (default: {default})")
# Freeform settings have no schema to enumerate, so unlike the schema-backed
# loop above, this can only walk identifiers already in the manifest -- it
# never surfaces an undeclared freeform setting via broad scan.
for identifier in manifest:
try:
setting = parse_identifier(identifier)
mechanism, default = resolve_mechanism(setting, kcfg_map)
if mechanism != "freeform":
continue
live = read_live_value(setting, default)
except (ValueError, RuntimeError) as e:
print(f"dot kde diff: {e}", file=sys.stderr)
continue
if live == "":
continue
print(f"declared {identifier} = {live} (default: {default or ''})")
return 0

1
.config/dot/kde-manifest Normal file
View File

@@ -0,0 +1 @@
kxkbrc.Layout.Options=caps:escape_shifted_capslock

View File

@@ -353,35 +353,49 @@ dot kde save kwinrc.Windows.BorderSize >/dev/null 2>&1
set -l declared_count_before_freeform (cat $manifest | count)
# a setting whose rc file never appears in the mapping table falls to the
# freeform branch, which the dispatch structure accounts for but does not
# implement yet
# freeform branch: read/write directly via kreadconfig6/kwriteconfig6, with
# "default" meaning "the key is absent" rather than any schema value
printf '[Group]\nKey=FreeformValue\n' >$HOME/.config/somefreeform
dot kde save somefreeform.Group.Key >/dev/null 2>&1
set -l unmapped_status $status
set -l freeform_save_status $status
set -l declared_count_after_freeform (cat $manifest | count)
@test "an unmapped rc file is not silently treated as schema-backed" $unmapped_status -ne 0
@test "a rejected freeform save adds no manifest entry" $declared_count_after_freeform -eq $declared_count_before_freeform
@test "dot kde save succeeds for a freeform (unmapped rc file) identifier" $freeform_save_status -eq 0
@test "declares the freeform identifier with its real live value" (string match -q '*somefreeform.Group.Key=FreeformValue*' -- (cat $manifest); echo $status) -eq 0
@test "a freeform save adds exactly one manifest entry" $declared_count_after_freeform -eq (math $declared_count_before_freeform + 1)
# an arg="true" schema *absent* from the exceptions list (unmapped.kcfg)
# must not be guessed at (e.g. from its own filename) -- it contributes
# nothing to the mapping table, so its settings fall to freeform too
# nothing to the mapping table, so its settings fall to freeform too. Proven
# here by reading with the key absent: a schema-backed read would fall back
# to the schema's declared default ("Unreachable"); freeform's "default" is
# instead "the key is absent", so it reads empty.
dot kde save unmapped.Whatever.Setting >/dev/null 2>&1
set -l unlisted_arg_true_status $status
set -l declared_count_after_unlisted (cat $manifest | count)
@test "an arg=true schema missing from the exceptions list resolves to freeform, not schema" $unlisted_arg_true_status -ne 0
@test "a rejected unlisted-arg=true save adds no manifest entry" $declared_count_after_unlisted -eq $declared_count_before_freeform
@test "an arg=true schema missing from the exceptions list resolves to freeform, not schema" $unlisted_arg_true_status -eq 0
@test "a freeform read never falls back to another schema's default" (string match -q '*Unreachable*' -- (cat $manifest); echo $status) -eq 1
@test "a freeform read of an absent key stores an empty value" (string match -q '*unmapped.Whatever.Setting=*' -- (cat $manifest); echo $status) -eq 0
# misuse: shortcuts settings remain unsupported until a later task
dot kde save kglobalshortcutsrc.someComponent.someAction >/dev/null 2>&1
set -l shortcuts_save_status $status
@test "dot kde save rejects a shortcuts identifier (not yet supported)" $shortcuts_save_status -ne 0
set -l declared_count_before_refresh (cat $manifest | count)
# --- dot kde save with no arguments refreshes every already-declared entry ---
printf '[General]\nGreeting=Changed\n' >$HOME/.config/testrc
printf '[Group]\nKey=RefreshedFreeform\n' >$HOME/.config/somefreeform
dot kde save >/dev/null 2>&1
set -l refresh_status $status
set -l declared_count_after_refresh (cat $manifest | count)
@test "dot kde save with no arguments succeeds" $refresh_status -eq 0
@test "refreshes an already-declared entry's value from the live system" (string match -q '*testrc.General.Greeting=Changed*' -- (cat $manifest); echo $status) -eq 0
@test "refreshes an already-declared schema-backed entry's value from the live system" (string match -q '*testrc.General.Greeting=Changed*' -- (cat $manifest); echo $status) -eq 0
@test "refreshes an already-declared freeform entry's value from the live system" (string match -q '*somefreeform.Group.Key=RefreshedFreeform*' -- (cat $manifest); echo $status) -eq 0
@test "refresh leaves other already-declared entries untouched" (string match -q '*testrc.General.RealKey=AliasDefault*' -- (cat $manifest); echo $status) -eq 0
@test "refresh adds no new undeclared entries" $declared_count_after_refresh -eq $declared_count_before_freeform
@test "refresh adds no new undeclared entries" $declared_count_after_refresh -eq $declared_count_before_refresh
# --- misuse: too many arguments / a malformed identifier ---
dot kde save one two >/dev/null 2>&1
@@ -444,12 +458,26 @@ set -l testrc_after_reapply (cat $HOME/.config/testrc)
@test "re-running dot kde apply succeeds" $reapply_status -eq 0
@test "re-running dot kde apply against an already-applied system is idempotent" "$testrc_after_reapply" = "$testrc_after_apply"
# a manifest entry whose rc file isn't schema-backed (freeform, not yet
# implemented) is rejected rather than silently mis-applied
# a manifest entry whose rc file has no schema (freeform) is written
# directly via kwriteconfig6, idempotently, just like a schema-backed entry
printf 'testrc.General.Greeting=Applied Greeting\nsomefreeform.Group.Key=Value\n' >$HOME/.config/dot/kde-manifest
dot kde apply >/dev/null 2>&1
set -l apply_freeform_status $status
@test "dot kde apply rejects a manifest entry whose mechanism isn't schema-backed yet" $apply_freeform_status -ne 0
set -l freeformrc_after_apply (cat $HOME/.config/somefreeform)
@test "dot kde apply succeeds for a manifest with a freeform entry" $apply_freeform_status -eq 0
@test "dot kde apply writes a freeform entry via kwriteconfig6" (string match -q '*Key=Value*' -- $freeformrc_after_apply; echo $status) -eq 0
dot kde apply >/dev/null 2>&1
set -l freeformrc_after_reapply (cat $HOME/.config/somefreeform)
@test "re-running dot kde apply against an already-applied freeform entry is idempotent" "$freeformrc_after_reapply" = "$freeformrc_after_apply"
# a manifest entry whose mechanism is shortcuts (not yet implemented) is
# rejected rather than silently mis-applied
printf 'testrc.General.Greeting=Applied Greeting\nkglobalshortcutsrc.someComponent.someAction=Value\n' >$HOME/.config/dot/kde-manifest
dot kde apply >/dev/null 2>&1
set -l apply_shortcuts_status $status
@test "dot kde apply rejects a manifest entry whose mechanism isn't implemented yet (shortcuts)" $apply_shortcuts_status -ne 0
# misuse: apply takes no arguments
printf 'testrc.General.Greeting=Applied Greeting\n' >$HOME/.config/dot/kde-manifest
@@ -498,9 +526,15 @@ mkdir -p $HOME/.config/dot
# RealKey differs from its default but has never been declared; Some.Key With
# Spaces is left unset, so it falls back to (and matches) its schema default,
# and kwinrc.Windows.BorderSize likewise matches its default via the
# arg=true/exceptions-list mapping -- neither should be reported.
# arg=true/exceptions-list mapping -- neither should be reported. On the
# freeform side: Group.Key is declared and present live (a mismatch against
# freeform's "absent" default); Group.AbsentKey is declared but never applied
# live, so it matches the absent default and isn't reported; Other.Undeclared
# is present live but never declared, and must never surface via broad scan
# since freeform has no schema to enumerate from.
printf '[General]\nGreeting=Bonjour\nRealKey=ChangedAlias\n' >$HOME/.config/testrc
printf 'testrc.General.Greeting=Bonjour\n' >$HOME/.config/dot/kde-manifest
printf '[Group]\nKey=CustomValue\n\n[Other]\nUndeclared=ShouldNeverAppear\n' >$HOME/.config/somefreeform
printf 'testrc.General.Greeting=Bonjour\nsomefreeform.Group.Key=CustomValue\nsomefreeform.Group.AbsentKey=NeverApplied\n' >$HOME/.config/dot/kde-manifest
set -l manifest_before_diff (cat $HOME/.config/dot/kde-manifest | string collect)
set -l diff_output (dot kde diff)
@@ -512,6 +546,9 @@ set -l manifest_after_diff (cat $HOME/.config/dot/kde-manifest | string collect)
@test "dot kde diff tags a never-declared mismatch as undeclared" (string match -q '*undeclared testrc.General.RealKey = ChangedAlias (default: AliasDefault)*' -- $diff_output; echo $status) -eq 0
@test "dot kde diff does not report a setting matching its default (unset key)" (string match -q '*Some.Key With Spaces*' -- $diff_output; echo $status) -eq 1
@test "dot kde diff does not report a setting matching its default (arg=true mapping)" (string match -q '*BorderSize*' -- $diff_output; echo $status) -eq 1
@test "dot kde diff reports an already-declared freeform mismatch (default is absent)" (string match -q '*declared somefreeform.Group.Key = CustomValue (default: )*' -- $diff_output; echo $status) -eq 0
@test "dot kde diff does not report a declared freeform entry matching its absent default" (string match -q '*AbsentKey*' -- $diff_output; echo $status) -eq 1
@test "dot kde diff never surfaces an undeclared freeform setting via broad scan" (string match -q '*Undeclared*' -- $diff_output; echo $status) -eq 1
@test "dot kde diff makes no writes to the manifest" "$manifest_after_diff" = "$manifest_before_diff"
dot kde diff extra-arg >/dev/null 2>&1