1use crate::{
10 error::{Error, IoResultExt as _},
11 puzzle::Puzzle,
12};
13use std::{
14 fs, io,
15 path::{Path, PathBuf},
16};
17
18#[derive(Debug, Clone)]
20pub struct InputStore {
21 root: PathBuf,
22}
23
24impl InputStore {
25 #[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 #[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 #[must_use]
42 pub fn holds(&self, puzzle: Puzzle) -> bool {
43 self.path(puzzle).is_file()
44 }
45
46 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 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 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#[cfg(unix)]
95fn symlink(target: &Path, at: &Path) -> io::Result<()> {
96 std::os::unix::fs::symlink(target, at)
97}
98
99#[cfg(windows)]
101fn symlink(target: &Path, at: &Path) -> io::Result<()> {
102 std::os::windows::fs::symlink_file(target, at)
103}
104
105#[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 #[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 #[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 #[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}