1mod 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#[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#[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}