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
}
}
}
}
#[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
);
}
#[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
);
}
}