aocd/
client.rs

1use std::fmt::Display;
2
3use crate::cache;
4use anyhow::{anyhow, Result};
5use regex::Regex;
6
7pub struct Aocd {
8    year: u16,
9    day: u8,
10    url: String,
11    session_token: String,
12    cache: cache::Cache,
13    test_file: Option<String>,
14}
15
16impl Aocd {
17    /// Create a new Aocd client.
18    ///
19    /// Requires a valid session cookie from adventofcode.com to be in a file named `~/.config/aocd/token`
20    /// It will also require write access to `~/.cache/aocd` to cache puzzle inputs and answers.
21    ///
22    /// Alternatively, if a test file is provided, the client will just be a thin wrapper using the
23    /// file as input and simply printing answers to stdout.
24    ///
25    /// # Examples
26    /// ```
27    /// use aocd::Aocd;
28    ///
29    /// let client = Aocd::new(2020, 1, None);
30    /// let test_client = Aocd::new(2020, 1, Some("test_input.txt"));
31    /// ```
32    ///
33    /// # Panics
34    /// Panics if the session cookie is not found or the cache could not be successfully setup/initialized.
35    #[must_use]
36    pub fn new(year: u16, day: u8, test_file: Option<&str>) -> Self {
37        let session_token = find_aoc_token();
38        let cache = cache::Cache::new(year, day, &session_token)
39            .expect("Should be able to create cache for aocd");
40
41        #[cfg(not(test))]
42        let url = "https://adventofcode.com".to_string();
43        #[cfg(test)]
44        let url = mockito::server_url();
45
46        Self {
47            year,
48            day,
49            url,
50            session_token,
51            cache,
52            test_file: test_file.map(|s| s.to_string()),
53        }
54    }
55
56    /// Get the puzzle input for the given year and day.
57    ///
58    /// If possible this will fetch from a local cache, and only fall back to the server if necessary.
59    ///
60    /// # Panics
61    /// Panics if the Advent of Code server responds with an error.
62    #[must_use]
63    pub fn get_input(&self) -> String {
64        if let Some(test_file) = &self.test_file {
65            return std::fs::read_to_string(test_file)
66                .expect("Failed to read test file")
67                .trim_end_matches('\n')
68                .trim_end_matches('\r')
69                .to_string();
70        }
71
72        if let Ok(input) = self.cache.get_input() {
73            return input;
74        }
75
76        let input = minreq::get(format!("{}/{}/day/{}/input", self.url, self.year, self.day))
77            .with_header("Cookie", format!("session={}", self.session_token))
78            .with_header("Content-Type", "text/plain")
79            .send()
80            .expect("Failed to get input")
81            .as_str()
82            .expect("Failed to parse input as string")
83            .trim_end_matches('\n')
84            .trim_end_matches('\r')
85            .to_string();
86        self.cache
87            .cache_input(&input)
88            .expect("Should be able to cache input");
89        input
90    }
91
92    /// Submit an answer to the given year, day, and part.
93    ///
94    /// # Panics
95    /// Panics if the Advent of Code server responds to the submission with an error.
96    pub fn submit(&self, part: u8, answer: impl Display) {
97        let answer = answer.to_string();
98
99        if self.test_file.is_some() {
100            println!("🕵️ Part {part} test result: {answer} 🕵️");
101            return;
102        }
103
104        // First check if we have already cached a _correct_ answer for this puzzle.
105        if let Ok(correct_answer) = self.cache.get_correct_answer(part) {
106            if correct_answer == answer {
107                println!("⭐ Part {part} already solved with the same answer: {correct_answer} ⭐");
108            } else {
109                println!("❌ Part {part} already solved with a different answer: {correct_answer} (you submitted: {answer}) ❌");
110            }
111            return;
112        }
113
114        // Now check if we have already checked this particular answer before. If so we know it is wrong.
115        if let Ok(response) = self.cache.get_answer_response(part, &answer) {
116            println!( "❌ You've already incorrectly guessed {answer}, and the server responed with: ❌ \n{response}");
117            return;
118        }
119
120        // Only now do we actually submit the (new) answer.
121        let url = format!("{}/{}/day/{}/answer", self.url, self.year, self.day);
122        let formdata = format!("level={}&answer={}", part, urlencoding::encode(&answer));
123        let r = minreq::post(url)
124            .with_header("Cookie", format!("session={}", self.session_token))
125            .with_header("Content-Type", "application/x-www-form-urlencoded")
126            .with_body(formdata);
127        let response = r.send().expect("Faled to submit answer");
128
129        assert!(
130            response.status_code == 200,
131            "Non 200 response from AoC when posting answer. Failed to submit answer. Check your token."
132        );
133        let response_html = response
134            .as_str()
135            .expect("Falied to read response from AoC after posting answer.");
136
137        self.handle_answer_response(part, &answer, response_html)
138            .expect("Failed to handle response from AoC");
139    }
140
141    fn handle_answer_response(&self, part: u8, answer: &str, html: &str) -> Result<()> {
142        let mut response = None;
143        for line in html.lines() {
144            if line.starts_with("<article>") {
145                response = Some(
146                    line.trim_start_matches("<article>")
147                        .trim_end_matches("</article>")
148                        .trim_start_matches("<p>")
149                        .trim_end_matches("</p>"),
150                );
151            }
152        }
153        let response = response.expect("Failed to parse response from AoC when submitting answer.");
154
155        if response.contains("That's the right answer!") {
156            println!("🌟 Part {part} correctly solved with answer: {answer} 🌟");
157            self.cache
158                .cache_answer_response(part, answer, response, true)?;
159        } else if response.contains("That's not the right answer") {
160            println!("❌ {response}");
161            self.cache
162                .cache_answer_response(part, answer, response, false)?;
163        } else if response.contains("You gave an answer too recently") {
164            // Don't cache this response.
165            println!("❌ {response}");
166        } else if response.contains("Did you already complete it") {
167            // We've apparently already solved this in the past, but the cache has no memory of that.
168            // In this case we look up what we've solved in the past, and cache it.
169            // Then we can restart the submit flow entirely, and it should not hit this case again.
170            match self.cache_past_answers() {
171                Ok(()) => self.submit(part, answer),
172                _ => panic!("Failed to cache past answers, even though we thought we had solved this puzzle before. BUG!"),
173            }
174        }
175        Ok(())
176    }
177
178    fn cache_past_answers(&self) -> Result<()> {
179        println!("You appear to have answered this puzzle before, but aocd doesn't remember that.");
180        println!(
181            "Caching past answers for {} day {} by parsing the puzzle page.",
182            self.year, self.day
183        );
184        let url = format!("{}/{}/day/{}/answer", self.url, self.year, self.day);
185        let response = minreq::get(url)
186            .with_header("Cookie", format!("session={}", self.session_token))
187            .with_header("Content-Type", "text/plain")
188            .send()?;
189        if response.status_code != 200 {
190            return Err(anyhow!(
191                "Non 200 response from AoC when getting puzzle page. Failed to cache past answers. Check your token."
192            ));
193        }
194        let response_html = response.as_str()?;
195
196        let mut part1: Option<String> = None;
197        let mut part2: Option<String> = None;
198        let re = Regex::new(r#"Your puzzle answer was <code>(.*?)</code>"#).unwrap();
199        for capture in re.captures_iter(response_html) {
200            if part1.is_none() {
201                part1 = Some(capture[1].to_string());
202            } else {
203                part2 = Some(capture[1].to_string());
204            }
205        }
206        println!("Found past answers: {part1:?} {part2:?}");
207        let mut found_any = false;
208        if let Some(part1) = part1 {
209            self.cache
210                .cache_answer_response(1, &part1, "That's the right answer!", true)?;
211            found_any = true;
212        }
213        if let Some(part2) = part2 {
214            self.cache
215                .cache_answer_response(2, &part2, "That's the right answer!", true)?;
216            found_any = true;
217        }
218        if found_any {
219            Ok(())
220        } else {
221            Err(anyhow!("Failed to find past answers"))
222        }
223    }
224}
225
226fn find_aoc_token() -> String {
227    if let Ok(session) = std::env::var("AOC_SESSION").or_else(|_| std::env::var("AOC_TOKEN")) {
228        return session.trim().to_string();
229    }
230
231    let token_path = std::env::var("AOC_TOKEN_PATH")
232        .unwrap_or_else(|_| shellexpand::tilde("~/.config/aocd/token").to_string());
233    std::fs::read_to_string(token_path)
234        .unwrap_or_else(|_| {
235            panic!(
236                "No AoC session token found. See https://crates.io/crates/aocd for how to set it.",
237            )
238        })
239        .trim()
240        .to_string()
241}
242
243#[cfg(test)]
244mod tests {
245    use super::*;
246    use mockito::mock;
247    use std::fs::File;
248    use std::io::Write;
249    use tempfile::tempdir;
250
251    struct TestClientBuilder {
252        year: u16,
253        day: u8,
254        input: Option<String>,
255    }
256
257    impl TestClientBuilder {
258        fn new() -> Self {
259            TestClientBuilder {
260                year: 2015,
261                day: 1,
262                input: None,
263            }
264        }
265        fn year(mut self, year: u16) -> Self {
266            self.year = year;
267            self
268        }
269        fn day(mut self, day: u8) -> Self {
270            self.day = day;
271            self
272        }
273        fn input(mut self, input: &str) -> Self {
274            self.input = Some(input.to_string());
275            self
276        }
277        fn run<F, T>(&self, test: F) -> Result<T>
278        where
279            T: std::panic::RefUnwindSafe,
280            F: FnOnce(&Aocd) -> Result<T>
281                + std::panic::UnwindSafe
282                + std::panic::RefUnwindSafe
283                + Copy,
284        {
285            let cache_path = std::env::temp_dir().join("aocd-tests");
286            let _ignore = std::fs::remove_dir_all(&cache_path);
287
288            temp_env::with_vars(
289                vec![
290                    ("AOC_SESSION", Some("test-session")),
291                    ("AOC_CACHE_DIR", Some(cache_path.to_str().unwrap())),
292                ],
293                move || {
294                    let client = Aocd::new(self.year, self.day, None);
295                    if let Some(input) = &self.input {
296                        let url = format!("/{}/day/{}/input", client.year, client.day);
297                        let m = mock("GET", url.as_str())
298                            .with_status(200)
299                            .with_header("Content-Type", "text/plain")
300                            .with_body(input)
301                            .expect(1)
302                            .create();
303                        let result = test(&client);
304                        m.assert();
305                        result
306                    } else {
307                        test(&client)
308                    }
309                },
310            )
311        }
312    }
313
314    #[test]
315    fn test_new_client() -> Result<()> {
316        TestClientBuilder::new().year(2022).day(1).run(|client| {
317            assert_eq!(client.year, 2022);
318            assert_eq!(client.day, 1);
319            assert_eq!(client.url, mockito::server_url());
320            Ok(())
321        })
322    }
323
324    #[test]
325    fn test_get_input() -> Result<()> {
326        TestClientBuilder::new()
327            .year(2022)
328            .day(1)
329            .input("test input")
330            .run(|client| {
331                assert_eq!(client.get_input(), "test input");
332                // A second call will trigger a cache hit. If it doesn't the test will fail because
333                // the mock endpoint only expects a single call.
334                assert_eq!(client.get_input(), "test input");
335                Ok(())
336            })
337    }
338
339    #[test]
340    #[ignore]
341    fn test_submit_answer() {
342        todo!()
343    }
344
345    #[test]
346    fn test_find_aoc_token_env() {
347        temp_env::with_var("AOC_SESSION", Some("testsession"), || {
348            assert_eq!(find_aoc_token(), "testsession");
349        });
350        temp_env::with_var("AOC_TOKEN", Some("testtoken"), || {
351            assert_eq!(find_aoc_token(), "testtoken");
352        });
353    }
354
355    #[test]
356    fn test_find_aoc_token_file() -> Result<()> {
357        let dir = tempdir()?;
358        let file_path = dir.path().join("aocd-token");
359        let mut file = File::create(&file_path)?;
360        writeln!(file, "testtokenintempfile")?;
361
362        temp_env::with_var("AOC_TOKEN_PATH", Some(&file_path), || {
363            assert_eq!(find_aoc_token(), "testtokenintempfile");
364            Ok(())
365        })
366    }
367}