feat(state): validation + friendly errors on both write and eval paths (item 11)
All checks were successful
Check / eval (push) Successful in 2m56s

'Never has to master Nix' includes the error messages. The tool grows
validate_state (appearance schema = hard errors; settings.* and
unknown keys = warnings so menu writers and newer schemas keep
working), a read-only `validate` subcommand, and validation BEFORE
every write — an invalid set/apply is refused with the on-disk file
untouched. JSON syntax errors now say line, column, and fix. theme.nix
enforces the same schema at eval time (on the post-defaults state, so
sparse/older files still evaluate) and throws a message naming each
field, its got-value, and the fix paths — the hand-edit escape hatch
no longer ends in a Nix stack trace. Doctor's JSON fix-text points at
validate.

V1: new checks.theme-sync-validate fixture corpus green (good state,
trailing comma, bad hex, bad type, unknown key, refused set with
byte-identical file); negative mkFlake eval of a broken scratch
downstream shows the friendly throw; good-path template-home builds.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Bernardo Magri
2026-07-04 20:44:08 +01:00
parent 352c681f48
commit bfb80cb60d
6 changed files with 291 additions and 14 deletions

View File

@@ -79,6 +79,61 @@ let
parsed = lib.recursiveUpdate defaults themeState;
# ── Friendly eval-time validation ───────────────────────────────────
# The same schema nomarchy-theme-sync enforces before every write. A
# HAND-edited state file (the one path that bypasses the tool) must
# fail with the field, the problem, and the fix — not a Nix stack
# trace deep in some consumer. Checks run on `parsed` (after the
# defaults), so missing fields are fine; only wrong values throw.
isHex = v: builtins.isString v && builtins.match "#[0-9a-fA-F]{6}" v != null;
isNum = v: builtins.isInt v || builtins.isFloat v;
colorRoles = [ "base" "mantle" "surface" "overlay" "text" "subtext"
"muted" "accent" "accentAlt" "good" "warn" "bad" ];
got = v: "got: ${builtins.toJSON v}";
problems = lib.concatLists [
(map (k: "colors.${k} must be \"#RRGGBB\" (${got (parsed.colors.${k} or null)})")
(builtins.filter (k: !isHex (parsed.colors.${k} or null)) colorRoles))
(lib.optional (!(parsed.mode == "dark" || parsed.mode == "light"))
"mode must be \"dark\" or \"light\" (${got parsed.mode})")
(map (k: "ui.${k} must be a non-negative whole number (${got (parsed.ui.${k} or null)})")
(builtins.filter
(k: let v = parsed.ui.${k} or null; in !(builtins.isInt v && v >= 0))
[ "gapsIn" "gapsOut" "borderSize" "rounding" "iconSize" ]))
(map (k: "ui.${k} must be a number between 0 and 1 (${got (parsed.ui.${k} or null)})")
(builtins.filter
(k: let v = parsed.ui.${k} or null; in !(isNum v && v >= 0 && v <= 1))
[ "activeOpacity" "inactiveOpacity" "terminalOpacity" ]))
(map (k: "ui.${k} must be true or false (${got (parsed.ui.${k} or null)})")
(builtins.filter (k: !builtins.isBool (parsed.ui.${k} or null))
[ "blur" "shadow" ]))
(map (k: "fonts.${k} must be a font-family string (${got (parsed.fonts.${k} or null)})")
(builtins.filter (k: !builtins.isString (parsed.fonts.${k} or null))
[ "mono" "ui" ]))
(lib.optional (!(isNum (parsed.fonts.size or null) && parsed.fonts.size > 0))
"fonts.size must be a positive number (${got (parsed.fonts.size or null)})")
(lib.optional (!(builtins.isList parsed.ansi
&& builtins.length parsed.ansi == 16
&& lib.all isHex parsed.ansi))
"ansi must be a list of exactly 16 \"#RRGGBB\" strings")
(map (k: "border.${k} must be a palette role or \"#RRGGBB\" (${got (parsed.border.${k} or null)})")
(builtins.filter
(k: let v = parsed.border.${k} or null;
in !(builtins.isString v && (builtins.elem v colorRoles || isHex v)))
[ "active" "inactive" ]))
];
checked =
if problems == [ ] then parsed
else throw ''
Nomarchy: your theme-state.json is invalid:
${lib.concatMapStrings (p: " ${p}\n") problems}
Fix the named field(s) in the theme-state.json of your flake
checkout (usually ~/.nomarchy/theme-state.json the store copy at
${toString cfg.stateFile} is a snapshot of it). The tool prints
the same report with per-field fixes: `nomarchy-theme-sync
validate`. Re-applying any preset resets all appearance fields:
`nomarchy-theme-sync apply tokyo-night`.'';
# A border value is a palette key (look it up in colors) unless it's
# already a literal hex; unknown keys fall through to the raw string.
resolveColor = v: if lib.hasPrefix "#" v then v else parsed.colors.${v} or v;
@@ -97,12 +152,12 @@ let
in
{
config = {
nomarchy.theme = parsed // { inherit iconTheme border; };
nomarchy.theme = checked // { inherit iconTheme border; };
# Feature toggles the menu writes (settings.nightlight.enable, …), exposed
# alongside the appearance state. Feature modules mkDefault-read from here
# so a menu toggle lands in the flake instead of in ~/.local/state.
nomarchy.settings = parsed.settings;
nomarchy.settings = checked.settings;
nomarchy.lib = {
# "#7aa2f7" -> "rgb(7aa2f7)" (Hyprland color syntax)