1use 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
24pub 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#[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 self.throttle.acquire();
59 self.inner.execute(request)
60 }
61}
62
63#[derive(Debug)]
69pub struct LiveClient {
70 runtime: Runtime,
71 transport: ThrottledTransport,
72}
73
74impl LiveClient {
75 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 Err(aoc_api::Error::Cooldown { wait }) => Ok(Verdict::Cooldown { wait }),
121 Err(error) => Err(translate(&error, puzzle)),
122 }
123 })
124 }
125}
126
127fn 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
137const 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
145fn 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 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 judged => Err(ApiError::Unexpected(judged.to_string())),
166 }
167}
168
169const 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
179fn translate(error: &aoc_api::Error, puzzle: Puzzle) -> ApiError {
181 match error {
182 aoc_api::Error::Unauthorized => ApiError::Unauthorized,
183 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
193fn 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 #[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}