From 8d5e27b4807d25308dfe369d5a923d87e7dbfda3 Mon Sep 17 00:00:00 2001
From: midchildan <git@midchildan.org>
Date: Thu, 13 Jun 2024 10:47:38 +0900
Subject: [PATCH] nix: add a declarative alternative to Nix channels (#4031)

* nix: add options 'nixPath' and 'keepOldNixPath'

By default, the system value for $NIX_PATH is kept as a fallback.
To completely override the system value for $NIX_PATH:

    nix.keepOldNixPath = false;

* nix: add more tests

* nix: add a declarative alternative to Nix channels

This adds a new option, 'nix.channels'. It's the Nix channels equivalent
of the 'nix.registry' option, and compatible with pre-Flake Nix tooling
including nix-env and nix-shell. Like 'nix.registry', this option is
useful for pinning Nix channels.

Channels defined in the new option can coexist with channels introduced
through the nix-channel command. If the same channel exists in both, the
one from Home Manager will be prioritized.

* nix: add news entry

* nix: make channels respect use-xdg-base-directories

* nix: remove 'with lib;'

---------

Co-authored-by: Michael Hoang <enzime@users.noreply.github.com>
---
 modules/misc/news.nix                         | 11 +++
 modules/misc/nix.nix                          | 83 ++++++++++++++++++-
 tests/modules/misc/nix/default.nix            |  3 +
 tests/modules/misc/nix/empty-settings.nix     |  1 +
 .../modules/misc/nix/example-channels-xdg.nix | 29 +++++++
 tests/modules/misc/nix/example-channels.nix   | 26 ++++++
 tests/modules/misc/nix/example-settings.nix   |  5 ++
 tests/modules/misc/nix/keep-old-nix-path.nix  | 16 ++++
 8 files changed, 172 insertions(+), 2 deletions(-)
 create mode 100644 tests/modules/misc/nix/example-channels-xdg.nix
 create mode 100644 tests/modules/misc/nix/example-channels.nix
 create mode 100644 tests/modules/misc/nix/keep-old-nix-path.nix

diff --git a/modules/misc/news.nix b/modules/misc/news.nix
index 2164d66a5..1242d640b 100644
--- a/modules/misc/news.nix
+++ b/modules/misc/news.nix
@@ -1656,6 +1656,17 @@ in {
           See https://codeberg.org/dnkl/yambar for more.
         '';
       }
+
+      {
+        time = "2024-05-25T14:36:03+00:00";
+        message = ''
+          Multiple new options are available:
+
+          - 'nix.nixPath'
+          - 'nix.keepOldNixPath'
+          - 'nix.channels'
+        '';
+      }
     ];
   };
 }
diff --git a/modules/misc/nix.nix b/modules/misc/nix.nix
index a4456946e..2daa56b23 100644
--- a/modules/misc/nix.nix
+++ b/modules/misc/nix.nix
@@ -1,15 +1,40 @@
 { config, lib, pkgs, ... }:
 
-with lib;
-
 let
 
+  inherit (lib)
+    boolToString concatStringsSep escape floatToString getVersion isBool
+    isConvertibleWithToString isDerivation isFloat isInt isList isString
+    literalExpression maintainers mapAttrsToList mkDefault mkEnableOption mkIf
+    mkMerge mkOption optionalString toPretty types versionAtLeast;
+
   cfg = config.nix;
 
   nixPackage = cfg.package;
 
   isNixAtLeast = versionAtLeast (getVersion nixPackage);
 
+  nixPath = concatStringsSep ":" cfg.nixPath;
+
+  useXdg = config.nix.enable
+    && (config.nix.settings.use-xdg-base-directories or false);
+  defexprDir = if useXdg then
+    "${config.xdg.stateHome}/nix/defexpr"
+  else
+    "${config.home.homeDirectory}/.nix-defexpr";
+
+  # The deploy path for declarative channels. The directory name is prefixed
+  # with a number to make it easier for files in defexprDir to control the order
+  # they'll be read relative to each other.
+  channelPath = "${defexprDir}/50-home-manager";
+
+  channelsDrv = let
+    mkEntry = name: drv: {
+      inherit name;
+      path = toString drv;
+    };
+  in pkgs.linkFarm "channels" (lib.mapAttrsToList mkEntry cfg.channels);
+
   nixConf = assert isNixAtLeast "2.2";
     let
 
@@ -102,6 +127,47 @@ in {
       '';
     };
 
+    nixPath = mkOption {
+      type = types.listOf types.str;
+      default = [ ];
+      example = [
+        "$HOME/.nix-defexpr/channels"
+        "darwin-config=$HOME/.config/nixpkgs/darwin-configuration.nix"
+      ];
+      description = ''
+        Adds new directories to the Nix expression search path.
+
+        Used by Nix when looking up paths in angular brackets
+        (e.g. `<nixpkgs>`).
+      '';
+    };
+
+    keepOldNixPath = mkOption {
+      type = types.bool;
+      default = true;
+      example = false;
+      description = ''
+        Whether {option}`nix.nixPath` should keep the previously set values in
+        {env}`NIX_PATH`.
+      '';
+    };
+
+    channels = lib.mkOption {
+      type = with lib.types; attrsOf package;
+      default = { };
+      example = lib.literalExpression "{ inherit nixpkgs; }";
+      description = ''
+        A declarative alternative to Nix channels. Whereas with stock channels,
+        you would register URLs and fetch them into the Nix store with
+        {manpage}`nix-channel(1)`, this option allows you to register the store
+        path directly. One particularly useful example is registering flake
+        inputs as channels.
+
+        This option can coexist with stock Nix channels. If the same channel is
+        defined in both, this option takes precedence.
+      '';
+    };
+
     registry = mkOption {
       type = types.attrsOf (types.submodule (let
         inputAttrs = types.attrsOf
@@ -210,6 +276,19 @@ in {
   };
 
   config = mkIf cfg.enable (mkMerge [
+    (mkIf (cfg.nixPath != [ ] && !cfg.keepOldNixPath) {
+      home.sessionVariables.NIX_PATH = "${nixPath}";
+    })
+
+    (mkIf (cfg.nixPath != [ ] && cfg.keepOldNixPath) {
+      home.sessionVariables.NIX_PATH = "${nixPath}\${NIX_PATH:+:$NIX_PATH}";
+    })
+
+    (lib.mkIf (cfg.channels != { }) {
+      nix.nixPath = [ channelPath ];
+      home.file."${channelPath}".source = channelsDrv;
+    })
+
     (mkIf (cfg.registry != { }) {
       xdg.configFile."nix/registry.json".source =
         jsonFormat.generate "registry.json" {
diff --git a/tests/modules/misc/nix/default.nix b/tests/modules/misc/nix/default.nix
index b0370c2e4..4c8822bf2 100644
--- a/tests/modules/misc/nix/default.nix
+++ b/tests/modules/misc/nix/default.nix
@@ -2,4 +2,7 @@
   nix-empty-settings = ./empty-settings.nix;
   nix-example-settings = ./example-settings.nix;
   nix-example-registry = ./example-registry.nix;
+  nix-keep-old-nix-path = ./keep-old-nix-path.nix;
+  nix-example-channels = ./example-channels.nix;
+  nix-example-channels-xdg = ./example-channels-xdg.nix;
 }
diff --git a/tests/modules/misc/nix/empty-settings.nix b/tests/modules/misc/nix/empty-settings.nix
index 18f1ab430..7e5844742 100644
--- a/tests/modules/misc/nix/empty-settings.nix
+++ b/tests/modules/misc/nix/empty-settings.nix
@@ -8,6 +8,7 @@ with lib;
 
     nmt.script = ''
       assertPathNotExists home-files/.config/nix
+      assertPathNotExists home-files/.nix-defexpr/50-home-manager
     '';
   };
 }
diff --git a/tests/modules/misc/nix/example-channels-xdg.nix b/tests/modules/misc/nix/example-channels-xdg.nix
new file mode 100644
index 000000000..8ca1569f3
--- /dev/null
+++ b/tests/modules/misc/nix/example-channels-xdg.nix
@@ -0,0 +1,29 @@
+{ lib, config, pkgs, ... }:
+
+let
+  exampleChannel = pkgs.writeTextDir "default.nix" ''
+    { pkgs ? import <nixpkgs> { } }:
+
+    {
+      example = pkgs.emptyDirectory;
+    }
+  '';
+in {
+  config = {
+    nix = {
+      package = config.lib.test.mkStubPackage {
+        version = lib.getVersion pkgs.nixVersions.stable;
+      };
+      channels.example = exampleChannel;
+      settings.use-xdg-base-directories = true;
+    };
+
+    nmt.script = ''
+      assertFileContains home-path/etc/profile.d/hm-session-vars.sh \
+        'export NIX_PATH="/home/hm-user/.local/state/nix/defexpr/50-home-manager''${NIX_PATH:+:$NIX_PATH}"'
+      assertFileContent \
+        home-files/.local/state/nix/defexpr/50-home-manager/example/default.nix \
+        ${exampleChannel}/default.nix
+    '';
+  };
+}
diff --git a/tests/modules/misc/nix/example-channels.nix b/tests/modules/misc/nix/example-channels.nix
new file mode 100644
index 000000000..4b98c55d4
--- /dev/null
+++ b/tests/modules/misc/nix/example-channels.nix
@@ -0,0 +1,26 @@
+{ config, pkgs, ... }:
+
+let
+  exampleChannel = pkgs.writeTextDir "default.nix" ''
+    { pkgs ? import <nixpkgs> { } }:
+
+    {
+      example = pkgs.emptyDirectory;
+    }
+  '';
+in {
+  config = {
+    nix = {
+      package = config.lib.test.mkStubPackage { };
+      channels.example = exampleChannel;
+    };
+
+    nmt.script = ''
+      assertFileContains home-path/etc/profile.d/hm-session-vars.sh \
+        'export NIX_PATH="/home/hm-user/.nix-defexpr/50-home-manager''${NIX_PATH:+:$NIX_PATH}"'
+      assertFileContent \
+        home-files/.nix-defexpr/50-home-manager/example/default.nix \
+        ${exampleChannel}/default.nix
+    '';
+  };
+}
diff --git a/tests/modules/misc/nix/example-settings.nix b/tests/modules/misc/nix/example-settings.nix
index 6cbcd9dfa..df62fe8d1 100644
--- a/tests/modules/misc/nix/example-settings.nix
+++ b/tests/modules/misc/nix/example-settings.nix
@@ -17,6 +17,8 @@ with lib;
         '';
       };
 
+      nixPath = [ "/a" "/b/c" ];
+
       settings = {
         use-sandbox = true;
         show-trace = true;
@@ -28,6 +30,9 @@ with lib;
       assertFileContent \
         home-files/.config/nix/nix.conf \
         ${./example-settings-expected.conf}
+
+      assertFileContains home-path/etc/profile.d/hm-session-vars.sh \
+        'export NIX_PATH="/a:/b/c''${NIX_PATH:+:$NIX_PATH}"'
     '';
   };
 }
diff --git a/tests/modules/misc/nix/keep-old-nix-path.nix b/tests/modules/misc/nix/keep-old-nix-path.nix
new file mode 100644
index 000000000..72bd9f164
--- /dev/null
+++ b/tests/modules/misc/nix/keep-old-nix-path.nix
@@ -0,0 +1,16 @@
+{ config, ... }:
+
+{
+  config = {
+    nix = {
+      package = config.lib.test.mkStubPackage { };
+      nixPath = [ "/a" "/b/c" ];
+      keepOldNixPath = false;
+    };
+
+    nmt.script = ''
+      assertFileContains home-path/etc/profile.d/hm-session-vars.sh \
+        'export NIX_PATH="/a:/b/c"'
+    '';
+  };
+}