Skip to main content

aoc_runtime/aoc/
cache.rs

1//! Remembering answers that were already accepted.
2//!
3//! Without this, every `aoc run` re-submits part one even when it was solved
4//! days ago, which costs two HTTP round trips and needlessly loads the site.
5//! Caching is strictly best effort: a failure to read or write never fails a
6//! run.
7
8use crate::puzzle::{Part, Puzzle};
9use std::{fs, path::PathBuf};
10
11/// Stores answers known to be correct.
12pub trait AnswerCache {
13    /// The accepted answer for this part, if one is known.
14    fn correct(&self, puzzle: Puzzle, part: Part) -> Option<String>;
15
16    /// Records an accepted answer. Failures are silently ignored.
17    fn record(&self, puzzle: Puzzle, part: Part, answer: &str);
18}
19
20/// A cache backed by one small file per answer.
21#[derive(Debug, Clone)]
22pub struct FileCache {
23    root: PathBuf,
24}
25
26impl FileCache {
27    /// Creates a cache rooted at the given state directory.
28    #[must_use]
29    pub fn new(state_dir: impl Into<PathBuf>) -> Self {
30        Self {
31            root: state_dir.into().join("answers"),
32        }
33    }
34
35    fn path(&self, puzzle: Puzzle, part: Part) -> PathBuf {
36        self.root.join(format!(
37            "{}-{:02}-part{}",
38            puzzle.year.get(),
39            puzzle.day.get(),
40            part.number()
41        ))
42    }
43}
44
45impl AnswerCache for FileCache {
46    fn correct(&self, puzzle: Puzzle, part: Part) -> Option<String> {
47        let answer = fs::read_to_string(self.path(puzzle, part)).ok()?;
48        let answer = answer.trim().to_owned();
49
50        (!answer.is_empty()).then_some(answer)
51    }
52
53    fn record(&self, puzzle: Puzzle, part: Part, answer: &str) {
54        let path = self.path(puzzle, part);
55        if fs::create_dir_all(&self.root).is_ok() {
56            let _ = fs::write(path, answer);
57        }
58    }
59}
60
61#[cfg(test)]
62pub(crate) mod memory {
63    use super::{AnswerCache, Part, Puzzle};
64    use std::cell::RefCell;
65    use std::collections::HashMap;
66
67    #[derive(Debug, Default)]
68    pub(crate) struct MemoryCache {
69        entries: RefCell<HashMap<(Puzzle, Part), String>>,
70    }
71
72    impl MemoryCache {
73        pub(crate) fn new() -> Self {
74            Self::default()
75        }
76
77        pub(crate) fn seeded(puzzle: Puzzle, part: Part, answer: &str) -> Self {
78            let cache = Self::new();
79            cache.record(puzzle, part, answer);
80            cache
81        }
82    }
83
84    impl AnswerCache for MemoryCache {
85        fn correct(&self, puzzle: Puzzle, part: Part) -> Option<String> {
86            self.entries.borrow().get(&(puzzle, part)).cloned()
87        }
88
89        fn record(&self, puzzle: Puzzle, part: Part, answer: &str) {
90            self.entries
91                .borrow_mut()
92                .insert((puzzle, part), answer.to_owned());
93        }
94    }
95}
96
97#[cfg(test)]
98mod tests {
99    use super::*;
100    use crate::puzzle::{Day, Year};
101
102    fn puzzle() -> Puzzle {
103        Puzzle::new(
104            Year::new(2024).expect("valid year"),
105            Day::new(7).expect("valid day"),
106        )
107        .expect("2024 has a day 7")
108    }
109
110    #[test]
111    fn remembers_answers_across_instances() {
112        let dir = tempfile::tempdir().expect("temp dir");
113        let cache = FileCache::new(dir.path());
114
115        assert_eq!(cache.correct(puzzle(), Part::One), None);
116        cache.record(puzzle(), Part::One, "1227");
117
118        let reopened = FileCache::new(dir.path());
119        assert_eq!(
120            reopened.correct(puzzle(), Part::One).as_deref(),
121            Some("1227")
122        );
123        assert_eq!(reopened.correct(puzzle(), Part::Two), None);
124    }
125
126    #[test]
127    fn keeps_parts_and_puzzles_apart() {
128        let dir = tempfile::tempdir().expect("temp dir");
129        let cache = FileCache::new(dir.path());
130        let other = Puzzle::new(
131            Year::new(2023).expect("valid year"),
132            Day::new(7).expect("valid day"),
133        )
134        .expect("2023 has a day 7");
135
136        cache.record(puzzle(), Part::One, "a");
137        cache.record(puzzle(), Part::Two, "b");
138        cache.record(other, Part::One, "c");
139
140        assert_eq!(cache.correct(puzzle(), Part::One).as_deref(), Some("a"));
141        assert_eq!(cache.correct(puzzle(), Part::Two).as_deref(), Some("b"));
142        assert_eq!(cache.correct(other, Part::One).as_deref(), Some("c"));
143    }
144
145    #[test]
146    fn an_unwritable_location_is_not_an_error() {
147        let dir = tempfile::tempdir().expect("temp dir");
148        let blocked = dir.path().join("state");
149        fs::write(&blocked, "a file, not a directory").expect("block the state directory");
150
151        let cache = FileCache::new(&blocked);
152
153        cache.record(puzzle(), Part::One, "1227");
154        assert_eq!(cache.correct(puzzle(), Part::One), None);
155    }
156}