Skip to main content

aoc_runtime/aoc/
live.rs

1//! The real Advent of Code client.
2//!
3//! This is the only module that mentions `aoc_api` or `tokio`. The upstream
4//! API is asynchronous while this tool performs a handful of strictly
5//! sequential requests, so a current-thread runtime is driven to completion
6//! here and the rest of the crate stays synchronous.
7//!
8//! Every endpoint upstream is a free function over an
9//! [`aoc_api::http::Transport`], which is where the throttle sits: requests are
10//! paced by the transport [`LiveClient`] holds rather than by the two calls
11//! below, so a request this module does not make itself - the puzzle page
12//! `submit` reads when a part turns out to be solved already - is paced like
13//! any other.
14
15use super::{AocClient, ApiError, Hint, Verdict, throttle::Throttle};
16use crate::puzzle::{Part, Puzzle};
17use aoc_api::{
18    http::{ClientOptions, Request, ReqwestTransport, Response, Transport, TransportError},
19    session,
20};
21use std::{error::Error as StdError, future::Future, path::PathBuf};
22use tokio::runtime::{Builder, Runtime};
23
24/// How this tool identifies itself in the `User-Agent` of every request.
25///
26/// The Advent of Code [automation guidelines] ask an automated tool to say what
27/// it is and how to reach whoever wrote it. The address is spelled out rather
28/// than read from `CARGO_PKG_AUTHORS`, because that field also holds a name an
29/// HTTP header cannot carry.
30///
31/// [automation guidelines]: https://www.reddit.com/r/adventofcode/wiki/faqs/automation
32pub const IDENTIFICATION: &str = concat!(
33    env!("CARGO_PKG_REPOSITORY"),
34    " v",
35    env!("CARGO_PKG_VERSION"),
36    " by antonio.subasic.public@gmail.com",
37);
38
39/// The transport every request this tool makes goes out through.
40///
41/// Wrapping the real transport rather than throttling at each call site means
42/// there is one place a request can leave from, and it waits its turn first.
43#[derive(Debug)]
44struct ThrottledTransport {
45    inner: ReqwestTransport,
46    throttle: Throttle,
47}
48
49impl Transport for ThrottledTransport {
50    fn execute(
51        &self,
52        request: Request,
53    ) -> impl Future<Output = Result<Response, TransportError>> + Send {
54        // The wait is taken when the request is asked for rather than when the
55        // returned future is first polled, so nothing can be queued up past the
56        // throttle. It blocks, which is what this whole client does: the
57        // runtime is current-thread and has nothing else to run.
58        self.throttle.acquire();
59        self.inner.execute(request)
60    }
61}
62
63/// A client backed by `adventofcode.com`.
64///
65/// This type is the one place in the crate where the site is actually
66/// contacted, and - through the transport it holds - the one place where the
67/// rate of contact is enforced.
68#[derive(Debug)]
69pub struct LiveClient {
70    runtime: Runtime,
71    transport: ThrottledTransport,
72}
73
74impl LiveClient {
75    /// Creates a client authenticated with the given session cookie, pacing its
76    /// requests using state kept in `state_dir`.
77    ///
78    /// # Errors
79    ///
80    /// Returns [`ApiError::Transport`] if the runtime cannot be started, or if
81    /// the cookie cannot be sent in a header.
82    pub fn new(cookie: &str, state_dir: impl Into<PathBuf>) -> Result<Self, ApiError> {
83        let runtime = Builder::new_current_thread()
84            .enable_all()
85            .build()
86            .map_err(|source| ApiError::Transport(source.to_string()))?;
87
88        let inner = ReqwestTransport::new(&ClientOptions::new(cookie, IDENTIFICATION))
89            .map_err(|error| ApiError::Transport(chained(&error)))?;
90
91        Ok(Self {
92            runtime,
93            transport: ThrottledTransport {
94                inner,
95                throttle: Throttle::new(state_dir),
96            },
97        })
98    }
99}
100
101impl AocClient for LiveClient {
102    fn fetch_input(&self, puzzle: Puzzle) -> Result<String, ApiError> {
103        let coordinates = coordinates(puzzle)?;
104
105        self.runtime.block_on(async {
106            session::input_text(&self.transport, coordinates)
107                .await
108                .map_err(|error| translate(&error, puzzle))
109        })
110    }
111
112    fn submit(&self, puzzle: Puzzle, part: Part, answer: &str) -> Result<Verdict, ApiError> {
113        let coordinates = coordinates(puzzle)?;
114
115        self.runtime.block_on(async {
116            match session::submit(&self.transport, coordinates, level(part), answer).await {
117                Ok(judged) => verdict(judged),
118                // Nothing was judged, but that is an outcome of the submission
119                // rather than a failure to make it.
120                Err(aoc_api::Error::Cooldown { wait }) => Ok(Verdict::Cooldown { wait }),
121                Err(error) => Err(translate(&error, puzzle)),
122            }
123        })
124    }
125}
126
127/// The client's own coordinates for a puzzle.
128fn coordinates(puzzle: Puzzle) -> Result<aoc_api::Puzzle, ApiError> {
129    aoc_api::Puzzle::at(puzzle.year.get(), puzzle.day.get()).map_err(|reason| {
130        ApiError::Coordinates {
131            puzzle,
132            reason: reason.to_string(),
133        }
134    })
135}
136
137/// The client's own name for a part.
138const fn level(part: Part) -> aoc_api::Part {
139    match part {
140        Part::One => aoc_api::Part::One,
141        Part::Two => aoc_api::Part::Two,
142    }
143}
144
145/// This crate's reading of a judged submission.
146fn verdict(judged: aoc_api::Verdict) -> Result<Verdict, ApiError> {
147    match judged {
148        aoc_api::Verdict::Correct | aoc_api::Verdict::AlreadyComplete { correct: true } => {
149            Ok(Verdict::Correct)
150        }
151        aoc_api::Verdict::Incorrect { hint, wait } => Ok(Verdict::Incorrect {
152            hint: recognised(hint),
153            wait,
154        }),
155        // The part was solved with something else, so this answer is wrong -
156        // and the site said so without being asked to judge it, so there is no
157        // wait attached.
158        aoc_api::Verdict::AlreadyComplete { correct: false } => Ok(Verdict::Incorrect {
159            hint: None,
160            wait: None,
161        }),
162        aoc_api::Verdict::WrongLevel => Ok(Verdict::WrongLevel),
163        // A newer client may judge an answer in a way this one has never seen.
164        // Saying so beats guessing which of the four it resembles.
165        judged => Err(ApiError::Unexpected(judged.to_string())),
166    }
167}
168
169/// A hint this crate knows, or none: not knowing which way an answer is wrong
170/// costs a line of advice rather than correctness.
171const fn recognised(hint: Option<aoc_api::Hint>) -> Option<Hint> {
172    match hint {
173        Some(aoc_api::Hint::TooHigh) => Some(Hint::TooHigh),
174        Some(aoc_api::Hint::TooLow) => Some(Hint::TooLow),
175        _ => None,
176    }
177}
178
179/// This crate's reading of a call that produced no answer.
180fn translate(error: &aoc_api::Error, puzzle: Puzzle) -> ApiError {
181    match error {
182        aoc_api::Error::Unauthorized => ApiError::Unauthorized,
183        // The client names the puzzle it was handed; this names the one the
184        // user asked for, which is the same puzzle either way.
185        aoc_api::Error::Locked { .. } => ApiError::Locked { puzzle },
186        aoc_api::Error::Parse(_) | aoc_api::Error::Puzzle(_) => {
187            ApiError::Unexpected(chained(error))
188        }
189        error => ApiError::Transport(chained(error)),
190    }
191}
192
193/// An error and its causes, flattened onto one line.
194///
195/// [`ApiError`] carries text rather than a boxed cause, so the reason a request
196/// failed is folded in here instead of being dropped at the boundary.
197fn chained(error: &dyn StdError) -> String {
198    let mut message = error.to_string();
199    let mut source = error.source();
200
201    while let Some(cause) = source {
202        message.push_str(": ");
203        message.push_str(&cause.to_string());
204        source = cause.source();
205    }
206
207    message
208}
209
210#[cfg(test)]
211mod tests {
212    use super::*;
213    use crate::puzzle::{Day, Year};
214    use std::time::Duration;
215
216    fn puzzle() -> Puzzle {
217        Puzzle::new(
218            Year::new(2024).expect("valid year"),
219            Day::new(7).expect("valid day"),
220        )
221        .expect("2024 has a day 7")
222    }
223
224    #[test]
225    fn the_identification_is_one_a_request_can_actually_carry() {
226        assert!(IDENTIFICATION.contains("aoc-runtime"), "{IDENTIFICATION}");
227        assert!(
228            ReqwestTransport::new(&ClientOptions::new("53616c7465645f5f", IDENTIFICATION)).is_ok(),
229            "the user agent must not be one an http header rejects: {IDENTIFICATION}"
230        );
231    }
232
233    #[test]
234    fn coordinates_cross_to_the_client_unchanged() {
235        assert_eq!(
236            coordinates(puzzle()),
237            Ok(aoc_api::Puzzle::at(2024, 7).expect("2024 has a day 7"))
238        );
239    }
240
241    // Both crates decide for themselves how long an event runs. If they ever
242    // disagree, the last day of a shortened event is where it shows first.
243    #[test]
244    fn the_last_day_of_a_shortened_event_crosses_too() {
245        let puzzle = Puzzle::new(
246            Year::new(2025).expect("valid year"),
247            Day::new(12).expect("valid day"),
248        )
249        .expect("2025 has a day 12");
250
251        assert_eq!(
252            coordinates(puzzle),
253            Ok(aoc_api::Puzzle::at(2025, 12).expect("2025 has a day 12"))
254        );
255    }
256
257    #[test]
258    fn parts_cross_as_the_levels_the_site_uses() {
259        assert_eq!(level(Part::One).number(), 1);
260        assert_eq!(level(Part::Two).number(), 2);
261    }
262
263    #[test]
264    fn an_answer_already_accepted_counts_as_accepted() {
265        assert_eq!(verdict(aoc_api::Verdict::Correct), Ok(Verdict::Correct));
266        assert_eq!(
267            verdict(aoc_api::Verdict::AlreadyComplete { correct: true }),
268            Ok(Verdict::Correct)
269        );
270    }
271
272    #[test]
273    fn a_rejected_answer_keeps_the_hint_and_the_wait() {
274        assert_eq!(
275            verdict(aoc_api::Verdict::Incorrect {
276                hint: Some(aoc_api::Hint::TooLow),
277                wait: Some(Duration::from_secs(60)),
278            }),
279            Ok(Verdict::Incorrect {
280                hint: Some(Hint::TooLow),
281                wait: Some(Duration::from_secs(60)),
282            })
283        );
284    }
285
286    #[test]
287    fn an_answer_that_differs_from_the_accepted_one_is_wrong() {
288        assert_eq!(
289            verdict(aoc_api::Verdict::AlreadyComplete { correct: false }),
290            Ok(Verdict::Incorrect {
291                hint: None,
292                wait: None,
293            })
294        );
295    }
296
297    #[test]
298    fn a_part_the_site_is_not_asking_about_is_not_a_failure() {
299        assert_eq!(
300            verdict(aoc_api::Verdict::WrongLevel),
301            Ok(Verdict::WrongLevel)
302        );
303    }
304
305    #[test]
306    fn a_refused_cookie_is_reported_as_itself() {
307        assert_eq!(
308            translate(&aoc_api::Error::Unauthorized, puzzle()),
309            ApiError::Unauthorized
310        );
311    }
312
313    #[test]
314    fn a_locked_puzzle_names_the_one_that_was_asked_for() {
315        let error = aoc_api::Error::Locked {
316            puzzle: aoc_api::Puzzle::at(2024, 7).expect("2024 has a day 7"),
317        };
318
319        assert_eq!(
320            translate(&error, puzzle()),
321            ApiError::Locked { puzzle: puzzle() }
322        );
323    }
324
325    #[test]
326    fn a_failed_request_keeps_the_reason_it_failed() {
327        let error = aoc_api::Error::from(TransportError::Request {
328            url: "https://adventofcode.com/2024/day/7/input".to_owned(),
329            source: "connection reset".into(),
330        });
331
332        let translated = translate(&error, puzzle());
333
334        assert!(matches!(translated, ApiError::Transport(_)), "{translated}");
335        let message = translated.to_string();
336        assert!(message.contains("/2024/day/7/input"), "{message}");
337        assert!(message.contains("connection reset"), "{message}");
338    }
339
340    #[test]
341    fn a_reply_the_client_could_not_read_is_not_a_transport_failure() {
342        let error = aoc_api::Error::Parse(aoc_api::parse::ParseError::Submission {
343            snippet: "Ho ho ho.".to_owned(),
344        });
345
346        assert!(
347            matches!(translate(&error, puzzle()), ApiError::Unexpected(_)),
348            "an unrecognised reply arrived, so the request itself succeeded"
349        );
350    }
351}