Ruby/Crystal/Rust Advent of Code 2022 - Day 4

Ruby/Crystal/Rust Advent of Code 2022 - Day 4

Day 4 takes an interesting turn with our Rust solutions. They are taking another shift towards architectural parity with the Ruby and Crystal solutions. Rust isn't object-oriented in the same way that those two languages are, but it does have object-oriented features that I have, so far in AoC, been ignoring. At the same time, this solution is the first that touches on the concept of lifetimes in Rust. Read on to see how Day 4 all comes together!

The Repository

All of the code for this day's solutions, as well as the data input file, can be found at:

https://github.com/wyhaines/advent-of-code-2022/tree/main/day_4

Part 1

To see the full details of the challenge, visit the Advent of Code 2022 - Day 4 page. However, the tl;dr version of the challenge is as follows:

The Elves are working to clean up a camp and have been assigned ranges of section IDs to clean. Each range is represented as a pair of integers, with the first integer representing the start of the range and the second integer representing the end of the range. For example, the range "2-4" refers to sections 2, 3, and 4.

The Elves have noticed that some of the assignments overlap, meaning that some sections may be cleaned by more than one Elf. To avoid duplicated effort, the Elves want to identify the pairs of assignments in which one range fully contains the other. In other words, they want to find pairs where one range starts and ends within the range of the other. For example, in the pair "2-5, 3-4", the range "2-5" fully contains the range "3-4", so this would be considered a pair where one range fully contains the other.

The Elves have created a list of pairs of section assignments and want to find the number of pairs in which one range fully contains the other.

So, for example, consider this set of sample data.

2-4,6-8
2-3,4-5
5-7,7-9
2-8,3-7
6-6,4-6
2-6,4-8

There are two pairs in which one range fully contains the other:

  • The range 3-7 in the pair 2-8,3-7 is fully contained within the range 2-8.

  • The range 6-6 in the pair 6-6,4-6 is fully contained within the range of 4-6.

The Approach

Today's task is conceptually pretty simple.

  • Parse the data into an array of individual lines.

  • Transform the array of lines into an array of two-element arrays, with each element being a range corresponding to one of the integer pairs in the line. i.e. 2-8,3-7 would be turned into an array with two ranges (shown in Ruby/Crystal syntax): [(2..8), (3..7)].

  • Iterate through each of the lines, and determine if either range is completely enclosed in the other.

  • Count that set of ranges.

Ruby Solution

This task has at least a couple of different basic approaches. The first that might come to mind is to treat the ranges as arrays/sets and use set intersection methods to figure out if either set completely encloses the other. In Ruby and Crystal, this might be coded something like this:

set = [(2..8), (3..7)]
set_as_arrays = set.map(&:to_a)
set_as_arrays.include?(set_as_arrays[0].to_a & set_as_arrays[1].to_a)

The advantage of this approach is that it is very simple. The disadvantage of it, however, is that it is inefficient. Advent of Code isn't typically about writing the fastest code, but this is a lot of arrays, then performs a fairly expensive set intersection on. It's a lot of work for something that can be achieved by doing just a handful of inexpensive comparisons of the start and end points of the ranges, so I chose to do these solutions that way, instead of depending on set operations.

# frozen_string_literal: true

class CampCleanup
  def initialize(filename)
    assignments = parse_assignments(filename)

    puts "Of #{assignments.size} assignments, #{count_redundant_assignments(assignments)} are redundant."
  end

  def parse_assignments(filename)
    File.read(filename).split("\n").map do |line|
      line.split(",").map do |assignment|
        min, max = assignment.split("-").map(&:to_i)
        (min..max)
      end
    end
  end

  def count_redundant_assignments(assignments)
    assignments.select do |left, right|
      redundant?(left, right)
    end.size
  end

  def redundant?(left, right)
    smaller, larger = sort_by_containment(left, right)
    smaller.min >= larger.min && smaller.max <= larger.max
  end

  def sort_by_containment(left, right)
    if left.min <= right.min && left.max >= right.max
      [right, left]
    else
      [left, right]
    end
  end
end

CampCleanup.new(ARGV[0] || 'input.txt')

The file reading and parsing code is straightforward.

  def parse_assignments(filename)
    File.read(filename).split("\n").map do |line|
      line.split(",").map do |assignment|
        min, max = assignment.split("-").map(&:to_i)
        (min..max)
      end
    end
  end

The file is read into memory, then split by newline into an array of lines. That array is mapped into a new array, with each line being split on its comma into two separate assignments, and the assignment split on its dash into two integers that represent the bottom and the top end of the assignment range.

Once that is done, nearly half of the work for this task is already complete. Ruby has a method, Array#select, that calls a block once with each element of the array. It returns an array that contains each element for which the block is evaluated to be true. If that block can determine if one of the ranges is contained in the other, then the size of the final array is the number of redundant ranges.

  def count_redundant_assignments(assignments)
    assignments.select do |left, right|
      redundant?(left, right)
    end.size
  end

The real work happens in that redundant?(left, right) call. Let's see how that works.

  def redundant?(left, right)
    smaller, larger = sort_by_containment(left, right)
    smaller.min >= larger.min && smaller.max <= larger.max
  end

  def sort_by_containment(left, right)
    if left.min <= right.min && left.max >= right.max
      [right, left]
    else
      [left, right]
    end
  end

Three possible options have to be checked.

  • The left assignment completely encloses the right assignment.

  • The right assignment completely encloses the left assignment.

  • Neither of the above is true.

The above code approaches this by first calling sort_by_containment. This method does one thing. It checks to see if the left assignment completely encloses the right assignment. If this is true, then it knows that right is equal to or smaller than left, and is completely enclosed by left, so it returns a result in an array, [right, left]. Otherwise, it assumes that the inverse might be true, that left is, in fact, less than right and enclosed by it, so it returns the inverse, [left, right].

The redundant method then takes this ordering and confirms the hypothesis by validating that whichever range was returned as the smaller range does have a minimum that is greater than or equal to the assumed larger range's minimum, and that the smaller range's maximum is less than or equal to the larger range's maximum. If this is true, then it is confirmed that smaller is completely enclosed by larger, and true is returned. Otherwise, false is returned.

If you have looked at the code in the repo, you will have seen that there is a note here in the Ruby version. This implementation is more complicated than it needs to be. Since there are only, in this case, three discrete outcomes, we could just use a slightly bigger if statement instead of breaking things across two methods.

  def redundant?(left, right)
    if left.min <= right.min && left.max >= right.max
      true
    elsif left.min >= right.min && left.max <= right.max
      true
    else
      false  
    end
  end

However, this felt like a Rust-teachable-moment to use the multiple-method approach instead of the simple if statement above. Read on to the Rust solution to see why, but know that the above is the better way to solve this problem because it is simultaneously both slightly more efficient in terms of execution time and easier for a person to look at and reason about.

Either way, though, this particular task to help those industrious Elves is quite simple to implement in Ruby. That bodes well for Crystal, as well!

Crystal solution

It is virtually a cut-and-paste of the Ruby solution.

class CampCleanup
  def initialize(filename)
    assignments = parse_assignments(filename)

    puts "Of #{assignments.size} assignments, #{count_redundant_assignments(assignments)} are redundant."
  end

  def parse_assignments(filename)
    File.read(filename).split("\n").reject(&.empty?).map do |line|
      line.split(",").map do |assignment|
        min, max = assignment.split("-").map(&.to_i)
        (min..max)
      end
    end
  end

  def count_redundant_assignments(assignments)
    assignments.select do |(left, right)|
      redundant?(left, right)
    end.size
  end

  def redundant?(left, right)
    smaller, larger = sort_by_containment(left, right)
    smaller.min >= larger.min && smaller.max <= larger.max
  end

  def sort_by_containment(left, right)
    if left.min <= right.min && left.max >= right.max
      [right, left]
    else
      [left, right]
    end
  end
end

CampCleanup.new(ARGV[0]? || "input.txt")

The only differences are in things that have been covered in previous days.

  1. CampCleanup.new(ARGV[0] || 'input.txt') => CampCleanup.new(ARGV[0]? || "input.txt")

  2. File.read(filename).split("\n").map do |line| => File.read(filename).split("\n").reject(&.empty?).map do |line|

  3. assignments.select do |left, right| => assignments.select do |(left, right)|

Rust solution

There is a lot to unpack here today.

use std::env;

struct CampCleanup {
    assignments: Vec<Vec<Assignment>>,
}

#[derive(Debug)]
struct Assignment {
    start: i32,
    end: i32,
}

impl CampCleanup {
    fn new(filename: &str) -> CampCleanup {
        let assignments = Self::parse_assignments(filename);

        //CampCleanup { assignments }
        Self { assignments }
    }

    fn run(&self) {
        println!(
            "Of {} assignments, {} are redundant.",
            self.assignments.len(),
            self.count_redundant_assignments(&self.assignments)
        );
    }

    fn parse_assignments(filename: &str) -> Vec<Vec<Assignment>> {
        let text = std::fs::read_to_string(filename).unwrap();
        text.lines()
            .map(|line| {
                line.split(",")
                    .map(|assignment| {
                        let mut parts = assignment.split("-");
                        let min = parts.next().unwrap().parse().unwrap();
                        let max = parts.next().unwrap().parse().unwrap();
                        Assignment {
                            start: min,
                            end: max,
                        }
                    })
                    .collect::<Vec<_>>()
            })
            .collect::<Vec<_>>()
    }

    fn count_redundant_assignments(&self, assignments: &Vec<Vec<Assignment>>) -> usize {
        assignments
            .iter()
            .filter(|assignment| self.is_redundant(&assignment[0], &assignment[1]))
            .count()
    }

    fn is_redundant(&self, left: &Assignment, right: &Assignment) -> bool {
        let (smaller, larger) = self.sort_by_containment(left, right);
        smaller.start >= larger.start && smaller.end <= larger.end
    }

    fn sort_by_containment<'a>(
        &self,
        left: &'a Assignment,
        right: &'a Assignment,
    ) -> (&'a Assignment, &'a Assignment) {
        if left.start <= right.start && left.end >= right.end {
            (right, left)
        } else {
            (left, right)
        }
    }
}

fn main() {
    let args: Vec<String> = env::args().collect();
    let mut filename = "input.txt";
    if let Some(arg) = args.get(1) {
        filename = arg;
    }

    CampCleanup::new(filename).run();
}

If you have been following along with the previous days, you will see that I've made a shift in the structure of the Rust solution from the first few.

Rust is not object-oriented in the way that languages like Ruby or Crystal are. While this dates me more than a little, Rust's object-oriented programming capabilities remind me a great deal of Perl's OO design. Starting with this solution, the Rust solutions are now structured in a way that more closely matches how the Ruby and the Crystal solutions are structured, with an object that can be instantiated to solve the day's task.

Without turning this article into a full tutorial on how to do object-oriented programming in Rust, let's take a quick look at that implementation.

struct CampCleanup {
    assignments: Vec<Vec<Assignment>>,
}

impl CampCleanup {
    fn new(filename: &str) -> CampCleanup {
        let assignments = Self::parse_assignments(filename);

        CampCleanup { assignments }
    }

    fn run(&self) {
        // Implementation stuff here
    }

    fn parse_assignments(filename: &str) -> Vec<Vec<Assignment>> {
        // implementation stuff here
    }
}

In Rust, a class is just a struct that has an implementation applied to it. And implementation, which is specified with the impl keyword, followed by the name of the struct to apply it to, and a block that contains the implementation is a set of functions that play the roles of class methods and instance methods.

The essential difference regarding whether something is a class method or an instance method is found in its definition and is reflected in how it is called.

Class methods are essentially just functions. However, instance methods are required to accept &self as their first argument. They are called using dot syntax:

some_rust_object.method_call("an argument")

When called with this syntax, Rust knows to pass a reference to some_rust_object as the first argument, implicitly. The function signature, however, must be written in a way that can accept this:

fn method_call(&self, text: &str) {
    // Implementation here
}

Given that understanding, let's take a closer look at today's specific implementation.

#[derive(Debug)]
struct Assignment {
    start: i32,
    end: i32,
}

impl CampCleanup {
    fn new(filename: &str) -> CampCleanup {
        let assignments = Self::parse_assignments(filename);

        CampCleanup { assignments }
    }

    fn parse_assignments(filename: &str) -> Vec<Vec<Assignment>> {
        let text = std::fs::read_to_string(filename).unwrap();
        text.lines()
            .map(|line| {
                line.split(",")
                    .map(|assignment| {
                        let mut parts = assignment.split("-");
                        let min = parts.next().unwrap().parse().unwrap();
                        let max = parts.next().unwrap().parse().unwrap();
                        Assignment {
                            start: min,
                            end: max,
                        }
                    })
                    .collect::<Vec<_>>()
            })
            .collect::<Vec<_>>()
    }
}

In the code above, you will see another struct definition, for Assignment. This struct is a simple container for the assignment's start and end values. It would have been possible to use a Range for this instead: (min..=max), but using a Range came with some challenges, which might end up being described in an entire standalone post, and they are just heavier than we need, given that all that is required for this solution are those numbers indicating the start and the end of the assignment span.

The constructor method, new(), has only two jobs. The first thing that it does is to call a class method to parse the assignments: Self::parse_assignments(filename). This calls the parse_assignments() function in the CampCleanup implementation, passing it a filename. It then does this:

CampCleanup { assignments }

That creates the instance of CampCleanup, which is what new() returns. This could also have been written as Self { assignments }, because Self refers to the current module. In this case, it felt clearer to be explicit about what is being returned.

There is nothing particularly novel about the parse_assignments() method, if you have been following along from previous days. The one thing that I will draw attention to is the fact that, as a class method, its definition is no different than that of any other function. Class methods are essentially just functions that exist within the struct/namespace in which they were defined.

What follows next is the meat of the solution.

    fn run(&self) {
        println!(
            "Of {} assignments, {} are redundant.",
            self.assignments.len(),
            self.count_redundant_assignments(&self.assignments)
        );
    }

    fn count_redundant_assignments(&self, assignments: &Vec<Vec<Assignment>>) -> usize {
        assignments
            .iter()
            .filter(|assignment| self.is_redundant(&assignment[0], &assignment[1]))
            .count()
    }

    fn is_redundant(&self, left: &Assignment, right: &Assignment) -> bool {
        let (smaller, larger) = self.sort_by_containment(left, right);
        smaller.start >= larger.start && smaller.end <= larger.end
    }

    fn sort_by_containment<'a>(
        &self,
        left: &'a Assignment,
        right: &'a Assignment,
    ) -> (&'a Assignment, &'a Assignment) {
        if left.start <= right.start && left.end >= right.end {
            (right, left)
        } else {
            (left, right)
        }
    }

Recall that in the main() method, the CampCleanup struct was instantiated and then the run() method called in a single line, CampCleanup::new(filename).run();.

The run() method is an instance method. In Rust, instance methods are required to take &self as their first argument. The calling syntax for such a method implicitly passes a reference to the object instance that the code is running within.

Thus, in the run() method, other instance methods can be referenced through self:

    fn run(&self) {
        println!(
            "Of {} assignments, {} are redundant.",
            self.assignments.len(),
            self.count_redundant_assignments(&self.assignments)
        );
    }

The actual calculation of the number of redundant assignments is handled in the count_redundant_assignments() method. You might have noticed that there is something a little different from most of the Rust code that I have written for AoC so far in the method call and the method definition.

The method is called via self.count_redundant_assignments(&self.assignments) and the definition looks like this: fn count_redundant_assignments(&self, assignments: &Vec<Vec<Assignment>>) -> usize. The code isn't passing self.assignments itself. Instead, it is passing a reference to self.assignments. If one tried to pass the assignments directly, there would be an error.

error[E0507]: cannot move out of `self.assignments` which is behind a shared reference
  --> day_4_1.rs:25:46
   |
25 |             self.count_redundant_assignments(self.assignments)
   |                                              ^^^^^^^^^^^^^^^^ move occurs because `self.assignments` has type `Vec<Vec<Assignment>>`, which does not implement the `Copy` trait

You can read more about the Copy trait in the Rust documentation, but essentially, if it is implemented via a #[derive(Copy)] line on the Assignment struct, it would enable instances of that struct to be duplicated. This is wholly unnecessary for this solution, so I have left it off, and we pass around a reference to the Vec<Vec<Assignment>> instead.

count_redundant_assignments() itself creates an iterator from the Vec that it receives. That call brings up another interesting feature of Rust.

    fn count_redundant_assignments(&self, assignments: &Vec<Vec<Assignment>>) -> usize {
        assignments
            .iter()
            .filter(|assignment| self.is_redundant(&assignment[0], &assignment[1]))
            .count()
    }

The method signature says that the method receives two references. One is a reference to self, and the other is a reference to the assignments. However, down in the method body, neither self nor assignments is being dereferenced. To see what I mean, the following code will work identically:

    fn count_redundant_assignments(&self, assignments: &Vec<Vec<Assignment>>) -> usize {
        (*assignments)
            .iter()
            .filter(|assignment| (*self).is_redundant(&assignment[0], &assignment[1]))
            .count()
    }

A leading asterisk, such as with *assignments, is a dereferencing operator. It allows the program to access the value that is pointed to by a reference. However, in the code that I wrote in this Rust solution, I didn't dereference assignments or self before using them, and it still worked. What's happening?

The Rust language will implicitly coerce a dereferencing operation on arguments to functions and methods if the type in question implements the Deref trait. This lets us keep the code a little visually cleaner, and eliminates a small amount of typing.

The actual code of this method creates an iterator for the Vec, and then calls filter() on it. The filter() method filters out any element for which the block, when evaluated, doesn't return true. The is_redundant() method takes the left and the right assignments and determines if one is redundant to the other; i.e. if it is completely enclosed by the range of the other. Then we call a count() on what is left to determine the number of assignment pairs where one is completely enclosed by the other.

The real meat of this, as with the Ruby and Crystal versions, is in the is_redundant() and the sort_by_containment() methods. There's also an interesting bit of fun to how these are defined that hasn't come up in previous AoC Rust solutions. So let's focus on those two methods for a moment.

    fn is_redundant(&self, left: &Assignment, right: &Assignment) -> bool {
        let (smaller, larger) = self.sort_by_containment(left, right);
        smaller.start >= larger.start && smaller.end <= larger.end
    }

    fn sort_by_containment<'a>(
        &self,
        left: &'a Assignment,
        right: &'a Assignment,
    ) -> (&'a Assignment, &'a Assignment) {
        if left.start <= right.start && left.end >= right.end {
            (right, left)
        } else {
            (left, right)
        }
    }

The is_redundant() method is, more or less, identical to the Ruby and the Crystal version. However, if you look at the syntax used to define sort_by_containment(), you should notice something new. What is this 'a stuff that is in that method definition?

That, my friends, is the syntax for specifying a Lifetime in Rust. Lifetimes are a way to ensure that references in your code are valid and will not be dangling (i.e., pointing to a location in memory that has been deallocated). Before going further, here's what happens if you try to compile the Rust solution without the lifetime syntax on that method:

error: lifetime may not live long enough
  --> day_4_1.rs:78:13
   |
73 |         &self,
   |         - let's call the lifetime of this reference `'2`
74 |         left: &Assignment,
   |               - let's call the lifetime of this reference `'1`
...
78 |             (right, left)
   |             ^^^^^^^^^^^^^ associated function was supposed to return data with lifetime `'2` but it is returning data with lifetime `'1`
   |
help: consider introducing a named lifetime parameter and update trait if needed
   |
72 ~     fn sort_by_containment<'a>(
73 ~         &'a self,
74 ~         left: &'a Assignment,
   |

error: lifetime may not live long enough

This means that the compiler isn't sure that the references that are being returned by this function will live long enough. If they were deallocated before they were used, that would result in an error, and the compiler wants to avoid that, so it throws an error here and advises the programmer to use an explicit lifetime.

It would be logical to assume that a lifetime has some sort of prescriptive power. That is, it tells the compiler/borrow checker how to manage the deallocation of data. However, lifetimes are descriptive. When data is deallocated is determined purely by how the code is written. The lifetime just lets the programmer help the compiler to understand more complex situations.

Going back to sort_by_containment(), the lifetime specification:

sort_by_containment<'a>(
        &self,
        left: &'a Assignment,
        right: &'a Assignment,
    ) -> (&'a Assignment, &'a Assignment)

is telling the compiler that the references returned by the function will have the same lifetime as the references passed into the function. Thus, the compiler is satisfied that everything will live long enough to avoid the error of trying to access some piece of data that has been deallocated. Lifetimes are an important concept, and if they still seem mystifying to you, it's worth your time to review that part of the Rust Book for more in-depth information than what you just read.

With solutions in all three languages, we could be done. However, the Elves aren't satisfied. They want more information.

Part 2

The Elves feel like there is still quite a bit of redundant work planned. Instead of determining only the ranges that are completely enclosed by the other in their pair, the Elves want to know how many of their assignments include any duplicated work at all.

The Approach

  • Keep everything the same as in Part 1, except that the methods that check for redundancy should be changed so that they detect any overlap at all. This can be done by checking to see if the left range's min or max is contained within the right range, and checking to see if the right range's min or max is contained within the left range. If either case is true, then there is redundant work.

Ruby solution

# frozen_string_literal: true

class CampCleanup
  def initialize(filename)
    assignments = parse_assignments(filename)

    puts "Of #{assignments.size} assignments, #{count_redundant_assignments(assignments)} overlap."
  end

  def parse_assignments(filename)
    File.read(filename).split("\n").map do |line|
      line.split(",").map do |assignment|
        min, max = assignment.split("-").map(&:to_i)
        (min..max)
      end
    end
  end

  def count_redundant_assignments(assignments)
    assignments.select do |left, right|
      redundant?(left, right)
    end.size
  end

  def redundant?(left, right)
    overlap?(left, right) || overlap?(right, left)
  end

  def overlap?(left, right)
    if left.min >= right.min && left.min <= right.max
      true
    elsif left.max >= right.min && left.max <= right.max
      true
    else
      false
    end
  end
end

CampCleanup.new(ARGV[0] || 'input.txt')

Most of this code is identical to the solution for Part 1. The only differences are in the last two methods, which determine redundancy/overlap.

  def redundant?(left, right)
    overlap?(left, right) || overlap?(right, left)
  end

  def overlap?(left, right)
    if left.min >= right.min && left.min <= right.max
      true
    elsif left.max >= right.min && left.max <= right.max
      true
    else
      false
    end
  end

The sort_by_containment method has been replaced by an overlap? method that is just a single if/elsif/else statement. The overlap? method just checks whether the left range's minimum or maximum is contained within the right range, and returns true/false. The redundant? method then checks whether the left range has a min or max inside of the right, or whether the right has a min or max inside of the left. If either is true, then redundant? will itself return true. No other changes are needed to solve Part 2.

Crystal solution

class CampCleanup
  def initialize(filename)
    assignments = parse_assignments(filename)

    puts "Of #{assignments.size} assignments, #{count_redundant_assignments(assignments)} overlap."
  end

  def parse_assignments(filename)
    File.read(filename).split("\n").reject(&.empty?).map do |line|
      line.split(",").map do |assignment|
        min, max = assignment.split("-").map(&.to_i)
        (min..max)
      end
    end
  end

  def count_redundant_assignments(assignments)
    assignments.select do |(left, right)|
      redundant?(left, right)
    end.size
  end

  def redundant?(left, right)
    overlap?(left, right) || overlap?(right, left)
  end

  def overlap?(left, right)
    if left.min >= right.min && left.min <= right.max
      true
    elsif left.max >= right.min && left.max <= right.max
      true
    else
      false
    end
  end
end

CampCleanup.new(ARGV[0]? || "input.txt")

You've read about the Ruby solution. The change to the Crystal solution is identical to the Ruby solution.

Rust solution

use std::env;

struct CampCleanup {
    assignments: Vec<Vec<Assignment>>,
}

#[derive(Debug)]
struct Assignment {
    start: i32,
    end: i32,
}

impl CampCleanup {
    fn new(filename: &str) -> CampCleanup {
        let assignments = Self::parse_assignments(filename);

        CampCleanup { assignments }
    }

    fn run(&self) {
        println!(
            "Of {} assignments, {} overlap.",
            self.assignments.len(),
            self.count_redundant_assignments(&self.assignments)
        );
    }

    fn parse_assignments(filename: &str) -> Vec<Vec<Assignment>> {
        let text = std::fs::read_to_string(filename).unwrap();
        text.lines()
            .map(|line| {
                line.split(",")
                    .map(|assignment| {
                        let mut parts = assignment.split("-");
                        let min = parts.next().unwrap().parse().unwrap();
                        let max = parts.next().unwrap().parse().unwrap();
                        Assignment {
                            start: min,
                            end: max,
                        }
                    })
                    .collect::<Vec<_>>()
            })
            .collect::<Vec<_>>()
    }

    fn count_redundant_assignments(&self, assignments: &Vec<Vec<Assignment>>) -> usize {
        assignments
            .iter()
            .filter(|assignment| self.is_redundant(&assignment[0], &assignment[1]))
            .count()
    }

    fn is_redundant(&self, left: &Assignment, right: &Assignment) -> bool {
        self.overlap(left, right) || self.overlap(right, left)
    }

    fn overlap(&self, left: &Assignment, right: &Assignment) -> bool {
        if left.start >= right.start && left.start <= right.end {
            true
        } else if left.end >= right.start && left.end <= right.end {
            true
        } else {
            false
        }
    }
}

fn main() {
    let args: Vec<String> = env::args().collect();
    let mut filename = "input.txt";
    if let Some(arg) = args.get(1) {
        filename = arg;
    }

    CampCleanup::new(filename).run();
}

The Rust solution, just like the Ruby and Crystal solution, deviates from Part 1 only in the is_redudant() and the overlap() methods.

    fn is_redundant(&self, left: &Assignment, right: &Assignment) -> bool {
        self.overlap(left, right) || self.overlap(right, left)
    }

    fn overlap(&self, left: &Assignment, right: &Assignment) -> bool {
        if left.start >= right.start && left.start <= right.end {
            true
        } else if left.end >= right.start && left.end <= right.end {
            true
        } else {
            false
        }
    }

With this solution, the code does not need to worry about the use of lifetimes because, while references are being passed into the overlap() method, only a bool is being returned. Thus, the Rust code, in this case, ends up being pretty similar to the Ruby code. There isn't a lot to say here. This works.

Conclusion

Day 4's challenge was a pretty simple one, in all three languages. I chose a slightly more complex and less efficient answer for Day 1 purely to present the concept of Rust lifetimes. Otherwise, Day 1's solution would have been as simple as Day 2's solution. Lifetimes in Rust are crucial for a Rust developer to understand because it won't always be possible to choose a solution that doesn't require them.