use std::fs;
use std::path::{Path, PathBuf};
use std::time::{Duration, SystemTime};
use serde::{Deserialize, Serialize};
use super::discover::{self, Discovery};
pub const SUCCESS_INTERVAL: Duration = Duration::from_secs(7 * 24 * 60 * 60);
pub const RETRY_INTERVAL: Duration = Duration::from_secs(60 * 60);
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
pub struct Cache {
pub attempted: Option<String>,
pub succeeded: Option<String>,
pub latest: Option<String>,
pub error: Option<String>,
}
#[derive(Debug, Clone)]
pub struct Paths {
pub cache: PathBuf,
pub lock: PathBuf,
}
impl Paths {
#[must_use]
pub fn for_key(key: &str) -> Option<Self> {
let dir = super::check_cache_dir()?;
Some(Self {
cache: dir.join(format!("{key}.json")),
lock: dir.join(format!("{key}.lock")),
})
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum Outcome {
Cached,
Refreshed,
Locked,
Unwritable(String),
}
impl Outcome {
#[must_use]
pub const fn word(&self) -> &'static str {
match self {
Self::Cached => "cached",
Self::Refreshed => "refreshed",
Self::Locked => "locked",
Self::Unwritable(_) => "unwritable",
}
}
}
fn time(text: Option<&str>) -> Option<SystemTime> {
humantime::parse_rfc3339(text?).ok()
}
#[must_use]
pub fn stamp(at: SystemTime) -> String {
humantime::format_rfc3339_seconds(at).to_string()
}
fn age(now: SystemTime, then: SystemTime) -> Duration {
now.duration_since(then).unwrap_or_default()
}
#[must_use]
pub fn due(cache: Option<&Cache>, now: SystemTime) -> bool {
let Some(cache) = cache else {
return true;
};
let attempted = time(cache.attempted.as_deref());
let succeeded = time(cache.succeeded.as_deref());
let unfinished = match (attempted, succeeded) {
(Some(attempted), Some(succeeded)) => attempted > succeeded,
(Some(_), None) => true,
(None, _) => false,
};
if cache.error.is_some() || unfinished {
return attempted.is_none_or(|attempted| age(now, attempted) >= RETRY_INTERVAL);
}
succeeded.is_none_or(|succeeded| age(now, succeeded) >= SUCCESS_INTERVAL)
}
#[must_use]
pub fn next_try(cache: &Cache) -> Option<String> {
time(cache.attempted.as_deref()).map(|attempted| stamp(attempted + RETRY_INTERVAL))
}
#[must_use]
pub fn read(path: &Path) -> Option<Cache> {
serde_json::from_slice(&fs::read(path).ok()?).ok()
}
fn write(path: &Path, cache: &Cache) -> std::io::Result<()> {
let mut bytes = serde_json::to_vec_pretty(cache).map_err(std::io::Error::other)?;
bytes.push(b'\n');
crate::atomic::write(path, &bytes)
}
#[derive(Debug, Clone)]
pub struct Run {
pub outcome: Outcome,
pub cache: Option<Cache>,
pub fetched: bool,
}
#[must_use]
pub fn run(paths: &Paths, now: SystemTime) -> Run {
let (outcome, cache, fetched) = refresh(paths, now);
Run {
outcome,
cache,
fetched,
}
}
fn refresh(paths: &Paths, now: SystemTime) -> (Outcome, Option<Cache>, bool) {
let cached = read(&paths.cache);
if !due(cached.as_ref(), now) {
return (Outcome::Cached, cached, false);
}
if let Some(parent) = paths.cache.parent()
&& let Err(source) = fs::create_dir_all(parent)
{
return (Outcome::Unwritable(source.to_string()), cached, false);
}
let lock = match fs::OpenOptions::new()
.create(true)
.truncate(false)
.write(true)
.open(&paths.lock)
{
Ok(lock) => lock,
Err(source) => return (Outcome::Unwritable(source.to_string()), cached, false),
};
match lock.try_lock() {
Ok(()) => {}
Err(fs::TryLockError::WouldBlock) => return (Outcome::Locked, cached, false),
Err(fs::TryLockError::Error(source)) => {
return (Outcome::Unwritable(source.to_string()), cached, false);
}
}
let cached = read(&paths.cache);
if !due(cached.as_ref(), now) {
return (Outcome::Cached, cached, false);
}
let mut cache = cached.unwrap_or_default();
cache.attempted = Some(stamp(now));
if let Err(source) = write(&paths.cache, &cache) {
return (
Outcome::Unwritable(source.to_string()),
read(&paths.cache),
false,
);
}
match discover::latest_tag() {
Discovery::Tag(tag) => {
cache.succeeded = Some(stamp(SystemTime::now()));
cache.latest = Some(tag);
cache.error = None;
}
Discovery::Unreachable(reason) => cache.error = Some(reason),
Discovery::Unparsable(answer) => {
cache.error = Some(format!("the release page answered no tag: {answer}"));
}
}
if let Err(source) = write(&paths.cache, &cache) {
return (Outcome::Unwritable(source.to_string()), Some(cache), true);
}
drop(lock);
(Outcome::Refreshed, Some(cache), true)
}
#[must_use]
pub fn warnings(
pin: Option<&str>,
cache: Option<&Cache>,
outcome: &Outcome,
path: &Path,
) -> Vec<String> {
let mut lines = Vec::new();
if let Outcome::Unwritable(reason) = outcome {
lines.push(format!(
"rk cannot record its version check at {}: {reason}.",
path.display()
));
}
let Some(cache) = cache else {
return lines;
};
if let (Some(pin), Some(latest)) = (pin, cache.latest.as_deref())
&& discover::version_order(latest, pin).is_gt()
{
lines.push(format!(
"rk {} is out, this project pins {}. To move: rk self-depend sync --apply",
latest.trim_start_matches('v'),
pin.trim_start_matches('v')
));
}
if let Some(error) = &cache.error {
lines.push(format!(
"rk could not check for a newer release: {error}. Next try after {}.",
next_try(cache).unwrap_or_else(|| "the next entry".to_owned())
));
}
lines
}
#[cfg(test)]
mod tests {
use std::time::{Duration, SystemTime};
use super::{Cache, due, stamp};
fn ago(secs: u64) -> String {
stamp(SystemTime::now() - Duration::from_secs(secs))
}
#[test]
fn a_success_is_fresh_for_seven_days() {
let now = SystemTime::now();
let cache = |secs| Cache {
attempted: Some(ago(secs)),
succeeded: Some(ago(secs)),
latest: Some("v0.9.15".to_owned()),
error: None,
};
assert!(!due(Some(&cache(6 * 86_400)), now));
assert!(due(Some(&cache(7 * 86_400)), now));
assert!(due(None, now));
}
#[test]
fn a_failure_waits_one_hour_whatever_the_success() {
let now = SystemTime::now();
let cache = |failed| Cache {
attempted: Some(ago(failed)),
succeeded: Some(ago(8 * 86_400)),
latest: Some("v0.9.15".to_owned()),
error: Some("no answer".to_owned()),
};
assert!(!due(Some(&cache(60)), now));
assert!(!due(Some(&cache(59 * 60)), now));
assert!(due(Some(&cache(60 * 60)), now));
}
#[test]
fn an_unfinished_attempt_waits_one_hour() {
let now = SystemTime::now();
let first = |secs| Cache {
attempted: Some(ago(secs)),
..Cache::default()
};
assert!(!due(Some(&first(60)), now));
assert!(due(Some(&first(60 * 60)), now));
let overdue = |secs| Cache {
attempted: Some(ago(secs)),
succeeded: Some(ago(8 * 86_400)),
latest: Some("v0.9.15".to_owned()),
error: None,
};
assert!(!due(Some(&overdue(60)), now));
assert!(due(Some(&overdue(60 * 60)), now));
}
#[test]
fn an_unreadable_time_counts_as_missing() {
let cache = Cache {
attempted: Some("yesterday".to_owned()),
succeeded: Some("last week".to_owned()),
latest: None,
error: None,
};
assert!(due(Some(&cache), SystemTime::now()));
}
}