dotcli: Implement dot kde apply for schema-backed settings

Pushes every manifest entry's declared value onto the live system via
kwriteconfig6, idempotently. Non-schema (shortcuts/freeform) entries are
rejected as not-yet-supported, deferred to later tasks.
This commit is contained in:
2026-07-05 19:56:32 -04:00
parent c28029681d
commit f7b9f1b251
5 changed files with 137 additions and 5 deletions

View File

@@ -17,8 +17,16 @@ Add a README row for `dot kde apply`.
## Acceptance criteria ## Acceptance criteria
- [ ] `dot kde apply` pushes every manifest entry's declared value onto the live system via `kwriteconfig6` - [x] `dot kde apply` pushes every manifest entry's declared value onto the live system via `kwriteconfig6`
- [ ] Re-running `dot kde apply` against a system already matching the manifest changes nothing (idempotent) - [x] Re-running `dot kde apply` against a system already matching the manifest changes nothing (idempotent)
- [ ] `dot kde apply help` prints usage without writing anything - [x] `dot kde apply help` prints usage without writing anything
- [ ] Tests run against a scratch `$HOME`, exercising apply over a manifest with schema-backed entries, verifying resulting rc-file contents and idempotence on a second run - [x] Tests run against a scratch `$HOME`, exercising apply over a manifest with schema-backed entries, verifying resulting rc-file contents and idempotence on a second run
- [ ] README has a row for `dot kde apply` - [x] README has a row for `dot kde apply`
## Implementation Notes
- File layout mirrors `save`'s: `write_live_value` (the `kwriteconfig6` counterpart to `read_live_value`) and `apply_one` (mirroring `save_one`'s `parse_identifier``resolve_mechanism` → schema-only gate) added to `commands/kde/kde.py`; `cmd_apply` mirrors `cmd_save`'s help/argument/error-handling scaffold. `kde.fish` gained an `apply` dispatch case above `save`.
- `apply` takes no arguments (unlike `save`, which supports an optional identifier) — the task only specifies pushing the whole manifest, and the parent spec's `apply` user story has no per-identifier mode, so `dot kde apply <extra-arg>` is rejected as misuse rather than silently ignored.
- `write_live_value` passes the value positionally after a `--` separator (`kwriteconfig6 --file ... --group ... --key ... -- <value>`) rather than via a `--value` flag, since `kwriteconfig6` takes the value as a mandatory positional argument, not a flag; `--` guards against a value that itself looks like an option.
- Non-schema (shortcuts/freeform) manifest entries are rejected with the same "not yet supported" error `save_one` already raises for those mechanisms, kept out of scope per this task's title ("...apply for schema-backed settings"); those mechanisms are added in later tasks (0004, 0005) without needing to restructure `cmd_apply`.
- `/review-uncommitted` flagged two baseline duplication smells (`apply_one`/`cmd_apply` mirroring `save_one`/`cmd_save`'s shape) and one observation (a failing entry mid-manifest halts `apply` immediately, leaving earlier writes already applied — a partial-apply state, untested either way). Left as-is: the duplication mirrors an already-established local convention from task 0001 rather than introducing a new one, and the partial-apply behavior is consistent with `cmd_save`'s pre-existing control flow, not a new risk introduced by this task.

View File

@@ -2,6 +2,7 @@ function _dot_kde_usage
echo "usage: dot kde <command> echo "usage: dot kde <command>
Commands: Commands:
apply push manifest entries onto the live system
save write live KDE settings into the manifest save write live KDE settings into the manifest
help show this message help show this message
@@ -17,6 +18,9 @@ function _dot_kde
set -l helper_dir (status dirname) set -l helper_dir (status dirname)
switch "$argv[1]" switch "$argv[1]"
case apply
python3 $helper_dir/kde.py apply $argv[2..-1]
return $status
case save case save
python3 $helper_dir/kde.py save $argv[2..-1] python3 $helper_dir/kde.py save $argv[2..-1]
return $status return $status

View File

@@ -21,6 +21,11 @@ SAVE_USAGE = """usage: dot kde save [identifier]
(no args) refresh every already-declared manifest entry from the live system (no args) refresh every already-declared manifest entry from the live system
help show this message""" help show this message"""
APPLY_USAGE = """usage: dot kde apply
Pushes every manifest entry's declared value onto the live system.
help show this message"""
Setting = namedtuple("Setting", ["file", "group", "key"]) Setting = namedtuple("Setting", ["file", "group", "key"])
@@ -138,6 +143,22 @@ def read_live_value(setting, default):
return result.stdout.rstrip("\n") return result.stdout.rstrip("\n")
def write_live_value(setting, value):
cmd = [
"kwriteconfig6",
"--file", setting.file,
"--group", setting.group,
"--key", setting.key,
"--",
value,
]
result = subprocess.run(cmd, capture_output=True, text=True)
if result.returncode != 0:
raise RuntimeError(
f"kwriteconfig6 failed for {setting.file}/{setting.group}/{setting.key}: {result.stderr.strip()}"
)
def save_one(identifier, kcfg_map): def save_one(identifier, kcfg_map):
setting = parse_identifier(identifier) setting = parse_identifier(identifier)
mechanism, default = resolve_mechanism(setting, kcfg_map) mechanism, default = resolve_mechanism(setting, kcfg_map)
@@ -146,6 +167,14 @@ def save_one(identifier, kcfg_map):
return read_live_value(setting, default) return read_live_value(setting, default)
def apply_one(identifier, value, kcfg_map):
setting = parse_identifier(identifier)
mechanism, _default = resolve_mechanism(setting, kcfg_map)
if mechanism != "schema":
raise RuntimeError(f"{identifier}: {mechanism} settings are not yet supported")
write_live_value(setting, value)
def cmd_save(args, manifest_path, schema_dir): def cmd_save(args, manifest_path, schema_dir):
if args and args[0] == "help": if args and args[0] == "help":
print(SAVE_USAGE) print(SAVE_USAGE)
@@ -172,6 +201,28 @@ def cmd_save(args, manifest_path, schema_dir):
return 0 return 0
def cmd_apply(args, manifest_path, schema_dir):
if args and args[0] == "help":
print(APPLY_USAGE)
return 0
if args:
print("dot kde apply: too many arguments", file=sys.stderr)
return 1
kcfg_map = build_kcfg_map(schema_dir)
manifest = load_manifest(manifest_path)
try:
for identifier, value in manifest.items():
apply_one(identifier, value, kcfg_map)
except (ValueError, RuntimeError) as e:
print(f"dot kde apply: {e}", file=sys.stderr)
return 1
return 0
def cmd_complete(schema_dir): def cmd_complete(schema_dir):
kcfg_map = build_kcfg_map(schema_dir) kcfg_map = build_kcfg_map(schema_dir)
for identifier in sorted(set(iter_schema_identifiers(kcfg_map))): for identifier in sorted(set(iter_schema_identifiers(kcfg_map))):
@@ -191,6 +242,9 @@ def main(argv):
if command == "save": if command == "save":
return cmd_save(rest, manifest_path, schema_dir) return cmd_save(rest, manifest_path, schema_dir)
if command == "apply":
return cmd_apply(rest, manifest_path, schema_dir)
# Internal, not a user-facing `dot kde` subcommand -- called directly by # Internal, not a user-facing `dot kde` subcommand -- called directly by
# completions/dot.fish to source candidates from the live schema, never # completions/dot.fish to source candidates from the live schema, never
# dispatched to via kde.fish. # dispatched to via kde.fish.

View File

@@ -392,6 +392,71 @@ dot kde save nodots >/dev/null 2>&1
set -l bad_identifier_status $status set -l bad_identifier_status $status
@test "dot kde save rejects an identifier without file.group.key structure" $bad_identifier_status -ne 0 @test "dot kde save rejects an identifier without file.group.key structure" $bad_identifier_status -ne 0
# --- dot kde apply help touches neither the manifest nor kwriteconfig6 ---
set -gx HOME (mktemp -d)
dot init --url $remote >/dev/null 2>&1
mkdir -p $HOME/.config/dot/commands/kde
cp $commands_dir/kde/kde.fish $HOME/.config/dot/commands/kde/kde.fish
cp $commands_dir/kde/kde.py $HOME/.config/dot/commands/kde/kde.py
set -l fake_bin_kwrite (mktemp -d)
set -gx KWRITECONFIG_LOG (mktemp)
echo '#!/bin/sh
echo "$@" >>"$KWRITECONFIG_LOG"
exit 1' >$fake_bin_kwrite/kwriteconfig6
chmod +x $fake_bin_kwrite/kwriteconfig6
set -gx PATH $fake_bin_kwrite $path_before_fake_kreadconfig
set -l kde_apply_help_output (dot kde apply help)
set -l kde_apply_help_status $status
set -l kwriteconfig_called_for_apply_help (test -s $KWRITECONFIG_LOG; and echo yes; or echo no)
set -l manifest_exists_after_apply_help (test -e $HOME/.config/dot/kde-manifest; and echo yes; or echo no)
@test "dot kde apply help succeeds" $kde_apply_help_status -eq 0
@test "dot kde apply help mentions manifest" (string match -q '*manifest*' -- $kde_apply_help_output; echo $status) -eq 0
@test "dot kde apply help never invokes kwriteconfig6" $kwriteconfig_called_for_apply_help = no
@test "dot kde apply help does not create a manifest" $manifest_exists_after_apply_help = no
set -gx PATH $path_before_fake_kreadconfig
# --- dot kde apply: pushes every declared manifest entry onto the live rc file ---
set -gx HOME (mktemp -d)
dot init --url $remote >/dev/null 2>&1
mkdir -p $HOME/.config/dot/commands/kde
cp $commands_dir/kde/kde.fish $HOME/.config/dot/commands/kde/kde.fish
cp $commands_dir/kde/kde.py $HOME/.config/dot/commands/kde/kde.py
mkdir -p $HOME/.config/dot
printf 'testrc.General.Greeting=Applied Greeting\ntestrc.General.RealKey=Hi=There\n' >$HOME/.config/dot/kde-manifest
dot kde apply >/dev/null 2>&1
set -l apply_status $status
set -l testrc_after_apply (cat $HOME/.config/testrc)
@test "dot kde apply succeeds" $apply_status -eq 0
@test "dot kde apply writes a declared value onto the live rc file" (string match -q '*Greeting=Applied Greeting*' -- $testrc_after_apply; echo $status) -eq 0
@test "dot kde apply preserves an embedded '=' in the applied value" (string match -q '*RealKey=Hi=There*' -- $testrc_after_apply; echo $status) -eq 0
# re-running against a system already matching the manifest changes nothing
dot kde apply >/dev/null 2>&1
set -l reapply_status $status
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
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
# misuse: apply takes no arguments
printf 'testrc.General.Greeting=Applied Greeting\n' >$HOME/.config/dot/kde-manifest
dot kde apply extra-arg >/dev/null 2>&1
set -l apply_extra_arg_status $status
@test "dot kde apply rejects an unexpected argument" $apply_extra_arg_status -ne 0
# --- kde.py complete: tab-completion candidates, sourced from the live # --- kde.py complete: tab-completion candidates, sourced from the live
# schema mapping table rather than a hardcoded list. This is the # schema mapping table rather than a hardcoded list. This is the
# underlying data completions/dot.fish shells out to; the fish # underlying data completions/dot.fish shells out to; the fish

1
.github/README.md vendored
View File

@@ -20,6 +20,7 @@ fish -c 'dot init'
| `dot init` | Bootstraps the dotfiles repo on a new machine. | | `dot init` | Bootstraps the dotfiles repo on a new machine. |
| `dot install <pkgs>` | Installs the given pacman packages and appends them to the tracked list (`~/.config/dot/packages/pacman`). | | `dot install <pkgs>` | Installs the given pacman packages and appends them to the tracked list (`~/.config/dot/packages/pacman`). |
| `dot install --restore` | Reinstalls every package from the tracked list. | | `dot install --restore` | Reinstalls every package from the tracked list. |
| `dot kde apply` | Pushes every manifest entry's declared value onto the live system. |
| `dot kde help` | Lists `dot kde`'s subcommands. | | `dot kde help` | Lists `dot kde`'s subcommands. |
| `dot kde save <identifier>` | Reads a KDE setting's current live value and declares it in the manifest (`~/.config/dot/kde-manifest`). | | `dot kde save <identifier>` | Reads a KDE setting's current live value and declares it in the manifest (`~/.config/dot/kde-manifest`). |
| `dot kde save` | Refreshes every already-declared manifest entry's value from the live system. | | `dot kde save` | Refreshes every already-declared manifest entry's value from the live system. |