advent2024

Understanding flake.nix

In the Nix ecosystem, a flake.nix file is the entry point for defining a project’s inputs (dependencies) and outputs (build results, development environments, etc.). Flakes aim to make Nix more reproducible and easier to use by providing a standardized way to manage dependencies and define project-specific configurations.

This specific flake.nix file is designed to create a self-contained development environment for a Rust project. It leverages rustup via Nix to manage the Rust toolchain versions defined in a rust-toolchain.toml file. This ensures that anyone using this flake will have the same specified versions of tools like the Rust compiler, Cargo, and useful development utilities, regardless of their operating system (as long as it’s one of the supported macOS architectures).

Deconstructing advent2024/flake.nix

Let’s go through the file section by section.

The Root Structure

The entire flake.nix file is a Nix expression that evaluates to an attribute set (similar to a dictionary or map in other languages).

{
    description = "My very first rust environment flake";

    inputs = {
        nixpkgs.url = "github:NixOS/nixpkgs/nixpkgs-unstable";
    };
    # ...
}

The outer curly braces {} define this attribute set.

The “description” Attribute

description = "My very first rust environment flake";

This is a human-readable description of the flake. It helps identify the purpose of this flake file.

The “inputs” Attribute

inputs = {
    nixpkgs.url = "github:NixOS/nixpkgs/nixpkgs-unstable";
};

The inputs section defines the dependencies that this flake needs. In this case, it depends on nixpkgs.

The “outputs” Attribute

outputs = {self, nixpkgs} :
let
    overrides = builtins.fromTOML (builtins.readFile (self + "/rust-toolchain.toml"));
# ... rest of outputs ...
in
{
    # ... devShells ...
    # ... packages ...
    # etc
};

The outputs section defines what the flake provides. It’s a function that takes an attribute set of the resolved inputs as arguments.

Inside the outputs function, there’s a let ... in ... block. The let block defines local variables or functions that can be used within the in block. This is a common pattern in Nix for organizing code and avoiding repetition.

Reading “rust-toolchain.toml”

overrides = builtins.fromTOML (builtins.readFile (self + "/rust-toolchain.toml"));

This line demonstrates reading a configuration file from the project itself.

“mkShell_attrSet” Function

mkShell_attrSet = {pkgs, target}: rec {
    packages = with pkgs; [
        rustup
        nil
        nixd
        git
    ];
    RUSTC_VERSION = overrides.toolchain.channel;
    shellHook = ''
        export PATH=$PATH:''${CARGO_HOME:-~/.cargo}/bin
        # Dynamically determine the Rust system string (architecture-os) for the current system
        export PATH=$PATH:''${RUSTUP_HOME:-~/.rustup}/toolchains/${RUSTC_VERSION}-${target}/bin/
        echo "Welcome to the Advent2024 development environment!"
        # /Applications/Zed.app/Contents/MacOS/zed . &
    '';

};

This function, mkShell_attrSet, is defined within the let block. Its purpose is to create a standard configuration (an attribute set) that will be used to build a development shell.

“build_DevShell” Function

build_DevShell = platform :
    let
        pkgs = nixpkgs.legacyPackages.${platform};
    in
    {
        default = pkgs.mkShell (
            mkShell_attrSet {
                pkgs = pkgs;
                target = pkgs.stdenv.hostPlatform.config;
            }
        );
    };

This function, build_DevShell, also in the let block, is designed to create a complete development shell output for a specific platform.

The “devShells” Attribute

devShells = nixpkgs.lib.genAttrs platforms  build_DevShell;

This is a key output attribute of the flake, named devShells. This attribute is expected to contain development environments.

The result of this genAttrs call will be an attribute set structure like this:

{
  aarch64-darwin = { default = <the dev shell for aarch64-darwin>; };
  x86_64-darwin = { default = <the dev shell for x86_64-darwin>; };
  x86_64-linux = { default = <the dev shell for x86_64-linux>; };
}

This structure tells Nix that this flake provides a default development shell for the specified systems.

Reproduceable Builds: “packages” Attribute

packages = nixpkgs.lib.genAttrs platforms (platform :
    let
        pkgs = nixpkgs.legacyPackages.${platform};
    in
    {
        # Define your Rust application package here
        # We'll call the Nix package 'advent2024-solutions' as it contains multiple solutions
        advent2024-solutions = pkgs.rustPlatform.buildRustPackage {
            pname = "advent2024-solutions";
            version = "0.1";
            # The source code for your Rust project.
            # 'self' refers to the root of your flake.
            # This assumes your Cargo.toml is directly in the flake root.
            src = self;

            # This is CRUCIAL for reproducible Rust builds.
            # It tells Nix to use your project's Cargo.lock file.
            cargoLock = { lockFile = self + "/Cargo.lock"; };

            # You can add build flags here, e.g., for release builds
            # cargoBuildFlags = "--release";

            # This tells cargo install to install ALL binaries defined in src/bin/*
            # by building the project from the current source path (.).
            cargoInstallFlags = "--path .";
        };
    }
);

This is another key output attribute, named packages. This attribute is expected to contain packages that can be built and installed from your project.

The result of this genAttrs call will be an attribute set structure like this:

{
  aarch64-darwin = { advent2024-solutions = <aarch64-darwin package>; };
  x86_64-darwin = { advent2024-solutions = <x86_64-darwin package>; };
  x86_64-linux = { advent2024-solutions = <x86_64-linux package>; };
}

You can build this package for your system by running nix build .#advent2024-solutions. The resulting executable(s) will be symlinked into ./result/bin/.

Now that you understand how reproducible packages are defined and built, let’s look at how to interact with this flake, including using the development environment.

How to Use This Flake

With this flake.nix file and a rust-toolchain.toml file specifying your desired Rust version in the root of your advent2024 project directory, you can enter the defined development environment by simply running:

nix develop

Nix will automatically detect the flake.nix file, resolve the nixpkgs input, build the development shell for your current system’s architecture (if it’s one of the supported ones), install rustup via Nix, and drop you into a shell. The shellHook will execute, installing the Rust toolchain specified in rust-toolchain.toml using rustup (if not already cached), setting up your PATH to use that toolchain, printing the welcome message, and attempting to open Zed.

When you exit the shell, your environment returns to its normal state, demonstrating the isolated nature of the Nix development environment.

This flake not only provides a development environment but also defines reproducible builds for the project’s packages. As explained in the “Reproducible Builds” section, you can build the package for your system using commands such as:

nix build .#advent2024-solutions

Benefits of This Setup

  1. Reproducibility: By using rustup via Nix and reading the toolchain version from rust-toolchain.toml, you ensure that everyone using this flake and the same rust-toolchain.toml gets the exact same versions of rustc, cargo, and other tools. This avoids “works on my machine” issues related to toolchain variations and aligns with standard Rust project practices for specifying toolchains.
  2. Isolation: The development environment is self-contained. The tools defined in the flake (including the rustup-managed toolchain) are only available when you are inside the nix develop shell. They don’t interfere with your system’s globally installed packages.
  3. Ease of Onboarding: New developers joining the project don’t need to manually install Rust, Cargo, or other tools via external means. They just need Nix installed and can run nix develop. The correct Rust toolchain will be set up automatically.
  4. Declarative: The required tools and environment setup are declared in the flake.nix file and the Rust version in rust-toolchain.toml, rather than relying on imperative installation scripts or manual steps.

This updated flake.nix file provides a robust foundation for a reproducible Rust development workflow using Nix flakes, leveraging rustup for flexible toolchain management within the isolated Nix environment. I hope this detailed explanation helps you understand the why and how behind its structure!