use std::cmp::Ordering;
use std::fmt;
use std::path::PathBuf;
use std::process::Command;
use anyhow::{Context, Result, anyhow, bail};
use serde::Deserialize;
use crate::{daemon, ipc, launchd};
const INDEX_URL: &str = "https://index.crates.io/ga/ld/galdr";
const CURL_MAX_TIME: &str = "3";
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct SemVer {
major: u64,
minor: u64,
patch: u64,
pre: Option<String>,
}
impl SemVer {
pub fn parse(raw: &str) -> Option<Self> {
let raw = raw.trim();
let (core, pre) = match raw.split_once('-') {
Some((core, rest)) => {
let pre = rest.split('+').next().unwrap_or(rest);
if pre.is_empty() {
return None;
}
(core, Some(pre.to_string()))
}
None => (raw.split('+').next().unwrap_or(raw), None),
};
let mut parts = core.split('.');
let major = parts.next()?.parse().ok()?;
let minor = parts.next()?.parse().ok()?;
let patch = parts.next()?.parse().ok()?;
if parts.next().is_some() {
return None; }
Some(Self {
major,
minor,
patch,
pre,
})
}
}
impl Ord for SemVer {
fn cmp(&self, other: &Self) -> Ordering {
(self.major, self.minor, self.patch)
.cmp(&(other.major, other.minor, other.patch))
.then_with(|| match (&self.pre, &other.pre) {
(None, None) => Ordering::Equal,
(None, Some(_)) => Ordering::Greater,
(Some(_), None) => Ordering::Less,
(Some(a), Some(b)) => a.cmp(b),
})
}
}
impl PartialOrd for SemVer {
fn partial_cmp(&self, other: &Self) -> Option<Ordering> {
Some(self.cmp(other))
}
}
impl fmt::Display for SemVer {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "{}.{}.{}", self.major, self.minor, self.patch)?;
if let Some(pre) = &self.pre {
write!(f, "-{pre}")?;
}
Ok(())
}
}
#[derive(Deserialize)]
struct IndexEntry {
vers: String,
#[serde(default)]
yanked: bool,
}
pub fn parse_index(raw: &str) -> Result<SemVer> {
let mut best: Option<SemVer> = None;
for line in raw.lines() {
let line = line.trim();
if line.is_empty() {
continue;
}
let Ok(entry) = serde_json::from_str::<IndexEntry>(line) else {
continue;
};
if entry.yanked {
continue;
}
let Some(version) = SemVer::parse(&entry.vers) else {
continue;
};
if best.as_ref().is_none_or(|current| version > *current) {
best = Some(version);
}
}
best.context("crates.io index carried no usable galdr version")
}
fn current_version() -> SemVer {
SemVer::parse(env!("CARGO_PKG_VERSION")).expect("galdr's own version is valid semver")
}
fn fetch_index() -> Option<String> {
if let Some(file) = std::env::var_os("GALDR_INDEX_FILE") {
return std::fs::read_to_string(file).ok();
}
let url = std::env::var("GALDR_INDEX_URL").unwrap_or_else(|_| INDEX_URL.to_string());
curl_get(&url)
}
fn curl_get(url: &str) -> Option<String> {
let output = Command::new("curl")
.args(["--max-time", CURL_MAX_TIME, "-sfL", url])
.output()
.ok()?;
if !output.status.success() {
return None;
}
String::from_utf8(output.stdout).ok()
}
#[derive(Debug, PartialEq, Eq)]
pub enum LatestCheck {
UpToDate { current: SemVer },
Newer { current: SemVer, latest: SemVer },
LocalAhead { current: SemVer, latest: SemVer },
Offline,
}
pub fn check_latest() -> Result<LatestCheck> {
let current = current_version();
let Some(raw) = fetch_index() else {
return Ok(LatestCheck::Offline);
};
let latest = parse_index(&raw)?;
Ok(match latest.cmp(¤t) {
Ordering::Equal => LatestCheck::UpToDate { current },
Ordering::Greater => LatestCheck::Newer { current, latest },
Ordering::Less => LatestCheck::LocalAhead { current, latest },
})
}
#[derive(Debug, PartialEq, Eq)]
pub enum InstallSource {
Crates,
Path(PathBuf),
}
impl InstallSource {
pub fn parse(from: Option<Vec<String>>) -> Result<Self> {
let Some(values) = from else {
return Ok(Self::Crates);
};
match values.as_slice() {
[kind] if kind == "crates" => Ok(Self::Crates),
[kind, dir] if kind == "path" => Ok(Self::Path(PathBuf::from(dir))),
[kind] if kind == "path" => {
bail!("`--from path` needs a directory: `galdr upgrade --from path <dir>`")
}
_ => bail!("invalid --from; use `--from crates` (default) or `--from path <dir>`"),
}
}
}
pub fn run(check: bool, from: Option<Vec<String>>) -> Result<i32> {
let source = InstallSource::parse(from)?;
match check_latest()? {
LatestCheck::Offline => {
println!("update check skipped (offline; could not reach crates.io)");
Ok(0)
}
LatestCheck::UpToDate { current } => {
println!("galdr {current} is up to date");
Ok(0)
}
LatestCheck::LocalAhead { current, latest } => {
println!("local build v{current} ahead of crates.io (v{latest})");
Ok(0)
}
LatestCheck::Newer { current, latest } => {
if check {
println!("galdr {latest} available (you have {current}) — run galdr upgrade");
return Ok(10);
}
println!("galdr {current} → {latest}: upgrading via cargo install…");
install(&source)?;
println!("galdr upgraded to {latest}");
restart_daemon_if_stale(&latest);
Ok(0)
}
}
}
fn install(source: &InstallSource) -> Result<()> {
let mut cmd = Command::new("cargo");
cmd.arg("install");
match source {
InstallSource::Crates => {
cmd.args(["galdr", "--locked", "--force"]);
}
InstallSource::Path(dir) => {
cmd.arg("--path").arg(dir).args(["--locked", "--force"]);
}
}
let status = cmd.status().map_err(|e| {
if e.kind() == std::io::ErrorKind::NotFound {
anyhow!(
"cargo not found on PATH; install Rust (https://rustup.rs), then re-run `galdr upgrade`"
)
} else {
anyhow!("could not run cargo install: {e}")
}
})?;
if !status.success() {
bail!(
"cargo install failed (exit {})",
status
.code()
.map_or_else(|| "signal".to_string(), |c| c.to_string())
);
}
Ok(())
}
fn restart_daemon_if_stale(latest: &SemVer) {
let Ok(ipc::Response::Pong { version }) = ipc::query(&ipc::Request::Ping) else {
return;
};
let already_current = version
.as_deref()
.and_then(SemVer::parse)
.is_some_and(|running| running == *latest);
if already_current {
println!("daemon already running {latest}; no restart needed");
return;
}
let was = version.as_deref().unwrap_or("unknown");
if launchd::is_managed() {
println!("restarting launchd-managed daemon (was {was}) so it runs {latest}…");
if let Err(e) = launchd::kickstart() {
eprintln!(
"warning: could not kickstart the daemon: {e:#}\n\
restart it yourself: launchctl kickstart -k gui/$(id -u)/dev.galdr.daemon"
);
}
} else {
println!("restarting daemon (was {was}) so it runs {latest}…");
if let Err(e) = restart_daemon() {
eprintln!(
"warning: could not restart the daemon: {e:#}\n\
restart it yourself: galdr daemon stop && galdr daemon"
);
}
}
}
fn restart_daemon() -> Result<()> {
daemon::stop_and_wait();
daemon::run(true)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn semver_parses_the_common_shapes() {
let v = SemVer::parse("0.15.0").unwrap();
assert_eq!((v.major, v.minor, v.patch), (0, 15, 0));
assert!(v.pre.is_none());
assert_eq!(
SemVer::parse("1.2.3+abc").unwrap(),
SemVer::parse("1.2.3").unwrap()
);
assert_eq!(
SemVer::parse("1.2.3-rc.1+build").unwrap().pre.as_deref(),
Some("rc.1")
);
assert!(SemVer::parse("not.a.version").is_none());
assert!(SemVer::parse("1.2").is_none());
assert!(SemVer::parse("1.2.3.4").is_none());
assert!(SemVer::parse("1.2.3-").is_none());
}
#[test]
fn semver_orders_including_local_ahead_and_prerelease() {
let older = SemVer::parse("0.14.2").unwrap();
let current = SemVer::parse("0.15.0").unwrap();
let newer = SemVer::parse("0.15.1").unwrap();
assert!(current > older);
assert!(newer > current);
assert_eq!(current, SemVer::parse("0.15.0").unwrap());
assert!(SemVer::parse("0.10.0").unwrap() > SemVer::parse("0.9.0").unwrap());
assert!(current > SemVer::parse("0.15.0-rc.1").unwrap());
}
#[test]
fn parse_index_picks_the_greatest_non_yanked_version() {
let raw = concat!(
r#"{"name":"galdr","vers":"0.14.0","deps":[],"cksum":"a","features":{},"yanked":false}"#,
"\n",
r#"{"name":"galdr","vers":"0.15.0","deps":[],"cksum":"b","features":{},"yanked":false}"#,
"\n",
r#"{"name":"galdr","vers":"0.16.0","deps":[],"cksum":"c","features":{},"yanked":true}"#,
"\n",
"not json at all\n",
r#"{"name":"galdr","vers":"0.14.2","deps":[],"cksum":"d","features":{},"yanked":false}"#,
"\n",
);
assert_eq!(parse_index(raw).unwrap(), SemVer::parse("0.15.0").unwrap());
}
#[test]
fn parse_index_errors_when_nothing_usable() {
assert!(parse_index("<html>hi</html>\n").is_err());
assert!(parse_index(r#"{"vers":"1.0.0","yanked":true}"#).is_err());
}
#[test]
fn install_source_parses_from_flag_values() {
assert_eq!(InstallSource::parse(None).unwrap(), InstallSource::Crates);
assert_eq!(
InstallSource::parse(Some(vec!["crates".into()])).unwrap(),
InstallSource::Crates
);
assert_eq!(
InstallSource::parse(Some(vec!["path".into(), "/tmp/galdr".into()])).unwrap(),
InstallSource::Path(PathBuf::from("/tmp/galdr"))
);
assert!(InstallSource::parse(Some(vec!["path".into()])).is_err());
assert!(InstallSource::parse(Some(vec!["bogus".into()])).is_err());
}
}