From bdcb842903a4d080626673874bf252fc93237f4e Mon Sep 17 00:00:00 2001 From: Austin Horstman Date: Sat, 12 Sep 2026 18:01:50 -0500 Subject: [PATCH] lib/deprecations: preserve settings overlay definitions Share raw overlay forwarding and effective-key detection for default-empty legacy options. Keep application defaults and conversions in consumers. Delegate aliases and diagnostics to doRename without rewrapping prepared priorities. Cover ordering, composition, empty values, and source filenames in the existing evaluations. --- docs/manual/contributing/guidelines.md | 35 +++ modules/lib/deprecations.nix | 109 ++++++-- tests/lib/deprecations/default.nix | 2 +- .../deprecations/ordered-settings-rename.nix | 33 --- .../lib/deprecations/settings-migrations.nix | 251 ++++++++++++++++++ 5 files changed, 372 insertions(+), 58 deletions(-) delete mode 100644 tests/lib/deprecations/ordered-settings-rename.nix create mode 100644 tests/lib/deprecations/settings-migrations.nix diff --git a/docs/manual/contributing/guidelines.md b/docs/manual/contributing/guidelines.md index 2bf17aa67e..a7b4f97185 100644 --- a/docs/manual/contributing/guidelines.md +++ b/docs/manual/contributing/guidelines.md @@ -85,6 +85,41 @@ configuration location with an environment variable, for example `FOO_HOME`, expose a `configDir` option and use it to respect `home.preferXdgDirectories`. +## Migrate settings with shared helpers {#sec-guidelines-settings-migrations} + +Use `lib.hm.deprecations.mkSettingsRenamedOptionModules` for unchanged values +moving into settings. Specify native key paths explicitly when casing or +literal dotted keys differ from the default snake-case transformation. +Set `preserveOrder = true` when legacy and new list definitions must retain +relative `mkBefore` and `mkAfter` ordering. Value conversions belong in +`lib.mkChangedOptionModule`, not in a path-rename mapping. + +For a default-empty attribute-set option formerly applied as a final overlay, +use `lib.hm.deprecations.mkSettingsOverlay`: + +``` nix +let + overlay = lib.hm.deprecations.mkSettingsOverlay { + inherit options; + from = [ "programs" "example" "extraConfig" ]; + to = [ "programs" "example" "settings" ]; + }; +in +{ + imports = [ overlay.module ]; +} +``` + +The helper forwards raw definitions, applying root priorities only to supplied +keys. Use `overlay.keys` to suppress modeled contributions for keys that the +old overlay overwrote. Disabled conditional keys are absent; explicit null +and empty values still count as supplied. Whole sources weaker than the old +empty option default are ignored. + +Keep application defaults, conversions, and output filtering in the module. +Test legacy overlay precedence and ordinary settings overrides separately. +Do not replace module merging with a final attribute-set overlay on settings. + ## Add relevant tests {#sec-guidelines-add-tests} If at all possible, make sure to add new tests and expand existing tests diff --git a/modules/lib/deprecations.nix b/modules/lib/deprecations.nix index bc31827086..710ccdbfdd 100644 --- a/modules/lib/deprecations.nix +++ b/modules/lib/deprecations.nix @@ -1,4 +1,29 @@ { lib }: +let + mkRenamedOptionModuleWith = + { + from, + to, + value, + condition, + }: + args@{ options, ... }: + lib.doRename + { + inherit from to condition; + visible = false; + warn = true; + use = lib.id; + # The forwarded definitions already carry their intended priorities. + withPriority = false; + } + ( + args + // { + options = lib.recursiveUpdate options (lib.setAttrByPath from { definitions = [ value ]; }); + } + ); +in { /* Builds a standard warning for an option value shape that is deprecated. @@ -96,7 +121,7 @@ if !preserveOrder then lib.mkRenamedOptionModule from to else - { options, ... }: + args@{ options, ... }: let option = lib.getAttrFromPath from options; forwardDefinition = @@ -108,31 +133,67 @@ ); }; in - { - imports = [ - (lib.doRename { - inherit from to; - visible = false; - warn = false; - use = lib.id; - condition = false; - }) - ]; - - config = lib.mkMerge [ - (lib.optionalAttrs (options ? warnings) { - warnings = lib.optional option.isDefined ( - "The option `${lib.showOption from}' defined in " - + "${lib.showFiles option.files} has been renamed to `${lib.showOption to}'." - ); - }) - (lib.setAttrByPath to ( - lib.mkIf option.isDefined (lib.mkMerge (map forwardDefinition option.definitionsWithLocations)) - )) - ]; - } + mkRenamedOptionModuleWith { + inherit from to; + condition = option.isDefined; + value = lib.mkMerge (map forwardDefinition option.definitionsWithLocations); + } args ); + /* + Migrates a default-empty attribute-set overlay to freeform settings. + Returns a module to import and the effective immediate keys, which callers + can use to suppress modeled values formerly overwritten by the overlay. + + Root priorities apply only to supplied keys. Explicit key priorities and + nested definitions remain intact. Keys with only disabled conditions are + absent; null and empty values still count as supplied. + + Example: + overlay = lib.hm.deprecations.mkSettingsOverlay { + inherit options; + from = [ "programs" "example" "extraConfig" ]; + to = [ "programs" "example" "settings" ]; + }; + + imports = [ overlay.module ]; + */ + mkSettingsOverlay = + { + options, + from, + to, + }: + let + old = lib.getAttrFromPath from options; + active = old.isDefined && old.highestPrio <= (lib.mkOptionDefault { }).priority; + definitions = lib.optionals active old.definitionsWithLocations; + in + { + keys = builtins.attrNames ((lib.types.attrsOf lib.types.raw).merge from definitions); + + module = mkRenamedOptionModuleWith { + inherit from to; + condition = active; + value = + let + withRootPriority = + value: + if (value._type or null) == "override" then + value + else if (value._type or null) == "if" then + lib.mkIf value.condition (withRootPriority value.content) + else if (value._type or null) == "merge" then + lib.mkMerge (map withRootPriority value.contents) + else if (value._type or null) == "definition" then + value // { value = withRootPriority value.value; } + else + lib.mkOverride old.highestPrio value; + in + lib.mkMerge (map (definition: lib.mapAttrs (_: withRootPriority) definition.value) definitions); + }; + }; + /* Recursively transforms attribute set keys, issuing a warning for each transformation. diff --git a/tests/lib/deprecations/default.nix b/tests/lib/deprecations/default.nix index 581c59c90b..513dc9c1bd 100644 --- a/tests/lib/deprecations/default.nix +++ b/tests/lib/deprecations/default.nix @@ -1,4 +1,4 @@ { - lib-deprecations-ordered-settings-rename = ./ordered-settings-rename.nix; + lib-deprecations-settings-migrations = ./settings-migrations.nix; lib-deprecations-warnings = ./warnings.nix; } diff --git a/tests/lib/deprecations/ordered-settings-rename.nix b/tests/lib/deprecations/ordered-settings-rename.nix deleted file mode 100644 index fec80b00ca..0000000000 --- a/tests/lib/deprecations/ordered-settings-rename.nix +++ /dev/null @@ -1,33 +0,0 @@ -{ lib, ... }: - -let - evaluated = lib.evalModules { - modules = [ - { - imports = lib.hm.deprecations.mkSettingsRenamedOptionModules [ ] [ "settings" ] { - preserveOrder = true; - } [ "items" ]; - - options.settings.items = lib.mkOption { - type = lib.types.listOf lib.types.str; - }; - } - { - items = lib.mkBefore [ "legacy" ]; - settings.items = [ "canonical" ]; - } - ]; - }; -in -{ - assertions = [ - { - assertion = - evaluated.config.settings.items == [ - "legacy" - "canonical" - ]; - message = "Ordered settings renames must work without a warnings option."; - } - ]; -} diff --git a/tests/lib/deprecations/settings-migrations.nix b/tests/lib/deprecations/settings-migrations.nix new file mode 100644 index 0000000000..d5078bc6c0 --- /dev/null +++ b/tests/lib/deprecations/settings-migrations.nix @@ -0,0 +1,251 @@ +{ + config, + lib, + options, + pkgs, + ... +}: + +let + cases = [ + "mixed" + "forced" + "weak" + "empty" + "canonicalForce" + "unset" + ]; + overlays = lib.genAttrs cases ( + name: + lib.hm.deprecations.mkSettingsOverlay { + inherit options; + from = [ + "test" + "overlays" + name + "old" + ]; + to = [ + "test" + "overlays" + name + "settings" + ]; + } + ); +in +{ + imports = map (name: overlays.${name}.module) cases ++ [ + { + _file = "overlay-first.nix"; + config.test.overlays.mixed.old = lib.mkDefault { provenanceA = 1; }; + } + { + _file = "overlay-second.nix"; + config.test.overlays.mixed.old = lib.mkDefault { provenanceB = 2; }; + } + ]; + options.test.overlays = lib.genAttrs cases (_: { + settings = lib.mkOption { + type = lib.types.attrsOf (pkgs.formats.json { }).type; + default = { }; + }; + }); + config = { + test.asserts.warnings.expected = + map + ( + name: + "The option `test.overlays.${name}.old' defined in ${ + lib.showFiles options.test.overlays.${name}.old.files + } has been renamed to `test.overlays.${name}.settings'." + ) + [ + "canonicalForce" + "empty" + "forced" + "mixed" + ]; + test.overlays = { + mixed = { + old = lib.mkDefault { + "literal.key" = 7; + defined = lib.mkDefinition { + file = "legacy-key.nix"; + value = lib.mkForce "legacy"; + }; + absent = lib.mkIf false (throw "disabled content was forced"); + conditional = lib.mkIf true (lib.mkDefault "old"); + nested = { + value = lib.mkDefault "old"; + list = lib.mkAfter [ "last" ]; + }; + first = lib.mkBefore [ "first" ]; + nullValue = null; + emptyString = ""; + falseValue = false; + zero = 0; + emptyList = [ ]; + emptyAttrs = { }; + lazy = { + value = throw "presence check forced nested content"; + }; + }; + settings = { + defined = "canonical"; + conditional = "new"; + nested = lib.mkDefault { + value = "new"; + list = [ "first" ]; + }; + first = lib.mkDefault [ "last" ]; + unrelated = "keep"; + }; + }; + forced = { + old = lib.mkForce { + value = "forced"; + conditional = lib.mkMerge [ + (lib.mkIf false (throw "disabled merge branch was forced")) + (lib.mkIf true (lib.mkDefault "old")) + ]; + }; + settings = { + value = "ordinary"; + conditional = "new"; + unrelated = true; + }; + }; + weak.old = lib.mkOverride 1501 { value = lib.mkForce "ignored"; }; + empty.old = { }; + canonicalForce = { + old.value = "legacy"; + settings = lib.mkForce { canonical = true; }; + }; + }; + assertions = [ + { + assertion = + let + withoutWarnings = lib.evalModules { + modules = [ + ({ options, ... }: { + imports = + (lib.hm.deprecations.mkSettingsRenamedOptionModules [ ] [ "settings" ] { + preserveOrder = true; + } [ "items" ]) + ++ [ + (lib.hm.deprecations.mkSettingsOverlay { + inherit options; + from = [ "old" ]; + to = [ "overlaySettings" ]; + }).module + ]; + options = { + settings.items = lib.mkOption { type = lib.types.listOf lib.types.str; }; + overlaySettings = lib.mkOption { + type = lib.types.attrsOf (pkgs.formats.json { }).type; + default = { }; + }; + }; + }) + { + items = lib.mkBefore [ "legacy" ]; + settings.items = [ "canonical" ]; + old.value = 1; + } + { items = lib.mkAfter [ "legacy-after" ]; } + ]; + }; + in + withoutWarnings.config.settings.items == [ + "legacy" + "canonical" + "legacy-after" + ] + && withoutWarnings.config.overlaySettings.value == 1; + message = "Settings migrations must work without a warnings option."; + } + { + assertion = + overlays.mixed.keys == [ + "conditional" + "defined" + "emptyAttrs" + "emptyList" + "emptyString" + "falseValue" + "first" + "lazy" + "literal.key" + "nested" + "nullValue" + "provenanceA" + "provenanceB" + "zero" + ]; + message = "Overlay keys must exclude absent conditions without forcing nested values."; + } + { + assertion = + let + settings = config.test.overlays.mixed.settings; + in + settings.provenanceA == 1 + && settings.provenanceB == 2 + && settings.defined == "legacy" + && settings."literal.key" == 7 + && !(settings ? literal) + && !(settings ? absent) + && settings.conditional == "new" + && settings.unrelated == "keep" + && + settings.nested == { + value = "new"; + list = [ + "first" + "last" + ]; + } + && + settings.first == [ + "first" + "last" + ] + && settings.nullValue == null + && settings.emptyString == "" + && settings.falseValue == false + && settings.zero == 0 + && settings.emptyList == [ ] + && settings.emptyAttrs == { }; + message = "Overlay forwarding must retain native composition and explicit empty values."; + } + { + assertion = + config.test.overlays.forced.settings == { + value = "forced"; + conditional = "new"; + unrelated = true; + } + && config.test.overlays.canonicalForce.settings == { canonical = true; } + && config.test.overlays.weak.settings == { } + && overlays.weak.keys == [ ] + && config.test.overlays.empty.settings == { } + && overlays.empty.keys == [ ] + && config.test.overlays.unset.settings == { } + && overlays.unset.keys == [ ]; + message = "Overlay root priorities must not affect unrelated settings or revive weak sources."; + } + { + assertion = + config.test.overlays.forced.old == config.test.overlays.forced.settings + && config.test.overlays.unset.old == { } + && lib.all (file: lib.any (lib.hasInfix file) config.warnings) [ + "overlay-first.nix" + "overlay-second.nix" + ]; + message = "Overlay aliases must preserve reads and emit one standard warning per active source."; + } + ]; + }; +}