use chrono::{DateTime, Utc};
use std::path::Path;
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum CacheDecision {
Build { reason: &'static str },
Load { reason: String },
Rebuild { reason: String },
}
pub struct FreshnessInputs<'a> {
pub force_rebuild: bool,
pub graph_meta_path: &'a Path,
pub source_meta_path: &'a Path,
pub cooldown_days: i64,
pub remote_mtime: Option<DateTime<Utc>>,
}
pub fn decide(inputs: FreshnessInputs<'_>) -> CacheDecision {
if inputs.force_rebuild {
return CacheDecision::Build {
reason: "force_rebuild",
};
}
if !inputs.graph_meta_path.exists() {
return CacheDecision::Build { reason: "no_cache" };
}
let graph_age = age_days(file_mtime_utc(inputs.graph_meta_path));
if graph_age < inputs.cooldown_days as f64 {
return CacheDecision::Load {
reason: format!(
"within_cooldown ({graph_age:.1}d < {}d)",
inputs.cooldown_days
),
};
}
let embedded_mtime = read_remote_mtime_from_source_meta(inputs.source_meta_path);
let Some(remote_mtime) = inputs.remote_mtime else {
return CacheDecision::Load {
reason: format!(
"remote_unreachable (cache built from {})",
embedded_mtime.map_or_else(|| "unknown".to_string(), |m| m.to_rfc3339()),
),
};
};
match embedded_mtime {
Some(emb) if remote_mtime <= emb => CacheDecision::Load {
reason: "remote_unchanged".to_string(),
},
Some(emb) => CacheDecision::Rebuild {
reason: format!(
"remote_newer (remote={} > embedded={})",
remote_mtime.to_rfc3339(),
emb.to_rfc3339()
),
},
None => CacheDecision::Rebuild {
reason: "embedded_mtime_missing".to_string(),
},
}
}
pub fn read_remote_mtime_from_source_meta(path: &Path) -> Option<DateTime<Utc>> {
let bytes = std::fs::read(path).ok()?;
let data: serde_json::Value = serde_json::from_slice(&bytes).ok()?;
let iso = data
.get("remote_last_modified_iso")
.or_else(|| data.get("source_mtime_iso"))
.and_then(|v| v.as_str())?;
DateTime::parse_from_rfc3339(iso)
.ok()
.map(|d| d.with_timezone(&Utc))
}
pub fn file_mtime_utc(path: &Path) -> Option<DateTime<Utc>> {
let meta = std::fs::metadata(path).ok()?;
let mtime = meta.modified().ok()?;
Some(mtime.into())
}
pub fn age_days(when: Option<DateTime<Utc>>) -> f64 {
let Some(when) = when else {
return f64::INFINITY;
};
(Utc::now() - when).num_milliseconds() as f64 / (86_400_000.0)
}
#[cfg(test)]
mod tests {
use super::*;
use std::io::Write;
use tempfile::tempdir;
fn make_meta(dir: &std::path::Path, name: &str, payload: Option<&str>) -> std::path::PathBuf {
let p = dir.join(name);
if let Some(body) = payload {
let mut f = std::fs::File::create(&p).unwrap();
f.write_all(body.as_bytes()).unwrap();
}
p
}
#[test]
fn force_rebuild_short_circuits() {
let tmp = tempdir().unwrap();
let graph = make_meta(tmp.path(), "graph.json", Some("{}"));
let source = tmp.path().join("source.json");
let d = decide(FreshnessInputs {
force_rebuild: true,
graph_meta_path: &graph,
source_meta_path: &source,
cooldown_days: 31,
remote_mtime: None,
});
assert!(matches!(
d,
CacheDecision::Build {
reason: "force_rebuild"
}
));
}
#[test]
fn no_cache_returns_build() {
let tmp = tempdir().unwrap();
let graph = tmp.path().join("missing.json"); let source = tmp.path().join("source.json");
let d = decide(FreshnessInputs {
force_rebuild: false,
graph_meta_path: &graph,
source_meta_path: &source,
cooldown_days: 31,
remote_mtime: None,
});
assert!(matches!(d, CacheDecision::Build { reason: "no_cache" }));
}
#[test]
fn within_cooldown_loads_without_remote_probe() {
let tmp = tempdir().unwrap();
let graph = make_meta(tmp.path(), "graph.json", Some("{}"));
let source = tmp.path().join("source.json");
let d = decide(FreshnessInputs {
force_rebuild: false,
graph_meta_path: &graph,
source_meta_path: &source,
cooldown_days: 31_000, remote_mtime: None,
});
assert!(matches!(d, CacheDecision::Load { .. }));
}
#[test]
fn cooldown_elapsed_remote_unreachable_loads_cache() {
let tmp = tempdir().unwrap();
let graph = make_meta(tmp.path(), "graph.json", Some("{}"));
let source = make_meta(
tmp.path(),
"source.json",
Some(r#"{"source_mtime_iso":"2024-01-01T00:00:00+00:00"}"#),
);
let d = decide(FreshnessInputs {
force_rebuild: false,
graph_meta_path: &graph,
source_meta_path: &source,
cooldown_days: 0, remote_mtime: None, });
match d {
CacheDecision::Load { reason } => assert!(reason.starts_with("remote_unreachable")),
other => panic!("expected Load, got {other:?}"),
}
}
#[test]
fn cooldown_elapsed_remote_unchanged_loads_cache() {
let tmp = tempdir().unwrap();
let graph = make_meta(tmp.path(), "graph.json", Some("{}"));
let source = make_meta(
tmp.path(),
"source.json",
Some(r#"{"remote_last_modified_iso":"2030-01-01T00:00:00+00:00"}"#),
);
let remote_mtime = Some(
DateTime::parse_from_rfc3339("2025-01-01T00:00:00+00:00")
.unwrap()
.with_timezone(&Utc),
);
let d = decide(FreshnessInputs {
force_rebuild: false,
graph_meta_path: &graph,
source_meta_path: &source,
cooldown_days: 0,
remote_mtime,
});
assert!(matches!(d, CacheDecision::Load { .. }));
}
#[test]
fn cooldown_elapsed_remote_newer_rebuilds() {
let tmp = tempdir().unwrap();
let graph = make_meta(tmp.path(), "graph.json", Some("{}"));
let source = make_meta(
tmp.path(),
"source.json",
Some(r#"{"remote_last_modified_iso":"2020-01-01T00:00:00+00:00"}"#),
);
let remote_mtime = Some(
DateTime::parse_from_rfc3339("2030-01-01T00:00:00+00:00")
.unwrap()
.with_timezone(&Utc),
);
let d = decide(FreshnessInputs {
force_rebuild: false,
graph_meta_path: &graph,
source_meta_path: &source,
cooldown_days: 0,
remote_mtime,
});
assert!(matches!(d, CacheDecision::Rebuild { .. }));
}
}