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.
This commit is contained in:
Austin Horstman
2026-09-12 18:01:50 -05:00
parent e16bc72cbb
commit bdcb842903
5 changed files with 372 additions and 58 deletions

View File

@@ -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

View File

@@ -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.

View File

@@ -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;
}

View File

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

View File

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