use anyhow::{Context, Result};
use semver::Version;
use serde::{Deserialize, Serialize};
use std::fs;
use std::path::{Path, PathBuf};
use std::time::{Duration, SystemTime, UNIX_EPOCH};
#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq)]
pub struct Checked {
pub name: String,
pub current: Version,
pub latest: Version,
}
impl Checked {
pub fn is_outdated(&self) -> bool {
self.latest > self.current
}
}
#[derive(Debug, PartialEq, Eq)]
pub enum Status<'a> {
UpToDate,
Outdated(&'a Version),
}
#[derive(Serialize, Deserialize, Debug)]
pub struct Report {
pub checked_at: u64,
pub prefix: PathBuf,
pub crates: Vec<Checked>,
}
fn identity(prefix: &Path) -> Result<PathBuf> {
let anchored = if prefix.is_absolute() {
prefix.to_path_buf()
} else {
std::env::current_dir()
.context("resolving relative prefix against the current directory")?
.join(prefix)
};
Ok(anchored.components().collect())
}
fn key(identity: &Path) -> String {
let mut hash: u64 = 0xcbf2_9ce4_8422_2325;
for byte in identity.as_os_str().as_encoded_bytes() {
hash ^= u64::from(*byte);
hash = hash.wrapping_mul(0x0000_0100_0000_01b3);
}
format!("{hash:016x}")
}
impl Report {
pub fn new(prefix: &Path, crates: Vec<Checked>) -> Result<Self> {
let checked_at = SystemTime::now()
.duration_since(UNIX_EPOCH)
.map_or(0, |d| d.as_secs());
Ok(Self {
checked_at,
prefix: identity(prefix)?,
crates,
})
}
pub fn path(cache: &Path, prefix: &Path) -> Result<PathBuf> {
let identity = identity(prefix)?;
Ok(cache
.join("checkupdate")
.join(format!("{}.json", key(&identity))))
}
pub fn load(cache: &Path, prefix: &Path) -> Result<Option<Self>> {
let identity = identity(prefix)?;
let path = Self::path(cache, &identity)?;
let raw = match fs::read_to_string(&path) {
Ok(raw) => raw,
Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(None),
Err(e) => return Err(e).with_context(|| format!("reading {}", path.display())),
};
let report: Self = serde_json::from_str(&raw)
.with_context(|| format!("corrupt update report at {}", path.display()))?;
if report.prefix != identity {
return Ok(None);
}
Ok(Some(report))
}
pub fn store(&self, cache: &Path) -> Result<()> {
let path = Self::path(cache, &self.prefix)?;
let dir = path
.parent()
.context("report path has no parent directory")?;
fs::create_dir_all(dir).with_context(|| format!("creating {}", dir.display()))?;
let tmp = dir.join(format!(".{}.{}.tmp", key(&self.prefix), std::process::id()));
let mut raw = serde_json::to_string_pretty(self)?;
raw.push('\n');
fs::write(&tmp, raw).with_context(|| format!("writing {}", tmp.display()))?;
fs::rename(&tmp, &path)
.with_context(|| format!("placing {}", path.display()))
.inspect_err(|_| {
let _ = fs::remove_file(&tmp);
})?;
Ok(())
}
pub fn age(&self) -> Duration {
let now = SystemTime::now()
.duration_since(UNIX_EPOCH)
.map_or(0, |d| d.as_secs());
Duration::from_secs(now.saturating_sub(self.checked_at))
}
pub fn status_for(&self, name: &str, current: &Version) -> Option<Status<'_>> {
let checked = self
.crates
.iter()
.find(|c| c.name == name && c.current == *current)?;
Some(if checked.is_outdated() {
Status::Outdated(&checked.latest)
} else {
Status::UpToDate
})
}
}
pub fn describe_age(age: Duration) -> String {
let secs = age.as_secs();
match secs {
0..=59 => "just now".to_owned(),
60..=3599 => format!("{}m ago", secs / 60),
3600..=86_399 => format!("{}h ago", secs / 3600),
_ => format!("{}d ago", secs / 86_400),
}
}
#[cfg(test)]
mod tests {
use super::*;
fn v(s: &str) -> Version {
Version::parse(s).unwrap()
}
fn id(p: &str) -> PathBuf {
identity(Path::new(p)).unwrap()
}
#[test]
fn identity_ignores_trailing_slash_and_dot_components() {
assert_eq!(id("/usr/local"), id("/usr/local/"));
assert_eq!(id("/usr/local"), id("/usr/./local"));
assert_ne!(id("/usr/local"), id("/usr"));
assert_ne!(id("/usr/local"), id("/usr/lib/../local"));
}
#[test]
fn relative_prefix_is_anchored_in_cwd() {
let cwd = std::env::current_dir().unwrap();
assert_eq!(id("local"), cwd.join("local"));
assert_eq!(id("./local/"), cwd.join("local"));
assert_ne!(key(&id("local")), key(Path::new("local")));
}
#[test]
fn key_is_stable() {
assert_eq!(key(&id("/usr/local")), "f7ab513049b9491c");
}
#[test]
fn round_trip_and_prefix_isolation() {
let tmp = std::env::temp_dir().join("cargo-lbin-test-report");
let _ = fs::remove_dir_all(&tmp);
let prefix = Path::new("/usr/local/");
let report = Report::new(
prefix,
vec![
Checked {
name: "bat".to_owned(),
current: v("0.26.0"),
latest: v("0.26.1"),
},
Checked {
name: "ripgrep".to_owned(),
current: v("14.1.1"),
latest: v("14.1.1"),
},
],
);
let report = report.unwrap();
report.store(&tmp).unwrap();
let leftovers: Vec<_> = fs::read_dir(tmp.join("checkupdate"))
.unwrap()
.map(|e| e.unwrap().file_name())
.filter(|n| n.to_string_lossy().ends_with(".tmp"))
.collect();
assert_eq!(leftovers, Vec::<std::ffi::OsString>::new());
let loaded = Report::load(&tmp, Path::new("/usr/local"))
.unwrap()
.unwrap();
assert_eq!(loaded.crates, report.crates);
assert_eq!(loaded.prefix, Path::new("/usr/local"));
assert!(loaded.age() < Duration::from_secs(60));
assert!(Report::load(&tmp, Path::new("/opt")).unwrap().is_none());
fs::write(Report::path(&tmp, prefix).unwrap(), b"{").unwrap();
assert!(Report::load(&tmp, prefix).is_err());
let _ = fs::remove_dir_all(&tmp);
}
#[test]
fn status_distinguishes_current_from_unchecked() {
let report = Report::new(
Path::new("/p"),
vec![
Checked {
name: "bat".to_owned(),
current: v("0.26.0"),
latest: v("0.26.1"),
},
Checked {
name: "ripgrep".to_owned(),
current: v("14.1.1"),
latest: v("14.1.1"),
},
],
);
let report = report.unwrap();
assert_eq!(
report.status_for("bat", &v("0.26.0")),
Some(Status::Outdated(&v("0.26.1")))
);
assert_eq!(
report.status_for("ripgrep", &v("14.1.1")),
Some(Status::UpToDate)
);
assert_eq!(report.status_for("bat", &v("0.26.1")), None);
assert_eq!(report.status_for("fd", &v("1.0.0")), None);
}
#[test]
fn age_description_is_coarse() {
assert_eq!(describe_age(Duration::from_secs(5)), "just now");
assert_eq!(describe_age(Duration::from_secs(90)), "1m ago");
assert_eq!(describe_age(Duration::from_secs(7200)), "2h ago");
assert_eq!(describe_age(Duration::from_secs(3 * 86_400 + 5)), "3d ago");
}
}