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 #[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 #[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 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 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 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 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 println!("❌ {response}");
166 } else if response.contains("Did you already complete it") {
167 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 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}