1use std::{env, fmt::Display, fs, time::Duration};
19
20use anyhow::Context;
21use colored::Colorize;
22use reqwest::{
23 blocking::{Client, Response},
24 header::{HeaderMap, HeaderValue, COOKIE},
25 Url,
26};
27
28use crate::score::ScoreMap;
29
30mod cache;
31mod problem;
32mod score;
33
34pub use problem::{Day, Level, Problem, Year};
35
36const TOKEN_NAME: &str = "AOC_TOKEN";
37
38#[derive(Debug)]
54pub struct AocClient {
55 base_url: Url,
56 http_client: Client,
57}
58
59impl Default for AocClient {
60 fn default() -> Self {
61 Self::new(default_url_for_advent_of_code(), get_token())
62 }
63}
64
65impl AocClient {
66 fn new(base_url: Url, aoc_token: String) -> Self {
68 let http_client = Self::build_client(&aoc_token);
69
70 AocClient {
71 base_url,
72 http_client,
73 }
74 }
75
76 pub fn from_token(aoc_token: String) -> Self {
78 Self {
79 base_url: default_url_for_advent_of_code(),
80 http_client: Self::build_client(&aoc_token),
81 }
82 }
83
84 pub fn get_input(&self, problem: Problem) -> anyhow::Result<String> {
86 match fs::read_to_string(cache::get_input_cache_full_filename(problem)) {
87 Ok(content) => Ok(content),
88 Err(_) => {
89 let input = self.download_input(problem)?;
90 cache::store_input_in_cache(problem, &input)?;
91 Ok(input)
92 }
93 }
94 }
95
96 pub fn submit(
103 &self,
104 problem: Problem,
105 level: Level,
106 answer: &String,
107 ) -> anyhow::Result<SubmissionResult> {
108 let mut scores = ScoreMap::load(*problem.year());
109
110 if scores
112 .get_score_for_day(*problem.day())
113 .map(|x| x >= level)
114 .unwrap_or_default()
115 {
116 return Ok(SubmissionResult::SkippingAlreadyCompleted);
117 }
118
119 let response = self.post_answer(problem, level, answer);
120 let result = response.map(|x| x.try_into())??;
121
122 match result {
123 SubmissionResult::Correct | SubmissionResult::AlreadyCompleted => {
124 scores.set_score_for_day(*problem.day(), &level);
125 }
126 _ => {}
127 }
128
129 Ok(result)
130 }
131
132 fn post_answer(
135 &self,
136 problem: Problem,
137 level: Level,
138 answer: &String,
139 ) -> Result<Response, reqwest::Error> {
140 println!("Submitting answer for {problem}/{level:?} is: {answer}");
141
142 self.http_client
143 .post(
144 self.get_base_url_for_problem(problem)
145 .join("answer")
146 .expect("Failed to create `answer` URL"),
147 )
148 .form(&[
149 ("level", level.as_int().to_string()),
150 ("answer", answer.to_string()),
151 ])
152 .send()
153 }
154
155 fn download_input(&self, problem: Problem) -> anyhow::Result<String> {
157 let url = self
158 .get_base_url_for_problem(problem)
159 .join("input")
160 .expect("Failed to create download URL for `input`");
161
162 match self.http_client.get(url).send() {
163 Ok(response) if response.status().is_success() => {
164 response.text().context("Failed to read response body")
165 }
166 Ok(response) => Err(anyhow::anyhow!(
167 "Invalid status code: {}. Message from server:\n{}",
168 response.status(),
169 response.text().unwrap()
170 )),
171 Err(e) => Err(anyhow::anyhow!("Request failed to download input: {e:?}")),
172 }
173 }
174
175 fn get_base_url_for_problem(&self, problem: Problem) -> Url {
177 self.base_url
178 .join(&format!(
179 "{year}/day/{day}/",
180 year = problem.year().as_int(),
181 day = problem.day()
182 ))
183 .expect("Failed to create URL for problem")
184 }
185
186 fn build_client(token: &str) -> Client {
188 reqwest::blocking::Client::builder()
189 .default_headers({
190 let mut headers = HeaderMap::new();
191 headers.insert(
192 COOKIE,
193 HeaderValue::from_str(&format!("session={token}"))
194 .expect("Failed to make header value with token"),
195 );
196 headers
197 })
198 .user_agent("github.com/OliverFlecke/advent-of-code-rust by oliverfl@live.dk")
199 .build()
200 .expect("Failed to create reqwest client")
201 }
202}
203
204fn default_url_for_advent_of_code() -> Url {
205 Url::parse("https://adventofcode.com/").expect("Failed to create URL for AoC")
206}
207
208fn get_token() -> String {
211 match env::var(TOKEN_NAME) {
212 Ok(token) => token,
213 Err(_) => panic!("Session token to authenticate against advent of code was not found. It should be an environment variable named 'AOC_TOKEN'"),
214 }
215}
216
217#[derive(Debug, Clone, Copy, PartialEq, Eq)]
219pub enum SubmissionResult {
220 Correct,
221 Incorrect,
222 AlreadyCompleted,
223 SkippingAlreadyCompleted,
224 TooRecent(Duration),
225}
226
227impl Display for SubmissionResult {
228 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
229 use SubmissionResult::*;
230 match self {
231 Correct => {
232 write!(f, "{}", "Answer is correct".green())
233 }
234 Incorrect => {
235 write!(f, "{}", "You answered incorrectly!".red())
236 }
237 AlreadyCompleted => {
238 write!(
239 f,
240 "{}",
241 "Problem already solved, but answer was correct".green()
242 )
243 }
244 SkippingAlreadyCompleted => {
245 write!(f, "Problem already solved. Skipping submission")
246 }
247 TooRecent(duration) => {
248 write!(
249 f,
250 "You have submitted an answer too recently. Wait a {duration:?} and try again"
251 )
252 }
253 }
254 }
255}
256
257impl TryFrom<Response> for SubmissionResult {
258 type Error = anyhow::Error;
259
260 fn try_from(response: Response) -> Result<Self, Self::Error> {
261 let body = get_main_part_from_html_response(response);
262
263 response_body_to_submission_result(&body)
264 }
265}
266
267fn response_body_to_submission_result(body: &str) -> anyhow::Result<SubmissionResult> {
268 if body.contains("That's the right answer") {
269 Ok(SubmissionResult::Correct)
270 } else if body.contains("already complete it") {
271 Ok(SubmissionResult::AlreadyCompleted)
272 } else if body.contains("answer too recently") {
273 use duration_string::DurationString;
274
275 let re = regex::RegexBuilder::new(r#"You have (?<time>[\d\w ]+) left to wait"#)
276 .build()
277 .expect("Invaild regex for too recent input");
278 let time: Duration = re
279 .captures(body)
280 .and_then(|caps| {
281 println!("Time: {}", &caps["time"]);
282 caps["time"].parse::<DurationString>().ok()
283 })
284 .map(|x| x.into())
285 .unwrap_or(Duration::from_secs(300));
288
289 println!("Body: {}", body);
290 Ok(SubmissionResult::TooRecent(time))
291 } else if body.contains("not the right answer") {
292 println!("Body: {}", body);
293 Ok(SubmissionResult::Incorrect)
294 } else {
295 Err(anyhow::anyhow!("Unknown response:\n\n{}", body))
296 }
297}
298
299fn get_main_part_from_html_response(response: Response) -> String {
303 let pattern = regex::RegexBuilder::new(r"<main>[\s\S]*</main>")
304 .multi_line(true)
305 .build()
306 .unwrap();
307 let body = response.text().unwrap();
308 let m = pattern.find(body.as_str()).unwrap();
309 m.as_str().to_string()
310}
311
312#[cfg(test)]
313mod test {
314 use fake::{Fake, Faker};
315 use wiremock::{
316 matchers::{method, path},
317 Mock, MockServer, ResponseTemplate,
318 };
319
320 use super::*;
321 use crate::Year;
322
323 #[test]
324 fn get_token_test() {
325 let value = "abc";
326 env::set_var(TOKEN_NAME, value);
327
328 assert_eq!(value, get_token());
329 }
330
331 #[test]
332 fn get_base_url_test() {
333 assert_eq!(
334 "https://adventofcode.com/2016/day/17/"
335 .parse::<Url>()
336 .unwrap(),
337 AocClient::default().get_base_url_for_problem((Year::Y2016, 17).into())
338 );
339 }
340
341 #[async_std::test]
342 async fn download_input() {
343 let body: String = Faker.fake();
345 let mock_server = MockServer::start().await;
346 Mock::given(method("GET"))
347 .and(path("/2017/day/1/input"))
348 .respond_with(ResponseTemplate::new(200).set_body_string(body.clone()))
349 .expect(1)
350 .mount(&mock_server)
351 .await;
352 let client = AocClient::new(Url::parse(&mock_server.uri()).unwrap(), Faker.fake());
353
354 let input = client.download_input((Year::Y2017, 1).into()).unwrap();
356
357 assert_eq!(body, input);
359 }
360
361 #[async_std::test]
362 async fn download_input_with_incorrect_response() {
363 let body: String = Faker.fake();
365 let mock_server = MockServer::start().await;
366 Mock::given(method("GET"))
367 .and(path("/2017/day/1/input"))
368 .respond_with(ResponseTemplate::new(401).set_body_string(body.clone()))
369 .expect(1)
370 .mount(&mock_server)
371 .await;
372 let client = AocClient::new(Url::parse(&mock_server.uri()).unwrap(), Faker.fake());
373
374 let response = client.download_input((Year::Y2017, 1).into());
376
377 assert!(response.is_err());
379 }
380
381 #[async_std::test]
382 async fn submit_answer() {
383 let mock_server = MockServer::start().await;
385 Mock::given(method("POST"))
386 .and(path("/2017/day/1/answer"))
387 .respond_with(ResponseTemplate::new(200))
388 .expect(1)
389 .mount(&mock_server)
390 .await;
391
392 let answer: String = Faker.fake();
393 let client = AocClient::new(Url::parse(&mock_server.uri()).unwrap(), Faker.fake());
394
395 let response = client.post_answer((Year::Y2017, 1).into(), Level::A, &answer);
397
398 assert!(response.is_ok());
400 }
401
402 #[test]
403 fn parse_to_recent_response() {
404 let body = include_str!("../data/too_recent.html");
406
407 let result = response_body_to_submission_result(body).unwrap();
409
410 assert_eq!(
412 result,
413 SubmissionResult::TooRecent(Duration::from_secs(4 * 60 + 36))
414 );
415 }
416}