use std::collections::HashMap;
use std::io::ErrorKind;
use std::path::{Path, PathBuf};
use std::sync::atomic::{AtomicU64, Ordering};
use std::sync::Mutex;
use std::time::Duration;
use time::format_description::well_known::Rfc3339;
use time::OffsetDateTime;
use crate::manifest::Manifest;
#[derive(Debug, Clone)]
pub enum Temporal {
Static,
ClosedPeriod,
Incomplete {
ttl: Duration,
},
}
impl Temporal {
pub fn is_fresh(&self, fetched_at: &str, now: OffsetDateTime) -> bool {
match self {
Temporal::Static | Temporal::ClosedPeriod => true,
Temporal::Incomplete { ttl } => match OffsetDateTime::parse(fetched_at, &Rfc3339) {
Ok(fetched) => {
let elapsed = now - fetched;
elapsed.is_negative() || elapsed.unsigned_abs() < *ttl
}
Err(_) => false,
},
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum CacheDecision {
Hit,
Revalidate,
Miss,
}
pub fn decide(
manifest: &Manifest,
temporal: Option<&Temporal>,
expected_checksum: Option<&str>,
) -> CacheDecision {
decide_at(
manifest,
temporal,
expected_checksum,
OffsetDateTime::now_utc(),
)
}
pub fn decide_at(
manifest: &Manifest,
temporal: Option<&Temporal>,
expected_checksum: Option<&str>,
now: OffsetDateTime,
) -> CacheDecision {
if let Some(expected) = expected_checksum {
return if expected.eq_ignore_ascii_case(&manifest.sha256_content) {
CacheDecision::Hit
} else {
CacheDecision::Miss
};
}
if matches!(
temporal,
None | Some(Temporal::Static) | Some(Temporal::ClosedPeriod)
) {
return CacheDecision::Hit;
}
if manifest.etag.is_some() || manifest.last_modified.is_some() {
return CacheDecision::Revalidate;
}
match temporal {
None | Some(Temporal::Static) | Some(Temporal::ClosedPeriod) => CacheDecision::Hit,
Some(t @ Temporal::Incomplete { .. }) => {
if t.is_fresh(&manifest.fetched_at, now) {
CacheDecision::Hit
} else {
CacheDecision::Miss
}
}
}
}
pub const REVALIDATE_FILE_ENV: &str = "EARTHSCI_REVALIDATE_FILE";
pub fn revalidate_file_sources(explicit: Option<bool>) -> bool {
match explicit {
Some(v) => v,
None => std::env::var(REVALIDATE_FILE_ENV)
.map(|s| !is_falsey(&s))
.unwrap_or(true),
}
}
fn is_falsey(s: &str) -> bool {
matches!(
s.trim().to_ascii_lowercase().as_str(),
"0" | "false" | "no" | "off"
)
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum SourceState {
Current,
Replaced,
Missing,
Unknown,
}
fn stat_source(source: &Path) -> Result<std::fs::Metadata, SourceState> {
match std::fs::metadata(source) {
Ok(meta) if meta.is_file() => Ok(meta),
Ok(_) => Err(SourceState::Missing),
Err(e) if e.kind() == ErrorKind::NotFound => Err(SourceState::Missing),
Err(_) => Err(SourceState::Unknown),
}
}
#[derive(Debug, Clone)]
struct Fingerprint {
len: u64,
mtime: std::time::SystemTime,
sha256: String,
hashed_at: std::time::SystemTime,
}
const MEMO_CAP: usize = 512;
const RACY_MARGIN: Duration = Duration::from_secs(2);
fn is_racy(mtime: std::time::SystemTime, hashed_at: std::time::SystemTime) -> bool {
match hashed_at.checked_sub(RACY_MARGIN) {
Some(safe_before) => mtime >= safe_before,
None => true,
}
}
#[derive(Debug)]
pub struct SourceRevalidator {
seen: Mutex<HashMap<PathBuf, Fingerprint>>,
full_reads: AtomicU64,
}
impl Default for SourceRevalidator {
fn default() -> Self {
Self::new()
}
}
impl SourceRevalidator {
pub fn new() -> Self {
Self {
seen: Mutex::new(HashMap::new()),
full_reads: AtomicU64::new(0),
}
}
pub fn full_reads(&self) -> u64 {
self.full_reads.load(Ordering::Relaxed)
}
pub fn state(&self, source: &Path, manifest: &Manifest) -> SourceState {
let meta = match stat_source(source) {
Ok(meta) => meta,
Err(state) => return state,
};
if meta.len() != manifest.bytes {
return SourceState::Replaced;
}
let mtime = meta.modified().ok();
if let Some(sha) = self.remembered(source, meta.len(), mtime) {
return verdict(&sha, manifest);
}
let hashed_at = std::time::SystemTime::now();
let sha = match crate::key::sha256_file(source) {
Ok(sha) => sha,
Err(e) if e.kind() == ErrorKind::NotFound => return SourceState::Missing,
Err(_) => return SourceState::Unknown,
};
self.full_reads.fetch_add(1, Ordering::Relaxed);
self.remember(source, meta.len(), mtime, &sha, hashed_at);
verdict(&sha, manifest)
}
fn remembered(
&self,
source: &Path,
len: u64,
mtime: Option<std::time::SystemTime>,
) -> Option<String> {
let mtime = mtime?;
let seen = self.seen.lock().ok()?;
let fp = seen.get(source)?;
(fp.len == len && fp.mtime == mtime && !is_racy(fp.mtime, fp.hashed_at))
.then(|| fp.sha256.clone())
}
fn remember(
&self,
source: &Path,
len: u64,
mtime: Option<std::time::SystemTime>,
sha: &str,
hashed_at: std::time::SystemTime,
) {
let Some(mtime) = mtime else { return };
let Ok(mut seen) = self.seen.lock() else { return };
if seen.len() >= MEMO_CAP {
seen.clear();
}
seen.insert(
source.to_path_buf(),
Fingerprint {
len,
mtime,
sha256: sha.to_string(),
hashed_at,
},
);
}
}
fn verdict(sha: &str, manifest: &Manifest) -> SourceState {
if sha.eq_ignore_ascii_case(&manifest.sha256_content) {
SourceState::Current
} else {
SourceState::Replaced
}
}
pub fn file_source_state(source: &Path, manifest: &Manifest) -> SourceState {
SourceRevalidator::new().state(source, manifest)
}
pub fn file_source_is_current(source: &Path, manifest: &Manifest) -> bool {
file_source_state(source, manifest) == SourceState::Current
}
#[cfg(test)]
mod tests {
use super::*;
fn manifest(etag: Option<&str>, fetched_at: &str) -> Manifest {
Manifest {
auth_realm: None,
bytes: 3,
etag: etag.map(String::from),
fetched_at: fetched_at.to_string(),
last_modified: None,
schema: crate::manifest::MANIFEST_SCHEMA.to_string(),
sha256_content: "abc123".to_string(),
source_loader: None,
url: "https://x/y".to_string(),
}
}
fn t(s: &str) -> OffsetDateTime {
OffsetDateTime::parse(s, &Rfc3339).unwrap()
}
#[test]
fn declared_checksum_match_is_hit() {
let m = manifest(None, "2026-01-01T00:00:00Z");
assert_eq!(decide(&m, None, Some("ABC123")), CacheDecision::Hit); assert_eq!(decide(&m, None, Some("deadbeef")), CacheDecision::Miss);
}
#[test]
fn an_immutable_source_is_never_revalidated_even_with_validators() {
let m = manifest(Some("\"v1\""), "2026-01-01T00:00:00Z");
assert_eq!(decide(&m, None, None), CacheDecision::Hit);
assert_eq!(
decide(&m, Some(&Temporal::Static), None),
CacheDecision::Hit
);
assert_eq!(
decide(&m, Some(&Temporal::ClosedPeriod), None),
CacheDecision::Hit
);
}
#[test]
fn validators_still_beat_the_ttl_heuristic() {
let m = manifest(Some("\"v1\""), "2026-01-01T00:00:00Z");
let temporal = Temporal::Incomplete {
ttl: Duration::from_secs(3600),
};
assert_eq!(
decide(&m, Some(&temporal), None),
CacheDecision::Revalidate
);
}
#[test]
fn static_and_closed_are_immutable_hits() {
let m = manifest(None, "2000-01-01T00:00:00Z");
assert_eq!(decide(&m, None, None), CacheDecision::Hit);
assert_eq!(
decide(&m, Some(&Temporal::Static), None),
CacheDecision::Hit
);
assert_eq!(
decide(&m, Some(&Temporal::ClosedPeriod), None),
CacheDecision::Hit
);
}
#[test]
fn incomplete_period_respects_ttl() {
let temporal = Temporal::Incomplete {
ttl: Duration::from_secs(3600),
};
let m = manifest(None, "2026-06-26T00:00:00Z");
assert_eq!(
decide_at(&m, Some(&temporal), None, t("2026-06-26T00:30:00Z")),
CacheDecision::Hit
);
assert_eq!(
decide_at(&m, Some(&temporal), None, t("2026-06-26T02:00:00Z")),
CacheDecision::Miss
);
}
fn file_manifest(body: &[u8]) -> Manifest {
Manifest {
auth_realm: None,
bytes: body.len() as u64,
etag: None,
fetched_at: "2026-01-01T00:00:00Z".to_string(),
last_modified: None,
schema: crate::manifest::MANIFEST_SCHEMA.to_string(),
sha256_content: crate::key::sha256_hex(body),
source_loader: None,
url: "file:///corpus/x.csv".to_string(),
}
}
#[test]
fn unchanged_source_is_current() {
let dir = tempfile::tempdir().unwrap();
let src = dir.path().join("x.csv");
std::fs::write(&src, b"year,value\n2016,1.0\n").unwrap();
let m = file_manifest(b"year,value\n2016,1.0\n");
assert!(file_source_is_current(&src, &m));
}
#[test]
fn replaced_source_of_different_length_is_not_current() {
let dir = tempfile::tempdir().unwrap();
let src = dir.path().join("x.csv");
let m = file_manifest(b"371-bytes-worth-of-old-corpus");
std::fs::write(&src, b"363-bytes-worth").unwrap();
assert!(!file_source_is_current(&src, &m));
}
#[test]
fn replaced_source_of_equal_length_is_not_current() {
let dir = tempfile::tempdir().unwrap();
let src = dir.path().join("x.csv");
let m = file_manifest(b"year,value\n2016,1.0\n");
std::fs::write(&src, b"year,value\n2016,9.0\n").unwrap();
assert_eq!(
std::fs::metadata(&src).unwrap().len(),
m.bytes,
"the point of this test is that the LENGTH still matches"
);
assert!(!file_source_is_current(&src, &m));
}
#[test]
fn deleted_source_is_not_current() {
let dir = tempfile::tempdir().unwrap();
let src = dir.path().join("gone.csv");
let m = file_manifest(b"whatever");
assert!(!file_source_is_current(&src, &m));
}
#[test]
fn directory_in_place_of_source_is_not_current() {
let dir = tempfile::tempdir().unwrap();
let src = dir.path().join("subdir");
std::fs::create_dir(&src).unwrap();
let m = file_manifest(b"whatever");
assert!(!file_source_is_current(&src, &m));
}
#[test]
fn revalidation_is_on_by_default_and_only_explicit_falsey_disables_it() {
assert!(revalidate_file_sources(Some(true)));
assert!(!revalidate_file_sources(Some(false)));
for v in ["0", "false", "NO", " off "] {
assert!(is_falsey(v), "{v:?} should disable the recheck");
}
for v in ["1", "true", "yes", "", "offf", "maybe"] {
assert!(!is_falsey(v), "{v:?} must NOT disable the recheck");
}
}
#[test]
fn unparseable_timestamp_forces_refetch() {
let temporal = Temporal::Incomplete {
ttl: Duration::from_secs(3600),
};
let m = manifest(None, "not-a-date");
assert!(!temporal.is_fresh("not-a-date", t("2026-06-26T00:00:00Z")));
assert_eq!(
decide_at(&m, Some(&temporal), None, t("2026-06-26T00:00:00Z")),
CacheDecision::Miss
);
}
}