diff --git a/AGENTS.md b/AGENTS.md index fb75709..9ce1979 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -65,23 +65,30 @@ Prefer the following placements: ## Unit Discovery and Identity -A directory below `modules/` is a unit if, and only if, it directly contains at -least one reserved file. Directories used only for classification, such as -`modules/applications/` or `modules/profiles/interface/`, are namespaces rather -than units when they have no reserved file of their own. +A directory below `modules/` is a unit if, and only if, it contains at least one +reserved root file or reserved Home Manager fragment. Directories used only for +classification, such as `modules/applications/` or +`modules/profiles/interface/`, are namespaces rather than units when they have +no reserved fragment of their own. -The Registry recognizes exactly these five reserved filenames: +The Registry recognizes exactly these eight reserved paths relative to a unit: -| File | Target and responsibility | -| ------------ | -------------------------------------------------------------------------------- | -| `common.nix` | System-side configuration fragment shared by NixOS and nix-darwin | -| `nixos.nix` | NixOS-only configuration fragment | -| `darwin.nix` | nix-darwin-only configuration fragment | -| `home.nix` | Home Manager configuration fragment | -| `meta.nix` | Registry descriptor for dependencies, external modules, and descriptive metadata | +| File | Target and responsibility | +| ----------------- | -------------------------------------------------------------------------------- | +| `common.nix` | System-side configuration fragment shared by NixOS and nix-darwin | +| `nixos.nix` | NixOS-only system configuration fragment | +| `darwin.nix` | nix-darwin-only system configuration fragment | +| `home.nix` | Home Manager fragment shared by NixOS and nix-darwin | +| `home/common.nix` | Home Manager fragment shared by NixOS and nix-darwin | +| `home/nixos.nix` | Home Manager fragment loaded only on NixOS | +| `home/darwin.nix` | Home Manager fragment loaded only on nix-darwin | +| `meta.nix` | Registry descriptor for dependencies, external modules, and descriptive metadata | -`common.nix` is never applied to Home Manager. OS-independent Home Manager -configuration still belongs in `home.nix`. +Root `common.nix` is never applied to Home Manager. `home.nix` and +`home/common.nix` have identical dispatch semantics; use either or both when a +useful file split exists. The `home/` directory is a reserved fragment directory +of its parent unit when it contains `common.nix`, `nixos.nix`, or `darwin.nix`; +it is not discovered as a child unit in that case. The Registry derives a unit ID from the path relative to `modules/`, joining path components with dots. Category names remain plural. It also derives the @@ -137,6 +144,9 @@ of the following are valid units: # Home Manager only modules/applications/ghostty/ ├── home.nix +├── home/ +│ ├── nixos.nix +│ └── darwin.nix └── settings.nix # NixOS only @@ -164,16 +174,16 @@ modules/systems/nix/ └── common.nix ``` -If a unit has no `home.nix`, do not generate or apply a Home Manager module for -it. The same rule applies independently to `common.nix`, `nixos.nix`, and -`darwin.nix`. +Each fragment is optional and registered independently. A unit may therefore +contain only `home/nixos.nix` or `home/darwin.nix`; it does not need a +placeholder `home.nix` or `home/common.nix`. ### Configuration fragments -`common.nix`, `nixos.nix`, `darwin.nix`, and `home.nix` are configuration -fragments to which the Registry adds the enable condition. They return the -configuration for their class directly and must not define top-level `imports`, -`options`, or `config` attributes: +Every reserved path except `meta.nix` is a configuration fragment to which the +Registry adds the enable condition. These fragments return the configuration +for their class directly and must not define top-level `imports`, `options`, or +`config` attributes: ```nix # modules/services/docker/nixos.nix @@ -208,9 +218,9 @@ fragment. ### Helper files and directories -Every filename other than the five reserved names is an ordinary helper, -regardless of its extension. The Registry neither discovers nor automatically -imports helper files such as `settings.nix`, `keybindings.nix`, `packages.nix`, +Every path other than the eight reserved paths is an ordinary helper, regardless +of its extension. The Registry neither discovers nor automatically imports +helper files such as `settings.nix`, `keybindings.nix`, `packages.nix`, `colors.nix`, `rules.nix`, or `helpers.nix`. Import a helper explicitly from the reserved fragment that uses it: @@ -253,9 +263,10 @@ modules/applications/niri/ ``` Here `parts/` is not a unit because it directly contains no reserved file. A -helper directory that directly contains `home.nix` or another reserved file is -itself discovered as a unit, so never use reserved filenames inside a directory -that is intended to contain helpers only. +helper directory that directly contains a root reserved file is itself +discovered as a unit, so never use reserved filenames inside a directory that +is intended to contain helpers only. The reserved `home/` fragment directory is +the sole exception to ordinary recursive child-unit discovery. ### Registry metadata @@ -436,8 +447,10 @@ mechanism, and do not design the repository around `import-tree`. Registry logic has these responsibilities: 1. Recursively visit directories below `modules/`. -2. Check only the five reserved filenames directly within each directory. -3. Register a directory as a unit when at least one reserved file exists there. +2. Check the five reserved root filenames and the three reserved filenames + directly inside the unit's `home/` fragment directory. +3. Register a directory as a unit when at least one reserved fragment exists + there, including a unit that has only a reserved `home/` fragment. 4. Derive the unit ID from the path relative to `modules/`. 5. Record only class fragments that exist. 6. Evaluate `meta.nix` as a descriptor only when it exists. @@ -446,7 +459,8 @@ has these responsibilities: 9. Enable included units from `meta.includes`. 10. Raise a clear evaluation error for a reference to a missing unit ID. 11. Apply only the fragments appropriate to the current host class. -12. Pass `home.nix` to Home Manager only for hosts that enable Home Manager. +12. Pass `home.nix`, `home/common.nix`, and the matching OS-specific Home + Manager fragment only for hosts that enable Home Manager. A unit record may conceptually look like this; the implementation need not use this exact representation: @@ -461,6 +475,9 @@ this exact representation: nixos = null; darwin = null; home = ./applications/ghostty/home.nix; + homeCommon = null; + homeNixos = ./applications/ghostty/home/nixos.nix; + homeDarwin = ./applications/ghostty/home/darwin.nix; }; meta = { }; @@ -706,7 +723,10 @@ Derive the system class from the host's `system`: - A Linux NixOS host receives `common.nix` and `nixos.nix`. - A nix-darwin host receives `common.nix` and `darwin.nix`. -- A host with integrated Home Manager additionally receives `home.nix`. +- A NixOS host with integrated Home Manager additionally receives `home.nix`, + `home/common.nix`, and `home/nixos.nix`. +- A nix-darwin host with integrated Home Manager additionally receives + `home.nix`, `home/common.nix`, and `home/darwin.nix`. Home Manager is additive, not a system class mutually exclusive with NixOS or nix-darwin. Normal machine configurations combine NixOS or nix-darwin with Home @@ -735,8 +755,8 @@ When implementing or modifying modules: unit's enable option from a class fragment. - Do not assume any non-reserved file is discovered or loaded automatically. - Do not require an `_` prefix for helper or private files. -- Do not create unused `common.nix`, `nixos.nix`, `darwin.nix`, `home.nix`, or - `meta.nix` files. +- Do not create unused reserved fragments, including placeholder files under the + reserved `home/` fragment directory. - Do not override a path-derived unit ID from `meta.nix`. - Keep technical application dependencies separate from the applications a personal environment chooses to combine in a profile. diff --git a/libs/hosts.nix b/libs/hosts.nix index c50caa6..50e6ada 100644 --- a/libs/hosts.nix +++ b/libs/hosts.nix @@ -74,7 +74,10 @@ let let homePath = hostFile spec "home.nix"; homeModules = [ - (registry.mkModule { class = "home"; }) + (registry.mkModule { + class = "home"; + systemClass = "nixos"; + }) (registry.mkSelectionModule selected) { home.stateVersion = spec.stateVersion; } ] @@ -96,7 +99,10 @@ let let homePath = hostFile spec "home.nix"; homeModules = [ - (registry.mkModule { class = "home"; }) + (registry.mkModule { + class = "home"; + systemClass = "darwin"; + }) (registry.mkSelectionModule selected) { home.stateVersion = spec.stateVersion; } ] diff --git a/libs/registry.nix b/libs/registry.nix index d573ec1..419d0d4 100644 --- a/libs/registry.nix +++ b/libs/registry.nix @@ -4,7 +4,7 @@ modulesRoot, }: let - reservedFiles = { + rootFragmentFiles = { common = "common.nix"; nixos = "nixos.nix"; darwin = "darwin.nix"; @@ -12,6 +12,14 @@ let meta = "meta.nix"; }; + homeFragmentFiles = { + homeCommon = "common.nix"; + homeNixos = "nixos.nix"; + homeDarwin = "darwin.nix"; + }; + + fragmentFileNames = rootFragmentFiles // lib.mapAttrs (_: name: "home/${name}") homeFragmentFiles; + isFile = kind: kind == "regular" || kind == "symlink"; ensure = @@ -103,13 +111,17 @@ let ); makeUnit = - relativePath: entries: + relativePath: entries: homeEntries: let directory = pathFor relativePath; id = lib.concatStringsSep "." relativePath; - fragments = lib.mapAttrs ( + rootFragments = lib.mapAttrs ( _class: fileName: if entryIsFile entries fileName then directory + "/${fileName}" else null - ) reservedFiles; + ) rootFragmentFiles; + homeFragments = lib.mapAttrs ( + _class: fileName: if entryIsFile homeEntries fileName then directory + "/home/${fileName}" else null + ) homeFragmentFiles; + fragments = rootFragments // homeFragments; baseUnit = { inherit id @@ -138,11 +150,22 @@ let let directory = pathFor relativePath; entries = builtins.readDir directory; - hasReservedFile = lib.any (fileName: entryIsFile entries fileName) ( - builtins.attrValues reservedFiles + homeEntries = + if relativePath != [ ] && (entries.home or null) == "directory" then + builtins.readDir (directory + "/home") + else + { }; + hasRootFragment = lib.any (fileName: entryIsFile entries fileName) ( + builtins.attrValues rootFragmentFiles ); - childDirectories = lib.filter (name: entries.${name} == "directory") (builtins.attrNames entries); - current = lib.optional hasReservedFile (makeUnit relativePath entries); + hasHomeFragment = lib.any (fileName: entryIsFile homeEntries fileName) ( + builtins.attrValues homeFragmentFiles + ); + hasFragment = hasRootFragment || hasHomeFragment; + childDirectories = lib.filter ( + name: entries.${name} == "directory" && !(name == "home" && hasHomeFragment) + ) (builtins.attrNames entries); + current = lib.optional hasFragment (makeUnit relativePath entries homeEntries); children = lib.concatMap (name: walk (relativePath ++ [ name ])) childDirectories; in current ++ children; @@ -208,12 +231,44 @@ let "common" "darwin" ]; - home = [ "home" ]; + home = { + nixos = [ + "home" + "homeCommon" + "homeNixos" + ]; + darwin = [ + "home" + "homeCommon" + "homeDarwin" + ]; + }; }; + fragmentClassesFor = + { + class, + systemClass, + }: + ensure (builtins.hasAttr class fragmentClasses) "unsupported module class '${class}'" ( + if class == "home" then + ensure + (builtins.elem systemClass [ + "nixos" + "darwin" + ]) + "the home module class requires systemClass to be 'nixos' or 'darwin'" + fragmentClasses.home.${systemClass} + else + ensure ( + systemClass == null + ) "systemClass is only supported for the home module class" fragmentClasses.${class} + ); + applyFragment = { config, + fragmentName, fragmentPath, options, specialArgs, @@ -244,8 +299,7 @@ let ); resultValue = if builtins.isFunction fragment then fragment fragmentArgs else fragment; result = - ensure (builtins.isAttrs resultValue) - "${unit.id}: ${builtins.baseNameOf fragmentPath} must return an attribute set" + ensure (builtins.isAttrs resultValue) "${unit.id}: ${fragmentName} must return an attribute set" resultValue; forbiddenKeys = lib.filter (name: builtins.hasAttr name result) [ "imports" @@ -254,14 +308,20 @@ let ]; in ensure (forbiddenKeys == [ ]) - "${unit.id}: ${builtins.baseNameOf fragmentPath} is a configuration fragment and cannot define top-level ${lib.concatStringsSep ", " forbiddenKeys}" + "${unit.id}: ${fragmentName} is a configuration fragment and cannot define top-level ${lib.concatStringsSep ", " forbiddenKeys}" result; externalImports = class: lib.concatMap (unit: unit.meta.imports.${class}) discoveredUnits; mkModule = - { class }: - ensure (builtins.hasAttr class fragmentClasses) "unsupported module class '${class}'" ( + { + class, + systemClass ? null, + }: + let + selectedFragmentClasses = fragmentClassesFor { inherit class systemClass; }; + in + builtins.seq selectedFragmentClasses ( builtins.seq dependencyValidation ( { config, @@ -277,6 +337,7 @@ let map ( fragmentClass: let + fragmentName = fragmentFileNames.${fragmentClass}; fragmentPath = unit.fragments.${fragmentClass}; in if fragmentPath == null then @@ -285,13 +346,14 @@ let lib.mkIf (enabled config unit) (applyFragment { inherit config + fragmentName fragmentPath options specialArgs unit ; }) - ) fragmentClasses.${class} + ) selectedFragmentClasses ) ) discoveredUnits; in diff --git a/tests/fixtures/registry/modules/applications/alpha/home/common.nix b/tests/fixtures/registry/modules/applications/alpha/home/common.nix new file mode 100644 index 0000000..76f4b33 --- /dev/null +++ b/tests/fixtures/registry/modules/applications/alpha/home/common.nix @@ -0,0 +1,3 @@ +{ + test.homeValues = [ "home-common" ]; +} diff --git a/tests/fixtures/registry/modules/applications/alpha/home/darwin.nix b/tests/fixtures/registry/modules/applications/alpha/home/darwin.nix new file mode 100644 index 0000000..15cef54 --- /dev/null +++ b/tests/fixtures/registry/modules/applications/alpha/home/darwin.nix @@ -0,0 +1,3 @@ +{ + test.homeValues = [ "home-darwin" ]; +} diff --git a/tests/fixtures/registry/modules/applications/alpha/home/nixos.nix b/tests/fixtures/registry/modules/applications/alpha/home/nixos.nix new file mode 100644 index 0000000..6429715 --- /dev/null +++ b/tests/fixtures/registry/modules/applications/alpha/home/nixos.nix @@ -0,0 +1,3 @@ +{ + test.homeValues = [ "home-nixos" ]; +} diff --git a/tests/fixtures/registry/modules/applications/gamma/home/nixos.nix b/tests/fixtures/registry/modules/applications/gamma/home/nixos.nix new file mode 100644 index 0000000..a0431d5 --- /dev/null +++ b/tests/fixtures/registry/modules/applications/gamma/home/nixos.nix @@ -0,0 +1,3 @@ +{ + test.homeValues = [ "gamma-home-nixos" ]; +} diff --git a/tests/registry.nix b/tests/registry.nix index f416712..19af5fc 100644 --- a/tests/registry.nix +++ b/tests/registry.nix @@ -24,7 +24,7 @@ let }; eval = - class: selected: + class: systemClass: selected: lib.evalModules { specialArgs = { inherit pkgs; @@ -32,14 +32,17 @@ let }; modules = [ baseModule - (registry.mkModule { inherit class; }) + (registry.mkModule { inherit class systemClass; }) (registry.mkSelectionModule selected) ]; }; - nixos = eval "nixos" [ "profiles.interface.test" ]; - darwin = eval "darwin" [ "applications.alpha" ]; - home = eval "home" [ "profiles.interface.test" ]; + nixos = eval "nixos" null [ "profiles.interface.test" ]; + darwin = eval "darwin" null [ "applications.alpha" ]; + nixosHome = eval "home" "nixos" [ "profiles.interface.test" ]; + darwinHome = eval "home" "darwin" [ "applications.alpha" ]; + nixosOnlyHome = eval "home" "nixos" [ "applications.gamma" ]; + darwinWithoutNixosHome = eval "home" "darwin" [ "applications.gamma" ]; nixosHost = hostLib.mkNixos "registry-test" { system = "x86_64-linux"; @@ -60,6 +63,19 @@ let missingUnit = builtins.tryEval ( builtins.deepSeq (registry.validateUnitIds [ "applications.missing" ]) true ); + homeWithoutSystemClass = builtins.tryEval (registry.mkModule { class = "home"; }); + homeWithInvalidSystemClass = builtins.tryEval ( + registry.mkModule { + class = "home"; + systemClass = "linux"; + } + ); + systemWithHomeSystemClass = builtins.tryEval ( + registry.mkModule { + class = "nixos"; + systemClass = "nixos"; + } + ); invalidDependencyRegistry = import ../libs/registry.nix { inherit inputs lib; @@ -73,7 +89,10 @@ let }; invalidFragmentEvaluation = lib.evalModules { modules = [ - (invalidFragmentRegistry.mkModule { class = "home"; }) + (invalidFragmentRegistry.mkModule { + class = "home"; + systemClass = "nixos"; + }) (invalidFragmentRegistry.mkSelectionModule [ "applications.broken" ]) ]; }; @@ -84,6 +103,7 @@ let registry.unitIds == [ "applications.alpha" "applications.beta" + "applications.gamma" "profiles.interface.test" "services.nested" "users.test" @@ -106,10 +126,20 @@ let "darwin" ]; assert - home.config.test.homeValues == [ + nixosHome.config.test.homeValues == [ "home" + "home-common" + "home-nixos" "beta-home" ]; + assert + darwinHome.config.test.homeValues == [ + "home" + "home-common" + "home-darwin" + ]; + assert nixosOnlyHome.config.test.homeValues == [ "gamma-home-nixos" ]; + assert darwinWithoutNixosHome.config.test.homeValues == [ ]; assert nixosHost.config.my.profiles.interface.test.enable; assert nixosHost.config.system.stateVersion == "26.05"; assert nixosHost.config.home-manager.users.test.home.stateVersion == "26.05"; @@ -117,18 +147,28 @@ let assert nixosHost.config.home-manager.users.test.test.homeValues == [ "home" + "home-common" + "home-nixos" "beta-home" ]; assert darwinHost.config.my.applications.alpha.enable; assert darwinHost.config.home-manager.users.test.home.stateVersion == "26.05"; assert darwinHost.config.home-manager.users.test.my.applications.alpha.enable; - assert darwinHost.config.home-manager.users.test.test.homeValues == [ "home" ]; + assert + darwinHost.config.home-manager.users.test.test.homeValues == [ + "home" + "home-common" + "home-darwin" + ]; assert darwinHost.config.test.systemValues == [ "common:host" "darwin" ]; assert !missingUnit.success; + assert !homeWithoutSystemClass.success; + assert !homeWithInvalidSystemClass.success; + assert !systemWithHomeSystemClass.success; assert !missingDependency.success; assert !invalidFragment.success; true;