1pub mod cache;
9pub mod input;
10pub mod live;
11pub mod throttle;
12
13use crate::puzzle::{Part, Puzzle};
14use std::{fmt, time::Duration};
15
16#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
18pub enum Hint {
19 TooHigh,
21 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#[derive(Debug, Clone, PartialEq, Eq)]
36pub enum Verdict {
37 Correct,
40
41 Incorrect {
43 hint: Option<Hint>,
45 wait: Option<Duration>,
48 },
49
50 Cooldown {
53 wait: Duration,
55 },
56
57 WrongLevel,
61}
62
63impl Verdict {
64 #[must_use]
69 pub const fn is_judged(&self) -> bool {
70 matches!(self, Self::Correct | Self::Incorrect { .. })
71 }
72
73 #[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
109fn 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
128pub trait AocClient {
130 fn fetch_input(&self, puzzle: Puzzle) -> Result<String, ApiError>;
137
138 fn submit(&self, puzzle: Puzzle, part: Part, answer: &str) -> Result<Verdict, ApiError>;
146}
147
148#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
150pub enum ApiError {
151 #[error("advent of code request failed: {0}")]
153 Transport(String),
154
155 #[error("advent of code did not accept the session cookie; it is missing, expired or invalid")]
157 Unauthorized,
158
159 #[error("{puzzle} has not unlocked yet")]
161 Locked {
162 puzzle: Puzzle,
164 },
165
166 #[error("{puzzle} is not a puzzle the advent of code client accepts: {reason}")]
172 Coordinates {
173 puzzle: Puzzle,
175 reason: String,
177 },
178
179 #[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}