use crate::{
error::{Error, IoResultExt as _},
puzzle::Puzzle,
};
use std::{
fs, io,
path::{Path, PathBuf},
};
#[derive(Debug, Clone)]
pub struct InputStore {
root: PathBuf,
}
impl InputStore {
#[must_use]
pub fn new(state_dir: impl Into<PathBuf>) -> Self {
Self {
root: state_dir.into().join("inputs"),
}
}
#[must_use]
pub fn path(&self, puzzle: Puzzle) -> PathBuf {
self.root
.join(format!("{}-{:02}.txt", puzzle.year.get(), puzzle.day.get()))
}
#[must_use]
pub fn holds(&self, puzzle: Puzzle) -> bool {
self.path(puzzle).is_file()
}
pub fn store(&self, puzzle: Puzzle, text: &str) -> Result<(), Error> {
fs::create_dir_all(&self.root).io_context("create input cache directory", &self.root)?;
let path = self.path(puzzle);
fs::write(&path, text).io_context("cache puzzle input", &path)
}
pub fn link(&self, puzzle: Puzzle, at: &Path) -> Result<(), Error> {
let target = self.path(puzzle);
if let Some(parent) = at.parent() {
fs::create_dir_all(parent).io_context("create input directory", parent)?;
}
if fs::symlink_metadata(at).is_ok() {
fs::remove_file(at).io_context("replace stale input link", at)?;
}
if symlink(&target, at).is_err() {
fs::copy(&target, at)
.map(drop)
.io_context("link cached input", at)?;
}
Ok(())
}
}
#[cfg(unix)]
fn symlink(target: &Path, at: &Path) -> io::Result<()> {
std::os::unix::fs::symlink(target, at)
}
#[cfg(windows)]
fn symlink(target: &Path, at: &Path) -> io::Result<()> {
std::os::windows::fs::symlink_file(target, at)
}
#[cfg(not(any(unix, windows)))]
fn symlink(_target: &Path, _at: &Path) -> io::Result<()> {
Err(io::Error::new(
io::ErrorKind::Unsupported,
"symbolic links are not supported on this platform",
))
}
#[cfg(test)]
mod tests {
use super::*;
use crate::puzzle::{Day, Year};
fn puzzle() -> Puzzle {
Puzzle::new(
Year::new(2024).expect("valid year"),
Day::new(7).expect("valid day"),
)
.expect("2024 has a day 7")
}
#[test]
fn an_input_survives_the_store_being_reopened() {
let dir = tempfile::tempdir().expect("temp dir");
let store = InputStore::new(dir.path());
assert!(!store.holds(puzzle()));
store.store(puzzle(), "puzzle input").expect("store input");
let reopened = InputStore::new(dir.path());
assert!(reopened.holds(puzzle()));
assert_eq!(
fs::read_to_string(reopened.path(puzzle())).expect("read stored input"),
"puzzle input"
);
}
#[test]
fn puzzles_are_kept_apart() {
let dir = tempfile::tempdir().expect("temp dir");
let store = InputStore::new(dir.path());
let other = Puzzle::new(
Year::new(2024).expect("valid year"),
Day::new(17).expect("valid day"),
)
.expect("2024 has a day 17");
store.store(puzzle(), "seven").expect("store input");
assert!(store.holds(puzzle()));
assert!(!store.holds(other), "day 7 must not answer for day 17");
}
#[test]
fn a_linked_input_reads_as_the_stored_one() {
let dir = tempfile::tempdir().expect("temp dir");
let project = tempfile::tempdir().expect("temp dir");
let store = InputStore::new(dir.path());
store.store(puzzle(), "puzzle input").expect("store input");
let at = project.path().join("day07").join("input.txt");
store.link(puzzle(), &at).expect("link input");
assert_eq!(
fs::read_to_string(&at).expect("read linked input"),
"puzzle input"
);
}
#[cfg(unix)]
#[test]
fn linking_points_at_the_cache_rather_than_copying_it() {
let dir = tempfile::tempdir().expect("temp dir");
let project = tempfile::tempdir().expect("temp dir");
let store = InputStore::new(dir.path());
store.store(puzzle(), "puzzle input").expect("store input");
let at = project.path().join("input.txt");
store.link(puzzle(), &at).expect("link input");
assert!(
fs::symlink_metadata(&at)
.expect("the link exists")
.is_symlink()
);
assert_eq!(fs::read_link(&at).expect("read link"), store.path(puzzle()));
}
#[cfg(unix)]
#[test]
fn a_link_left_over_from_a_wiped_cache_is_replaced() {
let dir = tempfile::tempdir().expect("temp dir");
let project = tempfile::tempdir().expect("temp dir");
let store = InputStore::new(dir.path());
let at = project.path().join("input.txt");
store.store(puzzle(), "first").expect("store input");
store.link(puzzle(), &at).expect("link input");
fs::remove_dir_all(dir.path()).expect("wipe the state directory");
assert!(!at.exists(), "the link now points at nothing");
store.store(puzzle(), "second").expect("store input again");
store.link(puzzle(), &at).expect("relink input");
assert_eq!(
fs::read_to_string(&at).expect("read linked input"),
"second"
);
}
#[test]
fn linking_replaces_whatever_is_already_there() {
let dir = tempfile::tempdir().expect("temp dir");
let project = tempfile::tempdir().expect("temp dir");
let store = InputStore::new(dir.path());
let at = project.path().join("input.txt");
store.store(puzzle(), "first").expect("store input");
store.link(puzzle(), &at).expect("link input");
store.store(puzzle(), "second").expect("store input again");
store.link(puzzle(), &at).expect("relink input");
assert_eq!(
fs::read_to_string(&at).expect("read linked input"),
"second"
);
}
#[test]
fn an_unwritable_location_is_reported_rather_than_ignored() {
let dir = tempfile::tempdir().expect("temp dir");
let blocked = dir.path().join("state");
fs::write(&blocked, "a file, not a directory").expect("block the state directory");
let error = InputStore::new(&blocked)
.store(puzzle(), "puzzle input")
.expect_err("the state directory cannot be written to");
assert!(matches!(error, Error::Io { .. }), "{error:?}");
}
}