use std::cmp::Ordering;
use std::time::Duration;
use anyhow::{Context, Result};
use chrono::Utc;
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() {
println!(" Upgrade with whichever channel you installed from:");
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!(" curl -fsSL {} | sh", constants::INSTALL_SH_URL);
println!(" iwr -useb {} | iex", constants::INSTALL_PS1_URL);
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum Channel {
Installer,
Cargo,
Npm,
UvTool,
Pipx,
Unknown,
}
fn detect_channel(exe: &std::path::Path, managed: Option<&std::path::Path>) -> Channel {
if let Some(managed) = managed
&& exe == managed
{
return Channel::Installer;
}
let has_dir = |name: &str| {
exe.components()
.any(|c| c.as_os_str().to_string_lossy().eq_ignore_ascii_case(name))
};
if has_dir(".cargo") {
Channel::Cargo
} else if has_dir("node_modules") {
Channel::Npm
} else if has_dir("uv") || has_dir("uv-tool") {
Channel::UvTool
} else if has_dir("pipx") {
Channel::Pipx
} else {
Channel::Unknown
}
}
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 = detect_channel(&exe, managed.as_deref());
#[cfg(windows)]
let aside = {
let aside = exe.with_extension("exe.old");
let _ = std::fs::remove_file(&aside);
std::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 _ = std::fs::remove_file(&aside);
} else if !exe.exists() {
let _ = std::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 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 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::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;
}
println!();
if let Err(e) = run_install() {
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())
}
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 each_channel_is_recognised_by_its_marker_directory() {
use std::path::Path;
let cases: &[(&str, Channel)] = &[
("/home/k/.cargo/bin/dev-prune", Channel::Cargo),
(
"/usr/lib/node_modules/dev-prune/bin/dev-prune",
Channel::Npm,
),
(
"/home/k/.local/share/uv/tools/dev-prune/bin/dev-prune",
Channel::UvTool,
),
(
"/home/k/.local/pipx/venvs/dev-prune/bin/dev-prune",
Channel::Pipx,
),
("/opt/somewhere/dev-prune", Channel::Unknown),
];
for (path, expected) in cases {
assert_eq!(detect_channel(Path::new(path), None), *expected, "{path}");
}
}
#[test]
fn the_managed_copy_wins_over_every_path_heuristic() {
use std::path::Path;
let managed = Path::new("/home/k/.cargo/odd/dev-prune/bin/dev-prune");
assert_eq!(detect_channel(managed, Some(managed)), Channel::Installer);
}
#[test]
fn auto_update_is_off_by_default_and_silent_when_off() {
let registry = Registry::default();
assert!(!registry.settings.auto_update);
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));
}
}