use std::path::{Path, PathBuf};
use std::process::{Command, Stdio};
use std::time::{Duration, SystemTime, UNIX_EPOCH};
use serde_json::{Value, json};
const PKG_NAME: &str = "coding-tools";
const REPO: &str = "https://github.com/jshook/coding-tools";
const INDEX_HOST: &str = "https://index.crates.io";
const STATE_FILE: &str = "update-check.json";
pub const BG_FLAG: &str = "--update-check-run";
const DAILY: u64 = 86_400;
pub fn parse_interval(value: Option<&str>) -> Option<u64> {
match value.map(|v| v.trim().to_ascii_lowercase()).as_deref() {
None | Some("") | Some("daily") => Some(DAILY),
Some("never" | "off" | "no" | "false" | "0") => None,
Some("weekly") => Some(7 * DAILY),
Some("hourly") => Some(3_600),
Some("always") => Some(0),
Some(other) => Some(other.parse::<u64>().unwrap_or(DAILY)),
}
}
fn interval_from_env() -> Option<u64> {
parse_interval(std::env::var("CT_UPDATE_CHECK").ok().as_deref())
}
pub fn index_path(name: &str) -> String {
let n = name.to_ascii_lowercase();
match n.len() {
0 => n,
1 => format!("1/{n}"),
2 => format!("2/{n}"),
3 => format!("3/{}/{}", &n[0..1], n),
_ => format!("{}/{}/{}", &n[0..2], &n[2..4], n),
}
}
pub fn index_url(name: &str) -> String {
format!("{INDEX_HOST}/{}", index_path(name))
}
pub fn latest_from_index(body: &str) -> Option<String> {
let mut best: Option<(Version, String)> = None;
for line in body.lines() {
let line = line.trim();
if line.is_empty() {
continue;
}
let Ok(v) = serde_json::from_str::<Value>(line) else {
continue;
};
if v.get("yanked").and_then(Value::as_bool) == Some(true) {
continue;
}
let Some(vers) = v.get("vers").and_then(Value::as_str) else {
continue;
};
let Some(parsed) = Version::parse(vers) else {
continue;
};
if best.as_ref().is_none_or(|(b, _)| parsed > *b) {
best = Some((parsed, vers.to_string()));
}
}
best.map(|(_, s)| s)
}
pub fn is_newer(latest: &str, current: &str) -> bool {
match (Version::parse(latest), Version::parse(current)) {
(Some(l), Some(c)) => l > c,
_ => false,
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Version {
core: (u64, u64, u64),
pre: Option<String>,
}
impl Version {
pub fn parse(s: &str) -> Option<Version> {
let s = s.trim();
let s = s.split('+').next().unwrap_or(s); let (core_str, pre) = match s.split_once('-') {
Some((c, p)) => (c, Some(p.to_string())),
None => (s, None),
};
let mut it = core_str.split('.');
let major = it.next()?.parse().ok()?;
let minor = it.next()?.parse().ok()?;
let patch = it.next()?.parse().ok()?;
if it.next().is_some() {
return None; }
Some(Version {
core: (major, minor, patch),
pre,
})
}
}
impl PartialOrd for Version {
fn partial_cmp(&self, other: &Self) -> Option<std::cmp::Ordering> {
Some(self.cmp(other))
}
}
impl Ord for Version {
fn cmp(&self, other: &Self) -> std::cmp::Ordering {
use std::cmp::Ordering::Equal;
match self.core.cmp(&other.core) {
Equal => match (&self.pre, &other.pre) {
(None, None) => Equal,
(None, Some(_)) => std::cmp::Ordering::Greater,
(Some(_), None) => std::cmp::Ordering::Less,
(Some(a), Some(b)) => a.cmp(b),
},
ord => ord,
}
}
}
#[derive(Debug, Default, Clone, PartialEq, Eq)]
struct State {
last_check: u64,
last_notified: u64,
latest: Option<String>,
etag: Option<String>,
notice_shown: bool,
}
impl State {
fn load(path: &Path) -> State {
let Ok(text) = std::fs::read_to_string(path) else {
return State::default();
};
let Ok(v) = serde_json::from_str::<Value>(&text) else {
return State::default();
};
let u64f = |k: &str| v.get(k).and_then(Value::as_u64).unwrap_or(0);
let strf = |k: &str| {
v.get(k)
.and_then(Value::as_str)
.map(str::to_string)
.filter(|s| !s.is_empty())
};
State {
last_check: u64f("last_check"),
last_notified: u64f("last_notified"),
latest: strf("latest"),
etag: strf("etag"),
notice_shown: v
.get("notice_shown")
.and_then(Value::as_bool)
.unwrap_or(false),
}
}
fn save(&self, path: &Path) {
if let Some(dir) = path.parent() {
let _ = std::fs::create_dir_all(dir);
}
let v = json!({
"last_check": self.last_check,
"last_notified": self.last_notified,
"latest": self.latest,
"etag": self.etag,
"notice_shown": self.notice_shown,
});
let _ = std::fs::write(path, format!("{v}\n"));
}
}
fn state_dir() -> Option<PathBuf> {
if let Some(d) = std::env::var_os("CT_STATE_DIR") {
return Some(PathBuf::from(d));
}
#[cfg(windows)]
{
std::env::var_os("LOCALAPPDATA").map(|p| PathBuf::from(p).join(PKG_NAME))
}
#[cfg(target_os = "macos")]
{
std::env::var_os("HOME").map(|p| PathBuf::from(p).join("Library/Caches").join(PKG_NAME))
}
#[cfg(all(unix, not(target_os = "macos")))]
{
std::env::var_os("XDG_CACHE_HOME")
.map(PathBuf::from)
.or_else(|| std::env::var_os("HOME").map(|p| PathBuf::from(p).join(".cache")))
.map(|p| p.join(PKG_NAME))
}
}
fn unix_now() -> u64 {
SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|d| d.as_secs())
.unwrap_or(0)
}
pub fn on_invocation() {
let _ = try_on_invocation();
}
fn try_on_invocation() -> Option<()> {
let interval = interval_from_env()?; let dir = state_dir()?;
let path = dir.join(STATE_FILE);
let mut state = State::load(&path);
let now = unix_now();
let current = env!("CARGO_PKG_VERSION");
let tty = {
use std::io::IsTerminal;
std::io::stderr().is_terminal()
};
if tty && !state.notice_shown {
eprint!("{}", first_run_notice());
state.notice_shown = true;
}
if tty
&& let Some(latest) = state.latest.clone()
&& is_newer(&latest, current)
&& now.saturating_sub(state.last_notified) >= interval
{
eprint!("{}", update_available_notice(&latest, current));
state.last_notified = now;
}
let due = now.saturating_sub(state.last_check) >= interval;
if due {
state.last_check = now;
}
state.save(&path);
if due {
spawn_background();
}
Some(())
}
fn spawn_background() {
let Ok(exe) = std::env::current_exe() else {
return;
};
let mut cmd = Command::new(exe);
cmd.arg(BG_FLAG)
.stdin(Stdio::null())
.stdout(Stdio::null())
.stderr(Stdio::null());
#[cfg(windows)]
{
use std::os::windows::process::CommandExt;
const DETACHED_PROCESS: u32 = 0x0000_0008;
cmd.creation_flags(DETACHED_PROCESS);
}
let _ = cmd.spawn();
}
pub fn run_background_poll() {
let _ = try_poll();
}
fn try_poll() -> Option<()> {
interval_from_env()?; let dir = state_dir()?;
let path = dir.join(STATE_FILE);
let mut state = State::load(&path);
match fetch(env!("CARGO_PKG_VERSION"), state.etag.as_deref()) {
Fetch::Updated { latest, etag } => {
state.latest = Some(latest);
if etag.is_some() {
state.etag = etag;
}
}
Fetch::NotModified | Fetch::Failed => {}
}
state.last_check = unix_now();
state.save(&path);
Some(())
}
enum Fetch {
Updated {
latest: String,
etag: Option<String>,
},
NotModified,
Failed,
}
fn fetch(current: &str, etag: Option<&str>) -> Fetch {
let url = index_url(PKG_NAME);
let ua = format!("{PKG_NAME}/{current} ({REPO})");
let agent: ureq::Agent = ureq::Agent::config_builder()
.http_status_as_error(false)
.timeout_global(Some(Duration::from_secs(10)))
.build()
.into();
let mut req = agent.get(&url).header("User-Agent", &ua);
if let Some(e) = etag {
req = req.header("If-None-Match", e);
}
let Ok(mut resp) = req.call() else {
return Fetch::Failed;
};
let status = resp.status().as_u16();
if status == 304 {
return Fetch::NotModified;
}
if status != 200 {
return Fetch::Failed;
}
let new_etag = resp
.headers()
.get("etag")
.and_then(|v| v.to_str().ok())
.map(str::to_string);
match resp.body_mut().read_to_string() {
Ok(body) => match latest_from_index(&body) {
Some(latest) => Fetch::Updated {
latest,
etag: new_etag,
},
None => Fetch::Failed,
},
Err(_) => Fetch::Failed,
}
}
fn first_run_notice() -> String {
format!(
"{PKG_NAME}: checking crates.io for updates about once a day, in the background.\n\
{PKG_NAME}: set CT_UPDATE_CHECK=never to disable (or =weekly / =hourly / a number of seconds).\n"
)
}
fn update_available_notice(latest: &str, current: &str) -> String {
format!(
"{PKG_NAME}: a newer version is available: {latest} (you have {current}).\n\
{PKG_NAME}: update with `cargo install {PKG_NAME}` — or set CT_UPDATE_CHECK=never to silence.\n"
)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn version_orders_core_and_prerelease() {
let v = Version::parse;
assert!(v("0.9.0").unwrap() > v("0.8.4").unwrap());
assert!(v("1.0.0").unwrap() > v("0.99.99").unwrap());
assert!(v("1.2.10").unwrap() > v("1.2.9").unwrap());
assert!(v("1.0.0").unwrap() > v("1.0.0-rc.1").unwrap());
assert!(v("1.0.0-rc.2").unwrap() > v("1.0.0-rc.1").unwrap());
assert_eq!(v("1.2.3+abc").unwrap(), v("1.2.3").unwrap());
assert!(v("1.2").is_none());
assert!(v("1.2.3.4").is_none());
assert!(v("x.y.z").is_none());
}
#[test]
fn latest_from_index_picks_highest_unyanked() {
let body = "\
{\"name\":\"coding-tools\",\"vers\":\"0.8.3\",\"yanked\":false}\n\
{\"name\":\"coding-tools\",\"vers\":\"0.8.4\",\"yanked\":false}\n\
{\"name\":\"coding-tools\",\"vers\":\"0.9.0\",\"yanked\":true}\n\
not even json\n\
{\"name\":\"coding-tools\",\"vers\":\"0.8.10\",\"yanked\":false}\n";
assert_eq!(latest_from_index(body).as_deref(), Some("0.8.10"));
assert_eq!(latest_from_index("").as_deref(), None);
assert_eq!(
latest_from_index("{\"vers\":\"1.0.0\",\"yanked\":true}").as_deref(),
None
);
}
#[test]
fn state_round_trips_through_a_file() {
let dir = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("target/test-tmp/update");
let _ = std::fs::create_dir_all(&dir);
let path = dir.join("state.json");
let _ = std::fs::remove_file(&path);
assert_eq!(State::load(&path), State::default());
let s = State {
last_check: 111,
last_notified: 222,
latest: Some("0.9.0".to_string()),
etag: Some("\"abc\"".to_string()),
notice_shown: true,
};
s.save(&path);
assert_eq!(State::load(&path), s);
std::fs::write(&path, "{ not json").unwrap();
assert_eq!(State::load(&path), State::default());
}
#[test]
fn notices_name_the_versions_and_the_off_switch() {
let avail = update_available_notice("0.9.0", "0.8.4");
assert!(
avail.contains("0.9.0") && avail.contains("0.8.4"),
"{avail}"
);
assert!(avail.contains("cargo install coding-tools"), "{avail}");
assert!(avail.contains("CT_UPDATE_CHECK=never"), "{avail}");
let first = first_run_notice();
assert!(first.contains("once a day"), "{first}");
assert!(first.contains("CT_UPDATE_CHECK=never"), "{first}");
}
#[test]
fn empty_string_etag_loads_as_none() {
let dir = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("target/test-tmp/update");
let _ = std::fs::create_dir_all(&dir);
let path = dir.join("state-empty.json");
std::fs::write(&path, r#"{"etag":"","latest":""}"#).unwrap();
let s = State::load(&path);
assert_eq!(s.etag, None);
assert_eq!(s.latest, None);
}
}