advent_of_code_client/
lib.rs

1//! # Advent of Code client
2//!
3//! A client for retreiving personalized inputs and submitting answers to the
4//! yearly [Advent of Code](https://adventofcode.com) puzzles.
5//!
6//! It can either be used as a CLI tool by installing it with `cargo install advent-of-code-client`.
7//! This will install the `aoc` client that can be used to submit answers.
8//!
9//! The main interface is through [AocClient], which provides a [AocClient::get_input]
10//! function to retreive your personalized input for a puzzle, and [AocClient::submit]
11//! to submit an answer for a given [Problem] and [Level].
12//!
13//! ## Authentication
14//!
15//! See [crate README](https://github.com/OliverFlecke/advent-of-code-rust/tree/main/advent-of-code-client/README.md#authentication)
16//! for details on getting your personal token.
17//!
18use 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/// Client for interacting with `https://adventofcode.com`.
39///
40/// The simplest way to get started is to set `AOC_TOKEN` to your personal
41/// session token in your environment and use `AocClient::default()`.
42/// Alternatively, you can programatically provide your token with `from_token`.
43///
44/// See crate docs on how to optain your token.
45///
46/// ```rust
47/// # use advent_of_code_client::AocClient;
48/// // Note that the `default` implementation will panic if `AOC_TOKEN` is missing.
49/// AocClient::default();
50///
51/// AocClient::from_token("your personal session token".to_string());
52/// ````
53#[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    /// Create a new client to interact with Advent of Code.
67    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    /// Create a new client from a AoC session token.
77    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    /// Get the personal input for a user for a given problem.
85    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    /// Submit an answer for a problem on a given year, day, and level.
97    ///
98    /// This will **not** resubmit the answer if the problem has already been
99    /// solved from this machine. To track this, the status for each puzzle is
100    /// tracked in `./stars` directory. In this case a
101    /// [SubmissionResult::SkippingAlreadyCompleted] is returned.
102    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        // Check if problem is already solved.
111        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    /// Send a HTTP POST request with the answer for the problem at a given year,
133    /// day, and level. The answer must always be provided as a string.
134    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    /// Download the input for a given problem.
156    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    /// Get the base url for a problem.
176    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    /// Build a HTTP client to send request to Advent of Code.
187    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
208/// Read the token required to authenticate against the Advent of Code server.
209/// Panics if it cannot be found.
210fn 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/// Result of a submission of an answer to a problem.
218#[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            // Default retry time is 5 minutes if too many answers has been provided.
286            // Otherwise we should be able to correctly parse it with the regex above.
287            .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
299/// This extracts the part of the submission response within the `<main>` tags.
300/// As this contains the primary message from AoC, the rest can be thrown away
301/// when you just want to know whether your answer was right or not.
302fn 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        // Arrange
344        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        // Act
355        let input = client.download_input((Year::Y2017, 1).into()).unwrap();
356
357        // Assert
358        assert_eq!(body, input);
359    }
360
361    #[async_std::test]
362    async fn download_input_with_incorrect_response() {
363        // Arrange
364        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        // Act
375        let response = client.download_input((Year::Y2017, 1).into());
376
377        // Assert
378        assert!(response.is_err());
379    }
380
381    #[async_std::test]
382    async fn submit_answer() {
383        // Arrange
384        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        // Act
396        let response = client.post_answer((Year::Y2017, 1).into(), Level::A, &answer);
397
398        // Assert
399        assert!(response.is_ok());
400    }
401
402    #[test]
403    fn parse_to_recent_response() {
404        // Arrange
405        let body = include_str!("../data/too_recent.html");
406
407        // Act
408        let result = response_body_to_submission_result(body).unwrap();
409
410        // Assert
411        assert_eq!(
412            result,
413            SubmissionResult::TooRecent(Duration::from_secs(4 * 60 + 36))
414        );
415    }
416}