advent_of_code_data/
client.rs

1mod protocol;
2
3use chrono::Datelike;
4use protocol::{AdventOfCodeHttpProtocol, AdventOfCodeProtocol};
5use thiserror::Error;
6
7use crate::{
8    cache::{CacheError, PuzzleCache, PuzzleFsCache, SessionCache, SessionFsCache},
9    config::{load_config, Config, ConfigError},
10    data::{Answers, CheckResult, Puzzle},
11    utils::get_puzzle_unlock_time,
12    Answer, Day, Part, Year,
13};
14
15/// Errors that can occur when interacting with the Advent of Code service.
16#[derive(Debug, Error)]
17pub enum ClientError {
18    /// The answer was submitted too soon. The `DateTime` indicates when submission will be allowed.
19    #[error("the answer was submitted too soon, please wait until {} trying again", .0)]
20    TooSoon(chrono::DateTime<chrono::Utc>),
21    /// The session ID is invalid or has expired.
22    #[error("the session id `{}` is invalid or has expired", .0)]
23    BadSessionId(String),
24    /// The puzzle for the given day and year could not be found.
25    #[error("a puzzle could not be found for day {} year {}", .0, .1)]
26    PuzzleNotFound(Day, Year),
27    /// A submission timeout is active; the `Duration` indicates how long to wait before retrying.
28    #[error("please wait {} before submitting another answer to the Advent of Code service", .0)]
29    SubmitTimeOut(chrono::Duration),
30    /// A correct answer has already been submitted for this puzzle.
31    #[error("a correct answer has already been submitted for day {} year {}", .0, .1)]
32    AlreadySubmittedAnswer(Day, Year),
33    /// An unexpected HTTP error was returned by the Advent of Code service.
34    #[error("an unexpected HTTP {} error was returned by the Advent of Code service", .0)]
35    Http(reqwest::StatusCode),
36    /// An error occurred while reading cached data.
37    #[error("an unexpected error {} error happened when reading cached data", .0)]
38    CacheError(#[from] CacheError),
39    /// An error occured while loading configuration values.
40    #[error("an unexpected error {} happened when reading configuration values", .0)]
41    SettingsError(#[from] ConfigError),
42}
43
44/// Primary abstraction for interacting with the Advent of Code service.
45///
46/// This trait provides methods to fetch puzzle inputs, submit answers, and retrieve cached puzzle
47/// data. Implementors of this trait must cache inputs and answers to minimize requests to the AoC
48/// service.
49///
50/// # Caching Behavior
51///
52/// - **Inputs** are cached with encryption (configured at client creation). `get_input()` returns
53///   cached data if possible.
54/// - **Answers** are cached unencrypted. `submit_answer()` checks the cache first before submitting
55///   to the service.
56/// - **Submission timeouts** are persisted and enforced by the client. If a submission fails with a
57///   retry timeout, the client will refuse further submissions until the timeout expires.
58///
59/// # Timezone Handling
60///
61/// The client uses **Eastern Time (UTC-5/-4)** for determining puzzle availability, matching the
62/// Advent of Code event timezone. Internally, times are stored in UTC. Puzzle availability is based
63/// on the Eastern Time date/time.
64///
65/// # Submission Constraints
66///
67/// The Advent of Code service enforces rate limiting on answer submissions:
68/// - You can submit one answer per puzzle part per minute.
69/// - After submitting an incorrect answer, you must wait an increasing duration before the next
70///   attempt.
71/// - After submitting a correct answer, that part is locked and cannot be resubmitted.
72pub trait Client {
73    /// Returns the list of available puzzle years starting at 2015. The current year is included
74    /// when the current month is December.
75    fn years(&self) -> Vec<Year>;
76    /// Returns the list of available puzzle days for a given year. `None` is returned when `year`
77    /// is the current year, and the current month is not December.
78    fn days(&self, year: Year) -> Option<Vec<Day>>;
79    /// Fetches the puzzle input for a given day and year. Cached inputs are returned without
80    /// fetching from the service.
81    fn get_input(&self, day: Day, year: Year) -> Result<String, ClientError>;
82    /// Submits an answer for a puzzle part. Cached answers are returned immediately without
83    /// submitting to the service.
84    fn submit_answer(
85        &mut self,
86        answer: Answer,
87        part: Part,
88        day: Day,
89        year: Year,
90    ) -> Result<CheckResult, ClientError>;
91    /// Fetches the complete puzzle data (input and cached answers) for a given day and year.
92    fn get_puzzle(&self, day: Day, year: Year) -> Result<Puzzle, ClientError>;
93}
94
95/// HTTP-based implementation of the `Client` trait that talks with the Advent of Code website.
96///
97/// # Initialization Patterns
98///
99/// 1. **`new()`** - Creates a client with default configuration. Requires a valid user config, a
100///    config in the local directory, or the `AOC_SESSION_ID` and `AOC_PASSPHRASE` environment
101///    variables to be set.
102///
103/// 2. **`with_options(ClientOptions)`** - Creates a client with custom configuration options
104///    (directories, passphrase, etc.). This is the standard path for most use cases.
105///
106/// 3. **`with_custom_impl(ClientConfig, Box<dyn AdventOfCodeProtocol>)`** - For testing usage.
107///    Allows callers to inject a mock HTTP implementation. Caches are still created automatically
108///    from the config.
109///
110/// # Dependencies
111///
112/// - **Session ID**: Required for authentication. Must be a valid Advent of Code session cookie.
113/// - **Network Access**: Required for fetching new puzzles and submitting answers.
114/// - **Passphrase**: Used to encrypt cached puzzle inputs on disk (as requested by AoC maintainer).
115/// - **Cache Directories**: Created automatically if missing.
116#[derive(Debug)]
117pub struct WebClient {
118    /// The client configuration (session ID, cache directories, passphrase, etc)
119    pub config: Config,
120    protocol: Box<dyn AdventOfCodeProtocol>,
121    /// Stores encrypted puzzle inputs and answer data.
122    pub puzzle_cache: Box<dyn PuzzleCache>,
123    /// Stores submission timeout state.
124    pub session_cache: Box<dyn SessionCache>,
125}
126
127impl WebClient {
128    /// Creates a client with default configuration from environment variables.
129    pub fn new() -> Result<Self, ClientError> {
130        Ok(Self::with_config(load_config()?.build()?))
131    }
132
133    /// Creates a client with custom configuration options.
134    pub fn with_config(config: Config) -> Self {
135        let advent_protocol = Box::new(AdventOfCodeHttpProtocol::new(&config));
136        Self::with_custom_impl(config, advent_protocol)
137    }
138
139    /// Creates a client with a custom HTTP protocol implementation.
140    ///
141    /// Useful for testing or using an alternative HTTP backend. Caches are automatically created
142    /// from the provided config.
143    pub fn with_custom_impl(
144        config: Config,
145        advent_protocol: Box<dyn AdventOfCodeProtocol>,
146    ) -> Self {
147        // Convert client options into a actual configuration values.
148        // TODO: validate config settings are sane.
149        let puzzle_dir = config.puzzle_dir.clone();
150        let sessions_dir = config.sessions_dir.clone();
151        let passphrase = config.passphrase.clone();
152
153        // Print configuration settings to debug log.
154        tracing::debug!("puzzle cache dir: {puzzle_dir:?}");
155        tracing::debug!("sessions dir: {sessions_dir:?}");
156        tracing::debug!("using encryption: {}", !passphrase.is_empty());
157
158        Self {
159            config,
160            protocol: advent_protocol,
161            puzzle_cache: Box::new(PuzzleFsCache::new(puzzle_dir, Some(passphrase))),
162            session_cache: Box::new(SessionFsCache::new(sessions_dir)),
163        }
164    }
165}
166
167impl Client for WebClient {
168    fn years(&self) -> Vec<Year> {
169        let start_time = self.config.start_time;
170        let unlock_time = get_puzzle_unlock_time(start_time.year().into());
171
172        let mut end_year = start_time.year();
173
174        if start_time < unlock_time {
175            end_year -= 1;
176        }
177
178        (2015..(end_year + 1)).map(|y| y.into()).collect()
179    }
180
181    fn days(&self, year: Year) -> Option<Vec<Day>> {
182        let start_time = self.config.start_time;
183        let eastern_start_time = start_time.with_timezone(&chrono_tz::US::Eastern);
184        let requested_year = year.0 as i32;
185
186        match (
187            eastern_start_time.year().cmp(&requested_year),
188            eastern_start_time.month() == 12,
189        ) {
190            (std::cmp::Ordering::Equal, true) => Some(
191                (1..(eastern_start_time.day() + 1))
192                    .map(|d| d.into())
193                    .collect(),
194            ),
195            (std::cmp::Ordering::Greater, _) => Some((0..25).map(|d| d.into()).collect()),
196            _ => None,
197        }
198    }
199
200    fn get_input(&self, day: Day, year: Year) -> Result<String, ClientError> {
201        tracing::trace!("get_input(day=`{day}`, year=`{year}`)",);
202
203        // Check if the input for this puzzle is cached locally before fetching
204        // it from the Advent of Code service.
205        if let Some(input) = self
206            .puzzle_cache
207            .load_input(day, year)
208            .map_err(ClientError::CacheError)?
209        {
210            return Ok(input);
211        }
212
213        // Fetch the puzzle input from the Advent of Code service.
214        let input = self.protocol.get_input(day, year)?;
215
216        // Cache the puzzle input on disk before returning to avoid repeatedly
217        // fetching input from the Advent of Code service.
218        self.puzzle_cache.save_input(&input, day, year)?;
219        Ok(input)
220    }
221
222    fn submit_answer(
223        &mut self,
224        answer: Answer,
225        part: Part,
226        day: Day,
227        year: Year,
228    ) -> Result<CheckResult, ClientError> {
229        tracing::trace!(
230            "submit_answer(answer=`{:?}`, part=`{}`, day=`{}`, year=`{}`)",
231            answer,
232            part,
233            day,
234            year
235        );
236
237        // Check the cache to see if this answer can be checked locally without having to hit the
238        // server. If the cache is not set then create a new answers dataset.
239        let mut answers = match self.puzzle_cache.load_answers(part, day, year)? {
240            Some(cached_answers) => {
241                if let Some(check_result) = cached_answers.check(&answer) {
242                    tracing::debug!("answer check result was found in the cache {check_result:?}");
243                    return Ok(check_result);
244                }
245
246                cached_answers
247            }
248            _ => Answers::new(),
249        };
250
251        // Check if there is an active time out on new submissions prior to
252        // submitting to the advent of code service.
253        let mut session = self.session_cache.load(&self.config.session_id)?;
254
255        if let Some(submit_wait_until) = session.submit_wait_until {
256            if self.config.start_time <= submit_wait_until {
257                tracing::warn!("you cannot submit an answer until {submit_wait_until}");
258                return Err(ClientError::SubmitTimeOut(
259                    submit_wait_until - self.config.start_time,
260                ));
261            } else {
262                // TODO: remove the timeout and save.
263                tracing::debug!("the submission timeout has expired, ignoring");
264            }
265        }
266
267        // Submit to the answer to Advent of Code service.
268        let (check_result, time_to_wait) = self.protocol.submit_answer(&answer, part, day, year)?;
269
270        // Write back the amount of time to wait to avoid hitting the server
271        // on future submissions.
272        if let Some(time_to_wait) = time_to_wait {
273            let wait_until = chrono::Utc::now() + time_to_wait;
274            tracing::debug!("setting time to wait ({time_to_wait}) to be {wait_until}");
275
276            session.submit_wait_until = Some(wait_until);
277            self.session_cache.save(&session)?;
278        }
279
280        // Write the response to the answers database and then save it back to
281        // the puzzle cache.
282        match check_result {
283            CheckResult::Correct => {
284                tracing::debug!("Setting correct answer as {answer}");
285                answers.set_correct_answer(answer);
286            }
287            CheckResult::Wrong => {
288                tracing::debug!("Setting wrong answer {answer}");
289                answers.add_wrong_answer(answer);
290            }
291            CheckResult::TooLow => {
292                tracing::debug!("Setting low bounds wrong answer {answer}");
293                answers.set_low_bounds(answer);
294            }
295            CheckResult::TooHigh => {
296                tracing::debug!("Setting high bounds wrong answer {answer}");
297                answers.set_high_bounds(answer);
298            }
299        };
300
301        tracing::debug!("Saving answers database to puzzle cache");
302        self.puzzle_cache.save_answers(&answers, part, day, year)?;
303
304        Ok(check_result)
305    }
306
307    fn get_puzzle(&self, day: Day, year: Year) -> Result<Puzzle, ClientError> {
308        Ok(Puzzle {
309            day,
310            year,
311            input: self.get_input(day, year)?,
312            part_one_answers: self
313                .puzzle_cache
314                .load_answers(Part::One, day, year)?
315                .unwrap_or_default(),
316            part_two_answers: self
317                .puzzle_cache
318                .load_answers(Part::Two, day, year)?
319                .unwrap_or_default(),
320        })
321    }
322
323    // TODO: personal leaderboard
324    // TODO: list of private leaderboards
325    // TODO: show private leaderboard
326}
327
328#[cfg(test)]
329mod tests {
330    use chrono::{NaiveDate, NaiveTime, TimeZone};
331    use chrono_tz::US::Eastern;
332
333    use crate::config::ConfigBuilder;
334
335    use super::*;
336
337    fn web_client_with_time(
338        year: i32,
339        month: u32,
340        day: u32,
341        hour: u32,
342        min: u32,
343        sec: u32,
344    ) -> WebClient {
345        WebClient::with_config(
346            ConfigBuilder::new()
347                .with_session_id("UNIT_TEST_SESSION_ID")
348                .with_passphrase("UNIT_TEST_PASSWORD")
349                .with_puzzle_dir("DO_NOT_USE")
350                .with_fake_time(
351                    Eastern
352                        .from_local_datetime(
353                            &NaiveDate::from_ymd_opt(year, month, day)
354                                .unwrap()
355                                .and_time(NaiveTime::from_hms_opt(hour, min, sec).unwrap()),
356                        )
357                        .unwrap()
358                        .with_timezone(&chrono::Utc),
359                )
360                .build()
361                .unwrap(),
362        )
363    }
364
365    #[test]
366    fn list_years_when_date_is_after_start() {
367        let client = web_client_with_time(2018, 12, 1, 0, 0, 0);
368        assert_eq!(
369            client.years(),
370            vec![Year(2015), Year(2016), Year(2017), Year(2018)]
371        );
372    }
373
374    #[test]
375    fn list_years_when_date_is_before_start() {
376        let client = web_client_with_time(2018, 11, 30, 23, 59, 59);
377        assert_eq!(client.years(), vec![Year(2015), Year(2016), Year(2017)]);
378    }
379
380    #[test]
381    fn list_years_when_date_aoc_start() {
382        let client = web_client_with_time(2015, 3, 10, 11, 15, 7);
383        assert_eq!(client.years(), vec![]);
384    }
385
386    #[test]
387    fn list_days_before_start() {
388        let client = web_client_with_time(2020, 11, 30, 23, 59, 59);
389        assert_eq!(client.days(Year(2020)), None);
390    }
391
392    #[test]
393    fn list_days_at_start() {
394        let client = web_client_with_time(2020, 12, 1, 0, 0, 0);
395        assert_eq!(client.days(Year(2020)), Some(vec![Day(1)]));
396    }
397
398    #[test]
399    fn list_days_in_middle() {
400        let client = web_client_with_time(2020, 12, 6, 0, 0, 0);
401        assert_eq!(
402            client.days(Year(2020)),
403            Some(vec![Day(1), Day(2), Day(3), Day(4), Day(5), Day(6)])
404        );
405    }
406}