use std::path::{Path, PathBuf};
use std::time::{Duration, SystemTime};
use sha2::{Digest, Sha256};
use super::fetcher::Fetcher;
use super::uri::ParsedUri;
use super::ResolveError;
pub const DEFAULT_MUTABLE_TTL: Duration = Duration::from_secs(24 * 60 * 60);
const TIMESTAMP_FILENAME: &str = ".lex-fetched-at";
#[derive(Debug, Clone)]
pub struct ResolverCache {
root: PathBuf,
mutable_ttl: Duration,
}
impl ResolverCache {
pub fn new(root: impl Into<PathBuf>) -> std::io::Result<Self> {
let root = root.into();
std::fs::create_dir_all(&root)?;
Ok(Self {
root,
mutable_ttl: DEFAULT_MUTABLE_TTL,
})
}
pub fn user_default() -> std::io::Result<Self> {
Self::new(Self::default_root())
}
pub fn default_root() -> PathBuf {
if let Ok(xdg) = std::env::var("XDG_CACHE_HOME") {
if !xdg.is_empty() {
return PathBuf::from(xdg).join("lex").join("labels");
}
}
if let Ok(home) = std::env::var("HOME") {
if !home.is_empty() {
return PathBuf::from(home)
.join(".cache")
.join("lex")
.join("labels");
}
}
std::env::temp_dir().join(format!("lex-labels-{}", std::process::id()))
}
pub fn with_mutable_ttl(mut self, ttl: Duration) -> Self {
self.mutable_ttl = ttl;
self
}
pub fn root(&self) -> &Path {
&self.root
}
pub fn entry_path(&self, uri: &ParsedUri) -> PathBuf {
self.root.join(hash_key(uri))
}
pub fn fetch_or_reuse(
&self,
uri: &ParsedUri,
fetcher: &dyn Fetcher,
) -> Result<PathBuf, ResolveError> {
let entry = self.entry_path(uri);
if entry.is_dir() {
if let Some(fetched_at) = read_completion_marker(&entry) {
let immutable = fetcher.is_immutable_rev(uri.rev.as_deref());
if immutable || self.is_within_ttl(fetched_at) {
return Ok(entry);
}
}
}
if entry.exists() {
std::fs::remove_dir_all(&entry).map_err(|source| ResolveError::CacheIo {
path: entry.clone(),
source,
})?;
}
std::fs::create_dir_all(&entry).map_err(|source| ResolveError::CacheIo {
path: entry.clone(),
source,
})?;
fetcher.fetch(uri, &entry).map_err(|source| {
let _ = std::fs::remove_dir_all(&entry);
ResolveError::Fetch {
uri: uri.original.clone(),
source,
}
})?;
let _ = self.write_timestamp(&entry);
Ok(entry)
}
fn is_within_ttl(&self, fetched_at: u64) -> bool {
let Ok(now) = SystemTime::now().duration_since(SystemTime::UNIX_EPOCH) else {
return false;
};
now.as_secs().saturating_sub(fetched_at) < self.mutable_ttl.as_secs()
}
fn write_timestamp(&self, entry: &Path) -> std::io::Result<()> {
let now = SystemTime::now()
.duration_since(SystemTime::UNIX_EPOCH)
.map(|d| d.as_secs())
.unwrap_or(0);
std::fs::write(entry.join(TIMESTAMP_FILENAME), now.to_string())
}
}
fn read_completion_marker(entry: &Path) -> Option<u64> {
let stamp = entry.join(TIMESTAMP_FILENAME);
let content = std::fs::read_to_string(&stamp).ok()?;
content.trim().parse::<u64>().ok()
}
fn hash_key(uri: &ParsedUri) -> String {
let mut h = Sha256::new();
h.update(uri.scheme.as_bytes());
h.update(b":");
h.update(uri.body.as_bytes());
if let Some(rev) = &uri.rev {
h.update(b"#");
h.update(rev.as_bytes());
}
if let Some(subdir) = &uri.subdir {
h.update(b"?subdir=");
h.update(subdir.as_bytes());
}
hex_encode(&h.finalize())
}
fn hex_encode(bytes: &[u8]) -> String {
let mut out = String::with_capacity(bytes.len() * 2);
for b in bytes {
out.push_str(&format!("{b:02x}"));
}
out
}
#[cfg(test)]
mod tests {
use super::*;
fn parse(uri: &str) -> ParsedUri {
ParsedUri::parse(uri).unwrap()
}
#[test]
fn hash_key_is_deterministic() {
let a = hash_key(&parse("github:acme/repo#v1"));
let b = hash_key(&parse("github:acme/repo#v1"));
assert_eq!(a, b);
assert_eq!(a.len(), 64);
}
#[test]
fn hash_key_distinguishes_rev() {
let a = hash_key(&parse("github:acme/repo#v1"));
let b = hash_key(&parse("github:acme/repo#v2"));
assert_ne!(a, b);
}
#[test]
fn hash_key_distinguishes_scheme() {
let a = hash_key(&parse("github:acme/repo"));
let b = hash_key(&parse("gitlab:acme/repo"));
assert_ne!(a, b);
}
#[test]
fn entry_path_is_stable_across_cache_instances() {
let tmp = tempfile::tempdir().unwrap();
let cache1 = ResolverCache::new(tmp.path()).unwrap();
let cache2 = ResolverCache::new(tmp.path()).unwrap();
let uri = parse("github:acme/repo#v1");
assert_eq!(cache1.entry_path(&uri), cache2.entry_path(&uri));
}
#[test]
fn default_root_uses_xdg_cache_home() {
let prev_xdg = std::env::var("XDG_CACHE_HOME").ok();
let prev_home = std::env::var("HOME").ok();
std::env::set_var("XDG_CACHE_HOME", "/tmp/xdg-test");
let r = ResolverCache::default_root();
assert_eq!(r, PathBuf::from("/tmp/xdg-test/lex/labels"));
match prev_xdg {
Some(v) => std::env::set_var("XDG_CACHE_HOME", v),
None => std::env::remove_var("XDG_CACHE_HOME"),
}
if let Some(h) = prev_home {
std::env::set_var("HOME", h);
}
}
struct MockFetcher;
impl Fetcher for MockFetcher {
fn fetch(&self, _uri: &ParsedUri, dest: &Path) -> Result<(), super::super::FetchError> {
std::fs::write(dest.join("schema.yaml"), b"schema_version: 1\nlabel: x.y\n")?;
Ok(())
}
fn schemes(&self) -> &'static [&'static str] {
&["mock"]
}
}
#[derive(Default)]
struct CountingFetcher {
calls: std::sync::atomic::AtomicUsize,
}
impl Fetcher for CountingFetcher {
fn fetch(&self, _uri: &ParsedUri, dest: &Path) -> Result<(), super::super::FetchError> {
self.calls.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
std::fs::write(dest.join("schema.yaml"), b"x")?;
Ok(())
}
fn schemes(&self) -> &'static [&'static str] {
&["mock"]
}
}
#[test]
fn fetch_or_reuse_writes_to_cache_on_miss() {
let tmp = tempfile::tempdir().unwrap();
let cache = ResolverCache::new(tmp.path()).unwrap();
let uri = parse("mock:something#v1");
let dir = cache.fetch_or_reuse(&uri, &MockFetcher).unwrap();
assert!(dir.starts_with(tmp.path()));
assert!(dir.join("schema.yaml").is_file());
assert!(dir.join(TIMESTAMP_FILENAME).is_file());
}
#[test]
fn fetch_or_reuse_reuses_immutable_entry() {
let tmp = tempfile::tempdir().unwrap();
let cache = ResolverCache::new(tmp.path()).unwrap();
let uri = parse("mock:something#v1");
let counter = CountingFetcher::default();
cache.fetch_or_reuse(&uri, &counter).unwrap();
let immutable = ImmutableCountingFetcher::default();
immutable
.inner
.calls
.store(0, std::sync::atomic::Ordering::SeqCst);
cache.fetch_or_reuse(&uri, &immutable).unwrap();
let after_first = immutable
.inner
.calls
.load(std::sync::atomic::Ordering::SeqCst);
cache.fetch_or_reuse(&uri, &immutable).unwrap();
let after_second = immutable
.inner
.calls
.load(std::sync::atomic::Ordering::SeqCst);
assert_eq!(
after_first, after_second,
"second call should be a cache hit (immutable rev), got {after_first} → {after_second}"
);
}
#[derive(Default)]
struct ImmutableCountingFetcher {
inner: CountingFetcher,
}
impl Fetcher for ImmutableCountingFetcher {
fn fetch(&self, uri: &ParsedUri, dest: &Path) -> Result<(), super::super::FetchError> {
self.inner.fetch(uri, dest)
}
fn schemes(&self) -> &'static [&'static str] {
self.inner.schemes()
}
fn is_immutable_rev(&self, _rev: Option<&str>) -> bool {
true
}
}
#[test]
fn fetch_or_reuse_reuses_mutable_entry_within_ttl() {
let tmp = tempfile::tempdir().unwrap();
let cache = ResolverCache::new(tmp.path()).unwrap();
let uri = parse("mock:something#main");
let counter = CountingFetcher::default();
cache.fetch_or_reuse(&uri, &counter).unwrap();
cache.fetch_or_reuse(&uri, &counter).unwrap();
assert_eq!(
counter.calls.load(std::sync::atomic::Ordering::SeqCst),
1,
"second call within TTL should reuse the cached entry"
);
}
#[test]
fn fetch_or_reuse_refetches_mutable_entry_past_ttl() {
let tmp = tempfile::tempdir().unwrap();
let cache = ResolverCache::new(tmp.path())
.unwrap()
.with_mutable_ttl(Duration::from_secs(0));
let uri = parse("mock:something#main");
let counter = CountingFetcher::default();
cache.fetch_or_reuse(&uri, &counter).unwrap();
cache.fetch_or_reuse(&uri, &counter).unwrap();
assert_eq!(
counter.calls.load(std::sync::atomic::Ordering::SeqCst),
2,
"second call past TTL should re-fetch"
);
}
#[test]
fn fetch_or_reuse_propagates_fetch_errors() {
struct FailingFetcher;
impl Fetcher for FailingFetcher {
fn fetch(
&self,
_uri: &ParsedUri,
_dest: &Path,
) -> Result<(), super::super::FetchError> {
Err(super::super::FetchError::Network {
message: "simulated".into(),
})
}
fn schemes(&self) -> &'static [&'static str] {
&["mock"]
}
}
let tmp = tempfile::tempdir().unwrap();
let cache = ResolverCache::new(tmp.path()).unwrap();
let uri = parse("mock:fail");
let err = cache.fetch_or_reuse(&uri, &FailingFetcher).unwrap_err();
match err {
ResolveError::Fetch {
source: super::super::FetchError::Network { .. },
..
} => {}
other => panic!("expected Fetch::Network error, got: {other}"),
}
}
#[test]
fn fetch_or_reuse_does_not_reuse_partial_entry_for_immutable_rev() {
let tmp = tempfile::tempdir().unwrap();
let cache = ResolverCache::new(tmp.path()).unwrap();
let uri = parse("mock:something#v1");
let entry = cache.entry_path(&uri);
std::fs::create_dir_all(&entry).unwrap();
std::fs::write(entry.join("partial-thing.yaml"), b"only half written").unwrap();
assert!(!entry.join(TIMESTAMP_FILENAME).exists());
struct ImmutableMockFetcher {
called: std::sync::atomic::AtomicUsize,
}
impl Fetcher for ImmutableMockFetcher {
fn fetch(&self, _uri: &ParsedUri, dest: &Path) -> Result<(), super::super::FetchError> {
self.called
.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
std::fs::write(dest.join("schema.yaml"), b"complete").unwrap();
Ok(())
}
fn schemes(&self) -> &'static [&'static str] {
&["mock"]
}
fn is_immutable_rev(&self, _rev: Option<&str>) -> bool {
true
}
}
let fetcher = ImmutableMockFetcher {
called: std::sync::atomic::AtomicUsize::new(0),
};
let dir = cache.fetch_or_reuse(&uri, &fetcher).unwrap();
assert_eq!(
fetcher.called.load(std::sync::atomic::Ordering::SeqCst),
1,
"partial entry must be wiped and re-fetched, not reused"
);
assert!(!dir.join("partial-thing.yaml").exists());
assert_eq!(std::fs::read(dir.join("schema.yaml")).unwrap(), b"complete");
assert!(dir.join(TIMESTAMP_FILENAME).is_file());
}
#[test]
fn fetch_or_reuse_wipes_partial_writes_when_fetcher_errors() {
struct PartialThenFailFetcher;
impl Fetcher for PartialThenFailFetcher {
fn fetch(&self, _uri: &ParsedUri, dest: &Path) -> Result<(), super::super::FetchError> {
std::fs::write(dest.join("half.yaml"), b"x").unwrap();
Err(super::super::FetchError::Network {
message: "interrupted".into(),
})
}
fn schemes(&self) -> &'static [&'static str] {
&["mock"]
}
}
let tmp = tempfile::tempdir().unwrap();
let cache = ResolverCache::new(tmp.path()).unwrap();
let uri = parse("mock:fail#partial");
let entry = cache.entry_path(&uri);
let _err = cache
.fetch_or_reuse(&uri, &PartialThenFailFetcher)
.unwrap_err();
assert!(
!entry.exists(),
"partial entry should have been removed; still at {}",
entry.display()
);
}
}