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."; + } + ]; + }; +}