Skip to main content

aoc_runtime/
aoc.rs

1//! The Advent of Code boundary.
2//!
3//! Everything that talks to `adventofcode.com` goes through [`AocClient`], so
4//! the submission logic can be exercised without a network - and so that a run
5//! without a session cookie provably cannot make a request, because there is no
6//! client to call.
7
8pub mod cache;
9pub mod input;
10pub mod live;
11pub mod throttle;
12
13use crate::puzzle::{Part, Puzzle};
14use std::{fmt, time::Duration};
15
16/// Which way a rejected answer was wrong, when the site says.
17#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
18pub enum Hint {
19    /// The answer is larger than the right one.
20    TooHigh,
21    /// The answer is smaller than the right one.
22    TooLow,
23}
24
25impl fmt::Display for Hint {
26    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
27        match self {
28            Self::TooHigh => f.write_str("too high"),
29            Self::TooLow => f.write_str("too low"),
30        }
31    }
32}
33
34/// The site's response to a submitted answer.
35#[derive(Debug, Clone, PartialEq, Eq)]
36pub enum Verdict {
37    /// The answer was accepted, or is the one already accepted for a part that
38    /// was solved earlier.
39    Correct,
40
41    /// The answer was rejected.
42    Incorrect {
43        /// Which way it was wrong, when the site says.
44        hint: Option<Hint>,
45        /// How long the site asks you to wait before trying again, when it
46        /// says.
47        wait: Option<Duration>,
48    },
49
50    /// Nothing was judged, because an answer was submitted too recently. This
51    /// answer still has to be sent again once the wait is over.
52    Cooldown {
53        /// How much of the wait is left.
54        wait: Duration,
55    },
56
57    /// The site was not asking for an answer to that part - either part one is
58    /// still unsolved, or the part was never a question, which is day 25's
59    /// second star.
60    WrongLevel,
61}
62
63impl Verdict {
64    /// Whether the site judged the answer at all.
65    ///
66    /// A cooldown, or a part the site is not asking about, leaves an answer
67    /// neither right nor wrong.
68    #[must_use]
69    pub const fn is_judged(&self) -> bool {
70        matches!(self, Self::Correct | Self::Incorrect { .. })
71    }
72
73    /// How long to wait before another answer can be judged, when the site
74    /// asked for a wait at all.
75    #[must_use]
76    pub const fn wait(&self) -> Option<Duration> {
77        match self {
78            Self::Incorrect { wait, .. } => *wait,
79            Self::Cooldown { wait } => Some(*wait),
80            Self::Correct | Self::WrongLevel => None,
81        }
82    }
83}
84
85impl fmt::Display for Verdict {
86    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
87        match self {
88            Self::Correct => f.write_str("that's the right answer"),
89            Self::Incorrect { hint, wait } => {
90                f.write_str("that's not the right answer")?;
91                if let Some(hint) = hint {
92                    write!(f, "; it is {hint}")?;
93                }
94                if let Some(wait) = wait {
95                    write!(f, " (wait {} before trying again)", describe(*wait))?;
96                }
97                Ok(())
98            }
99            Self::Cooldown { wait } => write!(
100                f,
101                "an answer was submitted too recently; {} left to wait",
102                describe(*wait)
103            ),
104            Self::WrongLevel => f.write_str("advent of code is not asking for this answer"),
105        }
106    }
107}
108
109/// Renders a wait the way the site phrases it.
110fn describe(wait: Duration) -> String {
111    let total = wait.as_secs();
112    let (hours, minutes, seconds) = (total / 3600, (total / 60) % 60, total % 60);
113
114    let mut parts = Vec::new();
115    if hours > 0 {
116        parts.push(format!("{hours}h"));
117    }
118    if minutes > 0 {
119        parts.push(format!("{minutes}m"));
120    }
121    if seconds > 0 || parts.is_empty() {
122        parts.push(format!("{seconds}s"));
123    }
124
125    parts.join(" ")
126}
127
128/// Reads puzzle input and submits answers.
129pub trait AocClient {
130    /// Downloads the puzzle input.
131    ///
132    /// # Errors
133    ///
134    /// Returns [`ApiError`] if the request fails or the response cannot be
135    /// interpreted.
136    fn fetch_input(&self, puzzle: Puzzle) -> Result<String, ApiError>;
137
138    /// Submits an answer and reports how the site responded.
139    ///
140    /// # Errors
141    ///
142    /// Returns [`ApiError`] if the request fails or the response cannot be
143    /// interpreted. Everything the site had to say about the answer itself -
144    /// including a refusal to judge it yet - is a [`Verdict`], not an error.
145    fn submit(&self, puzzle: Puzzle, part: Part, answer: &str) -> Result<Verdict, ApiError>;
146}
147
148/// Errors produced while talking to Advent of Code.
149#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
150pub enum ApiError {
151    /// The request could not be completed.
152    #[error("advent of code request failed: {0}")]
153    Transport(String),
154
155    /// The session cookie was missing, expired or refused.
156    #[error("advent of code did not accept the session cookie; it is missing, expired or invalid")]
157    Unauthorized,
158
159    /// The puzzle has not unlocked yet.
160    #[error("{puzzle} has not unlocked yet")]
161    Locked {
162        /// The puzzle that is still locked.
163        puzzle: Puzzle,
164    },
165
166    /// The coordinates were refused before any request was made.
167    ///
168    /// [`Puzzle`] and the client underneath validate a puzzle by the same
169    /// rules, so this means the two have drifted apart - not that anything was
170    /// asked of the site.
171    #[error("{puzzle} is not a puzzle the advent of code client accepts: {reason}")]
172    Coordinates {
173        /// The puzzle that was refused.
174        puzzle: Puzzle,
175        /// What the client said about it.
176        reason: String,
177    },
178
179    /// The response was not one this tool understands.
180    #[error("unexpected response from advent of code: {0}")]
181    Unexpected(String),
182}
183
184#[cfg(test)]
185pub(crate) mod fake {
186    use super::{AocClient, ApiError, Puzzle, Verdict};
187    use crate::puzzle::Part;
188    use std::cell::RefCell;
189    use std::collections::VecDeque;
190
191    #[derive(Debug, Default)]
192    pub(crate) struct FakeClient {
193        verdicts: RefCell<VecDeque<Result<Verdict, ApiError>>>,
194        input: Option<String>,
195        submitted: RefCell<Vec<(Puzzle, Part, String)>>,
196    }
197
198    impl FakeClient {
199        pub(crate) fn new() -> Self {
200            Self::default()
201        }
202
203        pub(crate) fn with_input(input: &str) -> Self {
204            Self {
205                input: Some(input.to_owned()),
206                ..Self::default()
207            }
208        }
209
210        pub(crate) fn push(&self, verdict: Verdict) -> &Self {
211            self.verdicts.borrow_mut().push_back(Ok(verdict));
212            self
213        }
214
215        pub(crate) fn submitted(&self) -> Vec<(Puzzle, Part, String)> {
216            self.submitted.borrow().clone()
217        }
218    }
219
220    impl AocClient for FakeClient {
221        fn fetch_input(&self, _puzzle: Puzzle) -> Result<String, ApiError> {
222            self.input
223                .clone()
224                .ok_or_else(|| ApiError::Transport("no input configured".to_owned()))
225        }
226
227        fn submit(&self, puzzle: Puzzle, part: Part, answer: &str) -> Result<Verdict, ApiError> {
228            self.submitted
229                .borrow_mut()
230                .push((puzzle, part, answer.to_owned()));
231
232            self.verdicts
233                .borrow_mut()
234                .pop_front()
235                .unwrap_or(Ok(Verdict::Correct))
236        }
237    }
238}
239
240#[cfg(test)]
241mod tests {
242    use super::*;
243
244    #[test]
245    fn only_a_judged_answer_is_right_or_wrong() {
246        assert!(Verdict::Correct.is_judged());
247        assert!(
248            Verdict::Incorrect {
249                hint: None,
250                wait: None
251            }
252            .is_judged()
253        );
254        assert!(
255            !Verdict::Cooldown {
256                wait: Duration::from_secs(30)
257            }
258            .is_judged()
259        );
260        assert!(!Verdict::WrongLevel.is_judged());
261    }
262
263    #[test]
264    fn a_wait_is_reported_however_the_site_phrased_the_refusal() {
265        assert_eq!(Verdict::Correct.wait(), None);
266        assert_eq!(Verdict::WrongLevel.wait(), None);
267        assert_eq!(
268            Verdict::Incorrect {
269                hint: None,
270                wait: Some(Duration::from_secs(60)),
271            }
272            .wait(),
273            Some(Duration::from_secs(60))
274        );
275        assert_eq!(
276            Verdict::Cooldown {
277                wait: Duration::from_secs(270)
278            }
279            .wait(),
280            Some(Duration::from_secs(270))
281        );
282    }
283
284    #[test]
285    fn a_verdict_reads_like_the_site_phrases_it() {
286        assert_eq!(Verdict::Correct.to_string(), "that's the right answer");
287        assert_eq!(
288            Verdict::Incorrect {
289                hint: Some(Hint::TooHigh),
290                wait: Some(Duration::from_secs(60)),
291            }
292            .to_string(),
293            "that's not the right answer; it is too high (wait 1m before trying again)"
294        );
295        assert_eq!(
296            Verdict::Incorrect {
297                hint: None,
298                wait: None
299            }
300            .to_string(),
301            "that's not the right answer"
302        );
303        assert_eq!(
304            Verdict::Cooldown {
305                wait: Duration::from_secs(270)
306            }
307            .to_string(),
308            "an answer was submitted too recently; 4m 30s left to wait"
309        );
310    }
311
312    #[test]
313    fn a_wait_is_written_in_the_largest_units_it_fills() {
314        assert_eq!(describe(Duration::ZERO), "0s");
315        assert_eq!(describe(Duration::from_secs(45)), "45s");
316        assert_eq!(describe(Duration::from_secs(60)), "1m");
317        assert_eq!(describe(Duration::from_secs(270)), "4m 30s");
318        assert_eq!(describe(Duration::from_secs(3661)), "1h 1m 1s");
319    }
320}