mod protocol;
use chrono::Datelike;
use protocol::{AdventOfCodeHttpProtocol, AdventOfCodeProtocol};
use thiserror::Error;
use crate::{
cache::{CacheError, PuzzleCache, PuzzleFsCache, SessionCache, SessionFsCache},
config::{load_config, Config, ConfigError},
data::{Answers, CheckResult, Puzzle},
utils::get_puzzle_unlock_time,
Answer, Day, Part, Year,
};
#[derive(Debug, Error)]
pub enum ClientError {
#[error("the answer was submitted too soon, please wait until {} trying again", .0)]
TooSoon(chrono::DateTime<chrono::Utc>),
#[error("the session id `{}` is invalid or has expired", .0)]
BadSessionId(String),
#[error("a puzzle could not be found for day {} year {}", .0, .1)]
PuzzleNotFound(Day, Year),
#[error("please wait {} before submitting another answer to the Advent of Code service", .0)]
SubmitTimeOut(chrono::Duration),
#[error("a correct answer has already been submitted for day {} year {}", .0, .1)]
AlreadySubmittedAnswer(Day, Year),
#[error("an unexpected HTTP {} error was returned by the Advent of Code service", .0)]
Http(reqwest::StatusCode),
#[error("an unexpected error {} error happened when reading cached data", .0)]
CacheError(#[from] CacheError),
#[error("an unexpected error {} happened when reading configuration values", .0)]
SettingsError(#[from] ConfigError),
}
pub trait Client {
fn years(&self) -> Vec<Year>;
fn days(&self, year: Year) -> Option<Vec<Day>>;
fn get_input(&self, day: Day, year: Year) -> Result<String, ClientError>;
fn submit_answer(
&mut self,
answer: Answer,
part: Part,
day: Day,
year: Year,
) -> Result<CheckResult, ClientError>;
fn get_puzzle(&self, day: Day, year: Year) -> Result<Puzzle, ClientError>;
}
#[derive(Debug)]
pub struct WebClient {
pub config: Config,
protocol: Box<dyn AdventOfCodeProtocol>,
pub puzzle_cache: Box<dyn PuzzleCache>,
pub session_cache: Box<dyn SessionCache>,
}
impl WebClient {
pub fn new() -> Result<Self, ClientError> {
Ok(Self::with_config(load_config()?.build()?))
}
pub fn with_config(config: Config) -> Self {
let advent_protocol = Box::new(AdventOfCodeHttpProtocol::new(&config));
Self::with_custom_impl(config, advent_protocol)
}
pub fn with_custom_impl(
config: Config,
advent_protocol: Box<dyn AdventOfCodeProtocol>,
) -> Self {
let puzzle_dir = config.puzzle_dir.clone();
let sessions_dir = config.sessions_dir.clone();
let passphrase = config.passphrase.clone();
tracing::debug!("puzzle cache dir: {puzzle_dir:?}");
tracing::debug!("sessions dir: {sessions_dir:?}");
tracing::debug!("using encryption: {}", !passphrase.is_empty());
Self {
config,
protocol: advent_protocol,
puzzle_cache: Box::new(PuzzleFsCache::new(puzzle_dir, Some(passphrase))),
session_cache: Box::new(SessionFsCache::new(sessions_dir)),
}
}
}
impl Client for WebClient {
fn years(&self) -> Vec<Year> {
let start_time = self.config.start_time;
let unlock_time = get_puzzle_unlock_time(start_time.year().into());
let mut end_year = start_time.year();
if start_time < unlock_time {
end_year -= 1;
}
(2015..(end_year + 1)).map(|y| y.into()).collect()
}
fn days(&self, year: Year) -> Option<Vec<Day>> {
let start_time = self.config.start_time;
let eastern_start_time = start_time.with_timezone(&chrono_tz::US::Eastern);
let requested_year = year.0 as i32;
match (
eastern_start_time.year().cmp(&requested_year),
eastern_start_time.month() == 12,
) {
(std::cmp::Ordering::Equal, true) => Some(
(1..(eastern_start_time.day() + 1))
.map(|d| d.into())
.collect(),
),
(std::cmp::Ordering::Greater, _) => Some((0..25).map(|d| d.into()).collect()),
_ => None,
}
}
fn get_input(&self, day: Day, year: Year) -> Result<String, ClientError> {
tracing::trace!("get_input(day=`{day}`, year=`{year}`)",);
if let Some(input) = self
.puzzle_cache
.load_input(day, year)
.map_err(ClientError::CacheError)?
{
return Ok(input);
}
let input = self.protocol.get_input(day, year)?;
self.puzzle_cache.save_input(&input, day, year)?;
Ok(input)
}
fn submit_answer(
&mut self,
answer: Answer,
part: Part,
day: Day,
year: Year,
) -> Result<CheckResult, ClientError> {
tracing::trace!(
"submit_answer(answer=`{:?}`, part=`{}`, day=`{}`, year=`{}`)",
answer,
part,
day,
year
);
let mut answers = match self.puzzle_cache.load_answers(part, day, year)? {
Some(cached_answers) => {
if let Some(check_result) = cached_answers.check(&answer) {
tracing::debug!("answer check result was found in the cache {check_result:?}");
return Ok(check_result);
}
cached_answers
}
_ => Answers::new(),
};
let mut session = self.session_cache.load(&self.config.session_id)?;
if let Some(submit_wait_until) = session.submit_wait_until {
if self.config.start_time <= submit_wait_until {
tracing::warn!("you cannot submit an answer until {submit_wait_until}");
return Err(ClientError::SubmitTimeOut(
submit_wait_until - self.config.start_time,
));
} else {
tracing::debug!("the submission timeout has expired, ignoring");
}
}
let (check_result, time_to_wait) = self.protocol.submit_answer(&answer, part, day, year)?;
if let Some(time_to_wait) = time_to_wait {
let wait_until = chrono::Utc::now() + time_to_wait;
tracing::debug!("setting time to wait ({time_to_wait}) to be {wait_until}");
session.submit_wait_until = Some(wait_until);
self.session_cache.save(&session)?;
}
match check_result {
CheckResult::Correct => {
tracing::debug!("Setting correct answer as {answer}");
answers.set_correct_answer(answer);
}
CheckResult::Wrong => {
tracing::debug!("Setting wrong answer {answer}");
answers.add_wrong_answer(answer);
}
CheckResult::TooLow => {
tracing::debug!("Setting low bounds wrong answer {answer}");
answers.set_low_bounds(answer);
}
CheckResult::TooHigh => {
tracing::debug!("Setting high bounds wrong answer {answer}");
answers.set_high_bounds(answer);
}
};
tracing::debug!("Saving answers database to puzzle cache");
self.puzzle_cache.save_answers(&answers, part, day, year)?;
Ok(check_result)
}
fn get_puzzle(&self, day: Day, year: Year) -> Result<Puzzle, ClientError> {
Ok(Puzzle {
day,
year,
input: self.get_input(day, year)?,
part_one_answers: self
.puzzle_cache
.load_answers(Part::One, day, year)?
.unwrap_or_default(),
part_two_answers: self
.puzzle_cache
.load_answers(Part::Two, day, year)?
.unwrap_or_default(),
})
}
}
#[cfg(test)]
mod tests {
use chrono::{NaiveDate, NaiveTime, TimeZone};
use chrono_tz::US::Eastern;
use crate::config::ConfigBuilder;
use super::*;
fn web_client_with_time(
year: i32,
month: u32,
day: u32,
hour: u32,
min: u32,
sec: u32,
) -> WebClient {
WebClient::with_config(
ConfigBuilder::new()
.with_session_id("UNIT_TEST_SESSION_ID")
.with_passphrase("UNIT_TEST_PASSWORD")
.with_puzzle_dir("DO_NOT_USE")
.with_fake_time(
Eastern
.from_local_datetime(
&NaiveDate::from_ymd_opt(year, month, day)
.unwrap()
.and_time(NaiveTime::from_hms_opt(hour, min, sec).unwrap()),
)
.unwrap()
.with_timezone(&chrono::Utc),
)
.build()
.unwrap(),
)
}
#[test]
fn list_years_when_date_is_after_start() {
let client = web_client_with_time(2018, 12, 1, 0, 0, 0);
assert_eq!(
client.years(),
vec![Year(2015), Year(2016), Year(2017), Year(2018)]
);
}
#[test]
fn list_years_when_date_is_before_start() {
let client = web_client_with_time(2018, 11, 30, 23, 59, 59);
assert_eq!(client.years(), vec![Year(2015), Year(2016), Year(2017)]);
}
#[test]
fn list_years_when_date_aoc_start() {
let client = web_client_with_time(2015, 3, 10, 11, 15, 7);
assert_eq!(client.years(), vec![]);
}
#[test]
fn list_days_before_start() {
let client = web_client_with_time(2020, 11, 30, 23, 59, 59);
assert_eq!(client.days(Year(2020)), None);
}
#[test]
fn list_days_at_start() {
let client = web_client_with_time(2020, 12, 1, 0, 0, 0);
assert_eq!(client.days(Year(2020)), Some(vec![Day(1)]));
}
#[test]
fn list_days_in_middle() {
let client = web_client_with_time(2020, 12, 6, 0, 0, 0);
assert_eq!(
client.days(Year(2020)),
Some(vec![Day(1), Day(2), Day(3), Day(4), Day(5), Day(6)])
);
}
}