Published on

Rust Development on NixOS, Part 1: Bootstrapping Rust Nightly via Flake

This post is part of a series about NixOS. The series is done in support of my upcoming Opus Magnum, "Practical NixOS: the Book", and you can read the detailed post here.

Should you find this article lacking some information, be sure to tell me in the comments or elsewhere, since it is a living document and I am very keen on expanding it.

This article's full code is available on GitHub.

Table of Contents (Click to Hide/Show)

Introduction

I had a need for development Rust development environment to develop my hackernews userscript. And as it happens, on NixOS, it is rather easy to bootstrap such an environment, moreso than on many other Linux systems, and this article will teach you how to do exactly that.

The prerequisite for Rust development is a flakes-enabled system, be it either NixOS, or any other system with nix package manager installed.

To learn, how to enable flakes on your NixOS, please refer to my previous article How to Convert Default NixOS to NixOS with Flakes.

If you're not using NixOS, or don't want to apply flakes to the whole NixOS configuration, you may also simply pass the --experimental-features 'nix-command flakes' flag to nix shell commands, e.g. nix develop --experimental-features 'nix-command flakes'

By the end of this article, you should have a Rust development shell defined within a development flake, and you'll be able to compile a Rust application and run it.

Custom Rust - Oxalica's Overlay

Since Rust is a very actively developed language, we would not want to miss on important security patches, as well as new features, so instead of using the potentially outdated Rust packages provided by nixpkgs, we would be aiming for a more recent build of Rust and its tools.

The packaging for Rust is provided by the nix community in a repository owned by Oxalica. This is done via an overlay.

Let's start with a simple development flake. A development flake is an independent flake which, unlike for example the /etc/nixos/flake.nix, is not part of your system's configuration. Such a flake would be most likely found in the root of the directory for your development project.

First, we create an empty flake.nix in any directory of your choice where you would want to start a new Rust project.

Then we copy the provided flake snippet from the Rust Overlay Readme:

{
  description = "A devShell example";

  inputs = {
    nixpkgs.url      = "github:NixOS/nixpkgs/nixos-unstable";
    rust-overlay.url = "github:oxalica/rust-overlay";
    flake-utils.url  = "github:numtide/flake-utils";
  };

  outputs = { self, nixpkgs, rust-overlay, flake-utils, ... }:
    flake-utils.lib.eachDefaultSystem (system:
      let
        overlays = [ (import rust-overlay) ];
        pkgs = import nixpkgs {
          inherit system overlays;
        };
      in
      with pkgs;
      {
        devShells.default = mkShell {
          buildInputs = [
            openssl
            pkg-config
            eza
            fd
            rust-bin.beta.latest.default
          ];

          shellHook = ''
            alias ls=eza
            alias find=fd
          '';
        };
      }
    );
}

That's a dev flake! What is happening here? Let's inspect it.

flake-utils.lib.eachDefaultSystem is a convenience function from the flake-utils library, and you can see the library being provided to the flake via inputs.flake-utils. Roughly speaking, this function makes it easy to expose the flake's outputs to multiple different systems, where a system is a combination of CPU architecture and OS, and with flake-utils, there is no need to repeat a specific output multiple times with minor variations in the system definition.

Next, let's take a look at this snippet:

    overlays = [ (import rust-overlay) ];
    pkgs = import nixpkgs {
        inherit system overlays;
    };

This code enables an overlay, which is made available via inputs.rust-overlay. The overlay contains community-packaged Rust binaries provided by Mozilla.

The devShells.default = mkShell line is the part that makes this flake a "development" flake (even though a flake is never limited to just one specific purpose, and we still can add more outputs to it, to expand on a flake's provided utility). The mkShell is a function, that accepts an attribute set as an argument, and inside this attribute set there are 2 attributes: buildInputs and shellHook.

buildInputs makes some applications (packages) available for the use within our development shell. These packages are coming from nixpkgs, as well as from the rust-overlay. You can roughly equate buildInputs with environment.systemPackages.

shellHook is a just some shell (bash) code, that is going to be run once we enter the development shell. As you may see, it provides 2 aliases to substitute the "old" unix cli-tools with the new shiny ones that were made available inside the shell via buildInputs.

Switching to Rust Nightly

As evident from the article's name, we actually aim for the Rust Nightly for our development. There is no copy-pasteable snippet in the Readme that tells exactly how to enable Rust Nightly unfortunately, but this also provides a great opportunity to learn how to manipulate a flake, as well as to learn some nix language quirks.

Consider the following partial snippet from the Readme:

rust-bin.selectLatestNightlyWith (toolchain: toolchain.default.override {
  extensions = [ "rust-src" ];
  targets = [ "arm-unknown-linux-gnueabihf" ];
})

The resulting flake should actually look like this:

{
  description = "A devShell example";

  inputs = {
    nixpkgs.url      = "github:NixOS/nixpkgs/nixos-unstable";
    rust-overlay.url = "github:oxalica/rust-overlay";
    flake-utils.url  = "github:numtide/flake-utils";
  };

  outputs = { self, nixpkgs, rust-overlay, flake-utils, ... }:
    flake-utils.lib.eachDefaultSystem (system:
      let
        overlays = [ (import rust-overlay) ];
        pkgs = import nixpkgs {
          inherit system overlays;
        };
      in
      with pkgs;
      {
        devShells.default = mkShell {
          buildInputs = [
            openssl
            pkg-config
            eza
            fd
            (
              rust-bin.selectLatestNightlyWith (toolchain: toolchain.default.override {
                extensions = [ "rust-src" ];
                targets = [ "arm-unknown-linux-gnueabihf" ];
              })
            )
          ];

          shellHook = ''
            alias ls=eza
            alias find=fd
          '';
        };
      }
    );
}

What we have done is we have replaced the rust-bin.beta.latest.default item with the rust-bin.selectLatestNightlyWith ( ... ) in the buildInputs = [ ] list.

It's important to note, that we have also enclosed the rust-bin.selectLatestNightlyWith ( ... ) with the round brackets ( ). Why? That's because of a feature of Nix Expression Language: the items inside the lists are separated by whitespace - a literal space, or a new line, - and not by commas, unlike in many other languages, and this is an unfortunate quirk of nix. This is not optimal, because it may prompt for an incidental introduction of subtle and hard-to-debug mistakes. Function calls in nix do not require parentheses for provided arguments, and arguments are also separated by whitespace. Without the said guidance, you may have tried to do something like this:

  buildInputs = [
    openssl

    rust-bin.selectLatestNightlyWith (toolchain: toolchain.default.override {
      extensions = [ "rust-src" ];
      targets = [ "arm-unknown-linux-gnueabihf" ];
    })

  ];

But that is actually equal to the following:

  buildInputs = [
    # 1st list item - openssl package
    openssl

    # 2nd list item - function provided by the overlay
    rust-bin.selectLatestNightlyWith

    # 3rd list item - anonymous function
    (toolchain: toolchain.default.override {
        extensions = [ "rust-src" ];
        targets = [ "arm-unknown-linux-gnueabihf" ];
    })

  ];

So, we have to use ( ) here to make nix language treat the whole expression as a function call, and not as 2 distinct items within a list.

Now, let's adapt this flake for our development.

{
  description = "Rust Development Shell";

  inputs = {
    nixpkgs.url      = "github:NixOS/nixpkgs/nixos-unstable";
    rust-overlay.url = "github:oxalica/rust-overlay";
    flake-utils.url  = "github:numtide/flake-utils";
  };

  outputs = { self, nixpkgs, rust-overlay, flake-utils, ... }:
    flake-utils.lib.eachDefaultSystem (system:
      let
        overlays = [ (import rust-overlay) ];
        pkgs = import nixpkgs {
          inherit system overlays;
        };
      in
      with pkgs;
      {
        devShells.default = mkShell {
          buildInputs = [
            openssl
            pkg-config
            (
              rust-bin.selectLatestNightlyWith (toolchain: toolchain.default.override {
                extensions = [
                  "rust-src"
                  "rust-analyzer"
                ];
              })
            )
          ];
        };
      }
    );
}

openssl and pkg-config should stay, otherwise our Rust app may not compile. We add rust-analyzer, because we want to have linting for our project, and we keep rust-src, because rust-analyzer depends on it. We get rid of shellHook, because vanilla bash (or any shell of your choice) experience is just enough here.

Invoking The Development Flake's Shell

Now let's see, what we have built so far.

To invoke the development shell, navigate to the directory where flake is located and type nix develop (or nix develop --experimental-features 'nix-command flakes').

This will create a flake.lock file, which "pins" the repositories mentioned in inputs to their most recent git commits. You should be tracking both flake.nix and flake.lock in git for guaranteed reproducibility, and for the possibility to roll-back in case something does not work once you update the inputs. Should there be new commits in repositories, you would be able to update the inputs, for that simply run nix flake update. It will re-pin repositories to the latest commit revisions.

Now, let's run cargo init. This will initialize git in our directory, create the Cargo.toml file, and the src/main.rs, that contains "Hello, world!" written in Rust. We can now compile our application and run via cargo run. It will print the compilation information on the first run, followed by Hello, world! in the shell.

We now have fully functional Rust Nightly development flake!

One More Thing - Tracking Flake in Git

If you exit the shell via Ctrl+D, by typing exit, or by simply closing the window, and then would want to invoke the shell once again via nix develop, you will get something like this as an error:

[user@host:~/rust-flake-project]$ nix develop
warning: Git tree '/home/user/rust-flake-project' is dirty
error: getting status of '/nix/store/0ccnxa25whszw7mgbgyzdm4nqc0zwnm8-source/flake.nix': No such file or directory

That's because we have initialized git with cargo init in our directory, but we did not add the flake.nix to the tracked files. If there's a git, nix wants flake to be tracked by it for the sake of reproducibility.

Type in git add flake.nix, next nix develop, and everything should work fine now.

Conclusion

We have explored how to bootstrap Rust for development on a nix machine with the help of a neat development flake, and do that with ease.

We have also learned, how to use a NixOS overlay, as well as some Nix Expression Language quirks!

In my next article I will tell the journey of actually implementing the browser extension in Rust Wasm, how I made it work on NixOS, what challenges I have encountered, and how it was possible to overcome these challenges with Rust (and some JavaScript). Be sure to check the article out!

I will really appreciate, if you subscribe to my newsletter.
You inspire me to keep writing. Every reader counts.
You will receive email with link to confirm the subscription.