Skip to main content

aoc_runtime/
puzzle.rs

1//! Validated puzzle coordinates.
2//!
3//! [`Year`] and [`Day`] have private fields and fallible constructors, so an
4//! out-of-range value cannot reach path rendering or the Advent of Code API.
5//! [`Puzzle`] checks the pairing on top of that: from [`Year::FIRST_SHORT`] on
6//! the event stops after [`Day::LAST_SHORT`].
7
8use std::fmt;
9
10/// A validated Advent of Code year.
11#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
12pub struct Year(u16);
13
14impl Year {
15    /// The first year Advent of Code ran.
16    pub const FIRST: Self = Self(2015);
17    /// The first shortened event. From 2025 on, Advent of Code publishes 12
18    /// puzzles instead of 25.
19    pub const FIRST_SHORT: Self = Self(2025);
20
21    /// Creates a year, rejecting anything before [`Year::FIRST`].
22    #[must_use]
23    pub const fn new(year: u16) -> Option<Self> {
24        if year >= Self::FIRST.0 {
25            Some(Self(year))
26        } else {
27            None
28        }
29    }
30
31    /// The last day this event publishes a puzzle on.
32    #[must_use]
33    pub const fn last_day(self) -> Day {
34        if self.0 >= Self::FIRST_SHORT.0 {
35            Day::LAST_SHORT
36        } else {
37            Day::LAST_FULL
38        }
39    }
40
41    /// Whether this event has a puzzle on the given day.
42    #[must_use]
43    pub const fn has_day(self, day: Day) -> bool {
44        day.0 <= self.last_day().0
45    }
46
47    /// The underlying value.
48    #[must_use]
49    pub const fn get(self) -> u16 {
50        self.0
51    }
52}
53
54impl fmt::Display for Year {
55    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
56        write!(f, "{}", self.0)
57    }
58}
59
60/// A validated Advent of Code day, always within `1..=25`.
61///
62/// The upper bound is the widest range any event has had; which days a
63/// *particular* event has is [`Year::has_day`], enforced by [`Puzzle::new`].
64#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
65pub struct Day(u8);
66
67impl Day {
68    /// The first puzzle day.
69    pub const FIRST: Self = Self(1);
70    /// The last puzzle day of events up to and including 2024.
71    pub const LAST_FULL: Self = Self(25);
72    /// The last puzzle day from [`Year::FIRST_SHORT`] on.
73    pub const LAST_SHORT: Self = Self(12);
74
75    /// Creates a day, rejecting anything outside `1..=25`.
76    #[must_use]
77    pub const fn new(day: u8) -> Option<Self> {
78        if day >= Self::FIRST.0 && day <= Self::LAST_FULL.0 {
79            Some(Self(day))
80        } else {
81            None
82        }
83    }
84
85    /// Clamps an arbitrary calendar day into the year's puzzle range.
86    ///
87    /// December has 31 days but only 25 puzzles - 12 from
88    /// [`Year::FIRST_SHORT`] on - so anything past the end clamps to the last
89    /// day of that event.
90    #[must_use]
91    pub const fn clamped(year: Year, day: u32) -> Self {
92        let last = year.last_day();
93
94        if day < Self::FIRST.0 as u32 {
95            Self::FIRST
96        } else if day > last.0 as u32 {
97            last
98        } else {
99            #[allow(clippy::cast_possible_truncation)]
100            Self(day as u8)
101        }
102    }
103
104    /// The underlying value.
105    #[must_use]
106    pub const fn get(self) -> u8 {
107        self.0
108    }
109}
110
111impl fmt::Display for Day {
112    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
113        write!(f, "{}", self.0)
114    }
115}
116
117/// Which half of a puzzle an answer belongs to.
118#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
119pub enum Part {
120    /// The first part.
121    One,
122    /// The second part, unlocked by solving the first.
123    Two,
124}
125
126impl Part {
127    /// The part number as understood by the Advent of Code API.
128    #[must_use]
129    pub const fn number(self) -> u8 {
130        match self {
131            Self::One => 1,
132            Self::Two => 2,
133        }
134    }
135}
136
137impl fmt::Display for Part {
138    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
139        write!(f, "part {}", self.number())
140    }
141}
142
143/// A specific puzzle: one day of one year.
144#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
145pub struct Puzzle {
146    /// The event year.
147    pub year: Year,
148    /// The day within the event.
149    pub day: Day,
150}
151
152impl Puzzle {
153    /// Creates a puzzle, rejecting a day the event never published.
154    ///
155    /// Both coordinates are valid on their own, but not every pairing is: from
156    /// [`Year::FIRST_SHORT`] on the event stops after [`Day::LAST_SHORT`].
157    #[must_use]
158    pub const fn new(year: Year, day: Day) -> Option<Self> {
159        if year.has_day(day) {
160            Some(Self { year, day })
161        } else {
162            None
163        }
164    }
165
166    /// The canonical puzzle URL.
167    #[must_use]
168    pub fn url(self) -> String {
169        format!("https://adventofcode.com/{}/day/{}", self.year, self.day)
170    }
171}
172
173impl fmt::Display for Puzzle {
174    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
175        write!(f, "{} day {}", self.year, self.day)
176    }
177}
178
179#[cfg(test)]
180mod tests {
181    use super::*;
182
183    fn year(year: u16) -> Year {
184        Year::new(year).expect("valid year")
185    }
186
187    fn day(day: u8) -> Day {
188        Day::new(day).expect("valid day")
189    }
190
191    #[test]
192    fn year_rejects_years_before_the_first_event() {
193        assert_eq!(Year::new(2014), None);
194        assert_eq!(Year::new(2015).map(Year::get), Some(2015));
195        assert_eq!(Year::new(2024).map(Year::get), Some(2024));
196    }
197
198    #[test]
199    fn day_accepts_only_puzzle_days() {
200        assert_eq!(Day::new(0), None);
201        assert_eq!(Day::new(26), None);
202        assert_eq!(Day::new(1).map(Day::get), Some(1));
203        assert_eq!(Day::new(25).map(Day::get), Some(25));
204    }
205
206    #[test]
207    fn events_from_2025_on_are_half_as_long() {
208        assert_eq!(year(2015).last_day().get(), 25);
209        assert_eq!(year(2024).last_day().get(), 25);
210        assert_eq!(year(2025).last_day().get(), 12);
211        assert_eq!(year(2030).last_day().get(), 12);
212
213        assert!(year(2024).has_day(day(25)));
214        assert!(year(2025).has_day(day(12)));
215        assert!(!year(2025).has_day(day(13)));
216        assert!(!year(2025).has_day(day(25)));
217    }
218
219    #[test]
220    fn day_clamps_late_december_dates() {
221        assert_eq!(Day::clamped(year(2024), 26).get(), 25);
222        assert_eq!(Day::clamped(year(2024), 31).get(), 25);
223        assert_eq!(Day::clamped(year(2024), 25).get(), 25);
224        assert_eq!(Day::clamped(year(2024), 7).get(), 7);
225        assert_eq!(Day::clamped(year(2024), 0).get(), 1);
226    }
227
228    #[test]
229    fn day_clamps_to_the_end_of_a_shortened_event() {
230        assert_eq!(Day::clamped(year(2025), 13).get(), 12);
231        assert_eq!(Day::clamped(year(2025), 25).get(), 12);
232        assert_eq!(Day::clamped(year(2025), 31).get(), 12);
233        assert_eq!(Day::clamped(year(2025), 7).get(), 7);
234    }
235
236    #[test]
237    fn puzzle_rejects_days_its_event_never_published() {
238        assert!(Puzzle::new(year(2024), day(25)).is_some());
239        assert!(Puzzle::new(year(2025), day(12)).is_some());
240        assert_eq!(Puzzle::new(year(2025), day(13)), None);
241        assert_eq!(Puzzle::new(year(2025), day(25)), None);
242    }
243
244    #[test]
245    fn puzzle_renders_the_canonical_url() {
246        let puzzle = Puzzle::new(year(2024), day(5)).expect("2024 has a day 5");
247
248        assert_eq!(puzzle.url(), "https://adventofcode.com/2024/day/5");
249    }
250
251    #[test]
252    fn parts_map_to_api_numbers() {
253        assert_eq!(Part::One.number(), 1);
254        assert_eq!(Part::Two.number(), 2);
255        assert_eq!(Part::Two.to_string(), "part 2");
256    }
257}