feat(state): validation + friendly errors on both write and eval paths (item 11)
All checks were successful
Check / eval (push) Successful in 2m56s
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:
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user