Two years ago, I did part of Advent of Code 2020 using the Rust language. It was a lot of fun, so let's try it again!

The problem statement

Our input looks something like this:

1000
2000
3000

4000

5000
6000

7000
8000
9000

10000

Each group of lines separated by an empty line is a list of food items an elf is carrying: each line corresponds to the number of calories in that food.

So, the first elf is carrying three food items: one is 1000 calories, the second is 2000 calories, the third is 3000 calories. The second elf is carrying only one food item of 4000 calories, etc.

We must answer "how many calories is the elf who carries the most calories carrying?"

This'll be a short one!

Getting started

If you're planning on following along, you'll need to install a Rust toolchain. The simplest way is through rustup.

If everything is set up correctly, you should be able to run these commands in a terminal:

Shell session
$ rustc -V
rustc 1.65.0 (897e37553 2022-11-02)

$ cargo -V 
cargo 1.65.0 (4bc8f24d3 2022-10-20)
Cool bear's hot tip

If this is showing something older than 1.65, it might be a while since you last did some Rust. Make sure to run rustup update first!

Me, I've made an aoc2022 folder, initialized a Git repository in there and pushed it to a private GitHub repo (no spoilers!). In there, I've made a new day1 Rust crate:

Shell session
$ cd aoc2022/

$ cargo new day1
     Created binary (application) `day1` package

I've also created a file in aoc2022/.gitignore with this line:

target

So that any folders or files named target won't be pushed to GitHub.

Cool bear's hot tip

cargo new would normally generate a .gitignore itself (and an empty Git repository) but it doesn't here because the outer directory is already a repository, so it assumes you're just creating a sub-crate or something.

If you don't use Git, you can configure cargo to use another VCS, see the cargo-new.vcs configuration option.

Our final file structure looks like this:

Shell session
$ tree $PWD
/home/amos/bearcove/aoc2022
├── day1
│   ├── Cargo.toml
│   └── src
│       └── main.rs
└── README.md

2 directories, 3 files

The day1 project generated is just a hello world, let's run it:

Shell session
$ cd day1/
$ cargo run
   Compiling day1 v0.1.0 (/home/amos/bearcove/aoc2022/day1)
    Finished dev [unoptimized + debuginfo] target(s) in 0.31s
     Running `target/debug/day1`
Hello, world!

We'll put the problem statement's sample input in there, and later on I'll replace it with my personal input file (that's how AOC works: we all get our own inputs, so to cheat you need at the very least to use someone else's code, you can't just paste their answer).

So, in day1/src/input.txt, let's paste this:

1000
2000
3000

4000

5000
6000

7000
8000
9000

10000
Cool bear's hot tip

As far as code editors go, if you already have a strong preference, so be it. If you don't, Visual Studio Code is the low-friction option, and it runs great on Windows, macOS and Linux.

Whatever you use, make sure you have rust-analyzer installed and configured correctly.

In particular, you may want to enable as many inlay hints as you're comfortable with: they let you see types, lifetimes, argument names at callsites, etc. They make your code view a little busier, but they're also a great help.

There's a couple ways we can read that file: we can read it at runtime.

Rust code
fn main() {
    let input = std::fs::read_to_string("src/input.txt").unwrap();
    println!("{input}");
}

Types and error handling

Why .unwrap()?

std::fs::read_to_string returns an std::io::Result<String>, which is an alias for std::result::Result<String, std::io::Error>.

Result<T, E> is an enum, also called "sum type": it can be either the Ok(T) variant (where T is the type of the value we get if everything went well), or the Err(E) variant (where E is the type of the value we get if something went wrong).

Here, if std::fs::read_to_string goes well, we get a String. In fact, if you have inlay hints enabled, where you have this code:

Rust code
    let input = std::fs::read_to_string("src/input.txt").unwrap();

You actually see this:

Rust code
    let input: String = std::fs::read_to_string("src/input.txt").unwrap();
    //       ~~~~~~~~

You can play around with that line of code: if you remove the .unwrap(), your file contains this:

Rust code
    let input = std::fs::read_to_string("src/input.txt");

But your code editor shows this:

Rust code
    let input: Result<String, Error> = std::fs::read_to_string("src/input.txt");
    //       ~~~~~~~~~~~~~~~~~~~~~~~

And that's one of the big things about Rust: our love language is types, and I think that's beautiful. read_to_string doesn't "throw an exception", it returns a value that lets us know if things went fine or not.

And most of the time, in Rust, we're trying to figure out how to go from one type to another type — it can get frustrating, but it's also a wonderful set of guardrails.

We can't accidentally get a String and "forget" to check the error (a problem you could have in a language where errors are passed in another channel, cf. Go's multi-valued returns, or C's "return a non-zero int to signal failure" pattern).

If we want to ignore the error, we have to be really explicit about it:

Rust code
    let input = match std::fs::read_to_string("src/input.txt") {
        Ok(s) => s,
        Err(e) => {
            // let's ignore the error and return the empty string
            "".into()
        }
    };

Again here, inlay type hints give us more information about what's really happening here:

Rust code
    let input: String = match std::fs::read_to_string("src/input.txt") {
//           ~~~~~~~~
        Ok(s: String) => s,
//          ~~~~~~~~
        Err(e: Error) => {
//           ~~~~~~~
            // let's ignore the error and return the empty string
            "".into()
        }
    };

And in fact, the compiler specifically warns us that we're not doing anything with the e: Error we've "destructured" in an "arm" of the "match" expression:

Shell session
$ cargo check
    Checking day1 v0.1.0 (/home/amos/bearcove/aoc2022/day1)
warning: unused variable: `e`
 --> src/main.rs:4:13
  |
4 |         Err(e) => {
  |             ^ help: if this is intentional, prefix it with an underscore: `_e`
  |
  = note: `#[warn(unused_variables)]` on by default

warning: `day1` (bin "day1") generated 1 warning
    Finished dev [unoptimized + debuginfo] target(s) in 0.19s

...and lets us know 1) which warning it is (unused_variables, which we can turn off altogether — probably a bad idea — with the #[allow(unused_variables)] attribute) and 2) how to get rid of it, by renaming e to _e for example, or even just _ to throw it away completely.

But if you look at that code, we're already forced to handle both codepaths anyway:

Rust code
    let input = match std::fs::read_to_string("src/input.txt") {
        Ok(s) => s,
        Err(e) => {
            // let's ignore the error and return the empty string
            "".into()
        }
    };

So we might as well handle the error properly anyway. For example, we could print a message (and maybe a backtrace), and safely stop the program's execution:

Rust code
    let input = match std::fs::read_to_string("src/input.txt") {
        Ok(s) => s,
        Err(e) => panic!("Couldn't read src/input.txt: {e}"),
    };

Which would look like this:

Shell session
$ cargo run
   Compiling day1 v0.1.0 (/home/amos/bearcove/aoc2022/day1)
    Finished dev [unoptimized + debuginfo] target(s) in 0.31s
     Running `target/debug/day1`
thread 'main' panicked at 'Couldn't read src/input.txt: No such file or directory (os error 2)', src/main.rs:4:19
note: run with `RUST_BACKTRACE=1` environment variable to display a backtrace

Or like this, if we set the RUST_BACKTRACE environment variable to 1. (Shown here for zsh/bash, might look different on, say, PowerShell):

Shell session
$ RUST_BACKTRACE=1 cargo run
    Finished dev [unoptimized + debuginfo] target(s) in 0.00s
     Running `target/debug/day1`
thread 'main' panicked at 'Couldn't read src/input.txt: No such file or directory (os error 2)', src/main.rs:4:19
stack backtrace:
   0: rust_begin_unwind
             at /rustc/897e37553bba8b42751c67658967889d11ecd120/library/std/src/panicking.rs:584:5
   1: core::panicking::panic_fmt
             at /rustc/897e37553bba8b42751c67658967889d11ecd120/library/core/src/panicking.rs:142:14
   2: day1::main
             at ./src/main.rs:4:19
   3: core::ops::function::FnOnce::call_once
             at /rustc/897e37553bba8b42751c67658967889d11ecd120/library/core/src/ops/function.rs:248:5
note: Some details are omitted, run with `RUST_BACKTRACE=full` for a verbose backtrace.

And that's what Result::unwrap does for us.

Instead of writing this:

Rust code
    let input = match std::fs::read_to_string("src/input.txt") {
        Ok(s) => s,
        Err(e) => panic!("{e}"),
    };

We can write this:

Rust code
    let input = std::fs::read_to_string("src/input.txt").unwrap();

And the result is about the same:

Rust code
$ RUST_BACKTRACE=1 cargo run
   Compiling day1 v0.1.0 (/home/amos/bearcove/aoc2022/day1)
    Finished dev [unoptimized + debuginfo] target(s) in 0.33s
     Running `target/debug/day1`
thread 'main' panicked at 'called `Result::unwrap()` on an `Err` value: Os { code: 2, kind: NotFound, message: "No such file or directory" }', src/main.rs:2:58
stack backtrace:
   0: rust_begin_unwind
             at /rustc/897e37553bba8b42751c67658967889d11ecd120/library/std/src/panicking.rs:584:5
   1: core::panicking::panic_fmt
             at /rustc/897e37553bba8b42751c67658967889d11ecd120/library/core/src/panicking.rs:142:14
   2: core::result::unwrap_failed
             at /rustc/897e37553bba8b42751c67658967889d11ecd120/library/core/src/result.rs:1785:5
   3: core::result::Result<T,E>::unwrap
             at /rustc/897e37553bba8b42751c67658967889d11ecd120/library/core/src/result.rs:1107:23
   4: day1::main
             at ./src/main.rs:2:17
   5: core::ops::function::FnOnce::call_once
             at /rustc/897e37553bba8b42751c67658967889d11ecd120/library/core/src/ops/function.rs:248:5
note: Some details are omitted, run with `RUST_BACKTRACE=full` for a verbose backtrace.

Note that our error is lacking context here. We could use Result::expect instead of Result::unwrap:

Rust code
fn main() {
    let input = std::fs::read_to_string("src/input.txt").expect("while reading src/input.txt");
    println!("{input}");
}
Shell session
$ cargo run
   Compiling day1 v0.1.0 (/home/amos/bearcove/aoc2022/day1)
    Finished dev [unoptimized + debuginfo] target(s) in 0.32s
     Running `target/debug/day1`
thread 'main' panicked at 'while reading src/input.txt: Os { code: 2, kind: NotFound, message: "No such file or directory" }', src/main.rs:2:58
note: run with `RUST_BACKTRACE=1` environment variable to display a backtrace

...but that's not great either.

Because maybe we've put all the file reading in its own function:

Rust code
fn main() {
    let input = read_input();
    println!("{input}");
}

fn read_input() -> String {
    std::fs::read_to_string("src/input.txt").expect("while reading src/input.txt")
}

...and we don't want our read_input function to panic (via Result::expect) if read_to_string returns a Result::Err(E).

Instead, we'd like the read_input to propagate the error, like so:

Rust code
fn read_input() -> Result<String, std::io::Error> {
    std::fs::read_to_string("src/input.txt")
}

But then we have to handle the error at the callsite, in main, like so:

Rust code
fn main() {
    let input = read_input().unwrap();
    println!("{input}");
}

And we once again lose context:

Shell session
$ cargo run
   Compiling day1 v0.1.0 (/home/amos/bearcove/aoc2022/day1)
    Finished dev [unoptimized + debuginfo] target(s) in 0.33s
     Running `target/debug/day1`
thread 'main' panicked at 'called `Result::unwrap()` on an `Err` value: Os { code: 2, kind: NotFound, message: "No such file or directory" }', src/main.rs:2:30
note: run with `RUST_BACKTRACE=1` environment variable to display a backtrace

It doesn't say which file or directory wasn't found. And doing .expect("while reading src/input.txt") in the main function feels wrong: it's an implementation detail of the read_input function, main shouldn't have to know about it.

So, what can we do?

We can add some context to our error! We can do that by making our own error type:

Rust code
/// An [std::io::Error] associated with a path
struct PathedIoError {
    path: String,
    inner: std::io::Error,
}

fn read_input() -> Result<String, PathedIoError> {
    let path = "src/input.txt";
    match std::fs::read_to_string(path) {
        Ok(s) => Ok(s),
        Err(e) => Err(PathedIoError {
            path: path.into(),
            inner: e,
        }),
    }
}

If we do this, we run into further compiler errors:

Shell session
$ cargo check
    Checking day1 v0.1.0 (/home/amos/bearcove/aoc2022/day1)
error[E0277]: `PathedIoError` doesn't implement `Debug`
    --> src/main.rs:2:17
     |
2    |     let input = read_input().unwrap();
     |                 ^^^^^^^^^^^^ ------ required by a bound introduced by this call
     |                 |
     |                 `PathedIoError` cannot be formatted using `{:?}`
     |
     = help: the trait `Debug` is not implemented for `PathedIoError`
     = note: add `#[derive(Debug)]` to `PathedIoError` or manually `impl Debug for PathedIoError`
note: required by a bound in `Result::<T, E>::unwrap`
    --> /home/amos/.rustup/toolchains/stable-x86_64-unknown-linux-gnu/lib/rustlib/src/rust/library/core/src/result.rs:1103:12
     |
1103 |         E: fmt::Debug,
     |            ^^^^^^^^^^ required by this bound in `Result::<T, E>::unwrap`
help: consider annotating `PathedIoError` with `#[derive(Debug)]`
     |
7    | #[derive(Debug)]
     |

For more information about this error, try `rustc --explain E0277`.
error: could not compile `day1` due to previous error

Returning an Err(E) where E was std::io::Error worked, because std::io::Error implemented all the right "traits". A trait is often just a list of functions a type can implement, here's the Debug trait it's complaining about for example:

Rust code
// somewhere in the rust standard library

pub trait Debug {
    /// Formats the value using the given formatter.
    ///
    /// (cut)
    fn fmt(&self, f: &mut Formatter<'_>) -> Result;
}

(Result here is naked because in the file where the Debug trait is declared, it's aliased to std::fmt::Result, which is an alias for std::result::Result<(), std::fmt::Error>. The "happy path" type is (), the empty tuple, which is just nothing, and the "unhappy path" type is a formatting error).

So, let's summarize what we know:

Why must we fix that? Because we're calling Result::unwrap here:

Rust code
fn main() {
//                             👇
    let input = read_input().unwrap();
    println!("{input}");
}

And Result::unwrap has this type signature:

Rust code
// somewhere in the rust standard library

impl<T, E> Result<T, E> {
    pub fn unwrap(self) -> T
    where
        E: fmt::Debug,
    {
      // (cut: implementation)
    }
}

See that where E: fmt::Debug? That's a "trait bound".

Result<T, E> is a generic type: it can exist for any type T and E. In our case, the concrete type read_input is returning is Result<String, PathedIoError>: it's right there in our code:

Rust code
fn read_input() -> Result<String, PathedIoError> {
//                 ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
}

So, in Result<T, E> we have T = String and E = PathedIoError, and, for a laugh, we can do the compiler's job by performing "monomorphization" of the impl block we saw above.

Replacing T and E with our concrete types, we have:

Rust code
impl Result<String, PathedIoError> {
    pub fn unwrap(self) -> String
    where
        PathedIoError: fmt::Debug,
    {
      // (cut: implementation)
    }
}

And here, it's a little clearer where the error comes from. We don't yet know what the Debug trait is really for, or why Result::unwrap wants it for its E type parameter, but we know we have to do something like:

Rust code
impl std::fmt::Debug for PathedIoError {
  // ???
}

And if we type that in, the compiler complains: "not all trait items implemented, missing: fmt".

But rust-analyzer offers a "quick fix" called "Implementing missing members":

A VS Code screenshot showing an empty impl Debug for PathedIoError, with a lightbulb, and the Quick Fix menu shown
Cool bear's hot tip

You can summon that "Quick Fix" menu by clicking on the light bulb icon, or pressing Ctrl+. (Cmd+. on macOS). If you're not using VS Code, you get to find out how to invoke it on your own! How fun.

The inline error message you see here is provided by VS Code's Error Lens extension.

And it generates this:

Rust code
impl std::fmt::Debug for PathedIoError {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        f.debug_struct("PathedIoError")
            .field("path", &self.path)
            .field("inner", &self.inner)
            .finish()
    }
}

And that's enough to make our program compile and run:

Shell session
$ cargo run
   Compiling day1 v0.1.0 (/home/amos/bearcove/aoc2022/day1)
    Finished dev [unoptimized + debuginfo] target(s) in 0.33s
     Running `target/debug/day1`
thread 'main' panicked at 'called `Result::unwrap()` on an `Err` value: PathedIoError { path: "src/input.txt", inner: Os { code: 2, kind: NotFound, message: "No such file or directory" } }', src/main.rs:2:30
note: run with `RUST_BACKTRACE=1` environment variable to display a backtrace

And here we have the context: we have the path ("src/input.txt") and we have the inner error, an std::io::Error with a code, a kind, and a message.

Here's the thing though: the Debug implementation that rust-analyzer generated is exactly the same as what would be generated by the Debug derive macro.

Oh no, more concepts

Which is to say, we can replace this:

Rust code
/// An [std::io::Error] associated with a path
struct PathedIoError {
    path: String,
    inner: std::io::Error,
}

impl std::fmt::Debug for PathedIoError {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        f.debug_struct("PathedIoError")
            .field("path", &self.path)
            .field("inner", &self.inner)
            .finish()
    }
}

With just this:

Rust code
/// An [std::io::Error] associated with a path
#[derive(Debug)] // 👈
struct PathedIoError {
    path: String,
    inner: std::io::Error,
}

...but also, this isn't the friendliest way to format that error.

Maybe we'd better provide our own Debug implementation, that prints something more human-readable:

Rust code
impl std::fmt::Debug for PathedIoError {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        write!(f, "for file {:?}: {}", self.path, self.inner)
    }
}

// (don't forget to remove `#[derive(Debug)]` on top of `struct PathedIoError`)
Shell session
$ cargo run
   Compiling day1 v0.1.0 (/home/amos/bearcove/aoc2022/day1)
    Finished dev [unoptimized + debuginfo] target(s) in 0.30s
     Running `target/debug/day1`
thread 'main' panicked at 'called `Result::unwrap()` on an `Err` value: for file "src/input.txt": No such file or directory (os error 2)', src/main.rs:2:30
note: run with `RUST_BACKTRACE=1` environment variable to display a backtrace

That is friendlier.

However, I don't really fancy having to write this for every one of my projects.

As it turns out, there's Rust crates (libraries) that do just that: fs-err is one of them, let's add a dependency on it:

Shell session
$ cargo add fs-err
    Updating crates.io index
      Adding fs-err v2.9.0 to dependencies.
             Features:
             - io_safety
             - tokio

What this did was look up the latest version of fs-err on https://crates.io and add it to our Cargo.toml manifest:

TOML markup
[package]
name = "day1"
version = "0.1.0"
edition = "2021"

# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html

[dependencies]
fs-err = "2.9.0" # 👈

And then our whole program can just be this:

Rust code
fn main() {
    let input = read_input().unwrap();
    println!("{input}");
}

fn read_input() -> Result<String, std::io::Error> {
    // now from the `fs-err` crate, rather than `std::fs`
    fs_err::read_to_string("src/input.txt")
}
Shell session
$ cargo run
   Compiling fs-err v2.9.0
   Compiling day1 v0.1.0 (/home/amos/bearcove/aoc2022/day1)
    Finished dev [unoptimized + debuginfo] target(s) in 0.48s
     Running `target/debug/day1`
thread 'main' panicked at 'called `Result::unwrap()` on an `Err` value: Custom { kind: NotFound, error: Error { kind: OpenFile, source: Os { code: 2, kind: NotFound, message: "No such file or directory" }, path: "src/input.txt" } }', src/main.rs:2:30
note: run with `RUST_BACKTRACE=1` environment variable to display a backtrace

And we can see that fs-err is doing essentially the same thing we were doing, except it's wrapping its Error type in std::io::Error.

Here's another way to tackle this: let's remove fs-err from our Cargo.toml manifest with cargo rm fs-err (or by directly editing the file), and add a dependency on color-eyre instead:

Shell session
$ cargo add color-eyre
    Updating crates.io index
      Adding color-eyre v0.6.2 to dependencies.
             Features:
             + capture-spantrace
             + color-spantrace
             + tracing-error
             + track-caller
             - issue-url
             - url

color-eyre provides us with its own color_eyre::Result<T> type, which is an alias for std::result::Result<T, color_eyre::Report>. Most error types can be converted to color_eyre::Report, so, for example, we can do this:

Rust code
fn read_input() -> color_eyre::Result<String> {
    let input = std::fs::read_to_string("src/input.txt")?;
    Ok(input)
}

Or this:

Rust code
fn read_input() -> color_eyre::Result<String> {
    std::fs::read_to_string("src/input.txt").map_err(|e| e.into())
    //                                               ~~~~~~~~~~~~
    //                                 this is a closure 👆
    //
    //                                (inlay type hints should show you
    //                                `e: std::io::Error`)
}

Or this:

Rust code
fn read_input() -> color_eyre::Result<String> {
    std::fs::read_to_string("src/input.txt").map_err(From::from)
}

Because the conversion from std::io::Error to color_eyre::Report is possible thanks to this impl block:

Rust code
impl<E> From<E> for Report
where
    E: StdError + Send + Sync + 'static,
{
    #[cfg_attr(track_caller, track_caller)]
    fn from(error: E) -> Self {
        Report::from_std(error)
    }
}

There's a lot more going on here that we're not going to cover, but, just like Debug is a common trait that means a type can be formatted in a somewhat-verbose way, From is a common trait that expresses that "infallible conversions" can occur between two types.

It is closely related to the Into trait, that converts in the other direction (and is what lets us do e.into())

Me personally, I prefer this form:

Rust code
fn read_input() -> color_eyre::Result<String> {
    let input = std::fs::read_to_string("src/input.txt")?;
    Ok(input)
}

Because we often want to do other things with the value, so it's useful to bind it to a name with let (here, the binding is named input).

As-is, our program (when src/input.txt does not exist) prints this:

Shell session
$ cargo run --quiet
thread 'main' panicked at 'called `Result::unwrap()` on an `Err` value: No such file or directory (os error 2)

Location:
    src/main.rs:7:17', src/main.rs:2:30
note: run with `RUST_BACKTRACE=1` environment variable to display a backtrace

So we're back to square one? There's no context here.

That's true, although it's somewhat prettier. And it shows where the error was actually built (main.rs:7:17, which is main.rs line 7 column 17), not just where it was handled (main.rs:2:30).

If we install the color-eyre handler, it's prettier still:

Rust code
fn main() {
    // here 👇
    color_eyre::install().unwrap();

    let input = read_input().unwrap();
    println!("{input}");
}

fn read_input() -> color_eyre::Result<String> {
    let input = std::fs::read_to_string("src/input.txt")?;
    Ok(input)
}
Shell session
$ cargo run --quiet
The application panicked (crashed).
Message:  called `Result::unwrap()` on an `Err` value: 
   0: No such file or directory (os error 2)

Location:
   src/main.rs:9

Backtrace omitted. Run with RUST_BACKTRACE=1 environment variable to display it.
Run with RUST_BACKTRACE=full to include source snippets.
Location: src/main.rs:4

Backtrace omitted. Run with RUST_BACKTRACE=1 environment variable to display it.
Run with RUST_BACKTRACE=full to include source snippets.

Here it is with colors:

The same error as above, but with colors: red, cyan, and purple

Still, we don't have context. So let's add some!

Rust code
// 👇 this `use` directive was added automatically by rust-analyzer when...
use color_eyre::eyre::Context;

fn main() {
    color_eyre::install().unwrap();

    let input = read_input().unwrap();
    println!("{input}");
}

fn read_input() -> color_eyre::Result<String> {
    // ...I did `Ctrl+Space` after typing `.wrap_e` to insert this call 👇
    let input = std::fs::read_to_string("src/input.txt").wrap_err("reading src/input.txt")?;
    Ok(input)
}
Shell session
$ cargo run --quiet
The application panicked (crashed).
Message:  called `Result::unwrap()` on an `Err` value: 
   0: reading src/input.txt
   1: No such file or directory (os error 2)

Location:
   src/main.rs:11

Backtrace omitted. Run with RUST_BACKTRACE=1 environment variable to display it.
Run with RUST_BACKTRACE=full to include source snippets.
Location: src/main.rs:6

Backtrace omitted. Run with RUST_BACKTRACE=1 environment variable to display it.
Run with RUST_BACKTRACE=full to include source snippets.

There! To simplify some more, we can even have main itself return a Result, instead of calling .unwrap() from there.

Let's try it out:

Rust code
use color_eyre::eyre::Context;

fn main() -> color_eyre::Result<()> {
    //       was unwrap 👇
    color_eyre::install()?;

    //                 was unwrap 👇
    let input = read_input()?;
    println!("{input}");

    // now we must return a Result
    Ok(())
}

fn read_input() -> color_eyre::Result<String> {
    let input = std::fs::read_to_string("src/input.txt").wrap_err("reading src/input.txt")?;
    Ok(input)
}
Shell session
$ cargo run --quiet
Error: 
   0: reading src/input.txt
   1: No such file or directory (os error 2)

Location:
   src/main.rs:16

Backtrace omitted. Run with RUST_BACKTRACE=1 environment variable to display it.
Run with RUST_BACKTRACE=full to include source snippets.

And that got rid of the duplicate "Backtrace omitted" messages. The only source location that's shown by default is where the error was constructed, here it's in read_input.

And that's all we need to know to solve this advent of code day 1 exercise with Rust.

Wait, what? We haven't solved it at all, not even a little. One might say we got distracted.

No no, on the contrary, we've just gone through a bunch of essential concepts in Rust. Values are of a certain type, types can have methods and they can implement traits. Some types and methods are generic, and can operate on any type that satisfy "trait bounds".

Our job is to turn types into other types, and that'll help us solve the task at hand.

If.. if you say so.

Okay, let's solve the problem then!

Iterators and for loops

First off, let's turn "reading the file" from a run-time problem (when the program is executed) into a compile-time problem (when the executable is generated by cargo / rustc / the linker), using the include_str macro:

Rust code
fn main() -> color_eyre::Result<()> {
    color_eyre::install()?;

    let input = include_str!("input.txt");
    println!("{input}");

    Ok(())
}

This does essentially the same thing, except that now, input.txt doesn't need to be present when we run the program: it's baked into it.

So we did... all this... for nothing?

Oh no bear, not nothing. We did all this as a gentle... as a rather brutal introduction to types, and traits, and stuff.

See now for example, if we do this:

Rust code
fn main() -> color_eyre::Result<()> {
    color_eyre::install()?;

    let input = include_str!("input.txt");
    let lines = input.lines();

    Ok(())
}

We're not sure what to do with that lines, right? Well, our code editor tells us it's of type Lines:

Rust code
    let lines: Lines = input.lines();
//           ~~~~~~~

Which helps because uhh... ok by itself that doesn't help much. But we can open the context menu on lines (with right click, most likely) and use the "Go To Type Definition" command and land in the middle of the standard library:

Rust code
// in `rustlib/src/rust/library/core/src/str/iter.rs`

/// An iterator over the lines of a string, as string slices.
///
/// This struct is created with the [`lines`] method on [`str`].
/// See its documentation for more.
///
/// [`lines`]: str::lines
#[stable(feature = "rust1", since = "1.0.0")]
#[must_use = "iterators are lazy and do nothing unless consumed"]
#[derive(Clone, Debug)]
pub struct Lines<'a>(pub(super) Map<SplitTerminator<'a, char>, LinesAnyMap>);

#[stable(feature = "rust1", since = "1.0.0")]
impl<'a> Iterator for Lines<'a> {
    type Item = &'a str;

    #[inline]
    fn next(&mut self) -> Option<&'a str> {
        self.0.next()
    }

    // (cut)
}

Ah, it's an iterator: there's a next method here, we can probably do something with that:

Rust code
    let mut lines = input.lines();
    loop {
        let maybe_line = lines.next();
        match maybe_line {
            Some(line) => {
                println!("Got line: {}", line);
            }
            None => break,
        }
    }

This is a lot more verbose than it needs to be, but might help draw a closer parallel to some other languages.

Again, inlay type hints give us some info about the next method:

Rust code
        let maybe_line: Option<&str> = lines.next();
//                    ~~~~~~~~~~~~~~

Option<T> is an enum, similar to Result<T, E>, but simpler. There's Some(T) (something) and None (nothing). And we use a match expression with two arms to "destructure it", just like we did with results before.

match is just one of the ways we can do pattern matching though: if-let is another one:

Rust code
fn main() -> color_eyre::Result<()> {
    color_eyre::install()?;

    let input = include_str!("input.txt");

    let mut lines = input.lines();
    loop {
        if let Some(line) = lines.next() {
            println!("Got line: {}", line);
        } else {
            break;
        }
    }

    Ok(())
}

But wait.. what's this?

Shell session
$ cargo clippy
warning: this loop could be written as a `while let` loop
  --> src/main.rs:7:5
   |
7  | /     loop {
8  | |         if let Some(line) = lines.next() {
9  | |             println!("Got line: {}", line);
10 | |         } else {
11 | |             break;
12 | |         }
13 | |     }
   | |_____^ help: try: `while let Some(line) = lines.next() { .. }`
   |
   = note: `#[warn(clippy::while_let_loop)]` on by default
   = help: for further information visit https://rust-lang.github.io/rust-clippy/master/index.html#while_let_loop

warning: `day1` (bin "day1") generated 1 warning
    Finished dev [unoptimized + debuginfo] target(s) in 0.02s

It's clippy! We love clippy.

Right! And we don't need to link to its website, because it ships by default with rust.

Shell session
$ rustup which cargo-clippy
/home/amos/.rustup/toolchains/stable-x86_64-unknown-linux-gnu/bin/cargo-clippy

And it replaces/augments cargo check, which is what Visual Studio Code's "rust-analyzer" extension runs by default, but we can change that setting:

JSON
{
  "rust-analyzer.checkOnSave.command": "clippy",
}

(...which can also be changed in VS Code's graphical settings editor, by searching for "checkOnSave" or something).

Applying the suggestion, our code becomes this:

Rust code
fn main() -> color_eyre::Result<()> {
    color_eyre::install()?;

    let input = include_str!("input.txt");

    let mut lines = input.lines();
    while let Some(line) = lines.next() {
        println!("Got line: {}", line);
    }

    Ok(())
}

And then... another suggestion appears!

Shell session
$ cargo clippy
warning: this loop could be written as a `for` loop
 --> src/main.rs:7:5
  |
7 |     while let Some(line) = lines.next() {
  |     ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ help: try: `for line in lines`
  |
  = note: `#[warn(clippy::while_let_on_iterator)]` on by default
  = help: for further information visit https://rust-lang.github.io/rust-clippy/master/index.html#while_let_on_iterator

warning: `day1` (bin "day1") generated 1 warning
    Finished dev [unoptimized + debuginfo] target(s) in 0.01s

Fine, fine. In fact, let's inline everything:

Rust code
fn main() -> color_eyre::Result<()> {
    color_eyre::install()?;

    for line in include_str!("input.txt").lines() {
        println!("Got line: {line}");
    }

    Ok(())
}

Wow hey, that almost looks like... idk, Python or something.

Yeah, except for the macros and ? to propagate errors and the fact that everything is strongly typed and..

Yes yeah okay, got it.

Splitting, grouping, and other -ings

So anyway, our program prints this:

Shell session
$ cargo run --quiet
Got line: 1000
Got line: 2000
Got line: 3000
Got line: 
Got line: 4000
Got line: 
Got line: 5000
Got line: 6000
Got line: 
Got line: 7000
Got line: 8000
Got line: 9000
Got line: 
Got line: 10000

So, that's cool, it reads each line individually.

Now how about we try to group these, separated by an empty line?

Wait, couldn't we just split on \n\n?

How do you mean?

Well, our file is something like:

1000\n2000\n3000\n\n4000\n\n5000\n6000...

...right? If we just split on \n\n we have our groups?

Ah, you're right, let's try it:

Rust code
    for group in include_str!("input.txt").split("\n\n") {
        println!("In group:");
        for line in group.lines() {
            println!("  - {line}");
        }
    }
Shell session
$ cargo run --quiet
In group:
  - 1000
  - 2000
  - 3000
In group:
  - 4000
In group:
  - 5000
  - 6000
In group:
  - 7000
  - 8000
  - 9000
In group:
  - 10000

Great! So that's it then? We can finish solving the problem?

Not so fast bear! On Windows..

Yes yes, I know, on windows newlines aren't \n they're \r\n because teletypes, blah, who cares, here I'll fix it:

Rust code
    for group in include_str!("input.txt")
        .replace("\r\n", "\n")
        .split("\n\n")
    {
        println!("In group:");
        for line in group.lines() {
            println!("  - {line}");
        }
    }

Boom, done. Can we move on?

Very well, if you insist — but we'll come back to my way later, right?

Yes, sure, whatever gets us the points for Advent of Code.

...you do realize we're late enough that we'll get 1 point no matter what?

SO THEN WHY ARE WE DO-

...ever hear of this new thing called fun?

Fun! Oh! Fun! Well excuse me, I hadn't noticed we were having fun.

Oh I don't know about you, but me, I've been having tons of fun.

Anyway sure, let's humor you and keep going.

So you want something real imperative huh? No more concepts?

Yes, enough with the concepts and the learning. Let's get straight to the point.

Very well. So, we'll want to parse every non-empty line as a number, right? If we're going to sum them?

Sure, okay, you can have that one.

So we must go from, like... &str to something like... let's say u64?

And we can do so like this:

Rust code
    for group in include_str!("input.txt")
        .replace("\r\n", "\n")
        .split("\n\n")
    {
        println!("In group:");
        for line in group.lines() {
            let value = line.parse::<u64>()?;
            println!("  - {value}");
        }
    }

And that gives us:

Shell session
$ cargo run --quiet
In group:
  - 1000
  - 2000
  - 3000
In group:
  - 4000
In group:
  - 5000
  - 6000
In group:
  - 7000
  - 8000
  - 9000
In group:
  - 10000

...that didn't change the output at all.

Yes yes, but now they're numbers. Sum them!

Okay fine, so, imperative style... I guess we make a sum "variable", set it to zero and add values as we go:

Rust code
    for group in include_str!("input.txt")
        .replace("\r\n", "\n")
        .split("\n\n")
    {
        let mut sum = 0;
        for line in group.lines() {
            let value = line.parse::<u64>()?;
            sum += value;
        }
        println!("Group has sum {sum}");
    }
Shell session
$ cargo run --quiet
Group has sum 6000
Group has sum 4000
Group has sum 11000
Group has sum 24000
Group has sum 10000

Yes, yes, good, now find the biggest one.

Well... again imperative style, I guess we can make a largest_sum variable and overwrite it if we find a bigger sum...

Rust code
fn main() -> color_eyre::Result<()> {
    color_eyre::install()?;

    let mut max = 0;
    for group in include_str!("input.txt")
        .replace("\r\n", "\n")
        .split("\n\n")
    {
        let mut sum = 0;
        for line in group.lines() {
            let value = line.parse::<u64>()?;
            sum += value;
        }
        if sum > max {
            max = sum;
        }
    }
    println!("The burdenedst elf is carrying {max} calories");

    Ok(())
}

Please note that even though every binding here is strongly typed, we don't have to specify the type of max, because..

Yes yes, the compiler infers it from usage because we add value to it, which is an u64 because we used a turbofish to specify the type parameter F of parse, excellent, let's run it.

Shell session
$ cargo run --quiet
The burdenedst elf is carrying 24000 calories

There, we're done, goodbye!

Not so fast bear! First of all, that's not even our real puzzle input. And secondly, you promised we'd give my way a try.

Fine.

Solving it the fun way

So let's go back to this version of the code:

Rust code
    for line in include_str!("input.txt").lines() {
        println!("{line}");
    }

What I personally would like to do here is to split that iterator into "groups" every time we encounter an empty line.

And this is surprisingly tricky to do!

We can collect all lines into a vector, and then, there's a bunch of interesting methods on slices, split for example:

Rust code
    let lines = include_str!("input.txt").lines().collect::<Vec<_>>();
    let groups = lines.split(|line| line.is_empty()).collect::<Vec<_>>();
    println!("groups = {groups:?}");
Shell session
$ cargo run --quiet
groups = [["1000", "2000", "3000"], ["4000"], ["5000", "6000"], ["7000", "8000", "9000"], ["10000"]]

See, this is a perfectly decent start!

Rust code
    let lines = include_str!("input.txt").lines().collect::<Vec<_>>();
    let groups = lines.split(|line| line.is_empty()).collect::<Vec<_>>();

    let groups = groups
        .into_iter()
        .map(|group| {
            group
                .iter()
                .map(|v| v.parse::<u64>().ok())
                .collect::<Vec<_>>()
        })
        .collect::<Vec<_>>();
    println!("groups = {groups:?}");
Shell session
$ cargo run --quiet
groups = [[Some(1000), Some(2000), Some(3000)], [Some(4000)], [Some(5000), Some(6000)], [Some(7000), Some(8000), Some(9000)], [Some(10000)]]

clippy suggests we don't need that middle collect, and it is of course, correct — in fact we can inline the whole thing:

Rust code
    let lines = include_str!("input.txt").lines().collect::<Vec<_>>();
    let groups = lines
        .split(|line| line.is_empty())
        .map(|group| {
            group
                .iter()
                .map(|v| v.parse::<u64>().ok())
                .collect::<Vec<_>>()
        })
        .collect::<Vec<_>>();
    println!("groups = {groups:?}");

But... it might be easier to do the "parsing as u64" bit before we split:

Rust code
    let lines = include_str!("input.txt")
        .lines()
        .map(|v| v.parse::<u64>().ok())
        .collect::<Vec<_>>();
    let groups = lines.split(|line| line.is_some()).collect::<Vec<_>>();
    println!("groups = {groups:?}");
Shell session
$ cargo run --quiet
groups = [[], [], [], [None], [None], [], [None], [], [], [None], []]

Woops, wrong way round, let's try this instead:

Rust code
//                           was: `is_some` 👇
    let groups = lines.split(|line| line.is_none()).collect::<Vec<_>>();
Shell session
$ cargo run --quiet
groups = [[Some(1000), Some(2000), Some(3000)], [Some(4000)], [Some(5000), Some(6000)], [Some(7000), Some(8000), Some(9000)], [Some(10000)]]

Now, for each group, we can sum all elements. They're Option<u64> right now, we can use Option::unwrap to turn them into u64 or panic (similar to what Result::unwrap does, which we've used a bunch earlier).

Our code becomes:

Rust code
fn main() -> color_eyre::Result<()> {
    color_eyre::install()?;

    let lines = include_str!("input.txt")
        .lines()
        .map(|v| v.parse::<u64>().ok())
        .collect::<Vec<_>>();
    let groups = lines
        .split(|line| line.is_none())
        .map(|group| group.iter().map(|v| v.unwrap()).sum::<u64>())
        .collect::<Vec<_>>();
    println!("groups = {groups:?}");

    Ok(())
}
Shell session
$ cargo run --quiet
groups = [6000, 4000, 11000, 24000, 10000]

And we can find the maximum here without even collecting the groups into their own Vec<u64>:

Rust code
fn main() -> color_eyre::Result<()> {
    color_eyre::install()?;

    let lines = include_str!("input.txt")
        .lines()
        .map(|v| v.parse::<u64>().ok())
        .collect::<Vec<_>>();
    let elven_lead = lines
        .split(|line| line.is_none())
        .map(|group| group.iter().map(|v| v.unwrap()).sum::<u64>())
        // 👇
        .max();
    println!("{elven_lead:?}");

    Ok(())
}
Shell session
$ cargo run --quiet
Some(24000)

Why does max() return an Option<T> you ask?

Nope, nobody asked.

Oh you're still here. Well it's because what's the maximum of an empty iterator? It's not zero, or i64::min, or negative infinity: it's... nothing. There's no maximum then. Hence, None, or Some(maximum).

I'm fairly happy with that solution... but not totally.

I still think that collect in the middle is unnecessary. I'd rather we do everything with iterators, so that we could work on very large datasets without memory usage going up.

Don't we bake the whole input into the executable anyway?

..yes, right, but it's mapped in memory and... because virtual memory... things are paged in and out and... uhh it all works out.

Uh-huh. Eloquent as ever. Continue.

We could, of course, implement our own iterator type:

Rust code
/// An iterator that takes `Option<u64>` items and yields sums of groups of
/// `Some(u64)` items separated by `None` items.
struct GroupSumIter<I> {
    inner: I,
}

impl<I> Iterator for GroupSumIter<I>
where
    I: Iterator<Item = Option<u64>>,
{
    type Item = u64;

    fn next(&mut self) -> Option<Self::Item> {
        let mut sum = loop {
            match self.inner.next() {
                Some(Some(v)) => break v,
                Some(None) => {
                    // huh, weird, didn't expect a separator there
                    // but let's just skip it
                }
                // we've reached the end of the inner iterator
                None => return None,
            }
        };

        loop {
            match self.inner.next() {
                Some(Some(v)) => sum += v,
                Some(None) | None => {
                    // reached a separator or the end of the iterator
                    break Some(sum);
                }
            }
        }
    }
}

Which we could use like this:

Rust code
fn main() -> color_eyre::Result<()> {
    color_eyre::install()?;

    let lines = include_str!("input.txt")
        .lines()
        .map(|v| v.parse::<u64>().ok());
    // here! 👋
    let elven_lead = GroupSumIter { inner: lines }.max();
    println!("{elven_lead:?}");

    Ok(())
}

And that does work!

Shell session
$ cargo run --quiet
Some(24000)

I'm afraid we're giving Cool Bear ammunition to nominate this solution as "most convoluted" though.

Yes. Yes indeed.

So let's try and achieve this in less code, shall we?

Whenever we want to do "advanced iterator stuff", the same crate always pops up: itertools.

So, let's try it:

Shell session
$ cargo add itertools
    Updating crates.io index
      Adding itertools v0.10.5 to dependencies.
             Features:
             + use_alloc
             + use_std

There's two methods I want to try. First, Itertools::batching, which lets us do the same, but without having to declare our own type.

Rust code
// 👋 this is important, it lets us call `.batching` on iterators
use itertools::Itertools;

fn main() -> color_eyre::Result<()> {
    color_eyre::install()?;

    let max = include_str!("input.txt")
        .lines()
        .map(|v| v.parse::<u64>().ok())
        .batching(|it| {
            let mut sum = None;
            while let Some(Some(v)) = it.next() {
                sum = Some(sum.unwrap_or(0) + v);
            }
            sum
        })
        .max();
    println!("{max:?}");

    Ok(())
}

This works!

Shell session
$ cargo run --quiet
Some(24000)

And honestly, I'd be happy with that as my solution.

I'm curious about Itertools::coalesce though, let's give it a shot:

Rust code
use itertools::Itertools;

fn main() -> color_eyre::Result<()> {
    color_eyre::install()?;

    let max = include_str!("input.txt")
        .lines()
        .map(|v| v.parse::<u64>().ok())
        .coalesce(|a, b| match (a, b) {
            (None, None) => Ok(None),
            (None, Some(b)) => Ok(Some(b)),
            (Some(a), Some(b)) => Ok(Some(a + b)),
            (Some(a), None) => Err((Some(a), None)),
        })
        .max()
        .flatten()
        .unwrap_or_default();
    println!("{max:?}");

    Ok(())
}
Shell session
$ cargo run --quiet
24000

This is... arguably worse than the the other solution, but it's also hilarious, so it's impossible to tell if it's good or not.

Also, it's easier to follow with inlay hints, as usual:

The same code as above, but as VS Code screenshots. It even shows closure return types, something that is off by default I believe

Anyway, let's try it with the real puzzle input (which is 2248 lines long).

We'll make a release build:

Rust code
$ cargo build --release
(omitted)

And time it with hyperfine:

Shell session
$ hyperfine ./target/release/day1   
Benchmark 1: ./target/release/day1
  Time (mean ± σ):       0.2 ms ±   0.4 ms    [User: 0.3 ms, System: 0.1 ms]
  Range (min … max):     0.0 ms …  13.9 ms    3081 runs
 
  Warning: Command took less than 5 ms to complete. Note that the results might be inaccurate because hyperfine can not calibrate the shell startup time much more precise than this limit. You can try to use the `-N`/`--shell=none` option to disable the shell completely.
  Warning: Statistical outliers were detected. Consider re-running this benchmark on a quiet PC without any interferences from other programs. It might help to use the '--warmup' or '--prepare' options.

Ah, well. Too fast to measure properly 🤷

Part 2

Oh, there's a part a two, forgot about that part. In part two, instead of finding the single buffest elf, we must find the top 3 (and compute the sum of the calories they're carrying).

Well it almost feels like cheating, but once again itertools has everything we need.

We can, first of all, flatten our Iterator<Item = Option<u64>> into a Iterator<Item = u64> with Iterator::flatten. This works because Option<u64> itself implements IntoIterator: it iterates over one item if we have the Some(T) variant, or over.. zero items if we have the None variant.

Then, we can sort it. Naively, it'll sort from smallest to largest. But if we give it u64::MAX - v instead of v, it'll sort from largest to smallest! We could've also called .rev() to reverse the iterator after sorting it, but that sounds more expensive for some reason.

It's low-key expensive in both cases tbh, since it needs to keep track of all elves rather than just the top 3 at any given time.

I mean, expensive is relative since this fits a gazillion times over in main memory, but, y'know.

Then we can take 3 items from the resulting iterator (elves sorted by calories carried, most to least) with take(3), and sum that!

And there we have it:

Rust code
use itertools::Itertools;

fn main() -> color_eyre::Result<()> {
    color_eyre::install()?;

    let answer = include_str!("input.txt")
        .lines()
        .map(|v| v.parse::<u64>().ok())
        .coalesce(|a, b| match (a, b) {
            (None, None) => Ok(None),
            (None, Some(b)) => Ok(Some(b)),
            (Some(a), Some(b)) => Ok(Some(a + b)),
            (Some(a), None) => Err((Some(a), None)),
        })
        .flatten()
        .sorted_by_key(|&v| u64::MAX - v)
        .take(3)
        .sum::<u64>();
    println!("{answer:?}");

    Ok(())
}

Again, coalesce here is a bit on the odd side, I still think batching worked better, but ah well.

Reader suggestion: std::cmp::Reverse

Instead of doing u64::MAX - v, we can use Reverse.

This line:

Rust code
        .sorted_by_key(|&v| u64::MAX - v)

becomes:

Rust code
        .sorted_by_key(|&v| std::cmp::Reverse(v))

...which I think expresses intent better! Thanks to freax13 on Reddit for this suggestion.

Reader suggestion: k_smallest

crazy01010 suggests on Reddit that we can use itertool's k_smallest function in conjunction with Reverse to find the "K largest values", which is what we want.

We can combine this with our batching solution and a suggestion of my own: the for loop we had here:

Rust code
        .batching(|it| {
            let mut sum = None;
            while let Some(Some(v)) = it.next() {
                sum = Some(sum.unwrap_or(0) + v);
            }
            sum
        })

...looks like a potential use case for itertool's fold_while.

The complete solution becomes:

Rust code
use itertools::{FoldWhile, Itertools};
use std::cmp::Reverse;

fn main() -> color_eyre::Result<()> {
    color_eyre::install()?;

    let answer = include_str!("input.txt")
        .lines()
        .map(|v| v.parse::<u64>().ok())
        // consider all lines separated by `None`
        .batching(|it| {
            it.fold_while(None, |acc: Option<u64>, v| match v {
                Some(v) => FoldWhile::Continue(Some(acc.unwrap_or_default() + v)),
                // that's the group separator, `fold_while` is done
                None => FoldWhile::Done(acc),
            })
            // this returns `Some(total)` if we found a group, `None` if we're
            // at the end, to let `batching` end.
            .into_inner()
        })
        // this turns `k_smallest` into `k_largest`
        .map(Reverse)
        .k_smallest(3)
        // and this strips the `Reverse` so we can sum
        .map(|x| x.0)
        .sum::<u64>();
    println!("{answer:?}");

    Ok(())
}

And it works beautifully!

Reader suggestions: using a BinaryHeap

Another suggestion by crazy01010 on Reddit: we can use a BinaryHeap to maintain the sums in order: we push the first three, and any time we push another one, we also pop from the heap.

That way, the heap never grows beyond 4 items, and thanks to std::cmp::Reverse, we get the three largest items (instead of the three smallest, if we used the heap naively).

Rust code
use itertools::{FoldWhile, Itertools};
use std::{cmp::Reverse, collections::BinaryHeap};

fn main() -> color_eyre::Result<()> {
    color_eyre::install()?;

    let mut group_sums = include_str!("input.txt")
        .lines()
        .map(|v| v.parse::<u64>().ok())
        .batching(|it| {
            it.fold_while(None, |acc: Option<u64>, v| match v {
                Some(v) => FoldWhile::Continue(Some(acc.unwrap_or_default() + v)),
                None => FoldWhile::Done(acc),
            })
            .into_inner()
        })
        .map(Reverse);

    let mut heap = BinaryHeap::new();

    for init in (&mut group_sums).take(3) {
        heap.push(init);
    }

    for rest in group_sums {
        heap.push(rest);
        heap.pop();
    }

    let answer = heap.into_iter().map(|Reverse(v)| v).sum::<u64>();
    println!("{answer:?}");

    Ok(())
}

I learned something while implementing this solution: I was initially doing group_sums.take(3) but it consumed the iterator. Since a mutable reference also implements Iterator, we can simply do (&mut group_sums).take(3).

Improving the batching solution

This gave me an idea to improve the batching solution further, with map_while and sum1:

Rust code
use itertools::Itertools;
use std::cmp::Reverse;

fn main() {
    let answer = include_str!("input.txt")
        .lines()
        .map(|v| v.parse::<u64>().ok())
        .batching(|it| it.map_while(|x| x).sum1::<u64>())
        .map(Reverse)
        .k_smallest(3)
        .map(|x| x.0)
        .sum::<u64>();
    println!("{answer:?}");
}

Look how pretty!

(Thanks to Hodkinson on GitHub for pointing out this version of the code doesn't need "mut sprinkling". It doesn't!)