use camino::{Utf8Path, Utf8PathBuf};
use chrono::{DateTime, Duration, Utc};
use serde::{Deserialize, Serialize};
use crate::orchestrator::{MetadataOnlyOptions, MetadataOnlyOutcome};
use crate::{Ref, RESOLVER_CACHE_TTL_DAYS};
const CACHE_SCHEMA_VERSION: &str = "1.0";
#[derive(Debug, Serialize, Deserialize)]
struct CacheEntry {
schema_version: String,
fetched_at: String,
ttl_seconds: i64,
source: String,
response: String,
}
#[must_use]
#[cfg(test)]
pub(crate) fn cache_file(cache_root: &Utf8Path, ref_: &Ref) -> Utf8PathBuf {
cache_file_with_options(cache_root, ref_, MetadataOnlyOptions::default())
}
#[must_use]
pub(crate) fn cache_file_with_options(
cache_root: &Utf8Path,
ref_: &Ref,
opts: MetadataOnlyOptions,
) -> Utf8PathBuf {
let dir = cache_root.join("resolver");
let dir = if opts.include_oa_location {
dir.join("oa")
} else {
dir
};
dir.join(format!("{}.toml", ref_.safekey().as_str()))
}
#[must_use]
#[cfg(test)]
pub(crate) fn read_at(
cache_root: &Utf8Path,
ref_: &Ref,
now: DateTime<Utc>,
) -> Option<MetadataOnlyOutcome> {
read_at_with_options(cache_root, ref_, now, MetadataOnlyOptions::default())
}
#[must_use]
pub(crate) fn read_at_with_options(
cache_root: &Utf8Path,
ref_: &Ref,
now: DateTime<Utc>,
opts: MetadataOnlyOptions,
) -> Option<MetadataOnlyOutcome> {
let path = cache_file_with_options(cache_root, ref_, opts);
let text = std::fs::read_to_string(&path).ok()?;
let entry: CacheEntry = toml::from_str(&text).ok()?;
let fetched: DateTime<Utc> = DateTime::parse_from_rfc3339(&entry.fetched_at)
.ok()?
.with_timezone(&Utc);
if now > fetched + Duration::seconds(entry.ttl_seconds) {
return None;
}
serde_json::from_str(&entry.response).ok()
}
#[must_use]
pub(crate) fn read_with_options(
cache_root: &Utf8Path,
ref_: &Ref,
opts: MetadataOnlyOptions,
) -> Option<MetadataOnlyOutcome> {
read_at_with_options(cache_root, ref_, Utc::now(), opts)
}
#[cfg(test)]
pub(crate) fn write_at(
cache_root: &Utf8Path,
ref_: &Ref,
outcome: &MetadataOnlyOutcome,
now: DateTime<Utc>,
) -> bool {
write_at_with_options(
cache_root,
ref_,
outcome,
now,
MetadataOnlyOptions::default(),
)
}
pub(crate) fn write_at_with_options(
cache_root: &Utf8Path,
ref_: &Ref,
outcome: &MetadataOnlyOutcome,
now: DateTime<Utc>,
opts: MetadataOnlyOptions,
) -> bool {
let response = match serde_json::to_string(outcome) {
Ok(s) => s,
Err(e) => {
tracing::debug!(error = %e, "resolver cache: serialize failed; skipping write");
return false;
}
};
let entry = CacheEntry {
schema_version: CACHE_SCHEMA_VERSION.to_string(),
fetched_at: now.to_rfc3339(),
ttl_seconds: i64::from(RESOLVER_CACHE_TTL_DAYS) * 86_400,
source: outcome.source.clone(),
response,
};
let toml_text = match toml::to_string(&entry) {
Ok(t) => t,
Err(e) => {
tracing::debug!(error = %e, "resolver cache: toml encode failed; skipping write");
return false;
}
};
let path = cache_file_with_options(cache_root, ref_, opts);
if let Some(parent) = path.parent() {
if let Err(e) = std::fs::create_dir_all(parent) {
tracing::debug!(error = %e, dir = %parent, "resolver cache: mkdir failed; skipping write");
return false;
}
}
if let Err(e) = crate::store::atomic_write(&path, toml_text.as_bytes()) {
tracing::debug!(error = %e, path = %path, "resolver cache: write failed");
return false;
}
true
}
pub(crate) fn write_with_options(
cache_root: &Utf8Path,
ref_: &Ref,
outcome: &MetadataOnlyOutcome,
opts: MetadataOnlyOptions,
) -> bool {
write_at_with_options(cache_root, ref_, outcome, Utc::now(), opts)
}
#[cfg(test)]
#[allow(clippy::expect_used, clippy::unwrap_used, clippy::panic)]
mod tests {
use super::*;
use serde_json::json;
fn outcome() -> MetadataOnlyOutcome {
MetadataOnlyOutcome {
source: "crossref".to_string(),
resolver_profile: "crossref".to_string(),
license: Some("cc-by".to_string()),
oa_url: None,
oa_status: Some("gold".to_string()),
metadata: json!({"title": ["Example"], "DOI": "10.1234/x"}),
}
}
#[test]
fn write_then_read_round_trips() {
let dir = tempfile::TempDir::new().unwrap();
let root = Utf8Path::from_path(dir.path()).unwrap();
let r = Ref::parse("10.1234/x").unwrap();
let now = Utc::now();
assert!(write_at(root, &r, &outcome(), now));
let got = read_at(root, &r, now).expect("cache hit");
assert_eq!(got.source, "crossref");
assert_eq!(got.metadata["DOI"], "10.1234/x");
}
#[test]
fn an_opt_in_read_does_not_hit_the_default_entry() {
let dir = tempfile::TempDir::new().unwrap();
let root = Utf8Path::from_path(dir.path()).unwrap();
let r = Ref::parse("10.1234/x").unwrap();
let now = Utc::now();
let with_oa = MetadataOnlyOptions::default().with_oa_location(true);
assert!(write_at(root, &r, &outcome(), now));
assert!(
read_at_with_options(root, &r, now, with_oa).is_none(),
"the default entry must not satisfy an opt-in read"
);
let dir2 = tempfile::TempDir::new().unwrap();
let root2 = Utf8Path::from_path(dir2.path()).unwrap();
assert!(write_at_with_options(root2, &r, &outcome(), now, with_oa));
assert!(read_at(root2, &r, now).is_none());
assert!(read_at_with_options(root2, &r, now, with_oa).is_some());
}
#[test]
fn a_dot_oa_doi_cannot_collide_with_an_opt_in_entry() {
let dir = tempfile::TempDir::new().unwrap();
let root = Utf8Path::from_path(dir.path()).unwrap();
let plain = Ref::parse("10.1234/foo").unwrap();
let dotted = Ref::parse("10.1234/foo.oa").unwrap();
assert!(
dotted.safekey().as_str().ends_with(".oa"),
"premise: safekey keeps '.', so a '.oa' suffix is reachable"
);
assert_ne!(
cache_file_with_options(root, &dotted, MetadataOnlyOptions::default()),
cache_file_with_options(
root,
&plain,
MetadataOnlyOptions::default().with_oa_location(true)
),
);
}
#[test]
fn miss_when_absent() {
let dir = tempfile::TempDir::new().unwrap();
let root = Utf8Path::from_path(dir.path()).unwrap();
let r = Ref::parse("10.1234/absent").unwrap();
assert!(read_at(root, &r, Utc::now()).is_none());
}
#[test]
fn miss_when_expired() {
let dir = tempfile::TempDir::new().unwrap();
let root = Utf8Path::from_path(dir.path()).unwrap();
let r = Ref::parse("10.1234/x").unwrap();
let written = Utc::now();
assert!(write_at(root, &r, &outcome(), written));
let later = written + Duration::days(8);
assert!(read_at(root, &r, later).is_none());
}
#[test]
fn fresh_within_ttl() {
let dir = tempfile::TempDir::new().unwrap();
let root = Utf8Path::from_path(dir.path()).unwrap();
let r = Ref::parse("10.1234/x").unwrap();
let written = Utc::now();
assert!(write_at(root, &r, &outcome(), written));
let later = written + Duration::days(6);
assert!(read_at(root, &r, later).is_some());
}
#[test]
fn cache_file_path_uses_safekey() {
let root = Utf8Path::new("/tmp/cache");
let r = Ref::parse("10.1234/x").unwrap();
let p = cache_file(root, &r);
assert!(p.components().any(|c| c.as_str() == "resolver"));
assert!(p.as_str().ends_with(".toml"));
}
}