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, channels: bool, yes: bool) -> Result<()> {
if install {
return run_install();
}
if channels {
return run_channels();
}
output::print_header("dev-prune version & upgrade");
output::print_info(&format!("Installed version: v{}", constants::VERSION));
let mut registry = Registry::load().ok();
let mut newer = None;
if offline {
output::print_info("Skipping the release check because `--offline` was passed.");
} else if let Some(reg) = registry.as_mut() {
if reg.settings.update_check {
match refresh_latest(reg) {
Ok(latest) => {
report_comparison(&latest);
if compare_versions(constants::VERSION, &latest) == Some(Ordering::Less) {
newer = Some(latest);
}
}
Err(e) => output::print_warning(&format!(
"Could not check the latest release ({e:#}). The upgrade commands below still apply."
)),
}
let _ = reg.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!();
if registry.is_some_and(|r| r.settings.version_lock) {
output::print_info(&locked_notice(None));
return Ok(());
}
print_upgrade_commands();
if let Some(latest) = newer
&& confirm_install(&latest, yes)
{
println!();
return install_latest(&latest);
}
Ok(())
}
fn confirm_install(latest: &str, yes: bool) -> bool {
use std::io::{IsTerminal, Write};
if yes {
return true;
}
if !std::io::stdin().is_terminal() {
return false;
}
eprint!("Install v{latest} now? [y/N]: ");
if std::io::stderr().flush().is_err() {
return false;
}
let mut input = String::new();
if std::io::stdin().read_line(&mut input).is_err() {
return false;
}
matches!(input.trim().to_lowercase().as_str(), "y" | "yes")
}
pub(crate) fn locked_notice(latest: Option<&str>) -> String {
let head = match latest {
Some(latest) => format!("dev-prune v{latest} is out. "),
None => String::new(),
};
format!(
"{head}`version_lock` is on, so this copy stays at v{}. \
`devp config set version_lock false` releases it.",
constants::VERSION
)
}
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.");
println!();
println!(" `devp update --channels` lists the command for every channel.");
}
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:");
print_every_upgrade_command();
}
}
}
fn run_channels() -> Result<()> {
output::print_header("dev-prune upgrade commands");
let current = Channel::detect();
println!();
println!(" This copy came from {}.", current.label());
println!();
print_every_upgrade_command();
println!();
output::print_info(
"`devp update --install` replaces this copy directly, without the manager. \
`devp install --channel <name>` moves it to a different one.",
);
Ok(())
}
fn print_every_upgrade_command() {
let channels = [
Channel::Installer,
Channel::Cargo,
Channel::Npm,
Channel::Bun,
Channel::Pnpm,
Channel::Yarn,
Channel::UvTool,
Channel::Pipx,
Channel::Pip,
Channel::WinGet,
Channel::Scoop,
Channel::Homebrew,
];
let width = channels.iter().map(|c| c.label().len()).max().unwrap_or(0);
for channel in channels {
if let Some(command) = channel.upgrade_command() {
println!(" {:<width$} {command}", channel.label());
}
}
}
fn run_install() -> Result<()> {
output::print_header("dev-prune self-update");
let mut registry = Registry::load()?;
if registry.settings.version_lock {
anyhow::bail!("{}", locked_notice(None));
}
if crate::setup::offline_requested() {
anyhow::bail!(
"{} is set — an install needs the network by definition.",
constants::ENV_OFFLINE
);
}
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
));
#[cfg(windows)]
{
let dirs = default_twin_dirs();
let refs: Vec<&Path> = dirs.iter().map(|p| p.as_path()).collect();
reconcile_windowless_twins(constants::VERSION, &refs);
}
return Ok(());
}
install_latest(&latest)
}
fn install_latest(latest: &str) -> Result<()> {
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 replace_exe_dir = primary != exe && exe.is_file() && !manager_owns_exe_dir(channel);
for path in companion_copies(primary, managed.is_some(), exe, replace_exe_dir) {
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()
));
}
}
#[cfg(windows)]
{
let mut dirs: Vec<&Path> = Vec::new();
if let Some(dir) = primary.parent() {
dirs.push(dir);
}
if replace_exe_dir && let Some(dir) = exe.parent() {
dirs.push(dir);
}
reconcile_windowless_twins(latest, &dirs);
}
crate::daemon::refresh_windowless_twin();
crate::receipt::refresh_after_upgrade(latest);
Ok(())
}
fn manager_owns_exe_dir(channel: Channel) -> bool {
channel.replaces_its_directory() || matches!(channel, Channel::Foreign(_))
}
fn companion_copies(
primary: &Path,
primary_is_managed: bool,
exe: &Path,
replace_exe_dir: bool,
) -> Vec<PathBuf> {
let names: [&str; 2] = if cfg!(windows) {
["dev-prune.exe", "devp.exe"]
} else {
["dev-prune", "devp"]
};
let mut also: Vec<PathBuf> = Vec::new();
if let Some(dir) = primary.parent() {
for name in names {
let twin = dir.join(name);
if twin != primary && (primary_is_managed || twin.is_file()) {
also.push(twin);
}
}
}
if replace_exe_dir {
if !also.contains(&exe.to_path_buf()) {
also.push(exe.to_path_buf());
}
if let Some(dir) = exe.parent() {
for name in names {
let twin = dir.join(name);
if twin != *exe && twin != primary && twin.is_file() && !also.contains(&twin) {
also.push(twin);
}
}
}
}
also
}
fn report_channel_bookkeeping(channel: Channel) {
if let Channel::Foreign(manager) = channel {
output::print_info(&format!(
"The managed copy is now v{}. The copy that {manager} installed was left \
exactly as it wrote it — replacing a file inside a manager-owned tree only \
makes {manager} and the disk disagree. Upgrade that copy through {manager} \
itself.",
constants::VERSION
));
return;
}
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
)
})?;
fetch_verified_asset(version, &asset)
}
#[cfg(windows)]
fn fetch_windowless_binary(version: &str) -> Result<Vec<u8>> {
let asset = constants::windowless_release_asset_name(version).with_context(|| {
format!(
"no published windowless binary for windows-{}",
std::env::consts::ARCH
)
})?;
fetch_verified_asset(version, &asset)
}
fn fetch_verified_asset(version: &str, asset: &str) -> Result<Vec<u8>> {
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)
}
#[cfg(windows)]
enum TwinReconcile {
UpToDate,
Updated,
Failed(String),
}
#[cfg(windows)]
fn reconcile_windowless_twins(version: &str, dirs: &[&Path]) -> TwinReconcile {
let mut stale: Vec<PathBuf> = Vec::new();
for dir in dirs {
let twin = dir.join(constants::WINDOWS_WINDOWLESS_BIN);
if stale.contains(&twin) || !twin.is_file() {
continue;
}
let stamped = fs::read(&twin)
.ok()
.and_then(|b| crate::commands::trust::version_from_stamp(&b));
let up_to_date = stamped
.as_deref()
.and_then(|v| compare_versions(v, version))
.is_some_and(|o| o != Ordering::Less);
if !up_to_date {
stale.push(twin);
}
}
if stale.is_empty() {
return TwinReconcile::UpToDate;
}
let bytes = match fetch_windowless_binary(version) {
Ok(b) => b,
Err(e) => {
let why = format!(
"The scheduler binary {} could not be downloaded ({e:#}). The scheduled \
pass keeps running the version it has until `devp update --install` or \
`devp doctor --fix` succeeds.",
constants::WINDOWS_WINDOWLESS_BIN
);
output::print_warning(&why);
return TwinReconcile::Failed(why);
}
};
let mut failed = None;
for twin in &stale {
if let Err(e) = install_bytes_at(&bytes, twin) {
let why = format!(
"{} could not be replaced ({e:#}); the scheduled pass keeps running the \
previous version from that copy.",
twin.display()
);
output::print_warning(&why);
failed = Some(why);
}
}
match failed {
Some(why) => TwinReconcile::Failed(why),
None => {
output::print_success(&format!(
"Scheduler binary {} updated to v{version}.",
constants::WINDOWS_WINDOWLESS_BIN
));
TwinReconcile::Updated
}
}
}
#[cfg(windows)]
fn default_twin_dirs() -> Vec<PathBuf> {
let mut dirs = Vec::new();
if let Ok(managed) = crate::setup::managed_exe_path()
&& let Some(dir) = managed.parent()
{
dirs.push(dir.to_path_buf());
}
if let Ok(exe) = std::env::current_exe()
&& let Some(dir) = exe.parent()
{
dirs.push(dir.to_path_buf());
}
dirs
}
pub(crate) fn repair_windowless_twins() -> crate::setup::Outcome {
#[cfg(windows)]
{
use crate::setup::Outcome;
match Registry::load() {
Ok(r) if r.settings.version_lock => {
return Outcome::Skipped(
"version_lock is set, and nothing dev-prune does replaces a binary \
while it is"
.to_string(),
);
}
_ => {}
}
if crate::setup::offline_requested() {
return Outcome::Skipped(format!(
"{} is set and this repair needs a download",
constants::ENV_OFFLINE
));
}
if std::env::current_exe().is_ok_and(|exe| crate::commands::uninstall::is_dev_build(&exe)) {
return Outcome::Skipped(
"this is a development build, and its version may not be released yet".to_string(),
);
}
let dirs = default_twin_dirs();
let refs: Vec<&Path> = dirs.iter().map(|p| p.as_path()).collect();
match reconcile_windowless_twins(constants::VERSION, &refs) {
TwinReconcile::UpToDate => Outcome::AlreadyPresent,
TwinReconcile::Updated => Outcome::Installed,
TwinReconcile::Failed(why) => Outcome::Failed(why),
}
}
#[cfg(not(windows))]
{
crate::setup::Outcome::AlreadyPresent
}
}
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();
}
if let Err(e) = fs::write(&staging, bytes) {
let _ = fs::remove_file(&staging);
return Err(e).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 Some(argv) = channel.upgrade_argv() else {
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;
}
if registry.settings.version_lock {
println!();
output::print_info(&locked_notice(Some(latest)));
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;
}
if matches!(channel, Channel::Foreign(_)) && managed.is_none() {
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 due = check_due(registry);
if due {
let _ = refresh_latest(registry);
}
if let Some(latest) = registry.latest_known_version.as_deref()
&& compare_versions(constants::VERSION, latest) == Some(Ordering::Less)
{
if registry.settings.version_lock {
output::print_info(&locked_notice(Some(latest)));
} else {
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 check_due(registry: &Registry) -> bool {
let interval = if registry.last_update_check_failed {
registry.settings.update_check_interval_days.min(1)
} else {
registry.settings.update_check_interval_days
};
registry
.last_update_check
.is_none_or(|last| Utc::now().signed_duration_since(last).num_days() >= interval)
}
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());
registry.last_update_check_failed = result.is_err();
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 response = ureq::get(constants::LATEST_RELEASE_URL)
.header("User-Agent", &format!("dev-prune/{}", constants::VERSION))
.config()
.timeout_global(Some(Duration::from_secs(timeout_secs.max(1))))
.max_redirects(0)
.build()
.call()
.context("request failed")?;
let location = response
.headers()
.get("location")
.and_then(|v| v.to_str().ok())
.context("GitHub did not answer with a redirect")?;
version_from_release_location(location).context("GitHub did not redirect to a release tag")
}
fn version_from_release_location(location: &str) -> Option<String> {
let tag = location
.split_once("/releases/tag/")?
.1
.trim_end_matches('/');
if tag.is_empty() || tag.contains('/') {
return None;
}
Some(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 the_tag_is_read_out_of_the_redirect_and_its_v_is_dropped() {
assert_eq!(
version_from_release_location(
"https://github.com/Life-Experimentalist/dev-prune/releases/tag/v1.18.0"
)
.as_deref(),
Some("1.18.0")
);
assert_eq!(
version_from_release_location("/Life-Experimentalist/dev-prune/releases/tag/1.2.3/")
.as_deref(),
Some("1.2.3")
);
}
#[test]
fn a_redirect_that_is_not_a_release_tag_is_not_a_version() {
assert_eq!(
version_from_release_location(
"https://github.com/Life-Experimentalist/dev-prune/releases"
),
None
);
assert_eq!(
version_from_release_location("https://github.com/other/name/releases/latest"),
None
);
assert_eq!(
version_from_release_location("https://github.com/o/r/releases/tag/"),
None
);
assert_eq!(
version_from_release_location("https://github.com/o/r/releases/tag/v1/extra"),
None
);
}
#[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);
}
fn bin_names() -> [&'static str; 2] {
if cfg!(windows) {
["dev-prune.exe", "devp.exe"]
} else {
["dev-prune", "devp"]
}
}
#[test]
fn an_update_reaches_the_twin_beside_the_copy_the_user_typed() {
let [prune, devp] = bin_names();
let managed_dir = tempfile::tempdir().unwrap();
let cargo_dir = tempfile::tempdir().unwrap();
let primary = managed_dir.path().join(prune);
let exe = cargo_dir.path().join(devp);
std::fs::write(&exe, b"old").unwrap();
std::fs::write(cargo_dir.path().join(prune), b"old").unwrap();
let also = companion_copies(&primary, true, &exe, true);
assert!(also.contains(&managed_dir.path().join(devp)));
assert!(also.contains(&exe));
assert!(also.contains(&cargo_dir.path().join(prune)));
assert!(!also.contains(&primary));
}
#[test]
fn a_name_that_does_not_exist_outside_the_managed_directory_is_not_invented() {
let [prune, devp] = bin_names();
let managed_dir = tempfile::tempdir().unwrap();
let solo_dir = tempfile::tempdir().unwrap();
let primary = managed_dir.path().join(prune);
let exe = solo_dir.path().join(devp);
std::fs::write(&exe, b"old").unwrap();
let also = companion_copies(&primary, true, &exe, true);
assert!(also.contains(&managed_dir.path().join(devp)));
assert!(also.contains(&exe));
assert!(!also.contains(&solo_dir.path().join(prune)));
}
#[test]
fn a_manager_owned_directory_is_left_exactly_as_the_manager_wrote_it() {
let [prune, devp] = bin_names();
let managed_dir = tempfile::tempdir().unwrap();
let store_dir = tempfile::tempdir().unwrap();
let primary = managed_dir.path().join(prune);
let exe = store_dir.path().join(devp);
std::fs::write(&exe, b"old").unwrap();
std::fs::write(store_dir.path().join(prune), b"old").unwrap();
let also = companion_copies(&primary, true, &exe, false);
assert_eq!(also, vec![managed_dir.path().join(devp)]);
}
#[test]
fn exactly_the_manager_owned_channels_keep_their_directory_untouched() {
for owned in [
Channel::WinGet,
Channel::Scoop,
Channel::Homebrew,
Channel::Foreign("Volta"),
Channel::Foreign("the system package manager"),
] {
assert!(manager_owns_exe_dir(owned), "{owned:?}");
}
for replaceable in [
Channel::Installer,
Channel::Cargo,
Channel::Npm,
Channel::Bun,
Channel::Pnpm,
Channel::Yarn,
Channel::UvTool,
Channel::Pipx,
Channel::Pip,
Channel::Unknown,
] {
assert!(!manager_owns_exe_dir(replaceable), "{replaceable:?}");
}
}
#[test]
fn without_a_managed_copy_only_existing_files_beside_the_binary_are_replaced() {
let [prune, devp] = bin_names();
let dir = tempfile::tempdir().unwrap();
let primary = dir.path().join(devp);
std::fs::write(&primary, b"old").unwrap();
assert!(companion_copies(&primary, false, &primary, false).is_empty());
std::fs::write(dir.path().join(prune), b"old").unwrap();
assert_eq!(
companion_copies(&primary, false, &primary, false),
vec![dir.path().join(prune)]
);
}
#[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 the_pin_is_off_until_somebody_asks_for_it() {
assert!(!Registry::default().settings.version_lock);
}
#[test]
fn the_refusal_names_the_version_it_is_holding_and_the_way_out() {
let notice = locked_notice(None);
assert!(notice.contains(constants::VERSION), "{notice}");
assert!(
notice.contains("devp config set version_lock false"),
"{notice}"
);
assert!(!notice.contains("is out"), "{notice}");
}
#[test]
fn a_known_release_is_named_in_the_refusal_that_withholds_it() {
let notice = locked_notice(Some("2.0.0"));
assert!(notice.contains("v2.0.0 is out"), "{notice}");
assert!(notice.contains(constants::VERSION), "{notice}");
}
#[test]
fn a_failed_check_is_retried_after_a_day_not_a_whole_interval() {
let mut registry = Registry {
last_update_check: Some(Utc::now() - ChronoDuration::days(2)),
last_update_check_failed: true,
..Default::default()
};
assert!(check_due(®istry));
registry.last_update_check_failed = false;
assert!(
!check_due(®istry),
"a successful check keeps the interval"
);
}
#[test]
fn even_a_failed_check_is_not_retried_within_the_day() {
let registry = Registry {
last_update_check: Some(Utc::now() - ChronoDuration::hours(2)),
last_update_check_failed: true,
..Default::default()
};
assert!(!check_due(®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);
}
#[cfg(windows)]
#[test]
fn the_windowless_asset_name_matches_what_the_release_workflow_builds() {
let name = constants::windowless_release_asset_name("1.4.0");
let expected = match std::env::consts::ARCH {
"x86_64" => Some("devpw-v1.4.0-windows-x64.exe"),
"aarch64" => Some("devpw-v1.4.0-windows-arm64.exe"),
"x86" => Some("devpw-v1.4.0-windows-x86.exe"),
_ => 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"
);
}
}
}