kache 0.6.0

Zero-copy, content-addressed Rust build cache. No copies, no wasted disk — just hardlinks locally and S3 for sharing.
# NixOS and nix-darwin module for kache.
# Works on both platforms - uses launchd on macOS, systemd on Linux.
{
  config,
  lib,
  options,
  pkgs,
  ...
}: let
  cfg = config.services.kache;
  tomlFormat = pkgs.formats.toml {};

  # Settings maps directly to config.toml. Freeform type allows arbitrary
  # keys so the module doesn't break when kache adds new config options.
  # Recursively strip null values - TOML has no null representation,
  # and the typed options use null as "use kache's built-in default".
  stripNulls = lib.filterAttrsRecursive (_: v: v != null);

  configFile = tomlFormat.generate "kache-config" (stripNulls cfg.settings);
  kacheExe = lib.getExe cfg.package;

  # Check which platform options exist rather than querying pkgs,
  # which would create a circular dependency with lib.optionals.
  hasLaunchd = options ? launchd;
  hasSystemd = options ? systemd;
in {
  options.services.kache = {
    enable = lib.mkEnableOption "kache, a Rust build cache";

    package = lib.mkOption {
      type = lib.types.package;
      default = pkgs.callPackage ./package.nix {};
      defaultText = lib.literalExpression "pkgs.callPackage ./package.nix {}";
      description = "kache package to install and run.";
    };

    rustcWrapper = lib.mkOption {
      type = lib.types.bool;
      default = true;
      description = ''
        Set `RUSTC_WRAPPER` system-wide so all Rust builds use the cache.
      '';
    };

    daemon = {
      enable = lib.mkEnableOption "the kache background daemon";

      logLevel = lib.mkOption {
        type = lib.types.str;
        default = "kache=info";
        description = "Log level for the daemon (`KACHE_LOG` value).";
      };
    };

    settings = lib.mkOption {
      description = ''
        Configuration written to `config.toml`.

        The typed options below cover known fields for documentation and
        validation. Any additional keys are passed through as-is, so the
        module stays usable when kache adds new config options.

        See <https://github.com/kunobi-ninja/kache#configuration> for
        the full reference.
      '';
      default = {};
      type = lib.types.submodule {
        freeformType = tomlFormat.type;

        options.cache = lib.mkOption {
          description = "Cache settings.";
          default = {};
          type = lib.types.submodule {
            freeformType = tomlFormat.type;

            options = {
              local_store = lib.mkOption {
                type = lib.types.nullOr lib.types.str;
                default = null;
                description = "Local cache store directory. Default: `~/.cache/kache`.";
                example = "~/.cache/kache";
              };

              local_max_size = lib.mkOption {
                type = lib.types.nullOr lib.types.str;
                default = null;
                description = "Maximum local store size. Default: `50GB`.";
                example = "50GB";
              };

              cache_executables = lib.mkOption {
                type = lib.types.nullOr lib.types.bool;
                default = null;
                description = "Whether to cache bin/dylib/cdylib outputs.";
              };

              clean_incremental = lib.mkOption {
                type = lib.types.nullOr lib.types.bool;
                default = null;
                description = "Auto-clean tracked incremental dirs during GC; active builds also remove the current crate's incremental dir eagerly.";
              };

              compression_level = lib.mkOption {
                type = lib.types.nullOr (lib.types.ints.between 1 22);
                default = null;
                description = "Zstd compression level (1-22). Default: 3.";
              };

              s3_concurrency = lib.mkOption {
                type = lib.types.nullOr lib.types.ints.positive;
                default = null;
                description = "Max concurrent S3 operations. Default: 16.";
              };

              daemon_idle_timeout_secs = lib.mkOption {
                type = lib.types.nullOr lib.types.ints.unsigned;
                default = null;
                description = "Daemon idle timeout in seconds. 0 = no timeout. Default: 600.";
              };

              remote = lib.mkOption {
                description = "S3 remote cache settings.";
                default = {};
                type = lib.types.submodule {
                  freeformType = tomlFormat.type;

                  options = {
                    type = lib.mkOption {
                      type = lib.types.nullOr lib.types.str;
                      default = null;
                      description = "Remote type. Currently only `s3`.";
                    };

                    bucket = lib.mkOption {
                      type = lib.types.nullOr lib.types.str;
                      default = null;
                      description = "S3 bucket for remote cache.";
                    };

                    endpoint = lib.mkOption {
                      type = lib.types.nullOr lib.types.str;
                      default = null;
                      description = "Custom S3 endpoint (for Ceph/MinIO/R2).";
                    };

                    region = lib.mkOption {
                      type = lib.types.nullOr lib.types.str;
                      default = null;
                      description = "AWS region. Default: `us-east-1`.";
                    };

                    prefix = lib.mkOption {
                      type = lib.types.nullOr lib.types.str;
                      default = null;
                      description = "S3 key prefix for artifacts. Default: `artifacts`.";
                    };

                    profile = lib.mkOption {
                      type = lib.types.nullOr lib.types.str;
                      default = null;
                      description = "AWS profile name for credential lookup.";
                    };
                  };
                };
              };
            };
          };
        };
      };
    };
  };

  config = lib.mkIf cfg.enable (lib.mkMerge ([
    # Install the package and config
    {
      environment.systemPackages = [cfg.package];
      environment.etc."kache/config.toml".source = configFile;
      environment.variables.KACHE_CONFIG = "${configFile}";
    }

    # Set RUSTC_WRAPPER system-wide
    (lib.mkIf cfg.rustcWrapper {
      environment.variables.RUSTC_WRAPPER = kacheExe;
    })
  ]
  # macOS: launchd user agent
  ++ lib.optionals hasLaunchd [
    (lib.mkIf cfg.daemon.enable {
      launchd.user.agents.kache = {
        serviceConfig = {
          Label = "ninja.kunobi.kache";
          ProgramArguments = [kacheExe "daemon" "run"];
          RunAtLoad = true;
          KeepAlive = {
            SuccessfulExit = false;
          };
          EnvironmentVariables = {
            KACHE_LOG = cfg.daemon.logLevel;
            KACHE_CONFIG = "${configFile}";
          };
          ThrottleInterval = 5;
        };
      };
    })
  ]
  # Linux: systemd user service
  ++ lib.optionals hasSystemd [
    (lib.mkIf cfg.daemon.enable {
      systemd.user.services.kache = {
        description = "kache build cache daemon";
        after = ["default.target"];
        wantedBy = ["default.target"];
        serviceConfig = {
          Type = "simple";
          ExecStart = "${kacheExe} daemon run";
          Restart = "on-failure";
          RestartSec = "5s";
        };
        environment = {
          KACHE_LOG = cfg.daemon.logLevel;
          KACHE_CONFIG = "${configFile}";
        };
      };
    })
  ]));
}