Skip to main content

aoc_runtime/aoc/
input.rs

1//! Keeping puzzle inputs for good.
2//!
3//! An input is personal, permanent and unchanging, so it is worth downloading
4//! exactly once. The copy that matters lives in the state directory; the
5//! `input.txt` a solution reads beside its project is a symbolic link pointing
6//! at it. Scaffolding a day again, moving the solutions tree or deleting a
7//! project therefore costs a link rather than another request to the site.
8
9use crate::{
10    error::{Error, IoResultExt as _},
11    puzzle::Puzzle,
12};
13use std::{
14    fs, io,
15    path::{Path, PathBuf},
16};
17
18/// Puzzle inputs, one file per puzzle under the state directory.
19#[derive(Debug, Clone)]
20pub struct InputStore {
21    root: PathBuf,
22}
23
24impl InputStore {
25    /// Creates a store rooted at the given state directory.
26    #[must_use]
27    pub fn new(state_dir: impl Into<PathBuf>) -> Self {
28        Self {
29            root: state_dir.into().join("inputs"),
30        }
31    }
32
33    /// Where a puzzle's input is kept, whether or not it has been downloaded.
34    #[must_use]
35    pub fn path(&self, puzzle: Puzzle) -> PathBuf {
36        self.root
37            .join(format!("{}-{:02}.txt", puzzle.year.get(), puzzle.day.get()))
38    }
39
40    /// Whether this puzzle's input has already been downloaded.
41    #[must_use]
42    pub fn holds(&self, puzzle: Puzzle) -> bool {
43        self.path(puzzle).is_file()
44    }
45
46    /// Writes a freshly downloaded input to the store.
47    ///
48    /// # Errors
49    ///
50    /// Returns [`Error::Io`] if the state directory cannot be written to.
51    pub fn store(&self, puzzle: Puzzle, text: &str) -> Result<(), Error> {
52        fs::create_dir_all(&self.root).io_context("create input cache directory", &self.root)?;
53
54        let path = self.path(puzzle);
55        fs::write(&path, text).io_context("cache puzzle input", &path)
56    }
57
58    /// Points `at` to this puzzle's stored input, which [`InputStore::holds`]
59    /// must already report as present.
60    ///
61    /// Anything already sitting at `at` is a link left over from an input the
62    /// store no longer has, so it is replaced rather than treated as an
63    /// obstacle: a state directory that was wiped heals on the next run.
64    ///
65    /// # Errors
66    ///
67    /// Returns [`Error::Io`] if the link cannot be created and the input cannot
68    /// be copied into its place either.
69    pub fn link(&self, puzzle: Puzzle, at: &Path) -> Result<(), Error> {
70        let target = self.path(puzzle);
71
72        if let Some(parent) = at.parent() {
73            fs::create_dir_all(parent).io_context("create input directory", parent)?;
74        }
75
76        if fs::symlink_metadata(at).is_ok() {
77            fs::remove_file(at).io_context("replace stale input link", at)?;
78        }
79
80        // Windows only lets an unprivileged process create symbolic links in
81        // developer mode, so a copy stands in where linking is refused. The
82        // download is still saved once and only once, which is the point.
83        if symlink(&target, at).is_err() {
84            fs::copy(&target, at)
85                .map(drop)
86                .io_context("link cached input", at)?;
87        }
88
89        Ok(())
90    }
91}
92
93/// Creates a symbolic link at `at` pointing to `target`.
94#[cfg(unix)]
95fn symlink(target: &Path, at: &Path) -> io::Result<()> {
96    std::os::unix::fs::symlink(target, at)
97}
98
99/// Creates a symbolic link at `at` pointing to `target`.
100#[cfg(windows)]
101fn symlink(target: &Path, at: &Path) -> io::Result<()> {
102    std::os::windows::fs::symlink_file(target, at)
103}
104
105/// Creates a symbolic link at `at` pointing to `target`.
106#[cfg(not(any(unix, windows)))]
107fn symlink(_target: &Path, _at: &Path) -> io::Result<()> {
108    Err(io::Error::new(
109        io::ErrorKind::Unsupported,
110        "symbolic links are not supported on this platform",
111    ))
112}
113
114#[cfg(test)]
115mod tests {
116    use super::*;
117    use crate::puzzle::{Day, Year};
118
119    fn puzzle() -> Puzzle {
120        Puzzle::new(
121            Year::new(2024).expect("valid year"),
122            Day::new(7).expect("valid day"),
123        )
124        .expect("2024 has a day 7")
125    }
126
127    #[test]
128    fn an_input_survives_the_store_being_reopened() {
129        let dir = tempfile::tempdir().expect("temp dir");
130        let store = InputStore::new(dir.path());
131
132        assert!(!store.holds(puzzle()));
133        store.store(puzzle(), "puzzle input").expect("store input");
134
135        let reopened = InputStore::new(dir.path());
136        assert!(reopened.holds(puzzle()));
137        assert_eq!(
138            fs::read_to_string(reopened.path(puzzle())).expect("read stored input"),
139            "puzzle input"
140        );
141    }
142
143    #[test]
144    fn puzzles_are_kept_apart() {
145        let dir = tempfile::tempdir().expect("temp dir");
146        let store = InputStore::new(dir.path());
147        let other = Puzzle::new(
148            Year::new(2024).expect("valid year"),
149            Day::new(17).expect("valid day"),
150        )
151        .expect("2024 has a day 17");
152
153        store.store(puzzle(), "seven").expect("store input");
154
155        assert!(store.holds(puzzle()));
156        assert!(!store.holds(other), "day 7 must not answer for day 17");
157    }
158
159    #[test]
160    fn a_linked_input_reads_as_the_stored_one() {
161        let dir = tempfile::tempdir().expect("temp dir");
162        let project = tempfile::tempdir().expect("temp dir");
163        let store = InputStore::new(dir.path());
164        store.store(puzzle(), "puzzle input").expect("store input");
165
166        let at = project.path().join("day07").join("input.txt");
167        store.link(puzzle(), &at).expect("link input");
168
169        assert_eq!(
170            fs::read_to_string(&at).expect("read linked input"),
171            "puzzle input"
172        );
173    }
174
175    #[cfg(unix)]
176    #[test]
177    fn linking_points_at_the_cache_rather_than_copying_it() {
178        let dir = tempfile::tempdir().expect("temp dir");
179        let project = tempfile::tempdir().expect("temp dir");
180        let store = InputStore::new(dir.path());
181        store.store(puzzle(), "puzzle input").expect("store input");
182
183        let at = project.path().join("input.txt");
184        store.link(puzzle(), &at).expect("link input");
185
186        assert!(
187            fs::symlink_metadata(&at)
188                .expect("the link exists")
189                .is_symlink()
190        );
191        assert_eq!(fs::read_link(&at).expect("read link"), store.path(puzzle()));
192    }
193
194    // A state directory that was cleared leaves every project pointing at
195    // nothing. The next run downloads the input again, and the link it writes
196    // has to survive meeting the dead one. Dangling links are the premise, so
197    // this is about the platforms that link rather than copy.
198    #[cfg(unix)]
199    #[test]
200    fn a_link_left_over_from_a_wiped_cache_is_replaced() {
201        let dir = tempfile::tempdir().expect("temp dir");
202        let project = tempfile::tempdir().expect("temp dir");
203        let store = InputStore::new(dir.path());
204        let at = project.path().join("input.txt");
205
206        store.store(puzzle(), "first").expect("store input");
207        store.link(puzzle(), &at).expect("link input");
208
209        fs::remove_dir_all(dir.path()).expect("wipe the state directory");
210        assert!(!at.exists(), "the link now points at nothing");
211
212        store.store(puzzle(), "second").expect("store input again");
213        store.link(puzzle(), &at).expect("relink input");
214
215        assert_eq!(
216            fs::read_to_string(&at).expect("read linked input"),
217            "second"
218        );
219    }
220
221    // Whatever is already at the link's place gives way, whether this platform
222    // got there by linking or by copying.
223    #[test]
224    fn linking_replaces_whatever_is_already_there() {
225        let dir = tempfile::tempdir().expect("temp dir");
226        let project = tempfile::tempdir().expect("temp dir");
227        let store = InputStore::new(dir.path());
228        let at = project.path().join("input.txt");
229
230        store.store(puzzle(), "first").expect("store input");
231        store.link(puzzle(), &at).expect("link input");
232
233        store.store(puzzle(), "second").expect("store input again");
234        store.link(puzzle(), &at).expect("relink input");
235
236        assert_eq!(
237            fs::read_to_string(&at).expect("read linked input"),
238            "second"
239        );
240    }
241
242    // A file where the state directory should be is the one way to make the
243    // store unwritable that means the same thing on every platform.
244    #[test]
245    fn an_unwritable_location_is_reported_rather_than_ignored() {
246        let dir = tempfile::tempdir().expect("temp dir");
247        let blocked = dir.path().join("state");
248        fs::write(&blocked, "a file, not a directory").expect("block the state directory");
249
250        let error = InputStore::new(&blocked)
251            .store(puzzle(), "puzzle input")
252            .expect_err("the state directory cannot be written to");
253
254        assert!(matches!(error, Error::Io { .. }), "{error:?}");
255    }
256}