Compare commits

...

17 Commits

Author SHA1 Message Date
9081354ce8 Solve day 6 2025-12-07 02:02:43 -05:00
7a3463e8cc Solve day 5 2025-12-06 23:29:18 -05:00
1311cb830b Add profiling flamegraphs with pprof 2025-12-06 15:58:11 -05:00
74c28c9344 Performance optimization settings & switch to nightly rust 2025-12-06 13:51:45 -05:00
9de4d77a63 Improve perf of day 4 2025-12-05 01:25:14 -05:00
8f4bc5b802 Solve day 4 2025-12-05 01:01:38 -05:00
de1e4b5b4f day03: simplify digit parsing and remove invalid character handling
Weirdly improves speed of part1 but hurts speed of part2 🤷
2025-12-03 22:46:02 -05:00
df96d619b9 day03: clean up debug printing a bit 2025-12-03 22:20:12 -05:00
3a305e1bce u8s are faster than u32s 2025-12-03 22:13:51 -05:00
7a3c36000c Solve day 3 2025-12-03 22:03:24 -05:00
8e163be240 Optimize the hell out of day02 2025-12-03 19:55:51 -05:00
3d001c690f Glob import days in main.rs
So I don't need to update it every time.
2025-12-03 01:25:14 -05:00
5433430628 Macro-ify benches and unify days list to one file
Also update README with new options.
2025-12-03 01:22:09 -05:00
cdc0225ca8 Better CLI with clap and day runner macro 2025-12-03 00:53:27 -05:00
9cce1d7fdb day02 solved 2025-12-03 00:05:12 -05:00
e490bc99f5 Add criterion benchmarking
zoom zoom
2025-12-02 22:04:05 -05:00
952a851b41 Update README 2025-12-02 09:42:16 -05:00
20 changed files with 2069 additions and 27 deletions

2
.cargo/config.toml Normal file
View File

@@ -0,0 +1,2 @@
[build]
rustflags = ["-C", "target-cpu=native", "-Cforce-frame-pointers=yes"]

1063
Cargo.lock generated

File diff suppressed because it is too large Load Diff

View File

@@ -4,14 +4,38 @@ version = "0.1.0"
edition = "2024"
[dependencies]
clap = { version = "4.5", features = ["derive"] }
color-eyre = "0.6"
tracing = "0.1"
itertools = "0.14"
rayon = "1.11"
tracing = { version = "0.1", features = ["release_max_level_info"] }
tracing-error = "0.2"
tracing-subscriber = { version = "0.3", features = ["env-filter"] }
[dev-dependencies]
test-log = {version = "0.2", default-features = false, features = ["trace"]}
criterion = "0.5"
pprof = { version = "0.15" , features = ["flamegraph", "criterion"] }
test-log = { version = "0.2", default-features = false, features = ["trace"] }
# Improve perf on debug builds: https://docs.rs/color-eyre/latest/color_eyre/#improving-perf-on-debug-builds
[profile.dev.package.backtrace]
opt-level = 3
# Gotta go fast
[profile.release]
codegen-units = 1
lto = "fat"
panic = "abort"
debug = false # set true for profiling
[lib]
name = "aoc"
path = "src/lib.rs"
[[bin]]
name = "aoc_bin"
path = "src/main.rs"
[[bench]]
name = "aoc"
harness = false

View File

@@ -1,15 +1,50 @@
# Advent of Code 2025
Rusty edition.
Rusty and over-engineered edition.
## Running
By request of AoC creator, I haven't included the input files (e.g. src/input/day01.txt). Log into the Advent of Code site and save the inputs there to the src/input/ folder.
Then to run: `cargo run`.
To run all days: `cargo run`.
To run a specific day and/or part: `cargo run -- --day 1 --part 1`.
To run in super-fast prod mode: `cargo run --release`.
To run with debug logs enabled: `RUST_LOG=debug cargo run`.
To run the tests against included test input files: `RUST_LOG=debug cargo test -- --no-capture`.
To run all the tests against included test input files: `RUST_LOG=debug cargo test -- --no-capture`.
To run the tests for a specific day and/or part: `RUST_LOG=debug cargo test day01::test::test_part1 -- --no-capture`.
## Benchmarks
Because this is over-engineered, I've included benchmarks for each day's solution. Because, why not?
To run benchmarks: `cargo bench`. Or a specific day and/or part: `cargo bench -- "day02 part1"`.
### Results
These were all run on my personal machine, an AMD Ryzen 9 3900X 12-Core Processor with 32 GB RAM, on Linux (WSL), with nightly rust.
Timings are given as: [lower-bound **best-estimate** upper-bound]
| Day | Part 1 | Part 2 |
|-----|--------|--------|
| 01 | [79.998 µs **80.349 µs** 80.721 µs] | [76.289 µs **76.616 µs** 76.950 µs] |
| 02 | [2.0386 ms **2.0483 ms** 2.0584 ms] | [2.0823 ms **2.0918 ms** 2.1015 ms] |
| 03 | [45.711 µs **45.937 µs** 46.177 µs] | [267.18 µs **267.95 µs** 268.75 µs] |
| 04 | [143.40 µs **144.00 µs** 144.73 µs] | [1.6165 ms **1.6258 ms** 1.6355 ms] |
| 05 | [187.25 µs **188.93 µs** 190.74 µs] | [63.809 µs **64.204 µs** 64.606 µs] |
| 06 | [128.44 µs **129.44 µs** 130.52 µs] | [165.05 µs **165.70 µs** 166.36 µs] |
## Profiling
To aid in increasing performance, the `pprof` crate can be used to generate flamegraphs off of the benchmarks.
To run profiling across all benchmarks: `cargo bench --bench aoc -- --profile-time 10`.
To run profile the benchmark for a specific day and/or part: `cargo bench --bench aoc -- --profile-time 30 "day01 part1"`.
The flamegraphs will be generated in `target/criterion/<benchmark_name>/profile/flamegraph.svg`.

36
benches/aoc.rs Normal file
View File

@@ -0,0 +1,36 @@
use criterion::{Criterion, criterion_group, criterion_main};
use pprof::criterion::{Output, PProfProfiler};
const PPROF_SAMPLING_FREQ_HZ: i32 = 997;
macro_rules! bench_days {
($($day_num:literal => $day_mod:ident),* $(,)?) => {
$(
mod $day_mod {
use super::*;
use aoc::$day_mod;
pub fn part1(c: &mut Criterion) {
c.bench_function(concat!(stringify!($day_mod), " part1"), |b| {
b.iter(|| $day_mod::part1($day_mod::INPUT))
});
}
pub fn part2(c: &mut Criterion) {
c.bench_function(concat!(stringify!($day_mod), " part2"), |b| {
b.iter(|| $day_mod::part2($day_mod::INPUT))
});
}
}
)*
criterion_group! {
name = benches;
config = Criterion::default().with_profiler(PProfProfiler::new(PPROF_SAMPLING_FREQ_HZ, Output::Flamegraph(None)));
targets = $($day_mod::part1, $day_mod::part2),*
}
criterion_main!(benches);
};
}
aoc::all_days!(bench_days);

View File

@@ -4,9 +4,9 @@ use color_eyre::{
Result,
eyre::{Error, eyre},
};
use tracing::{debug, info, instrument};
use tracing::{debug, instrument};
const INPUT: &str = include_str!("input/input.txt");
pub const INPUT: &str = include_str!("input/input.txt");
const LOCK_SIZE: i32 = 100;
const LOCK_STARTING_POSITION: i32 = 50;
@@ -30,7 +30,7 @@ impl FromStr for Direction {
}
#[instrument(skip(input))]
fn part1(input: &str) -> Result<i32> {
pub fn part1(input: &str) -> Result<i32> {
let mut dial = LOCK_STARTING_POSITION;
let mut visited_zero_count = 0;
for line in input.trim().split('\n') {
@@ -57,7 +57,7 @@ fn part1(input: &str) -> Result<i32> {
}
#[instrument(skip(input))]
fn part2(input: &str) -> Result<i32> {
pub fn part2(input: &str) -> Result<i32> {
let mut dial = LOCK_STARTING_POSITION;
let mut visited_zero_count = 0;
for line in input.trim().split('\n') {
@@ -98,18 +98,6 @@ fn part2(input: &str) -> Result<i32> {
Ok(visited_zero_count)
}
pub fn solve() -> Result<()> {
info!("Day 1");
{
let _span = tracing::info_span!("day01").entered();
let p1 = part1(INPUT)?;
info!("Part 1: {}", p1);
let p2 = part2(INPUT)?;
info!("Part 2: {}", p2);
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;

View File

@@ -0,0 +1 @@
11-22,95-115,998-1012,1188511880-1188511890,222220-222224,1698522-1698528,446443-446449,38593856-38593862,565653-565659,824824821-824824827,2121212118-2121212124

185
src/day02/mod.rs Normal file
View File

@@ -0,0 +1,185 @@
use std::{fmt::Display, str::FromStr};
use color_eyre::{
Result,
eyre::{Error, eyre},
};
use rayon::prelude::*;
use tracing::{debug, debug_span, instrument};
pub const INPUT: &str = include_str!("input/input.txt");
#[derive(Clone, Debug)]
struct ProductRange(std::ops::RangeInclusive<i64>);
impl FromStr for ProductRange {
type Err = Error;
fn from_str(s: &str) -> Result<Self, Self::Err> {
let mut parts = s.split('-');
let start = parts
.next()
.ok_or(eyre!("Invalid product range: no start"))?
.parse::<i64>()?;
let end = parts
.next()
.ok_or(eyre!("Invalid product range: no end"))?
.parse::<i64>()?;
if parts.next().is_some() {
return Err(eyre!("Invalid product range: too many parts"));
}
Ok(ProductRange(start..=end))
}
}
impl Display for ProductRange {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "{}-{}", self.0.start(), self.0.end())
}
}
impl Iterator for ProductRange {
type Item = i64;
fn next(&mut self) -> Option<Self::Item> {
self.0.next()
}
fn size_hint(&self) -> (usize, Option<usize>) {
self.0.size_hint()
}
}
impl ProductRange {
fn invalid_ids(self) -> Result<Vec<i64>> {
let start = *self.0.start();
let end = *self.0.end();
let mut invalid_ids = Vec::new();
// Determine digit ranges we need to check
let start_digits = if start == 0 { 1 } else { start.ilog10() + 1 };
let end_digits = if end == 0 { 1 } else { end.ilog10() + 1 };
for num_digits in start_digits..=end_digits {
// Skip odd digit counts - they're all valid
if num_digits % 2 != 0 {
continue;
}
let half_digits = num_digits / 2;
let half_min = 10_i64.pow(half_digits - 1);
let half_max = 10_i64.pow(half_digits) - 1;
let multiplier = 10_i64.pow(half_digits) + 1; // Pre-calculate: half * multiplier = AABB pattern
// Generate all patterns where first half == second half
for half in half_min..=half_max {
let id = half * multiplier;
if id >= start && id <= end {
invalid_ids.push(id);
}
}
}
debug!("Invalid IDs: {:?}", &invalid_ids);
Ok(invalid_ids)
}
fn invalid_ids2(self) -> Result<Vec<i64>> {
let start = *self.0.start();
let end = *self.0.end();
let mut invalid_ids = std::collections::HashSet::new();
// Determine digit ranges we need to check
let start_digits = if start == 0 { 1 } else { start.ilog10() + 1 };
let end_digits = if end == 0 { 1 } else { end.ilog10() + 1 };
for num_digits in start_digits..=end_digits {
// Try all possible chunk sizes that divide evenly
for chunk_size in 1..=num_digits / 2 {
if num_digits % chunk_size != 0 {
continue;
}
let num_chunks = num_digits / chunk_size;
if num_chunks < 2 {
continue;
}
// Generate all possible chunk patterns
let chunk_min = 10_i64.pow(chunk_size - 1);
let chunk_max = 10_i64.pow(chunk_size) - 1;
let chunk_power = 10_i64.pow(chunk_size);
// Calculate multiplier for repeating pattern
// For ABCABC: chunk * (10^6 + 10^3 + 1) = chunk * 1001001
let mut multiplier = 0_i64;
for i in 0..num_chunks {
multiplier += chunk_power.pow(i);
}
for chunk in chunk_min..=chunk_max {
let id = chunk * multiplier;
if id >= start && id <= end {
invalid_ids.insert(id);
}
}
}
}
let invalid_ids: Vec<i64> = invalid_ids.into_iter().collect();
debug!("Invalid IDs: {:?}", &invalid_ids);
Ok(invalid_ids)
}
}
#[instrument(skip(input))]
pub fn part1(input: &str) -> Result<i64> {
input
.trim()
.split(',')
.collect::<Vec<_>>()
.into_par_iter()
.map(|range| {
let _span = debug_span!("range", range = %range).entered();
let range: ProductRange = range.parse()?;
Ok(range.invalid_ids()?.iter().sum::<i64>())
})
.sum()
}
#[instrument(skip(input))]
pub fn part2(input: &str) -> Result<i64> {
input
.trim()
.split(',')
.collect::<Vec<_>>()
.into_par_iter()
.map(|range| {
let _span = debug_span!("range", range = %range).entered();
let range: ProductRange = range.parse()?;
Ok(range.invalid_ids2()?.iter().sum::<i64>())
})
.sum()
}
#[cfg(test)]
mod tests {
use super::*;
use test_log::test;
const TEST_INPUT1: &str = include_str!("input/test1.txt");
#[test]
fn test_part1() {
assert_eq!(part1(TEST_INPUT1).unwrap(), 1227775554);
}
#[test]
fn test_part2() {
assert_eq!(part2(TEST_INPUT1).unwrap(), 4174379265);
}
}

View File

@@ -0,0 +1,4 @@
987654321111111
811111111111119
234234234234278
818181911112111

72
src/day03/mod.rs Normal file
View File

@@ -0,0 +1,72 @@
use color_eyre::Result;
use tracing::{debug, instrument};
pub const INPUT: &str = include_str!("input/input.txt");
#[derive(Debug, Clone, Copy)]
struct Battery {
column: usize,
joltage: u8,
}
impl Default for Battery {
fn default() -> Self {
Battery {
column: 0,
joltage: 0,
}
}
}
fn largest_output_joltage<const N: usize>(input: &str) -> Result<u64> {
let mut output_joltage: u64 = 0;
for line in input.trim().split('\n') {
let mut batteries: [Battery; N] = [Battery::default(); N];
let line_len = line.len();
for (column, joltage) in line.bytes().map(|c| c - b'0').enumerate() {
let min = N.saturating_sub(line_len - column);
for i in min..N {
if joltage > batteries[i].joltage {
batteries[i].column = column;
batteries[i].joltage = joltage;
batteries[i + 1..].fill(Battery::default());
break;
}
}
}
let line_joltage = batteries
.iter()
.fold(0u64, |acc, &b| acc * 10 + b.joltage as u64);
debug!(line, line_joltage);
output_joltage += line_joltage;
}
Ok(output_joltage)
}
#[instrument(skip(input))]
pub fn part1(input: &str) -> Result<u64> {
largest_output_joltage::<2>(input)
}
#[instrument(skip(input))]
pub fn part2(input: &str) -> Result<u64> {
largest_output_joltage::<12>(input)
}
#[cfg(test)]
mod tests {
use super::*;
use test_log::test;
const TEST_INPUT1: &str = include_str!("input/test1.txt");
#[test]
fn test_part1() {
assert_eq!(part1(TEST_INPUT1).unwrap(), 357);
}
#[test]
fn test_part2() {
assert_eq!(part2(TEST_INPUT1).unwrap(), 3121910778619);
}
}

10
src/day04/input/test1.txt Normal file
View File

@@ -0,0 +1,10 @@
..@@.@@@@.
@@@.@.@.@@
@@@@@.@.@@
@.@@@@..@.
@@.@@@@.@@
.@@@@@@@.@
.@.@.@.@@@
@.@@@.@@@@
.@@@@@@@@.
@.@.@@@.@.

172
src/day04/mod.rs Normal file
View File

@@ -0,0 +1,172 @@
use std::{
fmt::{Display, Formatter},
str::FromStr,
};
use color_eyre::{Result, eyre::eyre};
use tracing::{debug, instrument};
pub const INPUT: &str = include_str!("input/input.txt");
const ADJACENT_DELTAS: [(isize, isize); 8] = [
(-1, -1),
(-1, 0),
(-1, 1),
(0, -1),
(0, 1),
(1, -1),
(1, 0),
(1, 1),
];
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum Cell {
Empty,
Paper,
AccessiblePaper,
}
impl FromStr for Cell {
type Err = color_eyre::Report;
fn from_str(s: &str) -> Result<Self, Self::Err> {
match s {
"." => Ok(Cell::Empty),
"@" => Ok(Cell::Paper),
"x" => Ok(Cell::AccessiblePaper),
_ => Err(eyre!("Invalid cell character: {}", s)),
}
}
}
impl Cell {
fn from_byte(b: u8) -> Result<Self, color_eyre::Report> {
match b {
b'.' => Ok(Cell::Empty),
b'@' => Ok(Cell::Paper),
b'x' => Ok(Cell::AccessiblePaper),
_ => Err(eyre!("Invalid cell byte: {}", b)),
}
}
}
struct Grid<const R: usize, const C: usize> {
cells: [[Cell; C]; R],
}
impl<const R: usize, const C: usize> FromStr for Grid<R, C> {
type Err = color_eyre::Report;
fn from_str(s: &str) -> Result<Self, Self::Err> {
let mut cells = [[Cell::Empty; C]; R];
for (row, line) in s.lines().enumerate() {
for (col, byte) in line.bytes().enumerate() {
cells[row][col] = Cell::from_byte(byte)?;
}
}
Ok(Grid { cells })
}
}
impl<const R: usize, const C: usize> Display for Grid<R, C> {
fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
for row in 0..R {
for col in 0..C {
let symbol = match self.cells[row][col] {
Cell::Empty => '.',
Cell::Paper => '@',
Cell::AccessiblePaper => 'x',
};
write!(f, "{}", symbol)?;
}
writeln!(f)?;
}
Ok(())
}
}
impl<const R: usize, const C: usize> Grid<R, C> {
fn count_accessible_papers(&mut self, replace_with: Cell) -> usize {
let mut count = 0;
for row in 0..R {
for col in 0..C {
if self.cells[row][col] != Cell::Paper {
continue;
}
let mut adjacent_papers = 0;
for &(dr, dc) in &ADJACENT_DELTAS {
let adj_row = row as isize + dr;
let adj_col = col as isize + dc;
if adj_row >= 0
&& adj_col >= 0
&& (adj_row as usize) < R
&& (adj_col as usize) < C
{
let adjacent = self.cells[adj_row as usize][adj_col as usize];
if matches!(adjacent, Cell::Paper | Cell::AccessiblePaper) {
adjacent_papers += 1;
}
}
}
if adjacent_papers < 4 {
self.cells[row][col] = replace_with;
count += 1;
}
}
}
count
}
}
fn solve_part1<const R: usize, const C: usize>(input: &str) -> Result<usize> {
let mut grid = Grid::<R, C>::from_str(input)?;
debug!("Parsed grid:\n{}", grid);
let count = grid.count_accessible_papers(Cell::AccessiblePaper);
debug!("Processed grid:\n{}", grid);
Ok(count)
}
#[instrument(skip(input))]
pub fn part1(input: &str) -> Result<usize> {
solve_part1::<135, 135>(input)
}
fn solve_part2<const R: usize, const C: usize>(input: &str) -> Result<usize> {
let mut grid = Grid::<R, C>::from_str(input)?;
debug!("Parsed grid:\n{}", grid);
let mut count = 0;
loop {
let removed = grid.count_accessible_papers(Cell::Empty);
if removed == 0 {
break;
}
debug!("Removed {} in grid:\n{}", removed, grid);
count += removed;
}
Ok(count)
}
#[instrument(skip(input))]
pub fn part2(input: &str) -> Result<usize> {
solve_part2::<135, 135>(input)
}
#[cfg(test)]
mod tests {
use super::*;
use test_log::test;
const TEST_INPUT1: &str = include_str!("input/test1.txt");
#[test]
fn test_part1() {
assert_eq!(solve_part1::<10, 10>(TEST_INPUT1).unwrap(), 13);
}
#[test]
fn test_part2() {
assert_eq!(solve_part2::<10, 10>(TEST_INPUT1).unwrap(), 43);
}
}

11
src/day05/input/test1.txt Normal file
View File

@@ -0,0 +1,11 @@
3-5
10-14
16-20
12-18
1
5
8
11
17
32

125
src/day05/mod.rs Normal file
View File

@@ -0,0 +1,125 @@
use std::{fmt::Display, str::FromStr};
use color_eyre::{
Result,
eyre::{Error, eyre},
};
use tracing::{debug, instrument};
pub const INPUT: &str = include_str!("input/input.txt");
#[derive(Clone, Debug)]
struct FreshRange(pub std::ops::RangeInclusive<i64>);
impl FromStr for FreshRange {
type Err = Error;
fn from_str(s: &str) -> Result<Self, Self::Err> {
let mut parts = s.split('-');
let start = parts
.next()
.ok_or(eyre!("Invalid fresh range: no start"))?
.parse::<i64>()?;
let end = parts
.next()
.ok_or(eyre!("Invalid fresh range: no end"))?
.parse::<i64>()?;
if parts.next().is_some() {
return Err(eyre!("Invalid fresh range: too many parts"));
}
Ok(FreshRange(start..=end))
}
}
impl Display for FreshRange {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "{}-{}", self.0.start(), self.0.end())
}
}
#[instrument(skip(input))]
pub fn part1(input: &str) -> Result<usize> {
let mut processing_ranges = true;
let mut fresh_ranges = Vec::new();
let mut fresh_ingredients = 0;
for line in input.trim().lines() {
if line.is_empty() {
processing_ranges = false;
continue;
}
if processing_ranges {
let range = line.parse::<FreshRange>()?;
debug!(range = %range);
fresh_ranges.push(range);
} else {
let ingredient = line.parse::<i64>()?;
debug!(ingredient);
for range in &mut fresh_ranges {
if range.0.contains(&ingredient) {
fresh_ingredients += 1;
debug!(fresh_ingredients, "fresh!");
break;
}
}
}
}
Ok(fresh_ingredients)
}
#[instrument(skip(input))]
pub fn part2(input: &str) -> Result<usize> {
let mut fresh_ranges: Vec<Option<FreshRange>> = Vec::new();
for line in input.trim().lines() {
if line.is_empty() {
break;
}
let range = line.parse::<FreshRange>()?;
let mut overlap_range = range.clone();
debug!(range = %range);
for range_slot in fresh_ranges.iter_mut() {
if let Some(existing_range) = range_slot {
if overlap_range.0.end() < existing_range.0.start()
|| overlap_range.0.start() > existing_range.0.end()
{
continue;
}
let start = *overlap_range.0.start().min(existing_range.0.start());
let end = *overlap_range.0.end().max(existing_range.0.end());
if start <= end {
overlap_range = FreshRange(start..=end);
debug!(overlap_range = %overlap_range, existing_range = %existing_range, "merging existing range");
*range_slot = None; // this existing range is now completely merged with the current range
}
}
}
fresh_ranges.push(Some(overlap_range));
}
Ok(fresh_ranges
.iter()
.flatten()
.map(|r| (r.0.end() - r.0.start() + 1) as usize)
.sum())
}
#[cfg(test)]
mod tests {
use super::*;
use test_log::test;
const TEST_INPUT1: &str = include_str!("input/test1.txt");
#[test]
fn test_part1() {
assert_eq!(part1(TEST_INPUT1).unwrap(), 3);
}
#[test]
fn test_part2() {
assert_eq!(part2(TEST_INPUT1).unwrap(), 14);
}
#[test]
fn test_part2_triple_overlap() {
assert_eq!(part2("3-4\n2-5\n1-6").unwrap(), 6);
}
}

View File

@@ -0,0 +1,4 @@
123 328 51 64
45 64 387 23
6 98 215 314
* + * +

212
src/day06/mod.rs Normal file
View File

@@ -0,0 +1,212 @@
use std::str::FromStr;
use color_eyre::{
Result,
eyre::{Context, Error, OptionExt, eyre},
};
use itertools::Itertools;
use tracing::{debug, instrument};
pub const INPUT: &str = include_str!("input/input.txt");
#[derive(Debug, Clone, Copy)]
enum Operation {
Add,
Multiply,
}
impl FromStr for Operation {
type Err = Error;
fn from_str(s: &str) -> Result<Self, Self::Err> {
match s {
"+" => Ok(Operation::Add),
"*" => Ok(Operation::Multiply),
_ => Err(eyre!("invalid operation: {}", s)),
}
}
}
impl Operation {
fn from_byte(byte: u8) -> Result<Self> {
match byte {
b'+' => Ok(Operation::Add),
b'*' => Ok(Operation::Multiply),
_ => Err(eyre!("invalid operation byte: {}", byte)),
}
}
}
#[derive(Debug, Clone)]
struct Problem {
numbers: Vec<u64>,
operation: Operation,
}
#[derive(Debug, Clone)]
struct CephalopodProblem {
columns: Vec<Vec<u8>>,
operation: Operation,
width: u8,
}
#[instrument(skip(input))]
pub fn part1(input: &str) -> Result<u64> {
let mut lines = input.trim().lines();
let first_numbers: Vec<u64> = lines
.next()
.ok_or_eyre("no first line in input")?
.split_whitespace()
.map(|s| s.parse::<u64>().context("parsing number"))
.collect::<Result<Vec<u64>>>()?;
let mut problems = first_numbers
.into_iter()
.map(|n| Problem {
numbers: vec![n],
operation: Operation::Add,
})
.collect::<Vec<Problem>>();
for line in lines {
let first_byte = line.bytes().nth(0).ok_or_eyre("empty line in input")?;
if matches!(first_byte, b'*' | b'+') {
for (index, op_result) in line.split_whitespace().map(|s| s.parse()).enumerate() {
let op = op_result?;
problems[index].operation = op;
}
} else {
for (index, num_result) in line.split_whitespace().map(|s| s.parse()).enumerate() {
let num = num_result?;
problems[index].numbers.push(num);
}
}
}
Ok(problems
.into_iter()
.map(|problem| match problem.operation {
Operation::Add => {
let sum = problem.numbers.iter().sum::<u64>();
debug!("{} = {}", problem.numbers.iter().join(" + "), sum);
return sum;
}
Operation::Multiply => {
let product = problem.numbers.iter().product::<u64>();
debug!("{} = {}", problem.numbers.iter().join(" * "), product);
return product;
}
})
.sum())
}
#[instrument(skip(input))]
pub fn part2(input: &str) -> Result<usize> {
let mut lines = input.lines();
let last_line = lines.next_back().ok_or_eyre("no last line in input")?;
let mut problems = Vec::new();
let mut spaces: u8 = 1;
for byte in last_line.bytes().rev() {
if byte.is_ascii_whitespace() {
spaces += 1;
} else {
problems.push(CephalopodProblem {
columns: (0..spaces).map(|_| Vec::new()).collect(),
operation: Operation::from_byte(byte)?,
width: spaces,
});
spaces = 0;
}
}
debug!(last_problem = ?problems[0]);
debug!(second_last_problem = ?problems[1]);
problems.reverse();
for line in lines {
let bytes = line.as_bytes();
let mut offset = 0;
for i in 0..problems.len() {
if offset + (problems[i].width as usize) > bytes.len() {
return Err(eyre!("line too short for problem columns"));
}
for (cell_col, &byte) in bytes[offset..offset + problems[i].width as usize]
.iter()
.enumerate()
{
if !byte.is_ascii_whitespace() {
problems[i].columns[cell_col].push(byte - b'0');
}
}
offset += problems[i].width as usize + 1;
}
}
debug!(first_problem = ?problems[0]);
debug!(second_problem = ?problems[1]);
Ok(problems
.iter()
.map(|problem| match problem.operation {
Operation::Add => {
let sum: usize = problem
.columns
.iter()
.map(|col| {
col.iter()
.fold(0usize, |acc, &digit| acc * 10 + (digit as usize))
})
.sum();
debug!(
"{} = {}",
problem
.columns
.iter()
.map(|col| {
col.iter()
.fold(0usize, |acc, &digit| acc * 10 + (digit as usize))
.to_string()
})
.join(" + "),
sum
);
sum
}
Operation::Multiply => {
let product: usize = problem
.columns
.iter()
.map(|col| {
col.iter()
.fold(0usize, |acc, &digit| acc * 10 + (digit as usize))
})
.product();
debug!(
"{} = {}",
problem
.columns
.iter()
.map(|col| {
col.iter()
.fold(0usize, |acc, &digit| acc * 10 + (digit as usize))
.to_string()
})
.join(" * "),
product
);
product
}
})
.sum())
}
#[cfg(test)]
mod tests {
use super::*;
use test_log::test;
const TEST_INPUT1: &str = include_str!("input/test1.txt");
#[test]
fn test_part1() {
assert_eq!(part1(TEST_INPUT1).unwrap(), 4277556);
}
#[test]
fn test_part2() {
assert_eq!(part2(TEST_INPUT1).unwrap(), 3263827);
}
}

16
src/days.rs Normal file
View File

@@ -0,0 +1,16 @@
// Single source of truth for all implemented days
// Add new days here and they'll automatically be available in both the runner and benchmarks
#[macro_export]
macro_rules! all_days {
($macro_name:path) => {
$macro_name! {
1 => day01,
2 => day02,
3 => day03,
4 => day04,
5 => day05,
6 => day06,
}
};
}

7
src/lib.rs Normal file
View File

@@ -0,0 +1,7 @@
pub mod day01;
pub mod day02;
pub mod day03;
pub mod day04;
pub mod day05;
pub mod day06;
pub mod days;

View File

@@ -1,5 +1,8 @@
pub mod day01;
mod days;
mod runner;
use aoc::*;
use clap::Parser;
use color_eyre::Result;
use tracing::info;
use tracing_error::ErrorLayer;
@@ -7,6 +10,21 @@ use tracing_subscriber::EnvFilter;
use tracing_subscriber::fmt::format::FmtSpan;
use tracing_subscriber::prelude::*;
#[derive(Parser, Debug)]
#[command(name = "Advent of Code 2025")]
#[command(about = "Solutions for Advent of Code 2025", long_about = None)]
struct Args {
/// Day to run (1-25). If not specified, runs all days.
#[arg(short, long)]
day: Option<u8>,
/// Part to run (1 or 2). If not specified, runs all parts.
#[arg(short, long)]
part: Option<u8>,
}
all_days!(runner::days);
fn main() -> Result<()> {
color_eyre::install()?;
@@ -20,10 +38,12 @@ fn main() -> Result<()> {
)
.init();
let args = Args::parse();
info!("Advent of Code 2025");
{
let _span = tracing::info_span!("aoc").entered();
day01::solve()?;
}
let _span = tracing::info_span!("aoc").entered();
run_days(args.day, args.part)?;
Ok(())
}

57
src/runner.rs Normal file
View File

@@ -0,0 +1,57 @@
use color_eyre::Result;
use tracing::info;
macro_rules! days {
($($day_num:literal => $day_mod:ident),* $(,)?) => {
pub fn run_days(day: Option<u8>, part: Option<u8>) -> Result<()> {
match day {
$(
Some($day_num) => $crate::runner::run_day($day_num, part, $day_mod::part1, $day_mod::part2, $day_mod::INPUT)?,
)*
Some(d) => color_eyre::eyre::bail!("Day {} is not yet implemented", d),
None => {
$(
$crate::runner::run_day($day_num, None, $day_mod::part1, $day_mod::part2, $day_mod::INPUT)?;
)*
}
}
Ok(())
}
};
}
pub(crate) use days;
pub fn run_day<T1, T2>(
day: u8,
part: Option<u8>,
part1_fn: fn(&str) -> Result<T1>,
part2_fn: fn(&str) -> Result<T2>,
input: &str,
) -> Result<()>
where
T1: std::fmt::Display,
T2: std::fmt::Display,
{
info!("Day {}", day);
let day_name = format!("{:02}", day);
let _span = tracing::info_span!("day", day = %day_name).entered();
if part.is_none() || part == Some(1) {
let result = part1_fn(input)?;
info!("Part 1: {}", result);
}
if part.is_none() || part == Some(2) {
let result = part2_fn(input)?;
info!("Part 2: {}", result);
}
if let Some(p) = part {
if p != 1 && p != 2 {
color_eyre::eyre::bail!("Part {} is invalid. Must be 1 or 2.", p);
}
}
Ok(())
}