lib/deprecations: support deferred state-version warnings

Extend mkStateVersionOptionDefault so merged attrset callers can defer
warning emission to config.warnings while still exposing the effective
state-version default and option priority metadata.

Add dedicated lib tests for deferred warning behavior on both legacy and
current state versions.

Signed-off-by: Austin Horstman <khaneliman12@gmail.com>
This commit is contained in:
Austin Horstman
2026-03-24 14:08:36 -05:00
parent b8cb89f2c4
commit 66aa75f6e8
4 changed files with 290 additions and 5 deletions
+64 -5
View File
@@ -109,11 +109,25 @@
pathStr: attrs: migrate pathStr attrs;
/*
Builds `default` and `defaultText` values for options whose defaults
change based on `home.stateVersion`, while warning users on the legacy
branch.
Builds the state-version migration values for options whose defaults change
based on `home.stateVersion`.
Example:
In the default mode, this returns `default` and `defaultText` for direct use
in `mkOption`, and emits the migration warning with `lib.warn` when the
legacy branch is active.
In deferred mode (`deferWarningToConfig = true`), this keeps `default`
pinned to `current.value` and additionally returns:
- `warning`: the warning text to add to `config.warnings`
- `shouldWarn`: whether the warning should be emitted
- `effectiveDefault`: the state-version-selected value
- `optionUsesDefaultPriority`: whether the option is still at its module default priority
Deferred mode is intended for merged attrset options where warning from the
option default itself is too early to be silenced reliably by explicit user
assignments. It requires passing both `config` and `options`.
Direct example:
let
stateVersionDefault = lib.hm.deprecations.mkStateVersionOptionDefault {
inherit (config.home) stateVersion;
@@ -126,6 +140,26 @@
lib.mkOption {
inherit (stateVersionDefault) default defaultText;
};
Deferred-warning example:
let
stateVersionDefault = lib.hm.deprecations.mkStateVersionOptionDefault {
inherit (config.home) stateVersion;
inherit config options;
since = "26.05";
optionPath = [ "programs" "example" "settings" ];
legacy.value = { FOO = "legacy"; };
current.value = { };
deferWarningToConfig = true;
};
in {
options.programs.example.settings = lib.mkOption {
default = { };
inherit (stateVersionDefault) defaultText;
};
config.warnings = lib.optional stateVersionDefault.shouldWarn stateVersionDefault.warning;
};
*/
mkStateVersionOptionDefault =
{
@@ -135,6 +169,11 @@
legacy,
current,
extraWarning ? "",
config ? null,
options ? null,
warningPriority ? (lib.mkOptionDefault { }).priority,
shouldWarn ? null,
deferWarningToConfig ? false,
}:
let
option = lib.showOption optionPath;
@@ -149,12 +188,32 @@
${option} = ${currentText};
''
+ lib.optionalString (extraWarning != "") ("\n" + extraWarning);
canDeferWarning = config != null && options != null;
optionInfo = lib.optionalAttrs canDeferWarning (lib.getAttrFromPath optionPath options);
optionUsesDefaultPriority = canDeferWarning && optionInfo.highestPrio >= warningPriority;
usingLegacyBranch = lib.versionOlder stateVersion since;
in
assert lib.assertMsg (!deferWarningToConfig || canDeferWarning) ''
`lib.hm.deprecations.mkStateVersionOptionDefault` requires both `config` and `options`
when `deferWarningToConfig = true`.
'';
{
default =
if lib.versionAtLeast stateVersion since then current.value else lib.warn warning legacy.value;
if usingLegacyBranch && !deferWarningToConfig then lib.warn warning legacy.value else current.value;
defaultText = lib.literalExpression ''
if lib.versionAtLeast config.home.stateVersion "${since}" then ${currentText} else ${legacyText}
'';
effectiveDefault = if usingLegacyBranch then legacy.value else current.value;
inherit warning optionUsesDefaultPriority;
shouldWarn =
deferWarningToConfig
&& usingLegacyBranch
&& (
if lib.isFunction shouldWarn then
shouldWarn { inherit optionInfo optionUsesDefaultPriority; }
else
optionUsesDefaultPriority
);
};
}
+2
View File
@@ -6,4 +6,6 @@
lib-types-gvariant-merge = ./gvariant-merge.nix;
lib-types-state-version-option-default = ./state-version-option-default.nix;
lib-types-state-version-option-default-deferred = ./state-version-option-default-deferred.nix;
lib-types-state-version-option-default-deferred-current = ./state-version-option-default-deferred-current.nix;
}
@@ -0,0 +1,67 @@
{
config,
lib,
pkgs,
options,
...
}:
let
attrSetOfString = lib.types.attrsOf lib.types.str;
currentDefault = lib.hm.deprecations.mkStateVersionOptionDefault {
inherit (config.home) stateVersion;
inherit config options;
since = "26.05";
optionPath = [
"test"
"values"
"current"
];
legacy.value = {
FOO = "legacy";
};
current.value = { };
deferWarningToConfig = true;
};
in
{
options.test.values.current = lib.mkOption {
type = attrSetOfString;
default = { };
inherit (currentDefault) defaultText;
};
config = {
home.stateVersion = "26.05";
assertions = [
{
assertion = currentDefault.default == { };
message = "Deferred mkStateVersionOptionDefault should keep the raw option default at current.value on current state versions.";
}
{
assertion = currentDefault.effectiveDefault == { };
message = "Deferred mkStateVersionOptionDefault should expose current.value as the effective default on current state versions.";
}
{
assertion = currentDefault.optionUsesDefaultPriority;
message = "Deferred mkStateVersionOptionDefault should still detect default priority on current state versions.";
}
{
assertion = !currentDefault.shouldWarn;
message = "Deferred mkStateVersionOptionDefault should not warn on current state versions.";
}
];
test.asserts.warnings.expected = [ ];
home.file."result.txt".text = ''
currentFoo=${currentDefault.effectiveDefault.FOO or ""}
'';
nmt.script = ''
assertFileContent home-files/result.txt ${pkgs.writeText "state-version-option-default-deferred-current.txt" ''
currentFoo=
''}
'';
};
}
@@ -0,0 +1,157 @@
{
config,
lib,
pkgs,
options,
...
}:
let
attrSetOfString = lib.types.attrsOf lib.types.str;
mkDeferredDefault =
name: extraArgs:
lib.hm.deprecations.mkStateVersionOptionDefault (
{
inherit (config.home) stateVersion;
inherit config options;
since = "26.05";
optionPath = [
"test"
"values"
name
];
legacy.value = {
FOO = "legacy";
};
current.value = { };
deferWarningToConfig = true;
}
// extraArgs
);
effectiveValue =
stateVersionDefault: value:
lib.optionalAttrs stateVersionDefault.shouldWarn stateVersionDefault.effectiveDefault // value;
cases = {
unset = {
default = mkDeferredDefault "unset" { };
expectedWarn = true;
expectedPriority = true;
};
explicitEmpty = {
default = mkDeferredDefault "explicitEmpty" { };
expectedWarn = false;
expectedPriority = false;
};
explicitLegacy = {
default = mkDeferredDefault "explicitLegacy" { };
expectedWarn = false;
expectedPriority = false;
};
priorityOnlyExplicit = {
default = mkDeferredDefault "priorityOnlyExplicit" { };
expectedWarn = false;
expectedPriority = false;
};
partial = {
default = mkDeferredDefault "partial" {
shouldWarn =
{ optionUsesDefaultPriority, ... }:
optionUsesDefaultPriority
|| (config.test.values.partial != { } && !(config.test.values.partial ? FOO));
};
expectedWarn = true;
expectedPriority = false;
};
};
in
{
options.test.values = lib.mapAttrs (
name: case:
lib.mkOption {
type = attrSetOfString;
default = { };
inherit (case.default) defaultText;
}
) cases;
config = {
assertions = [
{
assertion = cases.unset.default.defaultText._type == "literalExpression";
message = "Deferred mkStateVersionOptionDefault should return a literalExpression defaultText.";
}
{
assertion = cases.unset.default.default == { };
message = "Deferred mkStateVersionOptionDefault should keep the raw option default at current.value.";
}
{
assertion = cases.unset.default.effectiveDefault == { FOO = "legacy"; };
message = "Deferred mkStateVersionOptionDefault should expose the legacy effective default on old state versions.";
}
]
++ lib.flatten (
lib.mapAttrsToList (name: case: [
{
assertion = case.default.shouldWarn == case.expectedWarn;
message = "Deferred warning logic failed for ${name}: expected shouldWarn=${builtins.toJSON case.expectedWarn} but got ${builtins.toJSON case.default.shouldWarn}";
}
{
assertion = case.default.optionUsesDefaultPriority == case.expectedPriority;
message = "Deferred priority detection failed for ${name}: expected optionUsesDefaultPriority=${builtins.toJSON case.expectedPriority} but got ${builtins.toJSON case.default.optionUsesDefaultPriority}";
}
]) cases
);
test = {
values = {
explicitEmpty = { };
explicitLegacy = {
FOO = "legacy";
};
priorityOnlyExplicit.BAR = "explicit";
partial.BAR = "partial";
};
asserts.warnings.expected = lib.flatten (
lib.mapAttrsToList (name: case: lib.optional case.expectedWarn case.default.warning) cases
);
};
warnings = lib.flatten (
lib.mapAttrsToList (name: case: lib.optional case.default.shouldWarn case.default.warning) cases
);
home.file."result.txt".text = ''
unsetFoo=${(effectiveValue cases.unset.default config.test.values.unset).FOO or ""}
explicitEmptyFoo=${
(effectiveValue cases.explicitEmpty.default config.test.values.explicitEmpty).FOO or ""
}
explicitLegacyFoo=${
(effectiveValue cases.explicitLegacy.default config.test.values.explicitLegacy).FOO or ""
}
priorityOnlyExplicitFoo=${
(effectiveValue cases.priorityOnlyExplicit.default config.test.values.priorityOnlyExplicit).FOO
or ""
}
priorityOnlyExplicitBar=${
(effectiveValue cases.priorityOnlyExplicit.default config.test.values.priorityOnlyExplicit).BAR
or ""
}
partialFoo=${(effectiveValue cases.partial.default config.test.values.partial).FOO or ""}
partialBar=${(effectiveValue cases.partial.default config.test.values.partial).BAR or ""}
'';
nmt.script = ''
assertFileContent home-files/result.txt ${pkgs.writeText "state-version-option-default-deferred.txt" ''
unsetFoo=legacy
explicitEmptyFoo=
explicitLegacyFoo=legacy
priorityOnlyExplicitFoo=
priorityOnlyExplicitBar=explicit
partialFoo=legacy
partialBar=partial
''}
'';
};
}