use std::{
cmp::Ordering,
process::{Command, Stdio},
};
pub const CRATE_API: &str = "https://crates.io/api/v1/crates/dsopt";
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum UpdateOutcome {
AlreadyLatest { current: String, latest: String },
Updated { from: String, to: String },
}
pub fn parse_version(text: &str) -> Option<(u64, u64, u64)> {
let core = text.trim().trim_start_matches('v');
let core = core.split(['-', '+']).next()?;
let mut parts = core.split('.');
let major = parts.next()?.parse().ok()?;
let minor = parts.next().unwrap_or("0").parse().ok()?;
let patch = parts.next().unwrap_or("0").parse().ok()?;
Some((major, minor, patch))
}
pub fn compare_versions(left: &str, right: &str) -> Ordering {
match (parse_version(left), parse_version(right)) {
(Some(left), Some(right)) => left.cmp(&right),
_ => Ordering::Equal,
}
}
pub fn is_newer(candidate: &str, current: &str) -> bool {
compare_versions(candidate, current) == Ordering::Greater
}
pub fn current_version() -> &'static str {
env!("CARGO_PKG_VERSION")
}
pub fn latest_version() -> Result<String, String> {
let user_agent = format!("dsopt/{}", current_version());
let output = Command::new("curl")
.args([
"-fsSL",
"--max-time",
"20",
"--user-agent",
&user_agent,
CRATE_API,
])
.output()
.map_err(|error| format!("could not run curl: {error}"))?;
if !output.status.success() {
return Err(format!(
"crates.io lookup failed ({}); check your network connection",
output.status
));
}
let payload: serde_json::Value = serde_json::from_slice(&output.stdout)
.map_err(|error| format!("unexpected crates.io response: {error}"))?;
payload["crate"]["max_stable_version"]
.as_str()
.or_else(|| payload["crate"]["max_version"].as_str())
.map(str::to_owned)
.ok_or_else(|| "crates.io response did not include a version".to_owned())
}
pub fn install_latest() -> Result<(), String> {
let status = Command::new("cargo")
.args(["install", "dsopt", "--force"])
.stdin(Stdio::null())
.status()
.map_err(|error| {
format!("could not run cargo: {error}. Install Rust from https://rustup.rs")
})?;
if status.success() {
Ok(())
} else {
Err(format!("cargo install exited with {status}"))
}
}
pub fn run(force: bool) -> Result<UpdateOutcome, String> {
let current = current_version().to_owned();
let latest = latest_version()?;
if !force && !is_newer(&latest, ¤t) {
return Ok(UpdateOutcome::AlreadyLatest { current, latest });
}
install_latest()?;
Ok(UpdateOutcome::Updated {
from: current,
to: latest,
})
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn parses_release_and_pre_release_versions() {
assert_eq!(parse_version("0.1.0"), Some((0, 1, 0)));
assert_eq!(parse_version("v1.2.3"), Some((1, 2, 3)));
assert_eq!(parse_version("1.2.3-rc.1"), Some((1, 2, 3)));
assert_eq!(parse_version("2"), Some((2, 0, 0)));
assert_eq!(parse_version("nonsense"), None);
}
#[test]
fn detects_newer_versions() {
assert!(is_newer("0.1.1", "0.1.0"));
assert!(is_newer("0.2.0", "0.1.9"));
assert!(is_newer("1.0.0", "0.9.9"));
assert!(!is_newer("0.1.0", "0.1.0"));
assert!(!is_newer("0.0.9", "0.1.0"));
}
#[test]
fn compares_unparseable_versions_as_equal() {
assert_eq!(compare_versions("garbage", "0.1.0"), Ordering::Equal);
}
}