use crate::errors::*;
use std::io::Write;
use std::path::{Path, PathBuf};
use std::time::{Duration, SystemTime, UNIX_EPOCH};
#[derive(Clone, Debug)]
pub struct UpdateCheckGuard {
stamp_path: PathBuf,
interval: Duration,
}
impl UpdateCheckGuard {
pub fn new(stamp_path: impl Into<PathBuf>, interval: Duration) -> Self {
Self {
stamp_path: stamp_path.into(),
interval,
}
}
pub fn should_check(&self) -> Result<bool> {
let contents = match std::fs::read_to_string(&self.stamp_path) {
Ok(s) => s,
Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(true),
Err(e) => return Err(Error::Io(e)),
};
let stamp_secs = match contents.trim().parse::<u64>() {
Ok(secs) => secs,
Err(_) => return Ok(true),
};
let now_secs = now_epoch_secs();
if now_secs < stamp_secs {
return Ok(true);
}
Ok(now_secs - stamp_secs >= self.interval.as_secs())
}
pub fn record_check(&self) -> Result<()> {
let secs = now_epoch_secs();
let dir = match self.stamp_path.parent() {
Some(p) if !p.as_os_str().is_empty() => p,
_ => Path::new("."),
};
let mut tmp = tempfile::NamedTempFile::new_in(dir)?;
write!(tmp, "{secs}")?;
tmp.flush()?;
tmp.persist(&self.stamp_path)
.map_err(|e| Error::Io(e.error))?;
Ok(())
}
}
fn now_epoch_secs() -> u64 {
SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|d| d.as_secs())
.unwrap_or(0)
}
#[cfg(test)]
mod tests {
use super::UpdateCheckGuard;
use std::time::Duration;
#[test]
fn missing_stamp_is_due() {
let dir = tempfile::TempDir::new().unwrap();
let guard = UpdateCheckGuard::new(dir.path().join("stamp"), Duration::from_secs(3600));
assert!(guard.should_check().unwrap(), "a missing stamp must be due");
}
#[test]
fn record_then_not_due_within_interval() {
let dir = tempfile::TempDir::new().unwrap();
let path = dir.path().join("stamp");
let guard = UpdateCheckGuard::new(&path, Duration::from_secs(3600));
guard.record_check().unwrap();
assert!(
!guard.should_check().unwrap(),
"a freshly recorded check must not be due within the interval"
);
let written = std::fs::read_to_string(&path).unwrap();
assert!(
written.trim().parse::<u64>().is_ok(),
"record_check must write parseable epoch seconds, got {written:?}"
);
}
#[test]
fn back_dated_stamp_is_due() {
let dir = tempfile::TempDir::new().unwrap();
let path = dir.path().join("stamp");
std::fs::write(&path, "1000").unwrap();
let guard = UpdateCheckGuard::new(&path, Duration::from_secs(3600));
assert!(
guard.should_check().unwrap(),
"a stamp older than the interval must be due"
);
}
#[test]
fn zero_interval_is_always_due() {
let dir = tempfile::TempDir::new().unwrap();
let path = dir.path().join("stamp");
let guard = UpdateCheckGuard::new(&path, Duration::from_secs(0));
guard.record_check().unwrap();
assert!(
guard.should_check().unwrap(),
"a zero interval must always be due"
);
}
#[test]
fn garbage_stamp_is_due_not_error() {
let dir = tempfile::TempDir::new().unwrap();
let path = dir.path().join("stamp");
std::fs::write(&path, "not-a-timestamp").unwrap();
let guard = UpdateCheckGuard::new(&path, Duration::from_secs(3600));
assert!(
guard.should_check().unwrap(),
"a corrupt stamp must be treated as due, not error"
);
}
#[test]
fn future_stamp_is_due() {
let dir = tempfile::TempDir::new().unwrap();
let path = dir.path().join("stamp");
std::fs::write(&path, "99999999999").unwrap();
let guard = UpdateCheckGuard::new(&path, Duration::from_secs(3600));
assert!(
guard.should_check().unwrap(),
"a future-dated stamp must be treated as due"
);
}
#[test]
fn record_into_missing_dir_errors() {
let dir = tempfile::TempDir::new().unwrap();
let path = dir.path().join("does-not-exist").join("stamp");
let guard = UpdateCheckGuard::new(path, Duration::from_secs(3600));
assert!(
guard.record_check().is_err(),
"record_check into a missing directory must error"
);
}
#[cfg(unix)]
#[test]
fn record_into_readonly_dir_errors() {
use std::os::unix::fs::PermissionsExt;
let dir = tempfile::TempDir::new().unwrap();
let ro = dir.path().join("ro");
std::fs::create_dir(&ro).unwrap();
std::fs::set_permissions(&ro, std::fs::Permissions::from_mode(0o555)).unwrap();
let guard = UpdateCheckGuard::new(ro.join("stamp"), Duration::from_secs(3600));
let result = guard.record_check();
std::fs::set_permissions(&ro, std::fs::Permissions::from_mode(0o755)).ok();
assert!(
result.is_err(),
"record_check into a read-only directory must error"
);
}
#[test]
fn unreadable_stamp_surfaces_io_error() {
let dir = tempfile::TempDir::new().unwrap();
let path = dir.path().join("stamp-as-dir");
std::fs::create_dir(&path).unwrap();
let guard = UpdateCheckGuard::new(&path, Duration::from_secs(3600));
assert!(
guard.should_check().is_err(),
"a non-NotFound read error must surface, not be treated as due"
);
}
}