aoc_cache/
lib.rs

1//! A way of caching your input from the great and popular [Advent of Code].
2//!
3//! This is an attempt to reduce server load for the creator.
4//!
5//! Downloads using [`ureq`][ureq], stores cache in temporary files using
6//! [`scratch`][scratch].
7//!
8//! # Example
9//!
10//! ```no_run
11//! use aoc_cache::get;
12//! const MY_COOKIE: &str = "session=xxxxxxxxxx"; // or, e.g., `include_str!("my.cookie")`
13//! let input: Result<String, aoc_cache::Error> = // Grabs from web if it's the first run
14//!     get("https://adventofcode.com/2022/day/1/input", MY_COOKIE);
15//! let input: Result<String, aoc_cache::Error> = // Grabs from cache
16//!     get("https://adventofcode.com/2022/day/1/input", MY_COOKIE);
17//! ```
18//!
19//! [Advent of Code]: https://adventofcode.com/
20//! [ureq]: https://docs.rs/ureq/
21//! [scratch]: https://docs.rs/scratch/
22
23mod error;
24
25pub use error::Error;
26
27use cookie_store::{Cookie, CookieStore};
28use std::{
29    collections::hash_map::DefaultHasher,
30    fs::{read_to_string, File, OpenOptions},
31    hash::{Hash, Hasher},
32    io::{BufRead, BufReader, Write},
33    path::{Path, PathBuf},
34};
35use tracing::{debug, error, info, instrument, trace, warn};
36use ureq::AgentBuilder;
37
38type Result<T> = std::result::Result<T, Error>;
39
40const INDEX_FILE_NAME: &str = "index.cache";
41const TEMP_DIR_NAME: &str = "aoc_cache";
42const USER_AGENT: &str = "https://github.com/glennib/aoc-cache by glennib.pub@gmail.com";
43
44/// Gets input from the url or from cache if it has been retrieved before.
45///
46/// The url can be, e.g., <https://adventofcode.com/2022/day/1/input>. The cookie must be one
47/// retrieved by entering the site in your browser and inspecting network traffic. Instructions on
48/// how to retrieve the cookie can be found [here][github-cookie-example] or [here][google-cookie].
49/// The cookie should look like this: `session=abcd...` without a trailing newline.
50///
51/// # Example
52///
53/// ```no_run
54/// use aoc_cache::get;
55/// const MY_COOKIE: &str = "session=xxxxxxxxxx"; // or, e.g., `include_str!("my.cookie")`
56/// let input: Result<String, aoc_cache::Error> =
57///     get("https://adventofcode.com/2022/day/1/input", MY_COOKIE);
58/// ```
59///
60/// [github-cookie-example]: https://github.com/wimglenn/advent-of-code-wim/issues/1
61/// [google-cookie]: https://www.google.com/search?q=adventofcode+cookie
62#[instrument(skip(cookie))]
63pub fn get(url: &str, cookie: &str) -> Result<String> {
64    if let Some(content) = get_cache_for_url(url)? {
65        info!("returning content found in cache");
66        return Ok(content);
67    }
68    debug!("content not found in cache, requesting from web");
69    let content = get_content_from_web(url, cookie)?;
70    add_cache(url, &content)?;
71    info!("returning content from web");
72    Ok(content)
73}
74
75/// Dispatches to the `get`-function, see its documentation.
76#[deprecated]
77pub fn get_input_from_web_or_cache(url: &str, cookie: &str) -> Result<String> {
78    get(url, cookie)
79}
80
81#[instrument(skip(url, cookie))]
82fn get_content_from_web(url: &str, cookie: &str) -> Result<String> {
83    if cookie.is_empty() {
84        return Err(Error::InvalidCookie(
85            "empty cookie is not valid".to_string(),
86        ));
87    }
88
89    let url_parsed = url.parse()?;
90
91    let jar = CookieStore::load(BufReader::new(cookie.as_bytes()), |s| {
92        trace!(s, "parsed cookie from str");
93        Cookie::parse(s, &url_parsed).map(Cookie::into_owned)
94    })
95    .map_err(|_| Error::CookieParse("couldn't create cookie store".into()))?;
96
97    debug!(?jar);
98
99    let agent = AgentBuilder::new()
100        .cookie_store(jar)
101        .user_agent(USER_AGENT)
102        .build();
103    let response = agent.get(url).call()?;
104    let content = response.into_string()?.trim_end().to_string();
105    Ok(content)
106}
107
108#[instrument]
109fn create_or_get_cache_dir() -> PathBuf {
110    let cache_dir = scratch::path(TEMP_DIR_NAME);
111    debug!(?cache_dir);
112    cache_dir
113}
114
115#[instrument(skip(url))]
116fn get_cache_for_url(url: &str) -> Result<Option<String>> {
117    let cache_file_path = get_cache_file_path_from_index(url)?;
118    match cache_file_path {
119        None => Ok(None),
120        Some(path) => {
121            debug!("cache_file_path={}", path.to_str().unwrap());
122            Ok(Some(read_to_string(path)?))
123        }
124    }
125}
126
127#[instrument(skip(url))]
128fn encode_url(url: &str) -> String {
129    let mut hasher = DefaultHasher::new();
130    url.hash(&mut hasher);
131    let hash = hasher.finish();
132    hash.to_string()
133}
134
135#[instrument(skip(url))]
136fn filename_from_url(url: &str) -> String {
137    let mut filename = String::from("cache_");
138    filename.push_str(&encode_url(url));
139    filename.push_str(".cache");
140    filename
141}
142
143#[instrument(skip(url, content))]
144fn add_cache(url: &str, content: &str) -> Result<()> {
145    let cache_file_path = get_cache_file_path_from_index(url)?;
146    if cache_file_path.is_some() {
147        error!("found cache entry for {url} when attempting to add new cache for it");
148        return Err(Error::Duplicate(format!(
149            "found cache entry for {url} when attempting to add new cache for it"
150        )));
151    }
152
153    let cache_dir = create_or_get_cache_dir();
154    let cache_filename = filename_from_url(url);
155    let cache_file_path = cache_dir.join(cache_filename);
156
157    let mut file = OpenOptions::new()
158        .create(true)
159        .write(true)
160        .open(&cache_file_path)?;
161    write!(file, "{content}")?;
162    info!(
163        "Wrote content (size={}) to {cache_file_path:?}",
164        content.len()
165    );
166
167    let index_path = create_index_if_non_existent()?;
168    let mut file = OpenOptions::new().append(true).open(&index_path)?;
169    let cache_file_path_str = cache_file_path.to_str();
170    match cache_file_path_str {
171        None => {
172            error!(?cache_file_path, "cannot convert to str");
173            return Err(Error::Path("Cache file path was empty".to_string()));
174        }
175        Some(cache_file_path_str) => {
176            let index_line = format!("{url}: {}", cache_file_path_str);
177            writeln!(file, "{index_line}")?;
178            info!("Wrote `{index_line}` to {index_path:?}");
179        }
180    }
181    Ok(())
182}
183
184#[instrument]
185fn create_file_if_non_existent(path: &Path) -> Result<()> {
186    if Path::new(path).exists() {
187        debug!("file already existed, doing nothing");
188    } else {
189        info!("file didn't exist, creating");
190        File::create(path)?;
191    }
192    Ok(())
193}
194
195#[instrument]
196fn create_index_if_non_existent() -> Result<PathBuf> {
197    let cache_dir = create_or_get_cache_dir();
198    let index_path = cache_dir.join(INDEX_FILE_NAME);
199    create_file_if_non_existent(&index_path)?;
200    Ok(index_path)
201}
202
203#[instrument(skip(url))]
204fn get_cache_file_path_from_index(url: &str) -> Result<Option<PathBuf>> {
205    let index_path = create_index_if_non_existent()?;
206    let file = File::open(index_path)?;
207    for line in BufReader::new(file).lines() {
208        let line = line?;
209        let parts: Vec<_> = line.split(": ").collect();
210        if parts.len() != 2 {
211            return Err(Error::Parse(format!("could not parse index line `{line}`")));
212        }
213        let url_in_line = parts[0];
214        if url_in_line == url {
215            let cache_file_path = parts[1].to_string();
216            debug!(cache_file_path, "from index");
217            return Ok(Some(cache_file_path.into()));
218        }
219    }
220    Ok(None)
221}