advent_of_code_data/
client.rs

1pub mod protocol; // TODO: rename to service
2
3use chrono::{Datelike, Duration};
4use protocol::{AdventOfCodeService, ServiceConnector};
5use regex::Regex;
6use thiserror::Error;
7
8use crate::{
9    cache::{CacheError, PuzzleCache, PuzzleFsCache, SessionCache, SessionFsCache},
10    client::protocol::ServiceError,
11    config::{load_config, Config, ConfigError},
12    data::{Answers, CheckResult, Puzzle},
13    utils::get_puzzle_unlock_time,
14    Answer, Day, Part, Year,
15};
16
17const HTTP_BAD_REQUEST: u16 = 400;
18const HTTP_NOT_FOUND: u16 = 404;
19
20/// Errors that can occur when interacting with the Advent of Code service.
21#[derive(Debug, Error)]
22pub enum ClientError {
23    /// The answer was submitted too soon. The `DateTime` indicates when submission will be allowed.
24    #[error("the answer was submitted too soon, please wait until {} trying again", .0)]
25    TooSoon(chrono::DateTime<chrono::Utc>),
26    /// A session id was expected but not provided.
27    #[error(
28        "session cookie required; read the advent-of-code-data README for instructions on setting this"
29    )]
30    SessionIdRequired,
31    /// The session ID is invalid or has expired.
32    #[error("the session id `{:?}` is invalid or has expired", .0)]
33    BadSessionId(String),
34    /// The puzzle for the given day and year could not be found.
35    #[error("a puzzle could not be found for day {} year {}", .0, .1)]
36    PuzzleNotFound(Day, Year),
37    /// A submission timeout is active; the `Duration` indicates how long to wait before retrying.
38    #[error("please wait {} before submitting another answer to the Advent of Code service", .0)]
39    SubmitTimeOut(chrono::Duration),
40    /// A correct answer has already been submitted for this puzzle.
41    #[error("a correct answer has already been submitted for this puzzle")]
42    AlreadySubmittedAnswer,
43    /// An unexpected HTTP error was returned by the Advent of Code service.
44    #[error("an unexpected HTTP {} error was returned by the Advent of Code service", .0)]
45    ServerHttpError(u16),
46    /// An error occurred while reading cached data.
47    #[error("an unexpected error {} error happened when reading cached data", .0)]
48    CacheError(#[from] CacheError),
49    /// An error occured while loading configuration values.
50    #[error("an unexpected error {} happened when reading configuration values", .0)]
51    SettingsError(#[from] ConfigError),
52    #[error("{}", .0)]
53    ReqwestError(#[from] reqwest::Error),
54}
55
56/// Primary abstraction for interacting with the Advent of Code service.
57///
58/// This trait provides methods to fetch puzzle inputs, submit answers, and retrieve cached puzzle
59/// data. Implementors of this trait must cache inputs and answers to minimize requests to the AoC
60/// service.
61///
62/// # Caching Behavior
63///
64/// - **Inputs** are cached with encryption (configured at client creation). `get_input()` returns
65///   cached data if possible.
66/// - **Answers** are cached unencrypted. `submit_answer()` checks the cache first before submitting
67///   to the service.
68/// - **Submission timeouts** are persisted and enforced by the client. If a submission fails with a
69///   retry timeout, the client will refuse further submissions until the timeout expires.
70///
71/// # Timezone Handling
72///
73/// The client uses **Eastern Time (UTC-5/-4)** for determining puzzle availability, matching the
74/// Advent of Code event timezone. Internally, times are stored in UTC. Puzzle availability is based
75/// on the Eastern Time date/time.
76///
77/// # Submission Constraints
78///
79/// The Advent of Code service enforces rate limiting on answer submissions:
80/// - You can submit one answer per puzzle part per minute.
81/// - After submitting an incorrect answer, you must wait an increasing duration before the next
82///   attempt.
83/// - After submitting a correct answer, that part is locked and cannot be resubmitted.
84pub trait Client {
85    /// Returns the list of available puzzle years starting at 2015. The current year is included
86    /// when the current month is December.
87    fn years(&self) -> Vec<Year>;
88    /// Returns the list of available puzzle days for a given year. `None` is returned when `year`
89    /// is the current year, and the current month is not December.
90    fn days(&self, year: Year) -> Option<Vec<Day>>;
91    /// Fetches the puzzle input for a given day and year. Cached inputs are returned without
92    /// fetching from the service.
93    fn get_input(&self, day: Day, year: Year) -> Result<String, ClientError>;
94    /// Submits an answer for a puzzle part. Cached answers are returned immediately without
95    /// submitting to the service.
96    fn submit_answer(
97        &mut self,
98        answer: Answer,
99        part: Part,
100        day: Day,
101        year: Year,
102    ) -> Result<CheckResult, ClientError>;
103    /// Fetches the complete puzzle data (input and cached answers) for a given day and year.
104    fn get_puzzle(&self, day: Day, year: Year) -> Result<Puzzle, ClientError>;
105}
106
107/// HTTP-based implementation of the `Client` trait that talks with the Advent of Code website.
108///
109/// # Initialization Patterns
110///
111/// 1. **`new()`** - Creates a client with default configuration. Requires a valid user config, a
112///    config in the local directory, or the `AOC_SESSION_ID` and `AOC_PASSPHRASE` environment
113///    variables to be set.
114///
115/// 2. **`with_options(ClientOptions)`** - Creates a client with custom configuration options
116///    (directories, passphrase, etc.). This is the standard path for most use cases.
117///
118/// 3. **`with_custom_impl(ClientConfig, Box<dyn AdventOfCodeProtocol>)`** - For testing usage.
119///    Allows callers to inject a mock HTTP implementation. Caches are still created automatically
120///    from the config.
121///
122/// # Dependencies
123///
124/// - **Session ID**: Required for authentication. Must be a valid Advent of Code session cookie.
125/// - **Network Access**: Required for fetching new puzzles and submitting answers.
126/// - **Passphrase**: Used to encrypt cached puzzle inputs on disk (as requested by AoC maintainer).
127/// - **Cache Directories**: Created automatically if missing.
128pub struct WebClient {
129    /// The client configuration (session ID, cache directories, passphrase, etc)
130    pub config: Config,
131    protocol: Box<dyn ServiceConnector>,
132    /// Stores encrypted puzzle inputs and answer data.
133    pub puzzle_cache: Box<dyn PuzzleCache>,
134    /// Stores submission timeout state.
135    pub session_cache: Box<dyn SessionCache>,
136}
137
138impl WebClient {
139    /// Creates a client with default configuration from environment variables.
140    pub fn new() -> Result<Self, ClientError> {
141        Ok(Self::with_config(load_config()?.build()?))
142    }
143
144    /// Creates a client with custom configuration options.
145    pub fn with_config(config: Config) -> Self {
146        let advent_protocol = Box::new(AdventOfCodeService {});
147        Self::with_custom_impl(config, advent_protocol)
148    }
149
150    /// Creates a client with a custom HTTP protocol implementation.
151    ///
152    /// Useful for testing or using an alternative HTTP backend. Caches are automatically created
153    /// from the provided config.
154    pub fn with_custom_impl(config: Config, advent_protocol: Box<dyn ServiceConnector>) -> Self {
155        // Convert client options into a actual configuration values.
156        // TODO: validate config settings are sane.
157        let puzzle_dir = config.puzzle_dir.clone();
158        let sessions_dir = config.sessions_dir.clone();
159        let passphrase = config.passphrase.clone();
160
161        // Print configuration settings to debug log.
162        tracing::debug!("puzzle cache dir: {puzzle_dir:?}");
163        tracing::debug!("sessions dir: {sessions_dir:?}");
164        tracing::debug!("using encryption: {}", !passphrase.is_empty());
165
166        Self {
167            config,
168            protocol: advent_protocol,
169            puzzle_cache: Box::new(PuzzleFsCache::new(puzzle_dir, Some(passphrase))),
170            session_cache: Box::new(SessionFsCache::new(sessions_dir)),
171        }
172    }
173}
174
175impl Client for WebClient {
176    fn years(&self) -> Vec<Year> {
177        let start_time = self.config.start_time;
178        let unlock_time = get_puzzle_unlock_time(start_time.year().into());
179
180        let mut end_year = start_time.year();
181
182        if start_time < unlock_time {
183            end_year -= 1;
184        }
185
186        (2015..(end_year + 1)).map(|y| y.into()).collect()
187    }
188
189    fn days(&self, year: Year) -> Option<Vec<Day>> {
190        let start_time = self.config.start_time;
191        let eastern_start_time = start_time.with_timezone(&chrono_tz::US::Eastern);
192        let requested_year = year.0 as i32;
193
194        match (
195            eastern_start_time.year().cmp(&requested_year),
196            eastern_start_time.month() == 12,
197        ) {
198            (std::cmp::Ordering::Equal, true) => Some(
199                (1..(eastern_start_time.day() + 1))
200                    .map(|d| d.into())
201                    .collect(),
202            ),
203            (std::cmp::Ordering::Greater, _) => Some((0..25).map(|d| d.into()).collect()),
204            _ => None,
205        }
206    }
207
208    fn get_input(&self, day: Day, year: Year) -> Result<String, ClientError> {
209        // TODO: Convert expects and unwraps into errors.
210        // TODO:  Handle "Please don't repeatedly request this endpoint before it unlocks! The calendar countdown is synchronized with the server time; the link will be enabled on the calendar the instant this puzzle becomes available.""
211        // TODO: Convert trace into span.
212        tracing::trace!("get_input(day=`{day}`, year=`{year}`)",);
213
214        // Check if the input for this puzzle is cached locally before fetching it from the Advent
215        // of Code service.
216        if let Some(input) = self
217            .puzzle_cache
218            .load_input(day, year)
219            .map_err(ClientError::CacheError)?
220        {
221            return Ok(input);
222        }
223
224        // Fetch the puzzle input from the Advent of Code service. Try to catch common error cases
225        // so we can return an exact `ClieError` type to the caller, rather than a generic HTTP
226        // status code.
227        match self.protocol.get_input(
228            day,
229            year,
230            &self
231                .config
232                .session_id
233                .as_ref()
234                .cloned()
235                .ok_or(ClientError::SessionIdRequired)?,
236        ) {
237            Ok(input_text) => {
238                assert!(!input_text.is_empty());
239
240                // Cache the puzzle input on disk before returning to avoid repeatedly fetching
241                // input from the Advent of Code service.
242                self.puzzle_cache.save_input(&input_text, day, year)?;
243                Ok(input_text)
244            }
245            Err(ServiceError::HttpStatusError(HTTP_BAD_REQUEST)) => Err(ClientError::BadSessionId(
246                self.config
247                    .session_id
248                    .clone()
249                    .expect("already checked that session id was provided"),
250            )),
251            Err(ServiceError::HttpStatusError(HTTP_NOT_FOUND)) => {
252                // TODO: Return "Not available _yet_" if the requested data in the future.
253                Err(ClientError::PuzzleNotFound(day, year))
254            }
255            Err(ServiceError::HttpStatusError(c)) => Err(ClientError::ServerHttpError(c)),
256            Err(ServiceError::ReqwestError(x)) => Err(ClientError::ReqwestError(x)),
257        }
258    }
259
260    fn submit_answer(
261        &mut self,
262        answer: Answer,
263        part: Part,
264        day: Day,
265        year: Year,
266    ) -> Result<CheckResult, ClientError> {
267        tracing::trace!(
268            "submit_answer(answer=`{:?}`, part=`{}`, day=`{}`, year=`{}`)",
269            answer,
270            part,
271            day,
272            year
273        );
274
275        // Can this answer be checked locally using the cache?
276        let mut answers = match self.puzzle_cache.load_answers(part, day, year)? {
277            Some(cached_answers) => {
278                if let Some(check_result) = cached_answers.check(&answer) {
279                    tracing::debug!("answer check result was found in the cache {check_result:?}");
280                    return Ok(check_result);
281                }
282
283                cached_answers
284            }
285            _ => Answers::new(),
286        };
287
288        // Check if there is an active time out on new submissions prior to submitting to the
289        // advent of code service.
290        let mut session = self.session_cache.load(
291            self.config
292                .session_id
293                .as_ref()
294                .ok_or(ClientError::SessionIdRequired)?,
295        )?;
296
297        if let Some(submit_wait_until) = session.submit_wait_until {
298            if self.config.start_time <= submit_wait_until {
299                tracing::warn!("you cannot submit an answer until {submit_wait_until}");
300                return Err(ClientError::SubmitTimeOut(
301                    submit_wait_until - self.config.start_time,
302                ));
303            } else {
304                // TODO: remove the timeout and save.
305                tracing::debug!("the submission timeout has expired, ignoring");
306            }
307        }
308
309        // Submit to the answer to Advent of Code service.
310        match self.protocol.submit_answer(
311            &answer,
312            part,
313            day,
314            year,
315            &self
316                .config
317                .session_id
318                .as_ref()
319                .cloned()
320                .ok_or(ClientError::SessionIdRequired)?,
321        ) {
322            Ok(response_text) => {
323                assert!(!response_text.is_empty());
324                let (check_result, maybe_time_to_wait) = parse_submit_response(&response_text)?;
325
326                // Write back the amount of time to wait to avoid hitting the server
327                // on future submissions.
328                if let Some(time_to_wait) = maybe_time_to_wait {
329                    let wait_until = chrono::Utc::now() + time_to_wait;
330                    tracing::debug!("setting time to wait ({time_to_wait}) to be {wait_until}");
331
332                    session.submit_wait_until = Some(wait_until);
333                    self.session_cache.save(&session)?;
334                }
335
336                // Write the response to the answers database and then save it back to
337                // the puzzle cache.
338                match check_result {
339                    CheckResult::Correct => {
340                        tracing::debug!("Setting correct answer as {answer}");
341                        answers.set_correct_answer(answer);
342                    }
343                    CheckResult::Wrong => {
344                        tracing::debug!("Setting wrong answer {answer}");
345                        answers.add_wrong_answer(answer);
346                    }
347                    CheckResult::TooLow => {
348                        tracing::debug!("Setting low bounds wrong answer {answer}");
349                        answers.set_low_bounds(answer);
350                    }
351                    CheckResult::TooHigh => {
352                        tracing::debug!("Setting high bounds wrong answer {answer}");
353                        answers.set_high_bounds(answer);
354                    }
355                };
356
357                tracing::debug!("Saving answers database to puzzle cache");
358                self.puzzle_cache.save_answers(&answers, part, day, year)?;
359
360                Ok(check_result)
361            }
362            Err(ServiceError::HttpStatusError(HTTP_BAD_REQUEST)) => Err(ClientError::BadSessionId(
363                self.config
364                    .session_id
365                    .clone()
366                    .expect("already checked that session id was provided"),
367            )),
368            Err(ServiceError::HttpStatusError(HTTP_NOT_FOUND)) => {
369                // TODO: Return "Not available _yet_" if the requested data in the future.
370                Err(ClientError::PuzzleNotFound(day, year))
371            }
372            Err(ServiceError::HttpStatusError(c)) => Err(ClientError::ServerHttpError(c)),
373            Err(ServiceError::ReqwestError(x)) => Err(ClientError::ReqwestError(x)),
374        }
375    }
376
377    fn get_puzzle(&self, day: Day, year: Year) -> Result<Puzzle, ClientError> {
378        Ok(Puzzle {
379            day,
380            year,
381            input: self.get_input(day, year)?,
382            part_one_answers: self
383                .puzzle_cache
384                .load_answers(Part::One, day, year)?
385                .unwrap_or_default(),
386            part_two_answers: self
387                .puzzle_cache
388                .load_answers(Part::Two, day, year)?
389                .unwrap_or_default(),
390        })
391    }
392
393    // TODO: personal leaderboard
394    // TODO: list of private leaderboards
395    // TODO: show private leaderboard
396}
397
398// TODO: document this function
399// Converts the HTML response text into a check result and optional time to wait.
400fn parse_submit_response(
401    response_text: &str,
402) -> Result<(CheckResult, Option<Duration>), ClientError> {
403    // Look for a minimum wait time in the text.
404    let extract_wait_time_funcs = &[
405        extract_error_time_to_wait,
406        extract_one_minute_time_to_wait,
407        extract_wrong_answer_time_to_wait,
408    ];
409
410    let time_to_wait = extract_wait_time_funcs
411        .iter()
412        .filter_map(|f| f(response_text))
413        .next();
414
415    // Handle special cases.
416    // TODO: Remove this special casing if possible.
417    // TODO: Look into "You don't seem to be solving the right level.  Did you already complete it?"
418    //       Is this only returned for errors on solved levels?
419    if response_text.contains("gave an answer too recently") {
420        return Err(ClientError::SubmitTimeOut(time_to_wait.unwrap()));
421    }
422
423    if response_text.contains("you already complete it") {
424        return Err(ClientError::AlreadySubmittedAnswer);
425    }
426
427    // Translate the response text into a result.
428    let responses_texts = &[
429        ("not the right answer", CheckResult::Wrong),
430        ("the right answer", CheckResult::Correct),
431        ("answer is too low", CheckResult::TooLow),
432        ("answer is too high", CheckResult::TooHigh),
433    ];
434
435    let check_result = responses_texts
436            .iter()
437            .find(|x| response_text.contains(x.0))
438            .map(|x| x.1.clone())
439            .unwrap_or_else(|| panic!("expected server response text to map to predetermined response in LUT. Response:\n```\n{response_text}\n```\n"));
440
441    Ok((check_result, time_to_wait))
442}
443
444// TODO: refactor these functions below or move them back into parse_submit_response.
445
446/// Parses `response` and returns a one minute duration if `response` has text indicating the
447/// timeout should be one minute.
448fn extract_one_minute_time_to_wait(response: &str) -> Option<Duration> {
449    match response.contains("Please wait one minute before trying again") {
450        true => Some(Duration::minutes(1)),
451        false => None,
452    }
453}
454
455/// Parses `response` and returns a time to wait if the text was succesfully parsed.
456fn extract_wrong_answer_time_to_wait(response: &str) -> Option<Duration> {
457    let regex = Regex::new(r"please wait (\d) minutes?").unwrap();
458    regex
459        .captures(response)
460        .map(|c| Duration::minutes(c[1].parse::<i64>().unwrap()))
461}
462
463/// Parses `response` and returns a time to wait if the text was succesfully parsed.
464fn extract_error_time_to_wait(response: &str) -> Option<Duration> {
465    let regex = Regex::new(r"You have (\d+)m( (\d+)s)? left to wait").unwrap();
466    regex.captures(response).map(|c| {
467        let mut time_to_wait = Duration::minutes(c[1].parse::<i64>().unwrap());
468
469        if let Some(secs) = c.get(3) {
470            time_to_wait += Duration::seconds(secs.as_str().parse::<i64>().unwrap());
471        }
472
473        time_to_wait
474    })
475}
476
477#[cfg(test)]
478mod tests {
479    use chrono::{NaiveDate, NaiveTime, TimeZone};
480    use chrono_tz::US::Eastern;
481
482    use crate::config::ConfigBuilder;
483
484    use super::*;
485
486    fn web_client_with_time(
487        year: i32,
488        month: u32,
489        day: u32,
490        hour: u32,
491        min: u32,
492        sec: u32,
493    ) -> WebClient {
494        WebClient::with_config(
495            ConfigBuilder::new()
496                .with_session_id("UNIT_TEST_SESSION_ID")
497                .with_passphrase("UNIT_TEST_PASSWORD")
498                .with_puzzle_dir("DO_NOT_USE")
499                .with_fake_time(
500                    Eastern
501                        .from_local_datetime(
502                            &NaiveDate::from_ymd_opt(year, month, day)
503                                .unwrap()
504                                .and_time(NaiveTime::from_hms_opt(hour, min, sec).unwrap()),
505                        )
506                        .unwrap()
507                        .with_timezone(&chrono::Utc),
508                )
509                .build()
510                .unwrap(),
511        )
512    }
513
514    #[test]
515    fn list_years_when_date_is_after_start() {
516        let client = web_client_with_time(2018, 12, 1, 0, 0, 0);
517        assert_eq!(
518            client.years(),
519            vec![Year(2015), Year(2016), Year(2017), Year(2018)]
520        );
521    }
522
523    #[test]
524    fn list_years_when_date_is_before_start() {
525        let client = web_client_with_time(2018, 11, 30, 23, 59, 59);
526        assert_eq!(client.years(), vec![Year(2015), Year(2016), Year(2017)]);
527    }
528
529    #[test]
530    fn list_years_when_date_aoc_start() {
531        let client = web_client_with_time(2015, 3, 10, 11, 15, 7);
532        assert_eq!(client.years(), vec![]);
533    }
534
535    #[test]
536    fn list_days_before_start() {
537        let client = web_client_with_time(2020, 11, 30, 23, 59, 59);
538        assert_eq!(client.days(Year(2020)), None);
539    }
540
541    #[test]
542    fn list_days_at_start() {
543        let client = web_client_with_time(2020, 12, 1, 0, 0, 0);
544        assert_eq!(client.days(Year(2020)), Some(vec![Day(1)]));
545    }
546
547    #[test]
548    fn list_days_in_middle() {
549        let client = web_client_with_time(2020, 12, 6, 0, 0, 0);
550        assert_eq!(
551            client.days(Year(2020)),
552            Some(vec![Day(1), Day(2), Day(3), Day(4), Day(5), Day(6)])
553        );
554    }
555}