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

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

The Elves are on a roll as they get ready to head into the jungle on day 3 of Advent of Code. As usual, though, they need some help.

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_3

Part 1

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

The Elves have been preparing for a jungle journey and have assigned one of their own the important task of packing all of the supplies into rucksacks. However, it seems that the Elf responsible for packing didn't quite follow the instructions, and now some items need to be rearranged. Each rucksack has two compartments and all items of a given type are supposed to go into one of the two compartments. But for each rucksack, the Elf packed at least one item into both compartments.

To help fix the mistakes and prioritize the rearrangement of items, the Elves have made a list of all of the items currently in each rucksack and asked for your assistance in finding the errors. Every item is identified by a single letter, and the list of items for each rucksack is given as characters on a single line. The first half of the characters represent the items in the first compartment, while the second half represents the items in the second compartment. The Elves have also assigned a priority to each type of item, with lowercase items having priorities from 1 to 26 and uppercase items having priorities from 27 to 52.

Your task is to identify which items appear in both compartments of each rucksack and find the sum of their priorities. To do this, you will need to iterate through the list of rucksacks and, for each rucksack, compare the items in the first compartment to the items in the second compartment. If there is an item that appears in both compartments, you should add its priority to the sum. After you have processed all of the rucksacks, you should have the sum of the priorities of the items that appeared in both compartments of each rucksack.

Let's consider some sample input for this challenge:

vJrwpWtwJgWrhcsFMMfFFhFp
jqHRNqRjqzjGDLGLrsFMfFZSrLrFZsSL
PmmdzqPrVvPwwTWBwg
wMqvLMZHhHMvwLHjbvcjnnSBnvTQFn
ttgJtRGJQctTZtZT
CrZsJsPPZsGzwwsLwLmpwMDw

Consider the shortest of those, ttgJtRGJQctTZtZT. It is 16 characters long, so dividing it into two equal parts produces ttgJtRGJ and QctTZtZT. The common letter between those two sets it t, and it has a value of 20.

So, the challenge is to determine the common item (letter) between the two halves of each line and then determine the priority for that letter and sum all of the priorities.

The Approach

  1. Parse the data into an array of individual lines.

  2. Iterate over each line in the array, splitting each line in half, creating an array of two strings, one for each half, with the end result being an array of string arrays.

  3. Iterate over this array of arrays, finding the duplicate item (letter) in each half, and returning a new array of just those duplicated.

  4. Calculate a priority for each item, and sum the priorities.

Ruby Solution

# frozen_string_literal: true

class RuckSackn
  def initialize(filename)
    rucksacks = parse_rucksacks(filename)
    sum = rucksacks.map { |rucksack| item_priority(find_duplicates(*rucksack)) }.sum

    puts "Priority sum of all duplicated items: #{sum}"
  end

  def parse_rucksacks(filename)
    File.read(filename).split("\n").map do |line|
      middle = line.size / 2
      left = line[0..(middle - 1)]
      right = line[middle..]
      [left, right]
    end
  end

  def find_duplicates(left, right)
    left.split('') & right.split('')
  end

  def item_priority(items)
    items.reduce(0) do |sum, item|
      case item
      in ('a'..'z')
        sum += item.ord - 96
      in ('A'..'Z')
        sum += item.ord - 38
      end

      sum
    end
  end
end

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

As will be the pattern for the foreseeable future, from Day 2 onward, the solution will be structured into a Ruby class using the initialize() method to coordinate the execution of the solution.

This Ruby code defines a RuckSackn class that is used to find the sum of the priorities of the items that appear in both compartments of each rucksack. The use of the RuckSackn class starts with the initialize method, which coordinates solving the challenge.

  def initialize(filename)
    rucksacks = parse_rucksacks(filename)
    sum = rucksacks.map { |rucksack| item_priority(find_duplicates(*rucksack)) }.sum

    puts "Priority sum of all duplicated items: #{sum}"
  end

This method is called when a new RuckSackn object is created. It takes in a filename as an argument, which is the name of a file containing the list of rucksacks. It first calls the parse_rucksacks method to parse the list of rucksacks and split each rucksack into its two compartments. It then calls the item_priority method on the result of calling find_duplicates on each rucksack, and sums the results. Finally, it prints out the sum of the priorities of the duplicated items.

  def parse_rucksacks(filename)
    File.read(filename).split("\n").map do |line|
      middle = line.size / 2
      left = line[0..(middle - 1)]
      right = line[middle..]
      [left, right]
    end
  end

The parse_rucksacks method takes in a filename and reads the file to get the list of rucksacks. It then splits the rucksacks into two equal parts, with the first half representing the items in the first compartment and the second half representing the items in the second compartment. It returns an array of two-element arrays, each containing the items in the first and second compartments of a rucksack.

  def find_duplicates(left, right)
    left.split('') & right.split('')
  end

Next, the find_duplicates method takes in two arguments, left and right, which represent the items in the first and second compartments of a rucksack, respectively. It converts left and right into arrays of characters and then finds the intersection of the two arrays using the & operator. The intersection is the set of elements that appear in both arrays. The method returns the intersection as an array.

  def item_priority(items)
    items.reduce(0) do |sum, item|
      case item
      in ('a'..'z')
        sum += item.ord - 96
      in ('A'..'Z')
        sum += item.ord - 38
      end

      sum
    end
  end

Finally, item_priority takes in an array of items and sums up their priorities. The priority of an item is determined by its ASCII value: lowercase letters have ASCII codes between 97 for 'a' and 122 for 'z'. Lowercase letters have priorities from 1 to 26, so calculating that priority is a matter of subtracting 96 from the ASCII value of the letter. Likewise, uppercase 'A' has an ASCII code of 64, and a value of 27, so the priority of uppercase letters is found by subtracting 38 from the ASCII value.

The value so derived is added to a running total sum. Thus, the method returns the sum of the priorities.

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

The RuckSackn class is used by calling the new method and passing in the name of the input file as an argument. If no argument is provided, it defaults to 'input.txt'. The new method creates a new RuckSackn object and calls the initialize method on it, which performs the tasks described above.

Crystal Solution

This Crystal solution, like the previous two, ends up being nearly identical to the Ruby solution:

class RuckSackn
  def initialize(filename)
    rucksacks = parse_rucksacks(filename)
    sum = rucksacks.map { |rucksack| item_priority(find_duplicates(*rucksack)) }.sum

    puts "Priority sum of all duplicated items: #{sum}"
  end

  def parse_rucksacks(filename)
    File.read(filename).split("\n").map do |line|
      middle = (line.size / 2).to_i # line.size // 2 is better, but
      # HashNode syntax highlighting breaks on it.
      left = line[0..(middle - 1)]
      right = line[middle..]
      {left, right}
    end
  end

  def find_duplicates(left, right)
    left.split("") & right.split("")
  end

  def item_priority(items)
    items.reduce(0) do |sum, item|
      case item
      when ("a".."z")
        sum += item[0].ord - 96
      when ("A".."Z")
        sum += item[0].ord - 38
      end

      sum
    end
  end
end

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

The Crystal solution is nearly identical to the Ruby solution, but there are a couple of small differences.

  def parse_rucksacks(filename)
    File.read(filename).split("\n").map do |line|
      middle = (line.size / 2).to_i # line.size // 2 is better, but
      # HashNode syntax highlighting breaks on it.
      left = line[0..(middle - 1)]
      right = line[middle..]
      {left, right}
    end
  end

In parse_rucksacks there are two interesting pieces. The first is the calculation of the middle of the line. In Ruby, an integer division like 17 / 2 will return 8. In Crystal, the division operator, even with integer operands, will return a floating-point result if the answer is not an integer, so 17 / 2 returns 8.5.

The best way to handle this with Crystal is to use an integer division method, which in Crystal is //. In the code above, that is not being used, however, simply because HashNode's syntax highlighting is breaking on // (I am using the Ruby syntax highlighting because HashNode isn't supporting Crystal syntax highlighting yet).

The other difference is that in Crystal, instead of returning a two-element array with the left and right contents, we are returning a Tuple. A Tuple is a stack-allocated data structure with a size known at compile time. It is addressed similarly to an Array, via numeric indexes: tuple[1]. However, it has some unique behaviors in addition to being stack allocated.

In Ruby, one can use the splat operator to expand an array into method arguments: foo.bar(*args). In Crystal, an array can not be spatted into method arguments because the size of an array can not be known at compile time - method call signatures need to be known at compile time. Thus, the splat operator requires a Tuple, as its size is known at compile time.

  def item_priority(items)
    items.reduce(0) do |sum, item|
      case item
      when ("a".."z")
        sum += item[0].ord - 96
      when ("A".."Z")
        sum += item[0].ord - 38
      end

      sum
    end
  end

The other difference involves how ord is handled in Crystal versus in Ruby. In Ruby, the ord method, when called on a String, returns the Integer ordinal of the first character in the string. In Crystal, ord is not an available method on String. Crystal has a Char data type that represents a single character, and ord is a method on Char. So, to check the ordinal on a single character string, one must extract that character as its own individual entity. This can be done using the [] method with an integer index, to get the character at the index, or via the char_at method, which also returns the character at the given index. Crystal doesn't tend to have multiple methods that do the same thing but in this case, item[0] is equivalent to char_at(0).

Rust Solution

use std::collections::HashSet;
use std::env;
use std::fs;

fn main() {
    let args: Vec<String> = env::args().collect();
    let mut filename = "input.txt";
    if let Some(arg) = args.get(1) {
        filename = arg;
    }
    let rucksacks = parse_rucksacks(filename);
    let sum: u32 = rucksacks.into_iter().map (|rucksack| item_priority(find_duplicates(rucksack))).sum();

    print!("Priority sum of all duplicated items: {}\n", sum);
}

fn parse_rucksacks(filename: &str) -> Vec<Vec<String>> {
    let text = fs::read_to_string(filename).unwrap();
    text.split("\n")
        .map(|line| {
            let middle = line.len() / 2;
            let left = line.chars().take(middle).collect::<String>();
            let right = line.chars().skip(middle).collect::<String>();
            vec![left.to_string(), right.to_string()]
        })
        .collect::<Vec<_>>()
}

fn find_duplicates(rucksack: Vec<String>) -> HashSet<char> {
    let mut left_set = HashSet::new();
    let mut right_set = HashSet::new();

    for ch in rucksack[0].chars() {
        left_set.insert(ch);
    }

    for ch in rucksack[1].chars() {
        right_set.insert(ch);
    }

    left_set.intersection(&right_set).cloned().collect()
}

fn item_priority(items: HashSet<char>) -> u32 {
    items.iter().fold(0, |sum: u32, character| match character {
        'a'..='z' => sum + (*character as u32) - 96,
        'A'..='Z' => sum + (*character as u32) - 38,
        _ => sum,
    })
}

The Rust solution was fun. It follows the same general approach as the Ruby and the Crystal solutions, so I won't dwell on the similarities, or on the general language differences that have already been covered in Day 1 or Day 2, but will instead focus on a couple of the interesting implementation details.

The first of these involves something very easy in both Ruby and in Crystal - finding the duplicates between the compartments using a set intersection.

As a reminder, this is how it looks in Ruby and Crystal:

  def find_duplicates(left, right)
    left.split("") & right.split("")
  end

If one attempts to do something similar in Rust, however, an error results:

error[E0369]: no implementation for `std::str::Split<'_, &str> & std::str::Split<'_, &str>`
 --> a.rs:5:18
  |
5 |   left.split("") & right.split("")
  |   -------------- ^ --------------- std::str::Split<'_, &str>
  |   |
  |   std::str::Split<'_, &str>

Rust does not implement any set intersection operations on the results of a split, or on any array or vec data types. A different implementation is required:

use std::collections::HashSet;

fn find_duplicates(rucksack: Vec<String>) -> HashSet<char> {
    let mut left_set = HashSet::new();
    let mut right_set = HashSet::new();

    for ch in rucksack[0].chars() {
        left_set.insert(ch);
    }

    for ch in rucksack[1].chars() {
        right_set.insert(ch);
    }

    left_set.intersection(&right_set).cloned().collect()
}

Rust provides a crate for a HashSet, which provides a method to determine the intersection between two HashSet instances. In order to create a HashSet, the chars() method is used on each string representing the container contents. It returns an iterator to each of the characters that make up the string. That iterator is looped through, and each character is inserted into a HashMap.

Once two HashMap instances are created, the intersection between them can be found:

left_set.intersection(&right_set).cloned().collect()

The other interesting difference in implementation is found in the function to calculate item priority.

fn item_priority(items: HashSet<char>) -> u32 {
    items.iter().fold(0, |sum: u32, character| match character {
        'a'..='z' => sum + (*character as u32) - 96,
        'A'..='Z' => sum + (*character as u32) - 38,
        _ => sum,
    })
}

In Rust, the fold() method essentially does the same thing that reduce does in the other two languages. It creates an iterator, and takes two arguments, an initial value for the accumulator, and a closure that takes two values, the current value of the accumulator, and the next element from the iterator. It iterates over all of the elements, calling the closure with each, and assigning the return value from the closure to the value of the accumulator. Thus, it can be said to have folded all of the values that it iterated over into a single final value.

Rust also has a reduce method, but its behavior is subtly different from fold, and it returns its value in an Option, which for this challenge would require a little extra code to work with, so for this challenge, fold is the better choice.

The other interesting difference with the Rust code is that there is no method being called to turn the character into its ordinal integer value. Instead, the mechanism being used is a typecast. In Rust, an explicit typecast is done using the as keyword -- (*character as u32) takes the character being iterated over, and converts it to a u32. The end effect is the same as Ruby or Crystal's use of ord.

All of this gets tied together with a few lines in main():

    let rucksacks = parse_rucksacks(filename);
    let sum: u32 = rucksacks.into_iter().map (|rucksack| item_priority(find_duplicates(rucksack))).sum();

    print!("Priority sum of all duplicated items: {}\n", sum);

The rucksacks are parsed from the file, the duplicates are found, and then the priorities are found from the duplicates, and all of the priorities are summed. We have our answer.

Part 2

The second part of this day's challenge offers a twist on the first part.

The Elves are divided into groups of three and each group carries a badge that identifies their group. All of the badges need to be pulled out so that new authenticity stickers can be attached. However, nobody knows which item type corresponds to each group's badges. It can be determined by finding the one item type that is common between all three Elves in each group. For example, in the following data set, the first group's rucksacks are the first three lines:

vJrwpWtwJgWrhcsFMMfFFhFp jqHRNqRjqzjGDLGLrsFMfFZSrLrFZsSL PmmdzqPrVvPwwTWBwg

And the second group's rucksacks are the next three lines:

wMqvLMZHhHMvwLHjbvcjnnSBnvTQFn ttgJtRGJQctTZtZT CrZsJsPPZsGzwwsLwLmpwMDw

In the first group, the only item type that appears in all three rucksacks is lowercase r; this must be their badges. In the second group, their badge item type must be Z.

Each set of three lines in a list corresponds to a single group, but each group can have a different badge item type. The priorities for these items must be found to organize the sticker attachment efforts. In this example, the priorities are 18 (r) for the first group and 52 (Z) for the second group. The sum of these priorities is 70.

Like in Part 1, this task involves finding duplicates across sets of items, and it involves calculating the priorities of those duplicates. However, this time, the data file had to be read in groups of three lines, and the check for duplicates required checking across all three of those sets.

The Approach

  1. Read the data file, splitting it into groups of three lines, inserting each group of three lines as a three-element array into an array of three-element arrays.

  2. Find the common characters between the three lines in each three-element array.

  3. Calculate the priorities of the common characters that are found.

  4. Sum the priorities.

Ruby Solution

# frozen_string_literal: true

class RuckSackn
  def initialize(filename)
    rucksacks = parse_rucksacks(filename)
    sum = rucksacks.map { |rucksack| item_priority(find_duplicates(*rucksack)) }.sum

    puts "Priority sum of all duplicated items: #{sum}"
  end

  def parse_rucksacks(filename)
    rucksacks = []
    File.read(filename).split("\n").each_slice(3) do |trio|
      rucksacks << trio
    end

    rucksacks
  end

  def find_duplicates(left, middle, right)
    left.split('') & middle.split('') & right.split('')
  end

  def item_priority(items)
    items.reduce(0) do |sum, item|
      case item
      in ('a'..'z')
        sum += item.ord - 96
      in ('A'..'Z')
        sum += item.ord - 38
      end

      sum
    end
  end
end

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

The first challenge is to read the data set in groups of three lines. However, Ruby has a method, Enumerable#each_slice, which makes it simple.

  def parse_rucksacks(filename)
    rucksacks = []
    File.read(filename).split("\n").each_slice(3) do |trio|
      rucksacks << trio
    end

    rucksacks
  end

This method operates on an Enumerable, returning slices of N elements from the Enumerable until all of the elements are exhausted. By pushing those slices into a new array, the method builds an array of three-element arrays representing the collections of rucksacks which will be checked for duplicate items.

The second challenge is to get an intersection between three sets instead of two. In Ruby, and in Crystal, this isn't much of a challenge, but as you will see in the Rust example, not all languages have it this easy.

  def find_duplicates(left, middle, right)
    left.split('') & middle.split('') & right.split('')
  end

In Ruby, because the result of the result of array & array is an array, one can chain any number of arrays together with an & method call and the result will be an intersection between all of the arrays.

No other changes are required to solve this problem. The method that calculates the item priorities, and the line that sums the priorities still does what is needed with no alterations. This Ruby solution is complete.

Crystal Solution

class RuckSackn
  def initialize(filename)
    rucksacks = parse_rucksacks(filename)
    sum = rucksacks.map { |rucksack| item_priority(find_duplicates(*rucksack)) }.sum

    puts "Priority sum of all duplicated items: #{sum}"
  end

  def parse_rucksacks(filename)
    rucksacks = [] of Tuple(String, String, String)
    File.read(filename).split("\n").each_slice(3) do |trio|
      rucksacks << {trio[0], trio[1], trio[2]} if trio.size == 3
    end

    rucksacks
  end

  def find_duplicates(left, middle, right)
    left.split("") & middle.split("") & right.split("")
  end

  def item_priority(items)
    items.reduce(0) do |sum, item|
      case item
      when ("a".."z")
        sum += item[0].ord - 96
      when ("A".."Z")
        sum += item[0].ord - 38
      end

      sum
    end
  end
end

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

The Crystal solution ends up being very similar to the Ruby solution, except for the parsing of the rucksacks from the data file.

  def parse_rucksacks(filename)
    rucksacks = [] of Tuple(String, String, String)
    File.read(filename).split("\n").each_slice(3) do |trio|
      rucksacks << {trio[0], trio[1], trio[2]} if trio.size == 3
    end

    rucksacks
  end

Crystal's type inference engine is capable of figuring out the type signatures of most code without requiring a developer to explicitly specify it. However, there are some places where the engine needs to be giving explicit information. One of these areas is the creation of an empty array.

In the case of an array that is populated with data when it is created, like foo = [1, 2, 3], the type inference engine can look at the type(s) of the data in the array and can deduce that the array must have a type signature that will hold that data. However, in the case of an empty array, like rucksacks = [], the type inference engine does not know what type of data will be stored there. In that case, the developer has to provide that information.

rucksacks = [] of Tuple(String, String, String)

This says that rucksacks will be an Array of Tuple where each Tuple holds three instances of String. This could also have been written as:

rucksacks = Array(Tuple(String, String, String)).new

Crystal, like Ruby, has an each_slice method that works in the same way as the Ruby method, so the only difference here is the line that inserts the trio into the rucksacks array.

rucksacks << {trio[0], trio[1], trio[2]} if trio.size == 3

In this case, like with the previous Crystal solution, it constructs a Tuple, except this Tuple contains three elements. It also has a guard clause that will ensure that this code only runs if there are three lines available. This line protects against a situation where each_slice returns fewer than three lines, and just ensures that the program doesn't fail with an exception in that case.

There is nothing surprising about the rest of the solution. It essentially mirrors the Ruby solution.

Rust Solution

use std::collections::HashSet;
use std::env;
use std::fs;

fn main() {
    let args: Vec<String> = env::args().collect();
    let mut filename = "input.txt";
    if let Some(arg) = args.get(1) {
        filename = arg;
    }
    let rucksacks = parse_rucksacks(filename);
    let sum: u32 = rucksacks
        .into_iter()
        .map(|rucksack| item_priority(find_duplicates(rucksack)))
        .sum();

    print!("Priority sum of all duplicated items: {}\n", sum);
}

fn parse_rucksacks(filename: &str) -> Vec<Vec<String>> {
    let text = fs::read_to_string(filename).unwrap();
    text.lines()
        .collect::<Vec<_>>()
        .chunks(3)
        .map(|chunk| {
            vec![
                chunk[0].to_string(),
                chunk[1].to_string(),
                chunk[2].to_string(),
            ]
        })
        .collect()
}

fn find_duplicates(rucksack: Vec<String>) -> HashSet<char> {
    let mut left_set = HashSet::new();
    let mut middle_set = HashSet::new();
    let mut right_set = HashSet::new();

    rucksack[0].chars().for_each(|ch| {
        left_set.insert(ch);
    });

    rucksack[1].chars().for_each(|ch| {
        middle_set.insert(ch);
    });

    rucksack[2].chars().for_each(|ch| {
        right_set.insert(ch);
    });

    let intermediary = &left_set & &middle_set;

    &intermediary & &right_set
}

fn item_priority(items: HashSet<char>) -> u32 {
    items.iter().fold(0, |sum: u32, character| match character {
        'a'..='z' => sum + (*character as u32) - 96,
        'A'..='Z' => sum + (*character as u32) - 38,
        _ => sum,
    })
}

Rust, bless its little heart, always seems to be the challenging one. In this case, starting right at the parsing of the data, reading the data in three-line chunks, and building an array of three-element arrays out of it was significantly more involved in Rust than in either of the other two languages.

fn parse_rucksacks(filename: &str) -> Vec<Vec<String>> {
    let text = fs::read_to_string(filename).unwrap();
    text.lines()
        .collect::<Vec<_>>()
        .chunks(3)
        .map(|chunk| {
            vec![
                chunk[0].to_string(),
                chunk[1].to_string(),
                chunk[2].to_string(),
            ]
        })
        .collect()
}

To break the lines into three-line chunks, the lines are first converted to a Vec, and then the chunks() method is used to return consecutive 3-element chunks of the original Vec. That chunk is then explicitly converted into a Vec<String>, and all of those are collected back into an Vec<Vec<String>>.

There are several alternative implementations that all achieve the same effect. One of the most obvious to me was to use map() instead of the vec! macro and explicit chunk references to build the internal Vec instances.

fn parse_rucksacks(filename: &str) -> Vec<Vec<String>> {
    let text = fs::read_to_string(filename).unwrap();
    text.lines()
        .collect::<Vec<_>>()
        .chunks(3)
        .map(|chunk| chunk.iter().map(|s| s.to_string()).collect())
        .collect()
}

This implementation is shorter and is arguably more correct since it is employing an iterator to turn everything in the chunk into a String. If the method had a second parameter specifying the chunk size, it would readily adapt with no other changes to chunks of any size. However, for my implementation, I did not use this version of parse_rucksacks() specifically because being explicit about how the internal vec was built felt like it was more readable, at least to someone who wasn't well-versed in Rust.

The other challenge involved efficiently performing an intersection between three sets, instead of two. In Ruby and Crystal, as seen earlier, this is straightforward:

left.split("") & middle.split("") & right.split("")

Rust makes you do a little more work.

fn find_duplicates(rucksack: Vec<String>) -> HashSet<char> {
    let mut left_set = HashSet::new();
    let mut middle_set = HashSet::new();
    let mut right_set = HashSet::new();

    rucksack[0].chars().for_each(|ch| {
        left_set.insert(ch);
    });

    rucksack[1].chars().for_each(|ch| {
        middle_set.insert(ch);
    });

    rucksack[2].chars().for_each(|ch| {
        right_set.insert(ch);
    });

    let intermediary = &left_set & &middle_set;

    &intermediary & &right_set
}

Instead of building two instances of HashSet, this version of find_duplicates() constructs three. It is tempting to try something similar to the Ruby/Crystal code. Perhaps something like this:

&left_set & &middle_set & &right_set

This will fail.

error[E0369]: no implementation for `HashSet<char> & &HashSet<char>`
  --> day_3_2.rs:64:29
   |
64 |     &left_set & &middle_set & &right_set
   |     ----------------------- ^ ---------- &HashSet<char>
   |     |
   |     HashSet<char>

error: aborting due to previous error

To work with three HashSet instances, one must find the intersection between two of them, which is returned as a HashSet, and then use that result to find the intersection with the final instance.

    let intermediary = &left_set & &middle_set;

    &intermediary & &right_set

With those changes, the Day 3 Part 2 solution for Rust will now work.

Conclusion

The code required for the Day 3 problems incorporated a lot of what has already been done in the first two days. The requirement to use the std::collections::HashSet create the set intersections in Rust is different from Ruby and Crystal, in large part because those two languages both implement set operations as part of their Enumerable implementation, which is part of the core language.

Additionally, the method for determining the ASCII code of a given character is more intricate in Rust than in Ruby or Crystal. Those two languages provide a simple method to do the work, while Rust it requires casting from a Char type to an Integer type.

However, despite the substantially increased line count in Rust to achieve the same end result as Ruby and Crystal, the basic structure of the implementation remains quite similar.