pub mod protocol;
use chrono::{Datelike, Duration};
use protocol::{AdventOfCodeService, ServiceConnector};
use regex::Regex;
use thiserror::Error;
use crate::{
cache::{CacheError, PuzzleCache, PuzzleFsCache, SessionCache, SessionFsCache},
client::protocol::ServiceError,
config::{load_config, Config, ConfigError},
data::{Answers, CheckResult, Puzzle},
utils::get_puzzle_unlock_time,
Answer, Day, Part, Year,
};
const HTTP_BAD_REQUEST: u16 = 400;
const HTTP_NOT_FOUND: u16 = 404;
#[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(
"session cookie required; read the advent-of-code-data README for instructions on setting this"
)]
SessionIdRequired,
#[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 this puzzle")]
AlreadySubmittedAnswer,
#[error("an unexpected HTTP {} error was returned by the Advent of Code service", .0)]
ServerHttpError(u16),
#[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),
#[error("{}", .0)]
ReqwestError(#[from] reqwest::Error),
}
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>;
}
pub struct WebClient {
pub config: Config,
protocol: Box<dyn ServiceConnector>,
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(AdventOfCodeService {});
Self::with_custom_impl(config, advent_protocol)
}
pub fn with_custom_impl(config: Config, advent_protocol: Box<dyn ServiceConnector>) -> 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);
}
match self.protocol.get_input(
day,
year,
&self
.config
.session_id
.as_ref()
.cloned()
.ok_or(ClientError::SessionIdRequired)?,
) {
Ok(input_text) => {
assert!(!input_text.is_empty());
self.puzzle_cache.save_input(&input_text, day, year)?;
Ok(input_text)
}
Err(ServiceError::HttpStatusError(HTTP_BAD_REQUEST)) => Err(ClientError::BadSessionId(
self.config
.session_id
.clone()
.expect("already checked that session id was provided"),
)),
Err(ServiceError::HttpStatusError(HTTP_NOT_FOUND)) => {
Err(ClientError::PuzzleNotFound(day, year))
}
Err(ServiceError::HttpStatusError(c)) => Err(ClientError::ServerHttpError(c)),
Err(ServiceError::ReqwestError(x)) => Err(ClientError::ReqwestError(x)),
}
}
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
.as_ref()
.ok_or(ClientError::SessionIdRequired)?,
)?;
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");
}
}
match self.protocol.submit_answer(
&answer,
part,
day,
year,
&self
.config
.session_id
.as_ref()
.cloned()
.ok_or(ClientError::SessionIdRequired)?,
) {
Ok(response_text) => {
assert!(!response_text.is_empty());
let (check_result, maybe_time_to_wait) = parse_submit_response(&response_text)?;
if let Some(time_to_wait) = maybe_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)
}
Err(ServiceError::HttpStatusError(HTTP_BAD_REQUEST)) => Err(ClientError::BadSessionId(
self.config
.session_id
.clone()
.expect("already checked that session id was provided"),
)),
Err(ServiceError::HttpStatusError(HTTP_NOT_FOUND)) => {
Err(ClientError::PuzzleNotFound(day, year))
}
Err(ServiceError::HttpStatusError(c)) => Err(ClientError::ServerHttpError(c)),
Err(ServiceError::ReqwestError(x)) => Err(ClientError::ReqwestError(x)),
}
}
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(),
})
}
}
fn parse_submit_response(
response_text: &str,
) -> Result<(CheckResult, Option<Duration>), ClientError> {
let extract_wait_time_funcs = &[
extract_error_time_to_wait,
extract_one_minute_time_to_wait,
extract_wrong_answer_time_to_wait,
];
let time_to_wait = extract_wait_time_funcs
.iter()
.filter_map(|f| f(response_text))
.next();
if response_text.contains("gave an answer too recently") {
return Err(ClientError::SubmitTimeOut(time_to_wait.unwrap()));
}
if response_text.contains("you already complete it") {
return Err(ClientError::AlreadySubmittedAnswer);
}
let responses_texts = &[
("not the right answer", CheckResult::Wrong),
("the right answer", CheckResult::Correct),
("answer is too low", CheckResult::TooLow),
("answer is too high", CheckResult::TooHigh),
];
let check_result = responses_texts
.iter()
.find(|x| response_text.contains(x.0))
.map(|x| x.1.clone())
.unwrap_or_else(|| panic!("expected server response text to map to predetermined response in LUT. Response:\n```\n{response_text}\n```\n"));
Ok((check_result, time_to_wait))
}
fn extract_one_minute_time_to_wait(response: &str) -> Option<Duration> {
match response.contains("Please wait one minute before trying again") {
true => Some(Duration::minutes(1)),
false => None,
}
}
fn extract_wrong_answer_time_to_wait(response: &str) -> Option<Duration> {
let regex = Regex::new(r"please wait (\d) minutes?").unwrap();
regex
.captures(response)
.map(|c| Duration::minutes(c[1].parse::<i64>().unwrap()))
}
fn extract_error_time_to_wait(response: &str) -> Option<Duration> {
let regex = Regex::new(r"You have (\d+)m( (\d+)s)? left to wait").unwrap();
regex.captures(response).map(|c| {
let mut time_to_wait = Duration::minutes(c[1].parse::<i64>().unwrap());
if let Some(secs) = c.get(3) {
time_to_wait += Duration::seconds(secs.as_str().parse::<i64>().unwrap());
}
time_to_wait
})
}
#[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)])
);
}
}