libaoc/
lib.rs

1use std::{
2    env,
3    fmt::Write,
4    fs,
5    path::{Path, PathBuf},
6    process,
7};
8
9use anyhow::{Context, Result};
10use reqwest::{header::HeaderMap, redirect::Policy};
11use scraper::{Html, Selector};
12use tracing::{error, warn};
13
14pub const AOC_URL: &str = "https://adventofcode.com";
15pub const AUTH_VAR: &str = "AOC_AUTH_TOKEN";
16pub const CACHE_PATH: &str = ".cache/aoc";
17
18/// A `(year, day)` pair to identify a puzzle.
19pub type PuzzleId = (u16, u8);
20
21/// The `Advent of Code` client handles puzzle retrieval and cache.
22pub struct Client {
23    http: reqwest::blocking::Client,
24    cache: Cache,
25}
26
27impl Client {
28    pub fn new() -> Result<Self> {
29        let token = env::var(AUTH_VAR).unwrap_or_else(|e| {
30            error!(cause = %e, AUTH_VAR);
31            process::exit(1);
32        });
33
34        let mut headers = HeaderMap::new();
35        headers.insert("cookie", format!("session={token}").parse()?);
36        Ok(Self {
37            http: reqwest::blocking::Client::builder()
38                .user_agent("libaoc.rs")
39                .default_headers(headers)
40                .redirect(Policy::none())
41                .build()?,
42            cache: Cache::new(home_dir().join(CACHE_PATH))?,
43        })
44    }
45
46    /// Get a puzzle from cache or by scraping the website if not found.
47    pub fn get_puzzle(&self, id: &PuzzleId) -> Result<Puzzle> {
48        if let Some(puzzle) = self.cache.get(id) {
49            return Ok(puzzle);
50        }
51        self.download_puzzle(id)
52    }
53
54    /// Scrape a puzzle and store in cache.
55    pub fn download_puzzle(&self, id: &PuzzleId) -> Result<Puzzle> {
56        let puzzle = self.scrape_puzzle(id)?;
57        self.cache.insert(id, &puzzle);
58        Ok(puzzle)
59    }
60
61    /// Get the puzzle's input from cache or by requesting the server.
62    pub fn get_input(&self, id: &PuzzleId) -> Result<String> {
63        if let Some(input) = self.cache.get_input(id) {
64            return Ok(input);
65        }
66        self.download_input(id)
67    }
68
69    /// Retrieve the puzzle's input from the server and cache it.
70    pub fn download_input(&self, id: &PuzzleId) -> Result<String> {
71        let input = self
72            .http
73            .get(format!("{}/input", self.mkurl(id)))
74            .send()?
75            .error_for_status()?
76            .text()?;
77        self.cache.insert_input(id, &input);
78        Ok(input)
79    }
80
81    /// Scrape a puzzle's questions and answers.
82    pub fn scrape_puzzle(&self, id: &PuzzleId) -> Result<Puzzle> {
83        let html = self
84            .http
85            .get(self.mkurl(id))
86            .send()?
87            .error_for_status()?
88            .text()?;
89
90        let doc = Html::parse_document(&html);
91        let query = Selector::parse("article.day-desc").unwrap();
92        let mut questions = doc.select(&query);
93        let q1 = questions
94            .next()
95            .and_then(|el| html2text::from_read(el.inner_html().as_bytes(), 80).ok());
96        let q2 = questions
97            .next()
98            .and_then(|el| html2text::from_read(el.inner_html().as_bytes(), 80).ok());
99
100        let query = Selector::parse("article.day-desc + p code").unwrap();
101        let mut answers = doc.select(&query);
102        let a1 = answers.next().map(|el| el.text().collect::<String>());
103        let a2 = answers.next().map(|el| el.text().collect::<String>());
104
105        Ok(Puzzle {
106            id: *id,
107            q1,
108            q2,
109            a1,
110            a2,
111        })
112    }
113
114    /// Submit a puzzle's answer for a specific part.
115    pub fn submit(
116        &self,
117        id: &PuzzleId,
118        part: Option<u8>,
119        answer: impl AsRef<str>,
120    ) -> Result<Option<Puzzle>> {
121        // TODO: Check for answers in cache to be able to submit once the puzzle
122        // is finished.
123        let path = self.cache.mkpath(id);
124        let part = part.unwrap_or_else(|| {
125            if fs::metadata(path.join("a1")).is_ok_and(|m| m.len() > 0) {
126                2
127            } else {
128                1
129            }
130        });
131
132        let html = self
133            .http
134            .post(format!("{}/answer", self.mkurl(id)))
135            .header("content-type", "application/x-www-form-urlencoded")
136            .body(format!("level={}&answer={}", part, answer.as_ref()))
137            .send()?
138            .error_for_status()?
139            .text()?;
140
141        match self.submission_outcome(&html) {
142            Submit::Correct => {
143                println!("Correct!");
144                return Ok(Some(self.download_puzzle(id)?));
145            }
146            Submit::Incorrect => println!("Incorrect!"),
147            Submit::Wait => println!("Wait!"),
148            Submit::Error => println!("Unknown response"),
149        };
150        Ok(None)
151    }
152
153    fn submission_outcome(&self, response: &str) -> Submit {
154        if response.contains("That's the right answer") {
155            Submit::Correct
156        } else if response.contains("That's not the right answer") {
157            Submit::Incorrect
158        } else if response.contains("You gave an answer too recently") {
159            Submit::Wait
160        } else {
161            Submit::Error
162        }
163    }
164
165    fn mkurl(&self, (y, d): &PuzzleId) -> String {
166        format!("{AOC_URL}/{y}/day/{d}")
167    }
168}
169
170/// The outcome of a puzzle submission.
171pub enum Submit {
172    Correct,
173    Incorrect,
174    Wait,
175    Error,
176}
177
178/// File system cache to store downloaded puzzles.
179struct Cache {
180    path: PathBuf,
181}
182
183impl Cache {
184    pub fn new(path: impl AsRef<Path>) -> Result<Self> {
185        let path = path.as_ref();
186        if !path.exists() {
187            fs::create_dir_all(path).context("mkdir cache")?;
188        }
189        Ok(Self { path: path.into() })
190    }
191
192    pub fn get(&self, id: &PuzzleId) -> Option<Puzzle> {
193        let path = self.mkpath(id);
194        path.exists().then(|| Puzzle::read(path, id))
195    }
196
197    pub fn get_input(&self, id: &PuzzleId) -> Option<String> {
198        let path = self.mkpath(id).join("in");
199        path.exists().then(|| fs::read_to_string(path).unwrap())
200    }
201
202    #[allow(dead_code)]
203    pub fn get_answers(&self, id: &PuzzleId) -> (Option<String>, Option<String>) {
204        let path = self.mkpath(id);
205        (
206            fs::read_to_string(path.join("a1")).ok(),
207            fs::read_to_string(path.join("a2")).ok(),
208        )
209    }
210
211    pub fn insert(&self, id: &PuzzleId, puzzle: &Puzzle) {
212        puzzle
213            .write(self.mkpath(id))
214            .unwrap_or_else(|_| warn!("failed to insert puzzle"));
215    }
216
217    pub fn insert_input(&self, id: &PuzzleId, input: &str) {
218        fs::write(self.mkpath(id).join("in"), input)
219            .unwrap_or_else(|_| warn!("failed to insert input"));
220    }
221
222    #[allow(dead_code)]
223    pub fn update_answer(&self, id: &PuzzleId, part: u32, answer: &str) {
224        fs::write(self.mkpath(id).join(format!("a{part}")), answer)
225            .unwrap_or_else(|_| warn!("failed to update answer"));
226    }
227
228    fn mkpath(&self, (y, d): &PuzzleId) -> PathBuf {
229        self.path.join(format!("{y}/{d}"))
230    }
231}
232
233#[derive(Debug, Default, Clone)]
234pub struct Puzzle {
235    pub id: PuzzleId,
236    pub q1: Option<String>,
237    pub q2: Option<String>,
238    pub a1: Option<String>,
239    pub a2: Option<String>,
240}
241
242impl Puzzle {
243    pub fn read(path: impl AsRef<Path>, id: &PuzzleId) -> Puzzle {
244        let path = path.as_ref();
245        Puzzle {
246            id: *id,
247            q1: fs::read_to_string(path.join("q1")).ok(),
248            q2: fs::read_to_string(path.join("q2")).ok(),
249            a1: fs::read_to_string(path.join("a1")).ok(),
250            a2: fs::read_to_string(path.join("a2")).ok(),
251        }
252    }
253
254    pub fn write(&self, path: impl AsRef<Path>) -> Result<()> {
255        let path = path.as_ref();
256        fs::create_dir_all(path)?;
257        if let Some(q) = &self.q1 {
258            fs::write(path.join("q1"), q.as_bytes())?;
259        }
260        if let Some(q) = &self.q2 {
261            fs::write(path.join("q2"), q.as_bytes())?;
262        }
263        if let Some(a) = &self.a1 {
264            fs::write(path.join("a1"), a.as_bytes())?;
265        }
266        if let Some(a) = &self.a2 {
267            fs::write(path.join("a2"), a.as_bytes())?;
268        }
269        Ok(())
270    }
271
272    pub fn view(&self, show_answers: bool) -> String {
273        let mut buf = String::new();
274        if let Some(q1) = &self.q1 {
275            let _ = writeln!(&mut buf, "{q1}");
276            if show_answers {
277                if let Some(a1) = &self.a1 {
278                    let _ = writeln!(&mut buf, "**Answer**: `{a1}`.");
279                }
280            }
281        }
282        if let Some(q2) = &self.q2 {
283            let _ = writeln!(&mut buf, "\n{q2}");
284            if show_answers {
285                if let Some(a2) = &self.a2 {
286                    let _ = writeln!(&mut buf, "**Answer**: `{a2}`.");
287                }
288            }
289        }
290        buf
291    }
292
293    pub fn write_view(&self, path: impl AsRef<Path>) -> Result<()> {
294        Ok(fs::write(path, self.view(true))?)
295    }
296}
297
298fn home_dir() -> PathBuf {
299    PathBuf::from(env::var("HOME").unwrap_or_else(|e| {
300        error!(cause = %e, "HOME");
301        process::exit(1);
302    }))
303}
304
305/// Determine the puzzle's year and day from a path.
306pub fn puzzle_id_from_path(path: impl AsRef<Path>) -> Option<PuzzleId> {
307    let mut day = 0xff;
308    let mut year = 0;
309    for parent in path.as_ref().ancestors() {
310        let mut chars = parent
311            .file_name()
312            .unwrap()
313            .to_str()
314            .unwrap()
315            .chars()
316            .peekable();
317        let mut buf = String::new();
318        while let Some(c) = chars.next() {
319            if c.is_ascii_digit() {
320                buf.push(c);
321                if !chars.peek().is_some_and(|c| c.is_ascii_digit()) {
322                    break;
323                }
324            }
325        }
326        if !buf.is_empty() {
327            if day == 0xff {
328                day = buf.parse().unwrap();
329            } else {
330                year = buf.parse().unwrap();
331            }
332        }
333        if year > 0 {
334            return Some((year, day));
335        }
336    }
337    None
338}
339
340#[cfg(test)]
341mod tests {
342    use super::*;
343
344    #[test]
345    fn from_path() {
346        let cases = vec![
347            ("/Users/j0rdi/aoc/2015/d01", Some((2015, 1))),
348            ("/home/j0rdi/aoc/2024/25", Some((2024, 25))),
349            ("/Users/j0rdi/aoc/2017/other/d8", Some((2017, 8))),
350            ("/home/j0rdi/aoc/2017/other/08/sub", Some((2017, 8))),
351        ];
352
353        for (path, expected) in cases {
354            assert_eq!(puzzle_id_from_path(path), expected)
355        }
356
357        assert_eq!(puzzle_id_from_path("/invalid/path"), None)
358    }
359}
360
361// struct Id(u32, u32);
362//
363// impl TryFrom<&Path> for Id {
364//     type Error = &'static str;
365//
366//     fn try_from(path: &Path) -> Result<Self, Self::Error> {
367//         match puzzle_id_from_path(path) {
368//             Some((y, d)) => Ok(Id(y, d)),
369//             None => Err("could not determine puzzle id from path"),
370//         }
371//     }
372// }
373
374/* impl<P: AsRef<Path>> From<P> for Id {
375    fn from(value: P) -> Self {
376        todo!()
377    }
378} */
379
380// impl From<(u32, u32)> for Id {
381//     fn from((y, d): (u32, u32)) -> Self {
382//         Id(y, d)
383//     }
384// }