1use camino::{Utf8Path, Utf8PathBuf};
23use chrono::{DateTime, Duration, Utc};
24use serde::{Deserialize, Serialize};
25
26use crate::orchestrator::{MetadataOnlyOptions, MetadataOnlyOutcome};
27use crate::{Ref, RESOLVER_CACHE_TTL_DAYS};
28
29const CACHE_SCHEMA_VERSION: &str = "1.0";
31
32#[derive(Debug, Serialize, Deserialize)]
35struct CacheEntry {
36 schema_version: String,
37 fetched_at: String,
39 ttl_seconds: i64,
40 source: String,
41 response: String,
43}
44
45#[must_use]
48#[cfg(test)]
61pub(crate) fn cache_file(cache_root: &Utf8Path, ref_: &Ref) -> Utf8PathBuf {
62 cache_file_with_options(cache_root, ref_, MetadataOnlyOptions::default())
63}
64
65#[must_use]
85pub(crate) fn cache_file_with_options(
86 cache_root: &Utf8Path,
87 ref_: &Ref,
88 opts: MetadataOnlyOptions,
89) -> Utf8PathBuf {
90 let dir = cache_root.join("resolver");
91 let dir = if opts.include_oa_location {
92 dir.join("oa")
93 } else {
94 dir
95 };
96 dir.join(format!("{}.toml", ref_.safekey().as_str()))
97}
98
99#[must_use]
105#[cfg(test)]
106pub(crate) fn read_at(
107 cache_root: &Utf8Path,
108 ref_: &Ref,
109 now: DateTime<Utc>,
110) -> Option<MetadataOnlyOutcome> {
111 read_at_with_options(cache_root, ref_, now, MetadataOnlyOptions::default())
112}
113
114#[must_use]
117pub(crate) fn read_at_with_options(
118 cache_root: &Utf8Path,
119 ref_: &Ref,
120 now: DateTime<Utc>,
121 opts: MetadataOnlyOptions,
122) -> Option<MetadataOnlyOutcome> {
123 let path = cache_file_with_options(cache_root, ref_, opts);
124 let text = std::fs::read_to_string(&path).ok()?;
125 let entry: CacheEntry = toml::from_str(&text).ok()?;
126 let fetched: DateTime<Utc> = DateTime::parse_from_rfc3339(&entry.fetched_at)
127 .ok()?
128 .with_timezone(&Utc);
129 if now > fetched + Duration::seconds(entry.ttl_seconds) {
130 return None;
132 }
133 serde_json::from_str(&entry.response).ok()
134}
135
136#[must_use]
138pub(crate) fn read_with_options(
139 cache_root: &Utf8Path,
140 ref_: &Ref,
141 opts: MetadataOnlyOptions,
142) -> Option<MetadataOnlyOutcome> {
143 read_at_with_options(cache_root, ref_, Utc::now(), opts)
144}
145
146#[cfg(test)]
150pub(crate) fn write_at(
151 cache_root: &Utf8Path,
152 ref_: &Ref,
153 outcome: &MetadataOnlyOutcome,
154 now: DateTime<Utc>,
155) -> bool {
156 write_at_with_options(
157 cache_root,
158 ref_,
159 outcome,
160 now,
161 MetadataOnlyOptions::default(),
162 )
163}
164
165pub(crate) fn write_at_with_options(
167 cache_root: &Utf8Path,
168 ref_: &Ref,
169 outcome: &MetadataOnlyOutcome,
170 now: DateTime<Utc>,
171 opts: MetadataOnlyOptions,
172) -> bool {
173 let response = match serde_json::to_string(outcome) {
174 Ok(s) => s,
175 Err(e) => {
176 tracing::debug!(error = %e, "resolver cache: serialize failed; skipping write");
177 return false;
178 }
179 };
180 let entry = CacheEntry {
181 schema_version: CACHE_SCHEMA_VERSION.to_string(),
182 fetched_at: now.to_rfc3339(),
183 ttl_seconds: i64::from(RESOLVER_CACHE_TTL_DAYS) * 86_400,
184 source: outcome.source.clone(),
185 response,
186 };
187 let toml_text = match toml::to_string(&entry) {
188 Ok(t) => t,
189 Err(e) => {
190 tracing::debug!(error = %e, "resolver cache: toml encode failed; skipping write");
191 return false;
192 }
193 };
194 let path = cache_file_with_options(cache_root, ref_, opts);
195 if let Some(parent) = path.parent() {
196 if let Err(e) = std::fs::create_dir_all(parent) {
197 tracing::debug!(error = %e, dir = %parent, "resolver cache: mkdir failed; skipping write");
198 return false;
199 }
200 }
201 if let Err(e) = crate::store::atomic_write(&path, toml_text.as_bytes()) {
207 tracing::debug!(error = %e, path = %path, "resolver cache: write failed");
208 return false;
209 }
210 true
211}
212
213pub(crate) fn write_with_options(
215 cache_root: &Utf8Path,
216 ref_: &Ref,
217 outcome: &MetadataOnlyOutcome,
218 opts: MetadataOnlyOptions,
219) -> bool {
220 write_at_with_options(cache_root, ref_, outcome, Utc::now(), opts)
221}
222
223#[cfg(test)]
224#[allow(clippy::expect_used, clippy::unwrap_used, clippy::panic)]
225mod tests {
226 use super::*;
227 use serde_json::json;
228
229 fn outcome() -> MetadataOnlyOutcome {
230 MetadataOnlyOutcome {
231 metadata_quality: Vec::new(),
232 repaired_fields: std::collections::BTreeMap::new(),
233 source: "crossref".to_string(),
234 resolver_profile: "crossref".to_string(),
235 license: Some("cc-by".to_string()),
236 oa_url: None,
237 oa_status: Some("gold".to_string()),
238 metadata: json!({"title": ["Example"], "DOI": "10.1234/x"}),
239 }
240 }
241
242 #[test]
243 fn write_then_read_round_trips() {
244 let dir = tempfile::TempDir::new().unwrap();
245 let root = Utf8Path::from_path(dir.path()).unwrap();
246 let r = Ref::parse("10.1234/x").unwrap();
247 let now = Utc::now();
248 assert!(write_at(root, &r, &outcome(), now));
249 let got = read_at(root, &r, now).expect("cache hit");
250 assert_eq!(got.source, "crossref");
251 assert_eq!(got.metadata["DOI"], "10.1234/x");
252 }
253
254 #[test]
260 fn an_opt_in_read_does_not_hit_the_default_entry() {
261 let dir = tempfile::TempDir::new().unwrap();
262 let root = Utf8Path::from_path(dir.path()).unwrap();
263 let r = Ref::parse("10.1234/x").unwrap();
264 let now = Utc::now();
265 let with_oa = MetadataOnlyOptions::default().with_oa_location(true);
266
267 assert!(write_at(root, &r, &outcome(), now));
268 assert!(
269 read_at_with_options(root, &r, now, with_oa).is_none(),
270 "the default entry must not satisfy an opt-in read"
271 );
272 let dir2 = tempfile::TempDir::new().unwrap();
275 let root2 = Utf8Path::from_path(dir2.path()).unwrap();
276 assert!(write_at_with_options(root2, &r, &outcome(), now, with_oa));
277 assert!(read_at(root2, &r, now).is_none());
278 assert!(read_at_with_options(root2, &r, now, with_oa).is_some());
279 }
280
281 #[test]
289 fn a_dot_oa_doi_cannot_collide_with_an_opt_in_entry() {
290 let dir = tempfile::TempDir::new().unwrap();
291 let root = Utf8Path::from_path(dir.path()).unwrap();
292 let plain = Ref::parse("10.1234/foo").unwrap();
293 let dotted = Ref::parse("10.1234/foo.oa").unwrap();
294
295 assert!(
298 dotted.safekey().as_str().ends_with(".oa"),
299 "premise: safekey keeps '.', so a '.oa' suffix is reachable"
300 );
301
302 assert_ne!(
303 cache_file_with_options(root, &dotted, MetadataOnlyOptions::default()),
304 cache_file_with_options(
305 root,
306 &plain,
307 MetadataOnlyOptions::default().with_oa_location(true)
308 ),
309 );
310 }
311
312 #[test]
313 fn miss_when_absent() {
314 let dir = tempfile::TempDir::new().unwrap();
315 let root = Utf8Path::from_path(dir.path()).unwrap();
316 let r = Ref::parse("10.1234/absent").unwrap();
317 assert!(read_at(root, &r, Utc::now()).is_none());
318 }
319
320 #[test]
321 fn miss_when_expired() {
322 let dir = tempfile::TempDir::new().unwrap();
323 let root = Utf8Path::from_path(dir.path()).unwrap();
324 let r = Ref::parse("10.1234/x").unwrap();
325 let written = Utc::now();
326 assert!(write_at(root, &r, &outcome(), written));
327 let later = written + Duration::days(8);
329 assert!(read_at(root, &r, later).is_none());
330 }
331
332 #[test]
333 fn fresh_within_ttl() {
334 let dir = tempfile::TempDir::new().unwrap();
335 let root = Utf8Path::from_path(dir.path()).unwrap();
336 let r = Ref::parse("10.1234/x").unwrap();
337 let written = Utc::now();
338 assert!(write_at(root, &r, &outcome(), written));
339 let later = written + Duration::days(6);
341 assert!(read_at(root, &r, later).is_some());
342 }
343
344 #[test]
345 fn cache_file_path_uses_safekey() {
346 let root = Utf8Path::new("/tmp/cache");
347 let r = Ref::parse("10.1234/x").unwrap();
348 let p = cache_file(root, &r);
349 assert!(p.components().any(|c| c.as_str() == "resolver"));
352 assert!(p.as_str().ends_with(".toml"));
353 }
354}