Onwards to day two of Advent of Code 2023. This time, I wanted to focus on an issue I have not looked into how to
resolve until now. When dealing with input from a source you do not control, such as text input from a user on a blog
attempting Advent of Code, you have to provide some validation to ensure bad input is handled correctly. First and
foremost, validating the input before it's processed is key. Although this code is relatively safe, since it will only
fail in the browser, a panic in Rust can render the site unresponsive. This is less than ideal. Therefore, validate
inputs, return results, and handle those results appropriately. No unwrap()
!! I'll explain the case I want to handle
better later, but for now, let's dive into the challenge.
After our last ride in the trebuchet, we have been thrown on a not-so-snowy, snowy island in the sky. An elf wants to play a simple game with us. We have a set of cubes in a sack. They are a known quantity. The elf pulls out a handful of cubes of varying colour, shows it to us, and returns them to the sack. Repeat this a few times and call it a game. We have some input, and if we ever see a set of cubes that could not have come from the sack due to that known quantity limit, we ignore them. Finally, sum up the number ID for each game and present it as the answer. For handling input, this is a great opportunity to find an ideal solution for validating multiline input. But first, let's write a test.
#[cfg(test)]
mod tests {
use super::*;
use crate::DefaultOutputSignal;
#[test]
fn test_solve_part_one() {
let input = String::from(
r#"Game 1: 3 blue, 4 red; 1 red, 2 green, 6 blue; 2 green
Game 2: 1 blue, 2 green; 3 green, 4 blue, 1 red; 1 green, 1 blue
Game 3: 8 green, 6 blue, 20 red; 5 blue, 4 red, 13 green; 5 green, 1 red
Game 4: 1 green, 3 red, 6 blue; 3 green, 6 red; 3 green, 15 blue, 14 red
Game 5: 6 red, 1 blue, 3 green; 2 blue, 1 red, 2 green
"#,
);
let cube_conundrum = CubeConundrum::new(DefaultOutputSignal);
let result = cube_conundrum.solve_part_one(input);
assert_eq!(result, Ok("8".to_string()));
}
}
In the case above, thank you Advent of Code for providing it, we know that games 1, 2, and 5 are valid but games 3 and 4 are not when considering a sack containing 12 red cubes, 13 green, and 14 blue. 20 red in game 3, and 15 blue in game 4 is not possible so we can ignore this. There is one last consideration to make. Because this code may run against arbitrary input provided by you, the reader, let's write additional tests covering cases someone might use to "break" the solution.
#[test]
fn test_no_input() {
let input = String::from("");
let cube_conundrum = CubeConundrum::new(DefaultOutputSignal);
let result = cube_conundrum.solve_part_one(input);
assert_eq!(result, Ok("0".to_string()))
}
#[test]
fn test_invalid_game_number_is_letter() {
let input = String::from(
r#"Game a: 3 blue, 4 red; 1 red, 2 green, 6 blue; 2 green
Game 2: 1 blue, 2 green; 3 green, 4 blue, 1 red; 1 green, 1 blue
"#,
);
let cube_conundrum = CubeConundrum::new(DefaultOutputSignal);
let result = cube_conundrum.solve_part_one(input);
assert_eq!(result, Err(()))
}
#[test]
fn test_invalid_game_contains_no_cubes() {
let input = String::from(
r#"Game 1: ; 1 red, 2 green, 6 blue; 2 green
Game 2: 1 blue, 2 green; 3 green, 4 blue, 1 red; 1 green, 1 blue
"#,
);
let cube_conundrum = CubeConundrum::new(DefaultOutputSignal);
let result = cube_conundrum.solve_part_one(input);
assert_eq!(result, Err(()))
}
#[test]
fn test_invalid_game_identifier_missing() {
let input = String::from(
r#": 3 blue, 4 red; 1 red, 2 green, 6 blue; 2 green
Game 2: 1 blue, 2 green; 3 green, 4 blue, 1 red; 1 green, 1 blue
"#,
);
let cube_conundrum = CubeConundrum::new(DefaultOutputSignal);
let result = cube_conundrum.solve_part_one(input);
assert_eq!(result, Err(()))
}
#[test]
fn test_invalid_game_contains_no_rounds() {
let input = String::from(
r#"Game 1: 3 blue, 4 red; 1 red, 2 green, 6 blue; 2 green
Game 2: 1 blue, 2 green; 3 green, 4 blue, 1 red; 1 green, 1 blue
Game 3:
"#,
);
let cube_conundrum = CubeConundrum::new(DefaultOutputSignal);
let result = cube_conundrum.solve_part_one(input);
assert_eq!(result, Err(()))
}
#[test]
fn test_invalid_game_contains_unknown_colour() {
let input = String::from(
r#"Game 1: 3 blue, 4 red; 1 red, 2 green, 6 blue; 2 green
Game 2: 1 blue, 2 green; 3 green, 4 blue, 1 red; 1 green, 1 blue
Game 3: 8 green, 6 blue, 4 red; 5 blue, 4 red, 13 green; 5 green, 1 red; 1 yellow
"#,
);
let cube_conundrum = CubeConundrum::new(DefaultOutputSignal);
let result = cube_conundrum.solve_part_one(input);
assert_eq!(result, Err(()))
}
Great, we now have tests that should prevent you from breaking my code... hopefully ;) Let me know if you find something I have missed but for now, let's write the code to make these tests pass. First, we need a data structure to give our data some context. Let's start with a Game, round in the game, and the colours we expect.
#[derive(Clone)]
struct Game {
id: u32,
rounds: Vec<Round>,
}
#[derive(Clone)]
struct Round {
cubes: HashMap<Colour, u32>,
}
#[derive(Clone)]
enum Colour {
Red,
Green,
Blue,
}
Now to give some context, I have a standard trait I follow for each implementation of Advent of Code. This may change as I extend this system with more features but for now, it's as follows.
pub trait AoCImplementation {
fn new(output_signal: impl OutputSignal + 'static) -> Self
where
Self: Sized;
fn solve_part_one(&self, input: String) -> Result<String, ()>;
fn solve_part_two(&self, input: String) -> Result<String, ()>;
}
There are a few things when looking at this code. The first, just to touch on it, is the OutputSignal
trait. This is
purely for writing strings to some output. For the context of testing, it's just a direct write to the console. For the
site, however, it wraps a signal provided by Leptos. I use this to provide an explanation and the result as it is
calculated.
Second to this is the actual methods we care about: solve_part_one
and solve_part_two
. These are the methods that
contain our implementation for the challenge. As you might see, they return a result. The result is either a string or
an empty error. This is so that any errors can be correctly handled by the caller. But this sets forth the pattern we
will be using in the implementation as well. Every method that could fail will return a Result
. To help with the
context of debugging, we will return a string with the error.
So actually kicking off the implementation, what do we need to do with this input? First, we need to iterate over each line, then parse each line into our specified structs above. A game is the root of a line, containing one or more rounds, with each round containing one or more cubes of various colours. Let's start with the first part of this process, parsing content.
fn solve_part_one(&self, input: String) -> Result<String, ()> {
let input_as_lines = split_multiline_string(input);
let mut games = Vec::new();
for line in &input_as_lines {
match Self::parse_game(line.clone()) {
Ok(game) => games.push(game),
Err(e) => {
self.output_signal.write(&format!("Error parsing game: {}", e));
return Err(());
}
}
}
todo!()
}
fn parse_game(line: String) -> Result<Game, String> {
todo!()
}
This is pretty straightforward. We loop through each line, parsing it into a game. If the result is bad, we return an
empty error, and we send out a nice explanation why. But, we can do better. And this is where I want to try something
new. If we took this code and swapped the for
loop for an iterator, we would end up with a problem. For one, we are
forced to return a Result
from the map
function on an iterator, thanks to the parse_game
function. But if we
collect that, we will end up with Vec<Result<Game, String>>
. Ideally, we would want to consolidate those results into
one. This is where try_fold
comes in. try_fold
is a method available on iterators. As it says on the tin, it "folds"
the iterator into a single value. You can use it to, say, add a set of integers together, or in our case, consolidate a
set of results into one. So let's update the code above to make use of an iterator with try_fold
.
fn solve_part_one(&self, input: String) -> Result<String, ()> {
let input_as_lines = split_multiline_string(input);
let games_result: Result<Vec<Game>, String> = input_as_lines.iter()
.try_fold(Vec::new(), |mut acc, line| {
match Self::parse_game(line.clone()) {
Ok(game) => {
acc.push(game);
Ok(acc)
}
Err(e) => Err(e),
}
});
let games = match games_result {
Ok(games) => { games }
Err(e) => {
self.output_signal.write(&format!("Error parsing game: {}", e));
return Err(());
}
};
todo!()
}
Very nice, we now use an iterator to map and consolidate the results into a single vec
, and we then handle the result.
That way we don't have to iterate over the whole list to validate and map every game. In addition, if we find that there
is a failure in, say, line 6 of 10,000, then we only need to process those first 5 lines and fail on the 6th. The
remainder are ignored. Now let's actually parse the game.
impl CubeConundrum {
fn parse_game(line: String) -> Result<Game, String> {
let mut parts = Vec::new();
for part in line.split(":") {
parts.push(part.to_string());
}
if parts.len() != 2 {
return Err("Invalid input format: Expected '<game number>: <rounds>'".to_string());
}
let game_number = parts[0]
.trim()
.split_whitespace()
.nth(1)
.ok_or_else(|| "Failed to parse game number".to_string())
.and_then(|s| u32::from_str(s).map_err(|_| "Failed to parse game number".to_string()))?;
let mut rounds = Vec::new();
for round_raw in parts[1].split(";") {
rounds.push(Self::parse_round(round_raw)?);
}
Ok(Game {
id: game_number,
rounds,
})
}
fn parse_round(round_raw: &str) -> Result<Round, String> {
todo!()
}
}
Pretty simple, we split the line into parts, giving us the game and its rounds. Do a little validation, and then retrieve the game number. Extract the game number, parse it, and then proceed to parse the rounds. Nothing special here. If this were production code, I might split the game number retrieval into a separate function, but for now, this is fine.
And finally parsing the rounds themselves.
impl CubeConundrum {
fn parse_round(round_raw: &str) -> Result<Round, String> {
let cubes_map = round_raw.trim().split(",")
.map(|set| {
let set_parts = set.trim().split_whitespace().collect::<Vec<&str>>();
if set_parts.len() != 2 {
return Err(format!("Invalid input format: '{}'. Expected '<count> <colour>'", set));
}
let count = match u32::from_str(set_parts[0]) {
Ok(value) => value,
Err(_) => return Err(format!("Invalid count value: '{}'", set_parts[0])),
};
match set_parts[1] {
"red" => Ok((Colour::Red, count)),
"green" => Ok((Colour::Green, count)),
"blue" => Ok((Colour::Blue, count)),
_ => Err(format!("Invalid colour: '{}'", set_parts[1])),
}
})
.try_fold(HashMap::new(), |mut acc, result| {
match result {
Ok((colour, count)) => {
acc.insert(colour, count);
Ok(acc)
}
Err(e) => { Err(e) }
}
})?;
Ok(Round {
cubes: cubes_map
})
}
}
Here we split the round into separate cube colours. I have used a similar approach to the game parsing where I use
try_fold
, however, here we are parsing to a HashMap
. That way we end up with a HashMap
of colours and counts.
There are additional spots here where this may logically fail for bad input, but let's ignore those for now...
And finally, we need to both filter out invalid games and sum up the valid games by their ID number.
impl AoCImplementation for CubeConundrum {
fn solve_part_one(&self, input: String) -> Result<String, ()> {
let input_as_lines = split_multiline_string(input);
let limit = HashMap::from([(Colour::Red, 12), (Colour::Green, 13), (Colour::Blue, 14)]);
let games_result = input_as_lines.iter()
.try_fold(Vec::new(), |mut acc, line| {
match Self::parse_game(line.clone()) {
Ok(game) => {
acc.push(game);
Ok(acc)
}
Err(e) => Err(e),
}
});
let games = match games_result {
Ok(games) => { games }
Err(e) => {
self.output_signal.write(&format!("Error parsing game: {}", e));
return Err(());
}
};
let valid_games = Self::filter_valid_games(&limit, &games);
let sum = valid_games.iter().fold(0u32, |acc, game| acc + game.id);
Ok(sum.to_string())
}
}
impl CubeConundrum {
fn filter_valid_games<'a>(limit: &HashMap<Colour, u32>, games: &'a Vec<Game>) -> Vec<&'a Game> {
games.iter()
.filter(|game| {
game.rounds.iter().all(|round| {
round.cubes.iter().all(|(colour, count)| {
count <= limit.get(colour).unwrap()
})
})
})
.collect::<Vec<&'a Game>>()
}
}
The filtering process involves defining limits for each cube colour, represented in a HashMap
. Then, we iterate over
each game and its rounds, ensuring that no round exceeds the defined limits for each cube colour. Games that meet these
criteria are retained as valid. Lifetimes are used here to ensure the filtered games reference lives as long as the
input games vector. This allows us to avoid unnecessary cloning of the input, thereby improving efficiency. Given the
scale of this problem, cloning wouldn’t be problematic, but avoiding it is a good habit for performance-sensitive
applications. This means we don't need to clone our input, saving memory and computation.
And finally, we sum up the valid games. I have again used a fold
method to sum up the games, this time specifying an
unsigned 32-bit integer, and summing them here. With our implementation in place and the tests verified, we have a solid
foundation for handling Advent of Code Day 02's challenge. Now that our implementation is complete, the final step is to
run our tests and verify the solution.
cargo test
Running unittests src/lib.rs (target/debug/deps/adventofcode-5d8132c088c14cbf)
running 7 tests
test y2023::day2::tests::test_invalid_game_contains_no_cubes ... ok
test y2023::day2::tests::test_invalid_game_contains_no_rounds ... ok
test y2023::day2::tests::test_invalid_game_identifier_missing ... ok
test y2023::day2::tests::test_invalid_game_number_is_letter ... ok
test y2023::day2::tests::test_invalid_game_contains_unknown_colour ... ok
test y2023::day2::tests::test_no_input ... ok
test y2023::day2::tests::test_solve_part_one ... ok
test result: ok. 7 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s
Doc-tests adventofcode
running 0 tests
test result: ok. 0 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s
All tests ran successfully, confirming that our solution handles both valid and invalid cases effectively. This includes:
With these tests passing, we can be confident that our solution is robust against malformed or unexpected input.
In part 2 of Day 2, we need to solve a new challenge involving the games we played. Instead of filtering out the impossible games, we need to determine how many cubes we need for each game to make it possible—precisely as needed—no more, no less. From there, we need to multiply the number of cubes required for each game together. Finally, we need to sum all the games to reach our final answer. Part one provides the necessary game-parsing foundation, so let's start off with a test.
#[test]
fn test_solve_part_two() {
let input = String::from(
r#"Game 1: 3 blue, 4 red; 1 red, 2 green, 6 blue; 2 green
Game 2: 1 blue, 2 green; 3 green, 4 blue, 1 red; 1 green, 1 blue
Game 3: 8 green, 6 blue, 20 red; 5 blue, 4 red, 13 green; 5 green, 1 red
Game 4: 1 green, 3 red, 6 blue; 3 green, 6 red; 3 green, 15 blue, 14 red
Game 5: 6 red, 1 blue, 3 green; 2 blue, 1 red, 2 green
"#,
);
let cube_conundrum = CubeConundrum::new(DefaultOutputSignal);
let result = cube_conundrum.solve_part_two(input);
assert_eq!(result, Ok("2286".to_string()))
}
Now, let's grab the game-parsing logic and data structures from part 1 to get us ready.
impl AoCImplementation for CubeConundrum {
fn solve_part_two(&self, input: String) -> Result<String, ()> {
let input_as_lines = split_multiline_string(input);
let games_result = input_as_lines.iter()
.try_fold(Vec::new(), |mut acc, line| {
match Self::parse_game(line.clone()) {
Ok(game) => {
acc.push(game);
Ok(acc)
}
Err(e) => Err(e),
}
});
let games = match games_result {
Ok(games) => { games }
Err(e) => {
self.output_signal.write(&format!("Error parsing game: {}", e));
return Err(());
}
};
todo!()
}
}
An advantage here is that because we parsed the games in part 1, we can reuse the data model to build our solution. Let's write a function that calculates the power of a game.
fn calculate_game_power(game: &Game) -> u32 {
let min_cubes = game.rounds.iter().fold(HashMap::new(), |mut acc, round| {
round.cubes.iter().for_each(|(colour, count)| {
acc.insert(
colour.clone(),
max(*acc.entry(colour.clone()).or_insert_with(|| 0u32), *count),
);
});
acc
});
min_cubes.iter().fold(1u32, |acc, (_, count)| acc * count)
}
Again, we are making use of fold
here to first fold a game's rounds into a hashmap of minimum cubes. Then, we fold the
minimum cubes into a single value representing the power of the game. In theory, this should not fail, so we can use
fold
instead of try_fold
. Typically, try_fold
is used when there's a possibility of failure during iteration,
allowing for early exit with an error. Here, since we do not expect any failure, fold
is sufficient and more
straightforward. Let's use this function to calculate the power.
fn solve_part_two(&self, input: String) -> Result<String, ()> {
let input_as_lines = split_multiline_string(input);
let games_result = input_as_lines.iter().try_fold(Vec::new(), |mut acc, line| {
match Self::parse_game(line.clone()) {
Ok(game) => {
acc.push(game);
Ok(acc)
}
Err(e) => Err(e),
}
});
let games = match games_result {
Ok(games) => games,
Err(e) => {
self.output_signal
.write(&format!("Error parsing game: {}", e));
return Err(());
}
};
Ok(games
.iter()
.map(|game| Self::calculate_game_power(game))
.sum())
}
And that's it! We've solved part 2 of Day 2. We can now run our tests and verify the correctness of our solution.
cargo test
Running unittests src/lib.rs (target/debug/deps/adventofcode-5d8132c088c14cbf)
running 8 tests
test y2023::day2::tests::test_invalid_game_contains_no_cubes ... ok
test y2023::day2::tests::test_invalid_game_contains_no_rounds ... ok
test y2023::day2::tests::test_invalid_game_contains_unknown_colour ... ok
test y2023::day2::tests::test_invalid_game_number_is_letter ... ok
test y2023::day2::tests::test_invalid_game_identifier_missing ... ok
test y2023::day2::tests::test_no_input ... ok
test y2023::day2::tests::test_solve_part_one ... ok
test y2023::day2::tests::test_solve_part_two ... ok
test result: ok. 8 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s
Doc-tests adventofcode
running 0 tests
test result: ok. 0 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s
And that's a wrap for Day 2. This was a relatively straightforward challenge from the Advent of Code team, but they will ramp up their challenge quickly. Plus, at least for myself, I have learned a new tool to use in my Rust toolbelt. I hope you've enjoyed this write-up, and Day 3 will be coming soon. Until then, happy coding!