use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::{Arc, Mutex};
use std::time::Duration;
pub const REPO: &str = "chris-mclennan/mnml";
const USER_AGENT: &str = "mnml-update-check (https://github.com/chris-mclennan/mnml)";
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum InstallChannel {
Cargo,
Homebrew,
AppBundle,
Source,
}
impl InstallChannel {
pub fn detect() -> Self {
let exe = std::env::current_exe().ok();
let path = exe.as_ref().and_then(|p| p.to_str()).unwrap_or("");
if path.contains("/.cargo/bin/") {
InstallChannel::Cargo
} else if path.starts_with("/opt/homebrew/") || path.starts_with("/usr/local/") {
InstallChannel::Homebrew
} else if path.contains(".app/Contents/") {
InstallChannel::AppBundle
} else {
InstallChannel::Source
}
}
pub fn upgrade_hint(self, latest: &str) -> String {
match self {
InstallChannel::Cargo => format!("cargo install mnml-rs → v{latest}"),
InstallChannel::Homebrew => format!("brew upgrade mnml → v{latest}"),
InstallChannel::AppBundle => {
format!("download v{latest}: {}", UpdateCheck::release_url(latest))
}
InstallChannel::Source => {
format!("git pull && cargo install --path . → v{latest}")
}
}
}
}
pub struct UpdateCheck {
pub latest_version: Mutex<Option<String>>,
pub latest_published_at: Mutex<Option<u64>>,
pub announced: AtomicBool,
pub channel: InstallChannel,
}
impl UpdateCheck {
pub fn spawn() -> Arc<Self> {
let handle = Arc::new(Self {
latest_version: Mutex::new(None),
latest_published_at: Mutex::new(None),
announced: AtomicBool::new(false),
channel: InstallChannel::detect(),
});
let bg = Arc::clone(&handle);
std::thread::spawn(move || {
if let Some((latest, published_at)) = fetch_latest_release() {
let current = env!("CARGO_PKG_VERSION");
if is_newer(&latest, current) {
if let Ok(mut slot) = bg.latest_published_at.lock() {
*slot = published_at;
}
if let Ok(mut slot) = bg.latest_version.lock() {
*slot = Some(latest);
}
}
}
});
handle
}
pub fn take_pending_announcement(&self) -> Option<String> {
if self.announced.load(Ordering::Relaxed) {
return None;
}
let latest = self.latest_version.lock().ok()?.clone()?;
self.announced.store(true, Ordering::Relaxed);
Some(latest)
}
pub fn published_at(&self) -> Option<u64> {
self.latest_published_at.lock().ok().and_then(|g| *g)
}
pub fn release_url(latest: &str) -> String {
format!("https://github.com/{REPO}/releases/tag/v{latest}")
}
}
fn version_parts(v: &str) -> Option<(u64, u64, u64)> {
let v = v.trim_start_matches('v').split(['-', '+']).next()?;
let mut it = v.split('.').map(|s| s.parse::<u64>().ok());
let major = it.next()??;
let minor = it.next().flatten().unwrap_or(0);
let patch = it.next().flatten().unwrap_or(0);
Some((major, minor, patch))
}
fn is_newer(remote: &str, local: &str) -> bool {
match (version_parts(remote), version_parts(local)) {
(Some(r), Some(l)) => r > l,
_ => false,
}
}
const LAST_RUST_LINE: (u64, u64) = (0, 2);
fn crosses_codebase_line(current: &str, latest: &str) -> bool {
match (version_parts(current), version_parts(latest)) {
(Some((cm, cn, _)), Some((lm, ln, _))) => {
(cm, cn) <= LAST_RUST_LINE && (lm, ln) > LAST_RUST_LINE
}
_ => false,
}
}
const EXPORT_NOTE_DAYS: u64 = 30;
fn within_days(published_at: Option<u64>, now: u64, days: u64) -> bool {
match published_at {
Some(p) => now.saturating_sub(p) < days * 86_400,
None => false,
}
}
pub fn announcement(
current: &str,
latest: &str,
published_at: Option<u64>,
now: u64,
channel: InstallChannel,
) -> String {
let hint = channel.upgrade_hint(latest);
if crosses_codebase_line(current, latest) && within_days(published_at, now, EXPORT_NOTE_DAYS) {
format!(
"mnml v{latest} available — a new codebase: run `mnml export-config-zon` first, then {hint}"
)
} else {
format!("mnml v{latest} available — {hint}")
}
}
fn fetch_latest_release() -> Option<(String, Option<u64>)> {
let url = format!("https://api.github.com/repos/{REPO}/releases/latest");
let resp = reqwest::blocking::Client::builder()
.user_agent(USER_AGENT)
.timeout(Duration::from_secs(10))
.build()
.ok()?
.get(&url)
.send()
.ok()?;
if !resp.status().is_success() {
return None;
}
let body = resp.text().ok()?;
let parsed: serde_json::Value = serde_json::from_str(&body).ok()?;
let tag = parsed.get("tag_name")?.as_str()?;
let published_at = parsed
.get("published_at")
.and_then(|v| v.as_str())
.and_then(crate::marketplace::parse_iso8601_secs);
Some((tag.trim_start_matches('v').to_string(), published_at))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn release_url_strips_v_prefix_consistently() {
let url = UpdateCheck::release_url("0.1.3");
assert_eq!(
url,
format!("https://github.com/{REPO}/releases/tag/v0.1.3")
);
}
#[test]
fn take_pending_announcement_is_one_shot() {
let uc = UpdateCheck {
latest_version: Mutex::new(Some("0.99.0".into())),
latest_published_at: Mutex::new(None),
announced: AtomicBool::new(false),
channel: InstallChannel::Source,
};
assert_eq!(uc.take_pending_announcement().as_deref(), Some("0.99.0"));
assert_eq!(uc.take_pending_announcement(), None, "second call is no-op");
}
#[test]
fn is_newer_semver_compare() {
assert!(is_newer("0.1.4", "0.1.3"));
assert!(is_newer("0.2.0", "0.1.99"));
assert!(is_newer("1.0.0", "0.99.99"));
assert!(!is_newer("0.1.3", "0.1.4"), "same-minor downgrade");
assert!(!is_newer("0.1.3", "0.1.3"), "same version");
assert!(!is_newer("garbage", "0.1.3"), "unparseable → false");
}
#[test]
fn upgrade_hint_matches_channel() {
assert!(
InstallChannel::Cargo
.upgrade_hint("1.2.3")
.contains("cargo install")
);
assert!(
InstallChannel::Homebrew
.upgrade_hint("1.2.3")
.contains("brew upgrade")
);
assert!(
InstallChannel::AppBundle
.upgrade_hint("1.2.3")
.contains("github.com")
);
assert!(
InstallChannel::Source
.upgrade_hint("1.2.3")
.contains("git pull")
);
}
#[test]
fn crosses_codebase_line_only_past_0_2() {
assert!(!crosses_codebase_line("0.2.21", "0.2.22"));
assert!(crosses_codebase_line("0.2.22", "0.3.0"));
assert!(crosses_codebase_line("0.2.22", "1.0.0"));
assert!(
!crosses_codebase_line("0.3.0", "0.3.1"),
"already on the new line"
);
assert!(
!crosses_codebase_line("0.2.22", "garbage"),
"unparseable → false"
);
}
const PUB: u64 = 1_790_812_800;
const DAY: u64 = 86_400;
#[test]
fn published_at_parses_the_github_shape() {
assert_eq!(
crate::marketplace::parse_iso8601_secs("2026-10-01T00:00:00Z"),
Some(PUB)
);
}
#[test]
fn announcement_same_line_is_the_plain_hint() {
assert_eq!(
announcement("0.2.21", "0.2.22", Some(PUB), PUB, InstallChannel::Homebrew),
"mnml v0.2.22 available — brew upgrade mnml → v0.2.22"
);
}
#[test]
fn announcement_across_the_line_asks_for_the_export_for_30_days() {
let note = "mnml v0.3.0 available — a new codebase: run `mnml export-config-zon` first, \
then brew upgrade mnml → v0.3.0";
let plain = "mnml v0.3.0 available — brew upgrade mnml → v0.3.0";
let at = |now| announcement("0.2.22", "0.3.0", Some(PUB), now, InstallChannel::Homebrew);
assert_eq!(at(PUB), note, "day 0");
assert_eq!(at(PUB + 29 * DAY), note, "day 29");
assert_eq!(at(PUB + 31 * DAY), plain, "day 31");
assert!(
announcement("0.2.22", "1.0.0", Some(PUB), PUB, InstallChannel::Cargo)
.contains("mnml export-config-zon")
);
}
#[test]
fn announcement_without_a_published_at_is_the_plain_hint() {
let unparseable = crate::marketplace::parse_iso8601_secs("not a date");
assert_eq!(unparseable, None);
for published_at in [None, unparseable] {
assert_eq!(
announcement(
"0.2.22",
"0.3.0",
published_at,
PUB,
InstallChannel::Homebrew
),
"mnml v0.3.0 available — brew upgrade mnml → v0.3.0"
);
}
}
}