use serde::{Deserialize, Serialize};
use std::path::Path;
use std::process::{Command, Stdio};
const STALE_AFTER: u64 = 24 * 60 * 60;
const GIVE_UP_AFTER: u64 = 30;
const CACHE_FILE: &str = "release-check.json";
const PENDING_FILE: &str = "release-check.pending";
const OUT_FILE: &str = "release-check.out";
#[derive(Debug, Default, Serialize, Deserialize)]
struct Cache {
checked_at: u64,
#[serde(skip_serializing_if = "Option::is_none")]
latest: Option<String>,
}
#[derive(Debug, Serialize, Deserialize)]
struct Pending {
pid: u32,
started_at: u64,
}
pub fn poll() {
let dir = gy_ledger::location::base_dir().join("gy");
if stale(&dir) {
let running = settle(&dir);
if !running && stale(&dir) {
spawn(&dir);
}
}
if let Some(latest) = read_cache(&dir).latest {
let own = env!("CARGO_PKG_VERSION");
if gy_ledger::newer_than(own, &latest) {
eprintln!(
"gy {latest} is available (you have {own}); update with: cargo install gy --locked"
);
}
}
}
pub fn peer_newer(ledger: &Path) -> Option<String> {
let text = std::fs::read_to_string(ledger.join("sync.state")).ok()?;
let state: gy_ledger::SyncStatus = serde_json::from_str(&text).ok()?;
let peer = state.peer_version?;
if gy_ledger::newer_than(env!("CARGO_PKG_VERSION"), &peer) {
Some(peer)
} else {
None
}
}
pub fn peer_notice(last: &mut Option<String>, current: Option<String>) -> Option<String> {
if current == *last {
return None;
}
*last = current.clone();
current.map(|version| peer_line(&version))
}
pub fn peer_line(peer: &str) -> String {
let own = env!("CARGO_PKG_VERSION");
format!(
"someone sharing this ledger writes with gy {peer} (you have {own}); update with: cargo install gy --locked"
)
}
fn stale(dir: &Path) -> bool {
match read_cache_opt(dir) {
None => true,
Some(cache) => cache.checked_at.saturating_add(STALE_AFTER) <= now(),
}
}
fn settle(dir: &Path) -> bool {
let Some(pending) = read_pending(dir) else {
if dir.join(OUT_FILE).is_file() {
adopt_out(dir);
}
return false;
};
if parse_version(&read_out(dir)).is_some() {
adopt_out(dir);
let _ = std::fs::remove_file(dir.join(PENDING_FILE));
return false;
}
if process_alive(pending.pid) {
if pending.started_at.saturating_add(GIVE_UP_AFTER) > now() {
return true;
}
remove_flight(dir);
stamp_without_version(dir);
return false;
}
adopt_out(dir);
let _ = std::fs::remove_file(dir.join(PENDING_FILE));
false
}
fn read_out(dir: &Path) -> String {
std::fs::read_to_string(dir.join(OUT_FILE)).unwrap_or_default()
}
fn adopt_out(dir: &Path) {
let latest = parse_version(&read_out(dir)).or_else(|| read_cache(dir).latest);
write_cache(
dir,
&Cache {
checked_at: now(),
latest,
},
);
let _ = std::fs::remove_file(dir.join(OUT_FILE));
}
fn stamp_without_version(dir: &Path) {
let latest = read_cache(dir).latest;
write_cache(
dir,
&Cache {
checked_at: now(),
latest,
},
);
}
fn spawn(dir: &Path) {
if std::fs::create_dir_all(dir).is_err() {
return;
}
let out = match std::fs::File::create(dir.join(OUT_FILE)) {
Ok(out) => out,
Err(_) => return,
};
let mut command = Command::new("cargo");
command
.arg("info")
.arg("gy")
.current_dir(dir)
.stdin(Stdio::null())
.stdout(out)
.stderr(Stdio::null());
#[cfg(unix)]
{
use std::os::unix::process::CommandExt;
command.process_group(0);
}
let Ok(child) = command.spawn() else {
let _ = std::fs::remove_file(dir.join(OUT_FILE));
stamp_without_version(dir);
return;
};
let pending = Pending {
pid: child.id(),
started_at: now(),
};
let _ =
serde_json::to_string(&pending).map(|text| std::fs::write(dir.join(PENDING_FILE), text));
}
fn parse_version(text: &str) -> Option<String> {
text.lines().find_map(|line| {
let rest = line.trim().strip_prefix("version:")?.trim();
if gy_ledger::triple(rest).is_some() {
Some(rest.to_string())
} else {
None
}
})
}
fn read_cache(dir: &Path) -> Cache {
read_cache_opt(dir).unwrap_or_default()
}
fn read_cache_opt(dir: &Path) -> Option<Cache> {
std::fs::read_to_string(dir.join(CACHE_FILE))
.ok()
.and_then(|text| serde_json::from_str(&text).ok())
}
fn read_pending(dir: &Path) -> Option<Pending> {
std::fs::read_to_string(dir.join(PENDING_FILE))
.ok()
.and_then(|text| serde_json::from_str(&text).ok())
}
fn remove_flight(dir: &Path) {
let _ = std::fs::remove_file(dir.join(PENDING_FILE));
let _ = std::fs::remove_file(dir.join(OUT_FILE));
}
fn write_cache(dir: &Path, cache: &Cache) {
if std::fs::create_dir_all(dir).is_err() {
return;
}
if let Ok(text) = serde_json::to_string(cache) {
let _ = std::fs::write(dir.join(CACHE_FILE), text);
}
}
fn process_alive(pid: u32) -> bool {
Command::new("kill")
.arg("-0")
.arg(pid.to_string())
.output()
.map(|out| out.status.success())
.unwrap_or(false)
}
fn now() -> u64 {
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_secs())
.unwrap_or(0)
}
#[cfg(test)]
mod tests {
use super::peer_notice;
#[test]
fn peer_notice_speaks_once_per_version() {
use super::peer_line;
let mut last = None;
assert!(peer_notice(&mut last, None).is_none());
assert_eq!(
peer_line("9.9.9"),
format!(
"someone sharing this ledger writes with gy 9.9.9 (you have {}); update with: cargo install gy --locked",
env!("CARGO_PKG_VERSION")
)
);
let line = peer_notice(&mut last, Some("9.9.9".to_string())).unwrap();
assert!(line.contains("9.9.9"), "{line}");
assert!(peer_notice(&mut last, Some("9.9.9".to_string())).is_none());
assert!(peer_notice(&mut last, Some("9.9.10".to_string())).is_some());
assert!(peer_notice(&mut last, None).is_none());
assert_eq!(last, None);
}
}