use std::time::Duration;
use loftspec_store::clock::{Clock, SystemClock};
use serde_json::{Value, json};
use crate::endpoints;
use crate::install::{self, Way};
use crate::network::{self, Job};
use crate::out::errln;
use crate::settings;
use crate::switches::UPDATE_CHECK;
use crate::version::VERSION;
const DAY: u64 = 86_400;
pub fn after_run() -> Option<Job> {
if !UPDATE_CHECK.state().on {
return None;
}
let now = SystemClock::from_env().now().ok()?;
let mut kept = settings::read(UPDATE_CHECK.file);
let due = notice(&kept, now);
let stale = kept["receivedAt"]
.as_u64()
.is_none_or(|received| now >= received.saturating_add(DAY));
if due.is_none() && !stale {
return None;
}
if due.is_some() {
kept["noticeAt"] = json!(now);
}
settings::write(UPDATE_CHECK.file, &kept).ok()?;
if let Some(notice) = due {
errln!("{notice}");
}
stale.then(|| -> Job {
let address = endpoints::latest_release();
Box::new(move |left: Duration| {
if let Some((200, body)) = network::get_json(&address, left) {
keep(&body, now, kept);
}
})
})
}
fn notice(kept: &Value, now: u64) -> Option<String> {
let recent = kept["noticeAt"]
.as_u64()
.is_some_and(|at| now < at.saturating_add(DAY));
if recent {
return None;
}
let latest = kept["latest"]["tag"].as_str()?.strip_prefix('v')?;
let page = kept["latest"]["page"].as_str()?;
if parse(latest)? <= parse(VERSION)? {
return None;
}
let how = match install::way() {
Way::Npm => "update with `npm install @ask-loft/loft@latest`".to_owned(),
Way::Cargo => "update with `cargo install --locked loft-cli`".to_owned(),
Way::ReleaseBinary | Way::Unknown => format!("download it from {page}"),
};
Some(format!(
"loft: loft {latest} is available; this is {VERSION}. To update, {how}."
))
}
fn keep(body: &[u8], now: u64, mut kept: Value) {
let Ok(release) = serde_json::from_slice::<Value>(body) else {
return;
};
let Some(tag) = release["tag_name"].as_str() else {
return;
};
let published = release["draft"] == json!(false) && release["prerelease"] == json!(false);
if published {
let version = tag.strip_prefix('v').unwrap_or(tag);
let page = release["html_url"].as_str().unwrap_or_default();
kept["latest"] = json!({ "version": version, "tag": tag, "page": page });
}
kept["receivedAt"] = json!(now);
let _ = settings::write(UPDATE_CHECK.file, &kept);
}
fn parse(version: &str) -> Option<(u64, u64, u64)> {
let mut parts = version.split('.');
let mut number = || {
parts
.next()
.filter(|part| !part.is_empty() && part.bytes().all(|byte| byte.is_ascii_digit()))
.and_then(|part| part.parse().ok())
};
let parsed = (number()?, number()?, number()?);
parts.next().is_none().then_some(parsed)
}
#[cfg(test)]
mod tests {
use super::parse;
#[test]
fn only_three_numbers_parse_as_a_version() {
assert_eq!(parse("2.10.3"), Some((2, 10, 3)));
for text in [
"2.1",
"2.1.0.1",
"2.1.0-rc.1",
"nightly",
"",
"v2.1.0",
"2..1",
] {
assert_eq!(parse(text), None, "{text}");
}
}
#[test]
fn versions_compare_by_their_numbers() {
assert!(parse("2.10.0") > parse("2.9.9"));
assert!(parse("3.0.0") > parse("2.99.99"));
}
}