use std::cmp::Ordering;
use std::fs;
use std::io::Read;
use std::path::{Path, PathBuf};
use std::time::Duration;
use anyhow::{Context, Result};
use chrono::Utc;
use crate::channel::Channel;
use crate::config::Registry;
use crate::constants;
use crate::output;
pub fn run(offline: bool, install: bool) -> Result<()> {
if install {
return run_install();
}
output::print_header("dev-prune version & upgrade");
output::print_info(&format!("Installed version: v{}", constants::VERSION));
if offline {
output::print_info("Skipping the release check because `--offline` was passed.");
} else if let Ok(mut registry) = Registry::load() {
if registry.settings.update_check {
match refresh_latest(&mut registry) {
Ok(latest) => report_comparison(&latest),
Err(e) => output::print_warning(&format!(
"Could not reach the release API ({e}). The upgrade commands below still apply."
)),
}
let _ = registry.save();
} else {
output::print_info(
"The release check is off (`devp config set update_check true` re-enables it).",
);
}
}
println!();
println!(" Latest releases: {}", constants::RELEASES_URL);
println!();
print_upgrade_commands();
Ok(())
}
pub fn check_now(registry: &mut Registry) -> bool {
if !registry.settings.update_check {
return false;
}
match refresh_latest(registry) {
Ok(latest) => {
report_comparison(&latest);
if compare_versions(constants::VERSION, &latest) == Some(Ordering::Less) {
print_upgrade_commands();
}
}
Err(e) => output::print_info(&format!("Could not check for a newer release ({e}).")),
}
true
}
fn print_upgrade_commands() {
let channel = Channel::detect();
match channel.upgrade_command() {
Some(command) => {
println!(" Installed with {} — upgrade with:", channel.label());
println!(" {command}");
println!();
println!(" Or `devp update --install` to let dev-prune do it for you.");
}
None => {
println!(" This copy is not in a location any install channel owns, so there");
println!(" is no package manager to name. Replace it in place with:");
println!(" devp update --install");
println!();
println!(" Or install through a channel, which keeps it upgradeable:");
println!(" cargo binstall dev-prune --force");
println!(" cargo install dev-prune --force");
println!(" npm install -g dev-prune@latest");
println!(" uv tool upgrade dev-prune / pipx upgrade dev-prune");
println!(" winget upgrade {}", constants::WINGET_PACKAGE_ID);
println!(" scoop update dev-prune / brew upgrade dev-prune");
println!(" curl -fsSL {} | sh", constants::INSTALL_SH_URL);
println!(" iwr -useb {} | iex", constants::INSTALL_PS1_URL);
}
}
}
fn run_install() -> Result<()> {
output::print_header("dev-prune self-update");
if crate::setup::offline_requested() {
anyhow::bail!(
"{} is set — an install needs the network by definition.",
constants::ENV_OFFLINE
);
}
let mut registry = Registry::load()?;
let latest = refresh_latest(&mut registry)?;
let _ = registry.save();
if compare_versions(constants::VERSION, &latest) != Some(Ordering::Less) {
output::print_success(&format!(
"v{} is already the latest release — nothing to install.",
constants::VERSION
));
return Ok(());
}
output::print_info(&format!("Upgrading v{} -> v{latest} …", constants::VERSION));
let exe = std::env::current_exe().context("could not locate the running binary")?;
let managed = crate::setup::managed_exe_path().ok();
let channel = Channel::detect_at(&exe, managed.as_deref());
match install_directly(&latest, &exe, managed.as_deref(), channel) {
Ok(()) => {
output::print_success(&format!("dev-prune v{latest} installed."));
report_channel_bookkeeping(channel);
output::print_info(
"The scheduled pass was not interrupted: it runs the managed copy, which \
was replaced by atomic rename, so a pass already in flight keeps the \
image it loaded and the next one picks up the new binary.",
);
return Ok(());
}
Err(e) => output::print_warning(&format!(
"Direct download did not work ({e:#}).\nFalling back to the channel that \
installed this copy."
)),
}
#[cfg(windows)]
let aside = {
let aside = exe.with_extension("exe.old");
let _ = fs::remove_file(&aside);
fs::rename(&exe, &aside).ok().map(|_| aside)
};
let result = spawn_channel_upgrade(channel);
#[cfg(windows)]
if let Some(aside) = aside {
if result.is_ok() {
let _ = fs::remove_file(&aside);
} else if !exe.exists() {
let _ = fs::rename(&aside, &exe);
}
}
result?;
output::print_success(&format!("dev-prune v{latest} installed."));
output::print_info(
"The scheduled pass was not interrupted: it runs the managed copy, which \
refreshes itself from the new binary on its next run.",
);
Ok(())
}
fn install_directly(
latest: &str,
exe: &Path,
managed: Option<&Path>,
channel: Channel,
) -> Result<()> {
let bytes = fetch_release_binary(latest)?;
let primary = managed.unwrap_or(exe);
install_bytes_at(&bytes, primary)?;
let mut also: Vec<PathBuf> = Vec::new();
if let Some(dir) = primary.parent() {
also.push(dir.join(if cfg!(windows) { "devp.exe" } else { "devp" }));
}
if primary != exe && exe.is_file() && !channel.replaces_its_directory() {
also.push(exe.to_path_buf());
}
for path in also {
if path == primary {
continue;
}
if let Err(e) = install_bytes_at(&bytes, &path) {
output::print_warning(&format!(
"The managed copy is now v{latest}, but {} could not be replaced ({e:#}). Until it is, that copy runs the previous version whenever it is the one invoked.",
path.display()
));
}
}
crate::daemon::refresh_hidden_twin();
Ok(())
}
fn report_channel_bookkeeping(channel: Channel) {
let Some(resync) = channel
.owns_its_files()
.then(|| channel.upgrade_command())
.flatten()
else {
return;
};
if channel.replaces_its_directory() {
output::print_info(&format!(
"The managed copy is now v{}. The copy {} installed was left exactly as it \
wrote it — replacing a file inside a versioned package directory only makes \
the manager and the disk disagree. Run `{resync}` to move that one forward \
too.",
constants::VERSION,
channel.label()
));
} else {
output::print_info(&format!(
"The binaries are up to date. `{resync}` also updates that manager's own \
record of the version, which still reads v{}.",
constants::VERSION
));
}
}
fn fetch_release_binary(version: &str) -> Result<Vec<u8>> {
let asset = constants::release_asset_name(version).with_context(|| {
format!(
"no published binary for {}-{}; upgrade through the channel that installed \
this copy instead",
std::env::consts::OS,
std::env::consts::ARCH
)
})?;
let base = format!("{}/v{version}/{asset}", constants::RELEASE_DOWNLOAD_BASE);
let expected = fetch_expected_hash(&format!("{base}.sha256"))?;
output::print_info(&format!("Downloading {asset} …"));
let bytes = fetch_bytes(&base)?;
let actual = {
use sha2::{Digest, Sha256};
use std::fmt::Write as _;
let mut h = Sha256::new();
h.update(&bytes);
h.finalize().iter().fold(String::new(), |mut s, b| {
let _ = write!(s, "{b:02x}");
s
})
};
if actual != expected {
anyhow::bail!(
"checksum mismatch for {asset}\n expected {expected}\n got {actual}\n\
The download was corrupted or tampered with; nothing was installed."
);
}
Ok(bytes)
}
fn install_bytes_at(bytes: &[u8], target: &Path) -> Result<()> {
let staging = target.with_extension("new");
if let Some(parent) = target.parent() {
fs::create_dir_all(parent).ok();
}
fs::write(&staging, bytes).with_context(|| format!("could not write {}", staging.display()))?;
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
let _ = fs::set_permissions(&staging, fs::Permissions::from_mode(0o755));
}
replace_binary(&staging, target)
}
fn fetch_expected_hash(url: &str) -> Result<String> {
let body = String::from_utf8(fetch_bytes(url)?).context("the checksum sidecar was not text")?;
parse_sha256_sidecar(&body)
}
fn parse_sha256_sidecar(body: &str) -> Result<String> {
let hash = body
.split_whitespace()
.next()
.context("the checksum sidecar was empty")?
.to_ascii_lowercase();
if hash.len() != 64 || !hash.bytes().all(|b| b.is_ascii_hexdigit()) {
anyhow::bail!("the checksum sidecar did not contain a SHA-256 digest");
}
Ok(hash)
}
fn fetch_bytes(url: &str) -> Result<Vec<u8>> {
let mut body = ureq::get(url)
.header("User-Agent", &format!("dev-prune/{}", constants::VERSION))
.config()
.timeout_global(Some(Duration::from_secs(
constants::UPDATE_DOWNLOAD_TIMEOUT_SECS,
)))
.build()
.call()
.with_context(|| format!("could not download {url}"))?;
let mut buf = Vec::new();
body.body_mut()
.as_reader()
.read_to_end(&mut buf)
.with_context(|| format!("could not read {url}"))?;
Ok(buf)
}
fn replace_binary(staged: &Path, target: &Path) -> Result<()> {
#[cfg(windows)]
let aside = {
let aside = target.with_extension("exe.old");
let _ = fs::remove_file(&aside);
target
.exists()
.then(|| fs::rename(target, &aside).ok().map(|_| aside))
.flatten()
};
match fs::rename(staged, target) {
Ok(()) => {
#[cfg(windows)]
if let Some(aside) = aside {
let _ = fs::remove_file(&aside);
}
Ok(())
}
Err(e) => {
let _ = fs::remove_file(staged);
#[cfg(windows)]
if let Some(aside) = aside
&& !target.exists()
{
let _ = fs::rename(&aside, target);
}
Err(e).with_context(|| format!("could not install {}", target.display()))
}
}
}
fn spawn_channel_upgrade(channel: Channel) -> Result<()> {
let install_ps1 = format!("iwr -useb {} | iex", constants::INSTALL_PS1_URL);
let install_sh = format!("curl -fsSL {} | sh", constants::INSTALL_SH_URL);
let winget_id = constants::WINGET_PACKAGE_ID;
let argv: Vec<&str> = match channel {
Channel::Cargo => {
if crate::adapters::binary_available("cargo-binstall") {
vec!["cargo", "binstall", "dev-prune", "--force", "-y"]
} else {
vec!["cargo", "install", "dev-prune", "--force"]
}
}
Channel::Npm => vec!["npm", "install", "-g", "dev-prune@latest"],
Channel::UvTool => vec!["uv", "tool", "upgrade", "dev-prune"],
Channel::Pipx => vec!["pipx", "upgrade", "dev-prune"],
Channel::Pip => vec!["pip", "install", "--upgrade", "dev-prune"],
Channel::WinGet => vec![
"winget",
"upgrade",
"--id",
winget_id,
"--accept-package-agreements",
"--accept-source-agreements",
],
Channel::Scoop => vec!["scoop", "update", "dev-prune"],
Channel::Homebrew => vec!["brew", "upgrade", "dev-prune"],
Channel::Installer => {
if cfg!(windows) {
vec!["powershell", "-NoProfile", "-Command", &install_ps1]
} else {
vec!["sh", "-c", &install_sh]
}
}
Channel::Unknown => {
output::print_warning(
"Could not tell which channel installed this binary, so nothing was \
changed. Upgrade it yourself with one of:",
);
print_upgrade_commands();
anyhow::bail!("unrecognised install channel");
}
};
output::print_info(&format!("Running: {}", argv.join(" ")));
let status = crate::spawn::command(crate::adapters::resolve_program(argv[0]))
.args(&argv[1..])
.status()
.with_context(|| format!("could not start `{}`", argv[0]))?;
if !status.success() {
anyhow::bail!("`{}` exited with {status}", argv.join(" "));
}
Ok(())
}
pub fn maybe_auto_update(registry: &Registry) {
if !registry.settings.auto_update
|| crate::setup::offline_requested()
|| crate::setup::no_auto_setup_requested()
{
return;
}
let Some(latest) = registry.latest_known_version.as_deref() else {
return;
};
if compare_versions(constants::VERSION, latest) != Some(Ordering::Less) {
return;
}
let Ok(exe) = std::env::current_exe() else {
return;
};
let managed = crate::setup::managed_exe_path().ok();
let channel = Channel::detect_at(&exe, managed.as_deref());
if channel.replaces_its_directory() {
return;
}
println!();
output::print_info(&format!(
"Updating dev-prune v{} -> v{latest} …",
constants::VERSION
));
match install_directly(latest, &exe, managed.as_deref(), channel) {
Ok(()) => {
output::print_success(&format!("dev-prune v{latest} installed."));
report_channel_bookkeeping(channel);
}
Err(e) => output::print_warning(&format!(
"Automatic update failed ({e:#}). Run `devp update --install` yourself, or \
`devp config set auto_update false` to stop trying."
)),
}
}
pub fn notify_if_outdated(registry: &mut Registry) -> bool {
if !registry.settings.update_check {
return false;
}
let interval = registry.settings.update_check_interval_days;
let due = registry
.last_update_check
.is_none_or(|last| Utc::now().signed_duration_since(last).num_days() >= interval);
if due {
let _ = refresh_latest(registry);
}
if let Some(latest) = registry.latest_known_version.as_deref()
&& compare_versions(constants::VERSION, latest) == Some(Ordering::Less)
{
output::print_info(&format!(
"dev-prune v{latest} is out (you have v{}). `devp update` has the commands; \
`devp config set update_check false` silences this.",
constants::VERSION
));
}
due
}
fn refresh_latest(registry: &mut Registry) -> Result<String> {
let result = latest_release(registry.settings.update_check_timeout_secs);
registry.last_update_check = Some(Utc::now());
let latest = result?;
registry.latest_known_version = Some(latest.clone());
Ok(latest)
}
fn report_comparison(latest: &str) {
let installed = constants::VERSION;
match compare_versions(installed, latest) {
Some(Ordering::Less) => {
output::print_warning(&format!(
"Latest release: v{latest} — an upgrade is available."
));
}
Some(Ordering::Equal) => {
output::print_success(&format!(
"Latest release: v{latest} — you are up to date."
));
}
Some(Ordering::Greater) => {
output::print_info(&format!(
"Latest release: v{latest} — your build is newer than the last published one."
));
}
None => {
output::print_info(&format!(
"Latest release: v{latest} (could not compare it to v{installed})."
));
}
}
}
fn latest_release(timeout_secs: u64) -> Result<String> {
if crate::setup::offline_requested() {
anyhow::bail!("{} is set", constants::ENV_OFFLINE);
}
let body = ureq::get(constants::LATEST_RELEASE_API_URL)
.header("User-Agent", &format!("dev-prune/{}", constants::VERSION))
.header("Accept", "application/vnd.github+json")
.config()
.timeout_global(Some(Duration::from_secs(timeout_secs.max(1))))
.build()
.call()
.context("request failed")?
.body_mut()
.read_to_string()
.context("could not read the response")?;
let json: serde_json::Value =
serde_json::from_str(&body).context("the response was not JSON")?;
let tag = json
.get("tag_name")
.and_then(|v| v.as_str())
.context("the response carried no tag_name")?;
Ok(tag.trim_start_matches('v').to_string())
}
pub(crate) fn compare_versions(a: &str, b: &str) -> Option<Ordering> {
let parse = |v: &str| -> Option<[u64; 3]> {
let core = v.split(['-', '+']).next()?;
let mut parts = core.split('.');
let out = [
parts.next()?.parse().ok()?,
parts.next()?.parse().ok()?,
parts.next()?.parse().ok()?,
];
if parts.next().is_some() {
return None;
}
Some(out)
};
Some(parse(a)?.cmp(&parse(b)?))
}
#[cfg(test)]
mod tests {
use super::*;
use chrono::Duration as ChronoDuration;
#[test]
fn orders_by_component_not_lexically() {
assert_eq!(compare_versions("1.9.0", "1.10.0"), Some(Ordering::Less));
assert_eq!(compare_versions("1.0.0", "1.0.0"), Some(Ordering::Equal));
assert_eq!(
compare_versions("2.0.0", "1.99.99"),
Some(Ordering::Greater)
);
}
#[test]
fn pre_release_suffixes_compare_by_their_core() {
assert_eq!(
compare_versions("1.0.0", "1.0.0-rc.1"),
Some(Ordering::Equal)
);
assert_eq!(
compare_versions("1.0.0+build7", "1.0.1"),
Some(Ordering::Less)
);
}
#[test]
fn unparseable_versions_report_no_answer_rather_than_a_wrong_one() {
assert_eq!(compare_versions("1.0", "1.0.0"), None);
assert_eq!(compare_versions("1.0.0.1", "1.0.0"), None);
assert_eq!(compare_versions("nightly", "1.0.0"), None);
}
#[test]
fn the_check_is_on_unless_the_user_turns_it_off() {
assert!(Registry::default().settings.update_check);
}
#[test]
fn a_disabled_check_touches_neither_the_network_nor_the_registry() {
let mut registry = Registry::default();
registry.settings.update_check = false;
assert!(!notify_if_outdated(&mut registry));
assert!(registry.last_update_check.is_none());
}
#[test]
fn auto_update_is_on_by_default_and_silent_with_nothing_to_install() {
let registry = Registry::default();
assert!(registry.settings.auto_update);
assert!(registry.latest_known_version.is_none());
maybe_auto_update(®istry);
}
#[test]
fn a_recent_check_is_not_repeated() {
let mut registry = Registry::default();
let stamp = Utc::now() - ChronoDuration::days(constants::UPDATE_CHECK_INTERVAL_DAYS - 1);
registry.last_update_check = Some(stamp);
assert!(!notify_if_outdated(&mut registry));
assert_eq!(registry.last_update_check, Some(stamp));
}
#[test]
fn the_asset_name_matches_what_the_release_workflow_builds() {
let name = constants::release_asset_name("1.4.0");
let expected = match (std::env::consts::OS, std::env::consts::ARCH) {
("windows", "x86_64") => Some("dev-prune-v1.4.0-windows-x64.exe"),
("windows", "aarch64") => Some("dev-prune-v1.4.0-windows-arm64.exe"),
("windows", "x86") => Some("dev-prune-v1.4.0-windows-x86.exe"),
("linux", "x86_64") => Some("dev-prune-v1.4.0-linux-x64"),
("linux", "aarch64") => Some("dev-prune-v1.4.0-linux-arm64"),
("macos", "x86_64") => Some("dev-prune-v1.4.0-darwin-x64"),
("macos", "aarch64") => Some("dev-prune-v1.4.0-darwin-arm64"),
_ => None,
};
assert_eq!(name.as_deref(), expected);
}
#[test]
fn only_windows_has_a_32_bit_asset() {
let name = constants::release_asset_name("9.9.9");
if std::env::consts::ARCH == "x86" {
assert_eq!(name.is_some(), std::env::consts::OS == "windows");
}
}
#[test]
fn a_sidecar_is_read_as_the_first_field_of_sha256sum_format() {
let digest = "a".repeat(64);
assert_eq!(
parse_sha256_sidecar(&format!("{digest} dev-prune-v1.4.0-linux-x64\n")).unwrap(),
digest
);
assert_eq!(
parse_sha256_sidecar(&format!("{digest} asset.exe")).unwrap(),
digest
);
assert_eq!(
parse_sha256_sidecar(&format!("{} asset\r\n", digest.to_uppercase())).unwrap(),
digest,
"an upper-case digest must compare equal to the one we compute"
);
}
#[test]
fn anything_that_is_not_a_digest_is_refused_before_it_is_compared() {
for bad in [
"",
" ",
"<!DOCTYPE html>",
"not-a-hash asset",
&"a".repeat(63),
&"a".repeat(65),
&format!("{}g asset", "a".repeat(63)),
] {
assert!(
parse_sha256_sidecar(bad).is_err(),
"{bad:?} must not be accepted as a digest"
);
}
}
}