1pub mod protocol; use 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#[derive(Debug, Error)]
22pub enum ClientError {
23 #[error("the answer was submitted too soon, please wait until {} trying again", .0)]
25 TooSoon(chrono::DateTime<chrono::Utc>),
26 #[error(
28 "session cookie required; read the advent-of-code-data README for instructions on setting this"
29 )]
30 SessionIdRequired,
31 #[error("the session id `{:?}` is invalid or has expired", .0)]
33 BadSessionId(String),
34 #[error("a puzzle could not be found for day {} year {}", .0, .1)]
36 PuzzleNotFound(Day, Year),
37 #[error("please wait {} before submitting another answer to the Advent of Code service", .0)]
39 SubmitTimeOut(chrono::Duration),
40 #[error("a correct answer has already been submitted for this puzzle")]
42 AlreadySubmittedAnswer,
43 #[error("an unexpected HTTP {} error was returned by the Advent of Code service", .0)]
45 ServerHttpError(u16),
46 #[error("an unexpected error {} error happened when reading cached data", .0)]
48 CacheError(#[from] CacheError),
49 #[error("an unexpected error {} happened when reading configuration values", .0)]
51 SettingsError(#[from] ConfigError),
52 #[error("{}", .0)]
53 ReqwestError(#[from] reqwest::Error),
54}
55
56pub trait Client {
85 fn years(&self) -> Vec<Year>;
88 fn days(&self, year: Year) -> Option<Vec<Day>>;
91 fn get_input(&self, day: Day, year: Year) -> Result<String, ClientError>;
94 fn submit_answer(
97 &mut self,
98 answer: Answer,
99 part: Part,
100 day: Day,
101 year: Year,
102 ) -> Result<CheckResult, ClientError>;
103 fn get_puzzle(&self, day: Day, year: Year) -> Result<Puzzle, ClientError>;
105}
106
107pub struct WebClient {
129 pub config: Config,
131 protocol: Box<dyn ServiceConnector>,
132 pub puzzle_cache: Box<dyn PuzzleCache>,
134 pub session_cache: Box<dyn SessionCache>,
136}
137
138impl WebClient {
139 pub fn new() -> Result<Self, ClientError> {
141 Ok(Self::with_config(load_config()?.build()?))
142 }
143
144 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 pub fn with_custom_impl(config: Config, advent_protocol: Box<dyn ServiceConnector>) -> Self {
155 let puzzle_dir = config.puzzle_dir.clone();
158 let sessions_dir = config.sessions_dir.clone();
159 let passphrase = config.passphrase.clone();
160
161 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 tracing::trace!("get_input(day=`{day}`, year=`{year}`)",);
213
214 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 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 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 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 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 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 tracing::debug!("the submission timeout has expired, ignoring");
306 }
307 }
308
309 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 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 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 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 }
397
398fn parse_submit_response(
401 response_text: &str,
402) -> Result<(CheckResult, Option<Duration>), ClientError> {
403 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 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 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
444fn 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
455fn 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
463fn 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}