use std::collections::BTreeMap;
use std::env;
use std::fs;
use std::path::{Path, PathBuf};
use std::process::Command;
use std::time::{SystemTime, UNIX_EPOCH};
use anyhow::{Context, Result};
use semver::Version;
use serde::{Deserialize, Serialize};
use tempfile::NamedTempFile;
use crate::paths::display_path;
use crate::report::Reporter;
const BIN_NAME: &str = "nodus";
const CHECK_INTERVAL_SECS: u64 = 24 * 60 * 60;
const CRATES_IO_SOURCE: &str = "registry+https://github.com/rust-lang/crates.io-index";
const INSTALL_MARKER_FILE: &str = "nodus.install.json";
const REPO_SLUG: &str = "nodus-rs/nodus";
const STATE_FILE: &str = "update-check.json";
#[derive(Debug, Clone, PartialEq, Eq)]
struct LatestRelease {
tag: String,
version: Version,
}
#[derive(Debug, Clone)]
struct CheckOptions {
now_unix_secs: u64,
current_exe: PathBuf,
current_version: Version,
cargo_home: Option<PathBuf>,
}
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(default)]
struct UpdateCheckState {
last_attempted_at_unix_secs: Option<u64>,
latest_known_tag: Option<String>,
latest_known_version: Option<String>,
last_notified_tag: Option<String>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
enum InstallTarget {
CargoRegistry { binary_path: PathBuf },
GithubRelease { binary_path: PathBuf },
Unsupported(UnsupportedInstall),
}
#[derive(Debug, Clone, PartialEq, Eq)]
enum UnsupportedInstall {
Ambiguous {
binary_path: PathBuf,
},
CargoPath {
binary_path: PathBuf,
source: String,
},
CargoGit {
binary_path: PathBuf,
source: String,
},
CargoOther {
binary_path: PathBuf,
source: String,
},
}
#[derive(Debug, Clone, PartialEq, Eq)]
enum PlannedUpgrade {
AlreadyCurrent {
version: Version,
},
CargoRegistry {
current_version: Version,
latest: LatestRelease,
binary_path: PathBuf,
command: Vec<String>,
},
GithubRelease {
current_version: Version,
latest: LatestRelease,
binary_path: PathBuf,
install_dir: PathBuf,
script_url: String,
},
Unsupported {
latest: LatestRelease,
message: String,
},
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum HostPlatform {
Unix,
Windows,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
struct ReleaseInstallMarker {
install_method: String,
repo_slug: String,
binary_name: String,
binary_path: PathBuf,
}
struct InstallerInvocation {
program: &'static str,
action: &'static str,
script_path: PathBuf,
base_args: Vec<String>,
version_flag: &'static str,
install_dir_flag: &'static str,
}
impl InstallerInvocation {
fn args_for(&self, tag: &str, install_dir: &Path) -> Vec<String> {
let mut args = self.base_args.clone();
args.extend([
self.script_path.to_string_lossy().into_owned(),
self.version_flag.into(),
tag.into(),
self.install_dir_flag.into(),
install_dir.to_string_lossy().into_owned(),
]);
args
}
}
#[derive(Debug, Deserialize)]
struct CargoInstallState {
installs: BTreeMap<String, CargoInstallEntry>,
}
#[derive(Debug, Deserialize)]
struct CargoInstallEntry {
bins: Vec<String>,
}
#[derive(Debug, Deserialize)]
struct LegacyCargoInstallState {
v1: BTreeMap<String, Vec<String>>,
}
pub fn maybe_notify(store_root: &Path, reporter: &Reporter) {
let options = match CheckOptions::for_current_binary() {
Ok(options) => options,
Err(_) => return,
};
let _ = maybe_notify_with(store_root, reporter, &options, fetch_latest_release);
}
pub fn upgrade(reporter: &Reporter, check_only: bool) -> Result<()> {
let options = CheckOptions::for_current_binary()?;
reporter.status("Checking", "latest Nodus release")?;
let latest = fetch_latest_release()?.ok_or_else(|| {
anyhow::anyhow!(
"could not determine the latest Nodus release from {}",
releases_latest_url()
)
})?;
let plan = plan_upgrade(&options, &latest);
if check_only {
reporter.finish(upgrade_available_message(&options, &latest, &plan))?;
return Ok(());
}
match plan {
PlannedUpgrade::AlreadyCurrent { version } => {
reporter.finish(format!("nodus {version} is already current"))?;
Ok(())
}
PlannedUpgrade::CargoRegistry {
current_version,
latest,
binary_path,
command,
} => {
ensure_install_directory_writable(&binary_path).map_err(|_| {
anyhow::anyhow!(cargo_permission_guidance(&latest.version, &binary_path))
})?;
reporter.status(
"Updating",
format!(
"nodus {current_version} -> {} via cargo install",
latest.version
),
)?;
run_checked_command(
&command[0],
&command[1..],
"cargo install",
"failed to update nodus via cargo install",
)?;
reporter.finish(format!(
"updated nodus {current_version} -> {}",
latest.version
))?;
Ok(())
}
PlannedUpgrade::GithubRelease {
current_version,
latest,
binary_path,
install_dir,
script_url,
} => {
ensure_install_directory_writable(&binary_path).map_err(|_| {
anyhow::anyhow!(release_permission_guidance(&latest.tag, &install_dir))
})?;
let temp = tempfile::TempDir::new().context("failed to create temp dir")?;
let installer =
release_installer_invocation(&temp.path().join(release_installer_script_name()));
let script_path = installer.script_path.clone();
reporter.status("Downloading", format!("installer for {}", latest.tag))?;
download_to_path(&script_url, &script_path)?;
reporter.status(
"Updating",
format!(
"nodus {current_version} -> {} via GitHub release installer",
latest.version
),
)?;
run_checked_command(
installer.program,
&installer.args_for(latest.tag.as_str(), &install_dir),
installer.action,
"failed to update nodus via the GitHub release installer",
)?;
reporter.finish(format!(
"updated nodus {current_version} -> {}",
latest.version
))?;
Ok(())
}
PlannedUpgrade::Unsupported { message, .. } => anyhow::bail!(message),
}
}
impl CheckOptions {
fn for_current_binary() -> Result<Self> {
let current_exe =
env::current_exe().context("failed to determine the current nodus executable path")?;
Ok(Self {
now_unix_secs: SystemTime::now()
.duration_since(UNIX_EPOCH)
.context("system clock is before the Unix epoch")?
.as_secs(),
current_exe: canonicalize_or_identity(¤t_exe),
current_version: Version::parse(env!("CARGO_PKG_VERSION"))
.context("failed to parse the current package version")?,
cargo_home: resolve_cargo_home(),
})
}
}
impl UpdateCheckState {
fn latest_known_release(&self) -> Option<LatestRelease> {
let tag = self.latest_known_tag.clone()?;
let version = self.latest_known_version.as_deref()?;
Some(LatestRelease {
tag,
version: Version::parse(version).ok()?,
})
}
}
fn maybe_notify_with<F>(
store_root: &Path,
reporter: &Reporter,
options: &CheckOptions,
fetch_latest: F,
) -> Result<()>
where
F: FnOnce() -> Result<Option<LatestRelease>>,
{
let state_path = state_path(store_root);
let mut state = load_state(&state_path)?;
let mut latest_known = state.latest_known_release();
if should_attempt_remote_check(state.last_attempted_at_unix_secs, options.now_unix_secs) {
state.last_attempted_at_unix_secs = Some(options.now_unix_secs);
match fetch_latest() {
Ok(Some(release)) => {
state.latest_known_tag = Some(release.tag.clone());
state.latest_known_version = Some(release.version.to_string());
latest_known = Some(release);
}
Ok(None) => {}
Err(_) => {}
}
persist_state(&state_path, &state)?;
}
let Some(latest_release) = latest_known else {
return Ok(());
};
if latest_release.version <= options.current_version {
return Ok(());
}
if state.last_notified_tag.as_deref() == Some(latest_release.tag.as_str()) {
return Ok(());
}
let plan = plan_upgrade(options, &latest_release);
let notice = upgrade_available_message(options, &latest_release, &plan);
reporter.warning(notice)?;
state.last_notified_tag = Some(latest_release.tag);
persist_state(&state_path, &state)
}
fn plan_upgrade(options: &CheckOptions, latest: &LatestRelease) -> PlannedUpgrade {
plan_upgrade_for_platform(options, latest, current_host_platform())
}
fn plan_upgrade_for_platform(
options: &CheckOptions,
latest: &LatestRelease,
platform: HostPlatform,
) -> PlannedUpgrade {
if latest.version <= options.current_version {
return PlannedUpgrade::AlreadyCurrent {
version: options.current_version.clone(),
};
}
match detect_install_target(options) {
InstallTarget::CargoRegistry { binary_path } => PlannedUpgrade::CargoRegistry {
current_version: options.current_version.clone(),
latest: latest.clone(),
binary_path,
command: cargo_update_command(&latest.version),
},
InstallTarget::GithubRelease { binary_path } => PlannedUpgrade::GithubRelease {
current_version: options.current_version.clone(),
latest: latest.clone(),
install_dir: binary_path
.parent()
.unwrap_or_else(|| Path::new("."))
.to_path_buf(),
binary_path,
script_url: tagged_install_script_url_for_platform(&latest.tag, platform),
},
InstallTarget::Unsupported(install) => PlannedUpgrade::Unsupported {
latest: latest.clone(),
message: unsupported_upgrade_message(latest, &install, platform),
},
}
}
fn detect_install_target(options: &CheckOptions) -> InstallTarget {
if let Some(install) = detect_cargo_install(&options.current_exe, options.cargo_home.as_deref())
{
return install;
}
detect_release_install(&options.current_exe)
}
fn detect_cargo_install(current_exe: &Path, cargo_home: Option<&Path>) -> Option<InstallTarget> {
let cargo_home = cargo_home?;
let cargo_bin = cargo_home
.join("bin")
.join(executable_file_name(current_host_platform()));
if current_exe != cargo_bin {
return None;
}
let sources = load_cargo_install_sources(cargo_home, BIN_NAME);
let binary_path = current_exe.to_path_buf();
match sources.as_slice() {
[source] if source == CRATES_IO_SOURCE => {
Some(InstallTarget::CargoRegistry { binary_path })
}
[source] if source.starts_with("path+") => {
Some(InstallTarget::Unsupported(UnsupportedInstall::CargoPath {
binary_path,
source: source.clone(),
}))
}
[source] if source.starts_with("git+") => {
Some(InstallTarget::Unsupported(UnsupportedInstall::CargoGit {
binary_path,
source: source.clone(),
}))
}
[source] => Some(InstallTarget::Unsupported(UnsupportedInstall::CargoOther {
binary_path,
source: source.clone(),
})),
_ => Some(InstallTarget::Unsupported(UnsupportedInstall::Ambiguous {
binary_path,
})),
}
}
fn detect_release_install(current_exe: &Path) -> InstallTarget {
let marker_path = install_marker_path(current_exe);
let Some(marker) = load_release_install_marker(&marker_path) else {
return InstallTarget::Unsupported(UnsupportedInstall::Ambiguous {
binary_path: current_exe.to_path_buf(),
});
};
if marker.install_method != "github_release"
|| marker.repo_slug != REPO_SLUG
|| marker.binary_name != BIN_NAME
|| canonicalize_or_identity(&marker.binary_path) != current_exe
{
return InstallTarget::Unsupported(UnsupportedInstall::Ambiguous {
binary_path: current_exe.to_path_buf(),
});
}
InstallTarget::GithubRelease {
binary_path: current_exe.to_path_buf(),
}
}
fn upgrade_available_message(
options: &CheckOptions,
latest: &LatestRelease,
plan: &PlannedUpgrade,
) -> String {
match plan {
PlannedUpgrade::AlreadyCurrent { .. } => {
format!("nodus {} is already current", options.current_version)
}
PlannedUpgrade::CargoRegistry { .. } | PlannedUpgrade::GithubRelease { .. } => format!(
"nodus {} is available (current {}); run `nodus upgrade`",
latest.version, options.current_version
),
PlannedUpgrade::Unsupported { .. } => format!(
"nodus {} is available (current {}); see {}",
latest.version,
options.current_version,
install_url()
),
}
}
fn should_attempt_remote_check(
last_attempted_at_unix_secs: Option<u64>,
now_unix_secs: u64,
) -> bool {
match last_attempted_at_unix_secs {
None => true,
Some(last_attempted) => now_unix_secs.saturating_sub(last_attempted) >= CHECK_INTERVAL_SECS,
}
}
fn fetch_latest_release() -> Result<Option<LatestRelease>> {
let headers = match curl_head_request(&releases_latest_url()) {
Ok(headers) => headers,
Err(error) if is_missing_command_error(&error) => return Ok(None),
Err(error) => return Err(error),
};
let Some(location) = last_location_header(&headers) else {
return Ok(None);
};
Ok(parse_latest_release_from_location(&location))
}
fn curl_head_request(url: &str) -> Result<String> {
let output = Command::new("curl")
.args(["-fsSLI", url])
.output()
.with_context(|| format!("failed to run curl for {url}"))?;
if !output.status.success() {
anyhow::bail!(
"curl failed for {url}: {}",
String::from_utf8_lossy(&output.stderr).trim()
);
}
Ok(String::from_utf8_lossy(&output.stdout).into_owned())
}
fn run_checked_command(
program: &str,
args: &[String],
action: &str,
failure_context: &str,
) -> Result<()> {
let output = Command::new(program)
.args(args)
.output()
.with_context(|| format!("failed to run {action}"))?;
if !output.status.success() {
anyhow::bail!("{failure_context}: {}", command_failure_output(&output));
}
Ok(())
}
fn download_to_path(url: &str, output_path: &Path) -> Result<()> {
let output = Command::new("curl")
.arg("-fsSL")
.arg(url)
.arg("-o")
.arg(output_path)
.output()
.with_context(|| format!("failed to run curl for {url}"))?;
if !output.status.success() {
anyhow::bail!(
"failed to download {}: {}",
url,
command_failure_output(&output)
);
}
Ok(())
}
fn command_failure_output(output: &std::process::Output) -> String {
let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string();
if !stderr.is_empty() {
return stderr;
}
let stdout = String::from_utf8_lossy(&output.stdout).trim().to_string();
if !stdout.is_empty() {
return stdout;
}
format!("process exited with status {}", output.status)
}
fn ensure_install_directory_writable(binary_path: &Path) -> Result<()> {
let install_dir = binary_path.parent().ok_or_else(|| {
anyhow::anyhow!(
"cannot determine the install directory for {}",
binary_path.display()
)
})?;
let probe = NamedTempFile::new_in(install_dir)
.with_context(|| format!("failed to write into {}", install_dir.display()))?;
drop(probe);
Ok(())
}
fn is_missing_command_error(error: &anyhow::Error) -> bool {
error
.chain()
.filter_map(|cause| cause.downcast_ref::<std::io::Error>())
.any(|io_error| io_error.kind() == std::io::ErrorKind::NotFound)
}
fn last_location_header(headers: &str) -> Option<String> {
headers.lines().rev().find_map(|line| {
let (name, value) = line.split_once(':')?;
if name.trim().eq_ignore_ascii_case("location") {
Some(value.trim().to_string())
} else {
None
}
})
}
fn parse_latest_release_from_location(location: &str) -> Option<LatestRelease> {
let tag = location
.rsplit('/')
.next()?
.split('?')
.next()?
.trim()
.to_string();
let version = parse_release_version(&tag)?;
Some(LatestRelease { tag, version })
}
fn parse_release_version(tag: &str) -> Option<Version> {
let normalized = tag.strip_prefix('v').unwrap_or(tag);
Version::parse(normalized).ok()
}
fn cargo_update_command(version: &Version) -> Vec<String> {
vec![
"cargo".into(),
"install".into(),
"--locked".into(),
"--force".into(),
BIN_NAME.into(),
"--version".into(),
version.to_string(),
]
}
fn current_host_platform() -> HostPlatform {
if cfg!(windows) {
HostPlatform::Windows
} else {
HostPlatform::Unix
}
}
fn executable_file_name(platform: HostPlatform) -> &'static str {
match platform {
HostPlatform::Unix => BIN_NAME,
HostPlatform::Windows => "nodus.exe",
}
}
fn release_installer_script_name() -> &'static str {
release_installer_script_name_for_platform(current_host_platform())
}
fn release_installer_script_name_for_platform(platform: HostPlatform) -> &'static str {
match platform {
HostPlatform::Unix => "install.sh",
HostPlatform::Windows => "install.ps1",
}
}
fn tagged_install_script_url_for_platform(tag: &str, platform: HostPlatform) -> String {
format!(
"https://raw.githubusercontent.com/{REPO_SLUG}/{tag}/{}",
release_installer_script_name_for_platform(platform)
)
}
fn cargo_permission_guidance(version: &Version, binary_path: &Path) -> String {
format!(
"the current install target {} is not writable by this user.\nRerun `{}` in the account or shell environment that owns that install.",
display_path(binary_path),
cargo_update_command(version).join(" ")
)
}
fn release_permission_guidance(tag: &str, install_dir: &Path) -> String {
format!(
"the current install target {} is not writable by this user.\nRerun `{}` in a shell with permission to write there.",
display_path(install_dir),
manual_release_update_command(tag, install_dir)
)
}
fn unsupported_upgrade_message(
latest: &LatestRelease,
install: &UnsupportedInstall,
platform: HostPlatform,
) -> String {
match install {
UnsupportedInstall::CargoPath {
binary_path,
source,
} => format!(
"automatic upgrades only support crates.io cargo installs.\nThe current binary {} was installed from `{source}`.\nReinstall it from that original Cargo path source.",
display_path(binary_path),
),
UnsupportedInstall::CargoGit {
binary_path,
source,
} => format!(
"automatic upgrades only support crates.io cargo installs.\nThe current binary {} was installed from `{source}`.\nReinstall it from that original Cargo git source.",
display_path(binary_path),
),
UnsupportedInstall::CargoOther {
binary_path,
source,
} => format!(
"automatic upgrades do not support the current Cargo install source for {}.\nDetected source: `{source}`.\nUpdate it manually using that original installation method.",
display_path(binary_path),
),
UnsupportedInstall::Ambiguous { binary_path } => {
let install_dir = binary_path.parent().unwrap_or_else(|| Path::new("."));
format!(
"could not determine how {} was installed.\nUpdate it manually using the original installation method.\nCommon commands:\n {}\n {}",
display_path(binary_path),
cargo_update_command(&latest.version).join(" "),
manual_release_update_command_for_platform(&latest.tag, install_dir, platform),
)
}
}
}
fn manual_release_update_command(tag: &str, install_dir: &Path) -> String {
manual_release_update_command_for_platform(tag, install_dir, current_host_platform())
}
fn manual_release_update_command_for_platform(
tag: &str,
install_dir: &Path,
platform: HostPlatform,
) -> String {
match platform {
HostPlatform::Unix => format!(
"curl -fsSL {} | bash -s -- --version {} --install-dir {}",
shell_quote(&tagged_install_script_url_for_platform(tag, platform)),
shell_quote(tag),
shell_quote(&install_dir.to_string_lossy()),
),
HostPlatform::Windows => format!(
"powershell -NoProfile -ExecutionPolicy Bypass -Command \"& {{ $script = Join-Path $env:TEMP 'nodus-install.ps1'; Invoke-WebRequest {} -OutFile $script; & $script -Version {} -InstallDir {}; Remove-Item $script }}\"",
powershell_quote(&tagged_install_script_url_for_platform(tag, platform)),
powershell_quote(tag),
powershell_quote(&install_dir.to_string_lossy()),
),
}
}
fn shell_quote(value: &str) -> String {
format!("'{}'", value.replace('\'', "'\"'\"'"))
}
fn powershell_quote(value: &str) -> String {
format!("'{}'", value.replace('\'', "''"))
}
fn release_installer_invocation(script_path: &Path) -> InstallerInvocation {
release_installer_invocation_for_platform(script_path, current_host_platform())
}
fn release_installer_invocation_for_platform(
script_path: &Path,
platform: HostPlatform,
) -> InstallerInvocation {
match platform {
HostPlatform::Unix => InstallerInvocation {
program: "bash",
action: "bash",
script_path: script_path.to_path_buf(),
base_args: Vec::new(),
version_flag: "--version",
install_dir_flag: "--install-dir",
},
HostPlatform::Windows => InstallerInvocation {
program: "powershell",
action: "powershell",
script_path: script_path.to_path_buf(),
base_args: vec![
"-NoProfile".into(),
"-ExecutionPolicy".into(),
"Bypass".into(),
"-File".into(),
],
version_flag: "-Version",
install_dir_flag: "-InstallDir",
},
}
}
fn state_path(store_root: &Path) -> PathBuf {
store_root.join(STATE_FILE)
}
fn releases_latest_url() -> String {
format!("https://github.com/{REPO_SLUG}/releases/latest")
}
fn install_url() -> String {
format!("https://github.com/{REPO_SLUG}#install")
}
fn install_marker_path(binary_path: &Path) -> PathBuf {
binary_path
.parent()
.unwrap_or_else(|| Path::new("."))
.join(INSTALL_MARKER_FILE)
}
fn load_release_install_marker(path: &Path) -> Option<ReleaseInstallMarker> {
let contents = fs::read_to_string(path).ok()?;
serde_json::from_str(&contents).ok()
}
fn resolve_cargo_home() -> Option<PathBuf> {
if let Some(cargo_home) = env::var_os("CARGO_HOME") {
let cargo_home = PathBuf::from(cargo_home);
if cargo_home.is_absolute() {
return Some(cargo_home);
}
}
env::var_os("HOME").map(|home| PathBuf::from(home).join(".cargo"))
}
fn load_cargo_install_sources(cargo_home: &Path, bin_name: &str) -> Vec<String> {
let json_path = cargo_home.join(".crates2.json");
if let Ok(contents) = fs::read_to_string(&json_path) {
if let Ok(state) = serde_json::from_str::<CargoInstallState>(&contents) {
let sources = state
.installs
.into_iter()
.filter_map(|(package_id, install)| {
install
.bins
.iter()
.any(|bin| bin == bin_name)
.then_some(package_id)
})
.filter_map(|package_id| parse_cargo_source(&package_id))
.collect::<Vec<_>>();
if !sources.is_empty() {
return sources;
}
}
}
let toml_path = cargo_home.join(".crates.toml");
if let Ok(contents) = fs::read_to_string(&toml_path) {
if let Ok(state) = toml::from_str::<LegacyCargoInstallState>(&contents) {
return state
.v1
.into_iter()
.filter_map(|(package_id, bins)| {
bins.iter().any(|bin| bin == bin_name).then_some(package_id)
})
.filter_map(|package_id| parse_cargo_source(&package_id))
.collect();
}
}
Vec::new()
}
fn parse_cargo_source(package_id: &str) -> Option<String> {
let open = package_id.rfind(" (")?;
package_id
.strip_suffix(')')?
.get(open + 2..)
.map(ToOwned::to_owned)
}
fn canonicalize_or_identity(path: &Path) -> PathBuf {
fs::canonicalize(path).unwrap_or_else(|_| path.to_path_buf())
}
fn load_state(path: &Path) -> Result<UpdateCheckState> {
match fs::read_to_string(path) {
Ok(contents) => Ok(serde_json::from_str(&contents).unwrap_or_default()),
Err(error) if error.kind() == std::io::ErrorKind::NotFound => {
Ok(UpdateCheckState::default())
}
Err(error) => Err(error).with_context(|| format!("failed to read {}", path.display())),
}
}
fn persist_state(path: &Path, state: &UpdateCheckState) -> Result<()> {
let contents =
serde_json::to_vec_pretty(state).context("failed to serialize update check state")?;
crate::store::write_atomic(path, &contents)
}
#[cfg(test)]
mod tests {
use std::io::{self, Write};
use std::process::Command as ProcessCommand;
use std::sync::{Arc, Mutex};
use super::*;
use crate::report::{ColorMode, Reporter};
#[derive(Clone, Default)]
struct SharedBuffer(Arc<Mutex<Vec<u8>>>);
impl SharedBuffer {
fn contents(&self) -> String {
String::from_utf8(self.0.lock().unwrap().clone()).unwrap()
}
}
impl Write for SharedBuffer {
fn write(&mut self, buf: &[u8]) -> io::Result<usize> {
self.0.lock().unwrap().extend_from_slice(buf);
Ok(buf.len())
}
fn flush(&mut self) -> io::Result<()> {
Ok(())
}
}
fn reporter_with_buffer() -> (Reporter, SharedBuffer) {
let buffer = SharedBuffer::default();
let reporter = Reporter::sink(ColorMode::Never, buffer.clone());
(reporter, buffer)
}
fn host_binary_name() -> &'static str {
executable_file_name(current_host_platform())
}
fn host_installer_name() -> &'static str {
release_installer_script_name_for_platform(current_host_platform())
}
fn test_options(current_exe: PathBuf) -> CheckOptions {
CheckOptions {
now_unix_secs: CHECK_INTERVAL_SECS,
current_exe,
current_version: Version::parse("0.5.0").unwrap(),
cargo_home: None,
}
}
fn write_release_marker(binary_path: &Path) {
fs::create_dir_all(binary_path.parent().unwrap()).unwrap();
fs::write(
install_marker_path(binary_path),
serde_json::to_vec_pretty(&ReleaseInstallMarker {
install_method: "github_release".into(),
repo_slug: REPO_SLUG.into(),
binary_name: BIN_NAME.into(),
binary_path: binary_path.to_path_buf(),
})
.unwrap(),
)
.unwrap();
}
fn read_state(path: &Path) -> UpdateCheckState {
let contents = fs::read_to_string(path).unwrap();
serde_json::from_str(&contents).unwrap()
}
fn script_path() -> PathBuf {
Path::new(env!("CARGO_MANIFEST_DIR")).join("install.sh")
}
fn powershell_script_path() -> PathBuf {
Path::new(env!("CARGO_MANIFEST_DIR")).join("install.ps1")
}
#[cfg(not(target_os = "windows"))]
fn pwsh_available() -> bool {
ProcessCommand::new("pwsh")
.args([
"-NoProfile",
"-Command",
"$PSVersionTable.PSVersion.ToString()",
])
.output()
.is_ok_and(|output| output.status.success())
}
#[cfg(not(target_os = "windows"))]
fn zip_available() -> bool {
ProcessCommand::new("zip")
.arg("--version")
.output()
.is_ok_and(|output| output.status.success())
}
#[cfg(not(target_os = "windows"))]
fn python_command() -> Option<&'static str> {
["python3", "python"].into_iter().find(|candidate| {
ProcessCommand::new(candidate)
.args(["-c", "import sys"])
.output()
.is_ok_and(|output| output.status.success())
})
}
#[cfg(not(target_os = "windows"))]
fn create_zip_archive(asset_root: &Path, asset_path: &Path, files: &[&str]) {
if zip_available() {
let zip_status = ProcessCommand::new("zip")
.arg("-qr")
.arg(asset_path)
.args(files)
.current_dir(asset_root)
.status()
.unwrap();
assert!(zip_status.success());
return;
}
if let Some(python) = python_command() {
let python_status = ProcessCommand::new(python)
.arg("-c")
.arg(
"import pathlib, sys, zipfile\nroot = pathlib.Path(sys.argv[1])\nout = pathlib.Path(sys.argv[2])\nfiles = sys.argv[3:]\nwith zipfile.ZipFile(out, 'w', compression=zipfile.ZIP_DEFLATED) as archive:\n for name in files:\n archive.write(root / name, arcname=name)\n",
)
.arg(asset_root)
.arg(asset_path)
.args(files)
.status()
.unwrap();
assert!(python_status.success());
return;
}
assert!(
pwsh_available(),
"expected one of `zip`, `python3`, `python`, or `pwsh` to be available"
);
let quoted_files = files
.iter()
.map(|file| format!("'{}'", file.replace('\'', "''")))
.collect::<Vec<_>>()
.join(",");
let compress_cmd = format!(
"Compress-Archive -Path {quoted_files} -DestinationPath '{}' -Force",
asset_path.to_string_lossy().replace('\'', "''")
);
let zip_status = ProcessCommand::new("pwsh")
.args([
"-NoProfile",
"-ExecutionPolicy",
"Bypass",
"-Command",
&compress_cmd,
])
.current_dir(asset_root)
.status()
.unwrap();
assert!(zip_status.success());
}
#[cfg(not(target_os = "windows"))]
fn write_fake_unzip(fake_bin: &Path) {
fs::write(
fake_bin.join("unzip"),
r#"#!/usr/bin/env bash
set -euo pipefail
archive=''
destination=''
quiet=0
while [ "$#" -gt 0 ]; do
case "$1" in
-q)
quiet=1
shift
;;
-d)
destination="$2"
shift 2
;;
*)
archive="$1"
shift
;;
esac
done
python_cmd=''
for candidate in python3 python; do
if command -v "$candidate" >/dev/null 2>&1; then
python_cmd="$candidate"
break
fi
done
[ -n "$python_cmd" ] || { printf 'missing python for fake unzip\n' >&2; exit 1; }
"$python_cmd" - "$archive" "$destination" <<'PY'
import pathlib, sys, zipfile
archive = pathlib.Path(sys.argv[1])
destination = pathlib.Path(sys.argv[2])
destination.mkdir(parents=True, exist_ok=True)
with zipfile.ZipFile(archive) as zf:
zf.extractall(destination)
PY
"#,
)
.unwrap();
let status = ProcessCommand::new("chmod")
.args(["+x", fake_bin.join("unzip").to_str().unwrap()])
.status()
.unwrap();
assert!(status.success());
}
#[test]
fn parses_release_tags_with_or_without_a_v_prefix() {
assert_eq!(
parse_release_version("v0.5.1").unwrap(),
Version::parse("0.5.1").unwrap()
);
assert_eq!(
parse_release_version("0.5.1").unwrap(),
Version::parse("0.5.1").unwrap()
);
assert!(parse_release_version("release-0.5.1").is_none());
}
#[test]
fn round_trips_update_check_state() {
let temp = tempfile::TempDir::new().unwrap();
let path = state_path(temp.path());
let state = UpdateCheckState {
last_attempted_at_unix_secs: Some(42),
latest_known_tag: Some("v0.5.1".into()),
latest_known_version: Some("0.5.1".into()),
last_notified_tag: Some("v0.5.1".into()),
};
persist_state(&path, &state).unwrap();
assert_eq!(load_state(&path).unwrap(), state);
}
#[test]
fn detects_registry_cargo_installs_from_crates2_json() {
let temp = tempfile::TempDir::new().unwrap();
let cargo_home = temp.path().join(".cargo");
fs::create_dir_all(cargo_home.join("bin")).unwrap();
fs::write(
cargo_home.join(".crates2.json"),
r#"{"installs":{"nodus 0.5.0 (registry+https://github.com/rust-lang/crates.io-index)":{"bins":["nodus"]}}}"#,
)
.unwrap();
let binary_path = cargo_home.join("bin").join(host_binary_name());
let mut options = test_options(binary_path.clone());
options.cargo_home = Some(cargo_home);
assert_eq!(
detect_install_target(&options),
InstallTarget::CargoRegistry { binary_path }
);
}
#[test]
fn rejects_cargo_path_installs_for_upgrade() {
let temp = tempfile::TempDir::new().unwrap();
let cargo_home = temp.path().join(".cargo");
fs::create_dir_all(cargo_home.join("bin")).unwrap();
fs::write(
cargo_home.join(".crates2.json"),
r#"{"installs":{"nodus 0.5.0 (path+file:///tmp/nodus)":{"bins":["nodus"]}}}"#,
)
.unwrap();
let binary_path = cargo_home.join("bin").join(host_binary_name());
let mut options = test_options(binary_path.clone());
options.cargo_home = Some(cargo_home);
assert_eq!(
detect_install_target(&options),
InstallTarget::Unsupported(UnsupportedInstall::CargoPath {
binary_path,
source: "path+file:///tmp/nodus".into(),
})
);
}
#[test]
fn detects_release_installs_from_a_marker_file() {
let temp = tempfile::TempDir::new().unwrap();
let binary_path = temp.path().join("bin").join(BIN_NAME);
write_release_marker(&binary_path);
assert_eq!(
detect_install_target(&test_options(binary_path.clone())),
InstallTarget::GithubRelease { binary_path }
);
}
#[test]
fn falls_back_to_manual_guidance_for_ambiguous_installs() {
let temp = tempfile::TempDir::new().unwrap();
let binary_path = temp.path().join("bin").join(BIN_NAME);
let latest = LatestRelease {
tag: "v0.5.1".into(),
version: Version::parse("0.5.1").unwrap(),
};
match plan_upgrade(&test_options(binary_path), &latest) {
PlannedUpgrade::Unsupported { message, .. } => {
assert!(message.contains("could not determine"));
assert!(message.contains("cargo install --locked --force nodus --version 0.5.1"));
assert!(message.contains(host_installer_name()));
}
other => panic!("expected unsupported plan, got {other:?}"),
}
}
#[test]
fn plans_cargo_registry_updates_with_an_exact_version() {
let temp = tempfile::TempDir::new().unwrap();
let cargo_home = temp.path().join(".cargo");
fs::create_dir_all(cargo_home.join("bin")).unwrap();
fs::write(
cargo_home.join(".crates2.json"),
r#"{"installs":{"nodus 0.5.0 (registry+https://github.com/rust-lang/crates.io-index)":{"bins":["nodus"]}}}"#,
)
.unwrap();
let binary_path = cargo_home.join("bin").join(host_binary_name());
let latest = LatestRelease {
tag: "v0.5.1".into(),
version: Version::parse("0.5.1").unwrap(),
};
let mut options = test_options(binary_path.clone());
options.cargo_home = Some(cargo_home);
assert_eq!(
plan_upgrade(&options, &latest),
PlannedUpgrade::CargoRegistry {
current_version: Version::parse("0.5.0").unwrap(),
latest,
binary_path,
command: vec![
"cargo".into(),
"install".into(),
"--locked".into(),
"--force".into(),
"nodus".into(),
"--version".into(),
"0.5.1".into(),
],
}
);
}
#[test]
fn plans_release_updates_against_the_tagged_installer_script() {
let temp = tempfile::TempDir::new().unwrap();
let binary_path = temp.path().join("bin").join(BIN_NAME);
write_release_marker(&binary_path);
let latest = LatestRelease {
tag: "v0.5.1".into(),
version: Version::parse("0.5.1").unwrap(),
};
assert_eq!(
plan_upgrade(&test_options(binary_path.clone()), &latest),
PlannedUpgrade::GithubRelease {
current_version: Version::parse("0.5.0").unwrap(),
latest,
binary_path: binary_path.clone(),
install_dir: binary_path.parent().unwrap().to_path_buf(),
script_url: tagged_install_script_url_for_platform(
"v0.5.1",
current_host_platform(),
),
}
);
}
#[test]
fn plans_windows_release_updates_against_the_powershell_installer() {
let temp = tempfile::TempDir::new().unwrap();
let binary_path = temp.path().join("bin").join("nodus.exe");
write_release_marker(&binary_path);
let latest = LatestRelease {
tag: "v0.5.1".into(),
version: Version::parse("0.5.1").unwrap(),
};
assert_eq!(
plan_upgrade_for_platform(
&test_options(binary_path.clone()),
&latest,
HostPlatform::Windows
),
PlannedUpgrade::GithubRelease {
current_version: Version::parse("0.5.0").unwrap(),
latest,
binary_path: binary_path.clone(),
install_dir: binary_path.parent().unwrap().to_path_buf(),
script_url: tagged_install_script_url_for_platform("v0.5.1", HostPlatform::Windows),
}
);
}
#[test]
fn builds_a_powershell_manual_update_command_on_windows() {
let install_dir = Path::new(r"C:\Users\tester\AppData\Local\Programs\nodus\bin");
let command = manual_release_update_command_for_platform(
"v0.5.1",
install_dir,
HostPlatform::Windows,
);
assert!(command.contains("powershell -NoProfile -ExecutionPolicy Bypass"));
assert!(command.contains("install.ps1"));
assert!(command.contains("-Version 'v0.5.1'"));
assert!(
command.contains(r"-InstallDir 'C:\Users\tester\AppData\Local\Programs\nodus\bin'")
);
}
#[test]
fn uses_a_powershell_installer_invocation_on_windows() {
let install_dir = Path::new(r"C:\Users\tester\AppData\Local\Programs\nodus\bin");
let invocation = release_installer_invocation_for_platform(
&powershell_script_path(),
HostPlatform::Windows,
);
assert_eq!(invocation.program, "powershell");
assert_eq!(
invocation.args_for("v0.5.1", install_dir),
vec![
"-NoProfile".into(),
"-ExecutionPolicy".into(),
"Bypass".into(),
"-File".into(),
powershell_script_path().to_string_lossy().into_owned(),
"-Version".into(),
"v0.5.1".into(),
"-InstallDir".into(),
r"C:\Users\tester\AppData\Local\Programs\nodus\bin".into(),
]
);
}
#[test]
fn notices_suggest_upgrade_for_supported_installs() {
let temp = tempfile::TempDir::new().unwrap();
let cargo_home = temp.path().join(".cargo");
fs::create_dir_all(cargo_home.join("bin")).unwrap();
fs::write(
cargo_home.join(".crates2.json"),
r#"{"installs":{"nodus 0.5.0 (registry+https://github.com/rust-lang/crates.io-index)":{"bins":["nodus"]}}}"#,
)
.unwrap();
let binary_path = cargo_home.join("bin").join(host_binary_name());
let mut options = test_options(binary_path);
options.cargo_home = Some(cargo_home);
let latest = LatestRelease {
tag: "v0.5.1".into(),
version: Version::parse("0.5.1").unwrap(),
};
let plan = plan_upgrade(&options, &latest);
assert_eq!(
upgrade_available_message(&options, &latest, &plan),
"nodus 0.5.1 is available (current 0.5.0); run `nodus upgrade`"
);
}
#[test]
fn notices_fall_back_to_install_docs_for_unsupported_installs() {
let temp = tempfile::TempDir::new().unwrap();
let binary_path = temp.path().join("bin").join(BIN_NAME);
let latest = LatestRelease {
tag: "v0.5.1".into(),
version: Version::parse("0.5.1").unwrap(),
};
let options = test_options(binary_path);
let plan = plan_upgrade(&options, &latest);
assert_eq!(
upgrade_available_message(&options, &latest, &plan),
format!(
"nodus 0.5.1 is available (current 0.5.0); see {}",
install_url()
)
);
}
#[test]
fn upgrade_check_reports_when_current_version_is_already_latest() {
let options = test_options(PathBuf::from("/tmp/nodus"));
let latest = LatestRelease {
tag: "v0.5.0".into(),
version: Version::parse("0.5.0").unwrap(),
};
let plan = plan_upgrade(&options, &latest);
assert_eq!(
upgrade_available_message(&options, &latest, &plan),
"nodus 0.5.0 is already current"
);
}
#[test]
fn notifies_once_for_a_newer_release_and_persists_state() {
let temp = tempfile::TempDir::new().unwrap();
let state_file = state_path(temp.path());
let (reporter, buffer) = reporter_with_buffer();
maybe_notify_with(
temp.path(),
&reporter,
&test_options(temp.path().join("bin").join(BIN_NAME)),
|| {
Ok(Some(LatestRelease {
tag: "v0.5.1".into(),
version: Version::parse("0.5.1").unwrap(),
}))
},
)
.unwrap();
assert_eq!(
buffer.contents(),
format!(
"warning: nodus 0.5.1 is available (current 0.5.0); see {}\n",
install_url()
)
);
let state = read_state(&state_file);
assert_eq!(state.last_attempted_at_unix_secs, Some(CHECK_INTERVAL_SECS));
assert_eq!(state.latest_known_tag.as_deref(), Some("v0.5.1"));
assert_eq!(state.last_notified_tag.as_deref(), Some("v0.5.1"));
}
#[test]
fn skips_remote_probe_when_the_last_attempt_is_recent() {
let temp = tempfile::TempDir::new().unwrap();
persist_state(
&state_path(temp.path()),
&UpdateCheckState {
last_attempted_at_unix_secs: Some(100),
latest_known_tag: Some("v0.5.1".into()),
latest_known_version: Some("0.5.1".into()),
last_notified_tag: None,
},
)
.unwrap();
let (reporter, buffer) = reporter_with_buffer();
maybe_notify_with(
temp.path(),
&reporter,
&CheckOptions {
now_unix_secs: 100 + CHECK_INTERVAL_SECS - 1,
..test_options(temp.path().join("bin").join(BIN_NAME))
},
|| panic!("throttled checks should not probe remotely"),
)
.unwrap();
assert_eq!(
buffer.contents(),
format!(
"warning: nodus 0.5.1 is available (current 0.5.0); see {}\n",
install_url()
)
);
}
#[test]
fn does_not_repeat_a_notice_for_the_same_release_tag() {
let temp = tempfile::TempDir::new().unwrap();
persist_state(
&state_path(temp.path()),
&UpdateCheckState {
last_attempted_at_unix_secs: Some(0),
latest_known_tag: Some("v0.5.1".into()),
latest_known_version: Some("0.5.1".into()),
last_notified_tag: Some("v0.5.1".into()),
},
)
.unwrap();
let (reporter, buffer) = reporter_with_buffer();
maybe_notify_with(
temp.path(),
&reporter,
&CheckOptions {
now_unix_secs: CHECK_INTERVAL_SECS - 1,
..test_options(temp.path().join("bin").join(BIN_NAME))
},
|| panic!("throttled checks should not probe remotely"),
)
.unwrap();
assert!(buffer.contents().is_empty());
}
#[test]
fn notifies_again_when_a_newer_release_than_the_last_notice_appears() {
let temp = tempfile::TempDir::new().unwrap();
persist_state(
&state_path(temp.path()),
&UpdateCheckState {
last_attempted_at_unix_secs: Some(0),
latest_known_tag: Some("v0.5.1".into()),
latest_known_version: Some("0.5.1".into()),
last_notified_tag: Some("v0.5.1".into()),
},
)
.unwrap();
let (reporter, buffer) = reporter_with_buffer();
maybe_notify_with(
temp.path(),
&reporter,
&CheckOptions {
now_unix_secs: CHECK_INTERVAL_SECS,
..test_options(temp.path().join("bin").join(BIN_NAME))
},
|| {
Ok(Some(LatestRelease {
tag: "v0.5.2".into(),
version: Version::parse("0.5.2").unwrap(),
}))
},
)
.unwrap();
assert_eq!(
buffer.contents(),
format!(
"warning: nodus 0.5.2 is available (current 0.5.0); see {}\n",
install_url()
)
);
assert_eq!(
read_state(&state_path(temp.path()))
.last_notified_tag
.as_deref(),
Some("v0.5.2")
);
}
#[test]
fn does_not_notify_when_current_version_is_up_to_date() {
let temp = tempfile::TempDir::new().unwrap();
let (reporter, buffer) = reporter_with_buffer();
let mut options = test_options(temp.path().join("bin").join(BIN_NAME));
options.current_version = Version::parse("0.3.4").unwrap();
maybe_notify_with(temp.path(), &reporter, &options, || {
Ok(Some(LatestRelease {
tag: "v0.3.4".into(),
version: Version::parse("0.3.4").unwrap(),
}))
})
.unwrap();
assert!(buffer.contents().is_empty());
}
#[test]
fn does_not_notify_when_the_probe_returns_no_release() {
let temp = tempfile::TempDir::new().unwrap();
let (reporter, buffer) = reporter_with_buffer();
maybe_notify_with(
temp.path(),
&reporter,
&test_options(temp.path().join("bin").join(BIN_NAME)),
|| Ok(None),
)
.unwrap();
assert!(buffer.contents().is_empty());
}
#[test]
fn updates_last_attempt_time_even_when_the_probe_fails() {
let temp = tempfile::TempDir::new().unwrap();
let state_file = state_path(temp.path());
let (reporter, buffer) = reporter_with_buffer();
maybe_notify_with(
temp.path(),
&reporter,
&CheckOptions {
now_unix_secs: 123,
..test_options(temp.path().join("bin").join(BIN_NAME))
},
|| anyhow::bail!("network unavailable"),
)
.unwrap();
assert!(buffer.contents().is_empty());
assert_eq!(
read_state(&state_file).last_attempted_at_unix_secs,
Some(123)
);
}
#[test]
fn extracts_the_latest_release_from_redirect_headers() {
let headers = "\
HTTP/2 302 \r\n\
location: https://github.com/nodus-rs/nodus/releases/tag/v0.3.4\r\n\
\r\n\
HTTP/2 200 \r\n\
\r\n";
assert_eq!(
last_location_header(headers).as_deref(),
Some("https://github.com/nodus-rs/nodus/releases/tag/v0.3.4")
);
let release = parse_latest_release_from_location(
"https://github.com/nodus-rs/nodus/releases/tag/v0.3.4?foo=bar",
)
.unwrap();
assert_eq!(release.tag, "v0.3.4");
assert_eq!(release.version, Version::parse("0.3.4").unwrap());
}
#[test]
fn release_urls_are_derived_from_the_repo_slug() {
assert_eq!(
releases_latest_url(),
format!("https://github.com/{REPO_SLUG}/releases/latest")
);
assert_eq!(
install_url(),
format!("https://github.com/{REPO_SLUG}#install")
);
assert_eq!(
tagged_install_script_url_for_platform("v0.3.4", HostPlatform::Unix),
format!("https://raw.githubusercontent.com/{REPO_SLUG}/v0.3.4/install.sh")
);
assert_eq!(
tagged_install_script_url_for_platform("v0.3.4", HostPlatform::Windows),
format!("https://raw.githubusercontent.com/{REPO_SLUG}/v0.3.4/install.ps1")
);
}
#[test]
fn install_script_writes_and_removes_the_release_install_marker() {
let temp = tempfile::TempDir::new().unwrap();
let fake_bin = temp.path().join("fake-bin");
let install_dir = temp.path().join("install");
let asset_root = temp
.path()
.join("asset")
.join("nodus-v0.3.4-x86_64-unknown-linux-gnu");
let asset_path = temp
.path()
.join("nodus-v0.3.4-x86_64-unknown-linux-gnu.tar.gz");
fs::create_dir_all(&fake_bin).unwrap();
fs::create_dir_all(&asset_root).unwrap();
fs::write(asset_root.join(BIN_NAME), "#!/usr/bin/env bash\nexit 0\n").unwrap();
let tar_status = ProcessCommand::new("tar")
.args(["-czf", asset_path.to_str().unwrap(), "-C"])
.arg(temp.path().join("asset"))
.arg(asset_root.file_name().unwrap())
.status()
.unwrap();
assert!(tar_status.success());
fs::write(
fake_bin.join("uname"),
"#!/usr/bin/env bash\ncase \"$1\" in\n -s) printf 'Linux\\n' ;;\n -m) printf 'x86_64\\n' ;;\n *) printf 'unexpected uname args: %s\\n' \"$*\" >&2; exit 1 ;;\nesac\n",
)
.unwrap();
fs::write(
fake_bin.join("curl"),
format!(
"#!/usr/bin/env bash\nset -euo pipefail\noutput=''\nprev=''\nurl=''\nfor arg in \"$@\"; do\n if [ \"$prev\" = '-o' ]; then\n output=\"$arg\"\n prev=''\n continue\n fi\n case \"$arg\" in\n -o) prev='-o' ;;\n http://*|https://*) url=\"$arg\" ;;\n esac\ndone\ncase \"$url\" in\n *nodus-v0.3.4-x86_64-unknown-linux-gnu.tar.gz)\n cp {} \"$output\"\n ;;\n *)\n printf 'unexpected curl url: %s\\n' \"$url\" >&2\n exit 1\n ;;\nesac\n",
shell_quote(&asset_path.to_string_lossy())
),
)
.unwrap();
for helper in ["uname", "curl"] {
let status = ProcessCommand::new("chmod")
.args(["+x", fake_bin.join(helper).to_str().unwrap()])
.status()
.unwrap();
assert!(status.success());
}
let path = format!("{}:{}", fake_bin.display(), env::var("PATH").unwrap());
let install_output = ProcessCommand::new("bash")
.arg(script_path())
.args(["--version", "v0.3.4", "--install-dir"])
.arg(&install_dir)
.env("PATH", &path)
.output()
.unwrap();
assert!(
install_output.status.success(),
"{}",
String::from_utf8_lossy(&install_output.stderr)
);
let marker_path = install_dir.join(INSTALL_MARKER_FILE);
let marker: serde_json::Value =
serde_json::from_str(&fs::read_to_string(&marker_path).unwrap()).unwrap();
assert_eq!(marker["install_method"], "github_release");
assert_eq!(marker["repo_slug"], REPO_SLUG);
assert_eq!(marker["binary_name"], BIN_NAME);
let marker_binary_path = PathBuf::from(marker["binary_path"].as_str().unwrap());
assert_eq!(
canonicalize_or_identity(&marker_binary_path),
canonicalize_or_identity(&install_dir.join(BIN_NAME))
);
let uninstall_output = ProcessCommand::new("bash")
.arg(script_path())
.args(["--uninstall", "--install-dir"])
.arg(&install_dir)
.env("PATH", &path)
.output()
.unwrap();
assert!(
uninstall_output.status.success(),
"{}",
String::from_utf8_lossy(&uninstall_output.stderr)
);
assert!(!install_dir.join(BIN_NAME).exists());
assert!(!marker_path.exists());
}
#[cfg(not(target_os = "windows"))]
#[test]
fn install_script_handles_windows_release_assets_from_msys_shells() {
let temp = tempfile::TempDir::new().unwrap();
let fake_bin = temp.path().join("fake-bin");
let install_dir = temp.path().join("install");
let asset_root = temp.path().join("asset");
let asset_path = temp.path().join("nodus-v0.3.4-x86_64-pc-windows-msvc.zip");
fs::create_dir_all(&fake_bin).unwrap();
fs::create_dir_all(&asset_root).unwrap();
fs::write(asset_root.join("nodus.exe"), "windows-binary").unwrap();
fs::write(asset_root.join("README.md"), "readme").unwrap();
fs::write(asset_root.join("LICENSE"), "license").unwrap();
create_zip_archive(
&asset_root,
&asset_path,
&["nodus.exe", "README.md", "LICENSE"],
);
fs::write(
fake_bin.join("uname"),
"#!/usr/bin/env bash\ncase \"$1\" in\n -s) printf 'MINGW64_NT-10.0\\n' ;;\n -m) printf 'x86_64\\n' ;;\n *) printf 'unexpected uname args: %s\\n' \"$*\" >&2; exit 1 ;;\nesac\n",
)
.unwrap();
fs::write(
fake_bin.join("curl"),
format!(
"#!/usr/bin/env bash\nset -euo pipefail\noutput=''\nprev=''\nurl=''\nfor arg in \"$@\"; do\n if [ \"$prev\" = '-o' ]; then\n output=\"$arg\"\n prev=''\n continue\n fi\n case \"$arg\" in\n -o) prev='-o' ;;\n http://*|https://*) url=\"$arg\" ;;\n esac\ndone\ncase \"$url\" in\n *nodus-v0.3.4-x86_64-pc-windows-msvc.zip)\n cp {} \"$output\"\n ;;\n *)\n printf 'unexpected curl url: %s\\n' \"$url\" >&2\n exit 1\n ;;\nesac\n",
shell_quote(&asset_path.to_string_lossy())
),
)
.unwrap();
fs::write(
fake_bin.join("cygpath"),
"#!/usr/bin/env bash\ncase \"$1\" in\n -w|-u) printf '%s\\n' \"$2\" ;;\n *) printf 'unexpected cygpath args: %s\\n' \"$*\" >&2; exit 1 ;;\nesac\n",
)
.unwrap();
write_fake_unzip(&fake_bin);
for helper in ["uname", "curl", "cygpath"] {
let status = ProcessCommand::new("chmod")
.args(["+x", fake_bin.join(helper).to_str().unwrap()])
.status()
.unwrap();
assert!(status.success());
}
let path = format!("{}:{}", fake_bin.display(), env::var("PATH").unwrap());
let install_output = ProcessCommand::new("bash")
.arg(script_path())
.args(["--version", "v0.3.4", "--install-dir"])
.arg(&install_dir)
.env("PATH", &path)
.output()
.unwrap();
assert!(
install_output.status.success(),
"{}",
String::from_utf8_lossy(&install_output.stderr)
);
let marker_path = install_dir.join(INSTALL_MARKER_FILE);
let marker: serde_json::Value =
serde_json::from_str(&fs::read_to_string(&marker_path).unwrap()).unwrap();
assert_eq!(marker["binary_name"], BIN_NAME);
let marker_binary_path = PathBuf::from(marker["binary_path"].as_str().unwrap());
assert_eq!(
canonicalize_or_identity(&marker_binary_path),
canonicalize_or_identity(&install_dir.join("nodus.exe"))
);
assert!(install_dir.join("nodus.exe").exists());
let uninstall_output = ProcessCommand::new("bash")
.arg(script_path())
.args(["--uninstall", "--install-dir"])
.arg(&install_dir)
.env("PATH", &path)
.output()
.unwrap();
assert!(
uninstall_output.status.success(),
"{}",
String::from_utf8_lossy(&uninstall_output.stderr)
);
assert!(!install_dir.join("nodus.exe").exists());
assert!(!marker_path.exists());
}
#[cfg(not(target_os = "windows"))]
#[test]
fn install_script_handles_windows_arm64_release_assets_from_msys_shells() {
let temp = tempfile::TempDir::new().unwrap();
let fake_bin = temp.path().join("fake-bin");
let install_dir = temp.path().join("install");
let asset_root = temp.path().join("asset");
let asset_path = temp.path().join("nodus-v0.3.4-aarch64-pc-windows-msvc.zip");
fs::create_dir_all(&fake_bin).unwrap();
fs::create_dir_all(&asset_root).unwrap();
fs::write(asset_root.join("nodus.exe"), "windows-binary").unwrap();
fs::write(asset_root.join("README.md"), "readme").unwrap();
fs::write(asset_root.join("LICENSE"), "license").unwrap();
create_zip_archive(
&asset_root,
&asset_path,
&["nodus.exe", "README.md", "LICENSE"],
);
fs::write(
fake_bin.join("uname"),
"#!/usr/bin/env bash\ncase \"$1\" in\n -s) printf 'MINGW64_NT-10.0\\n' ;;\n -m) printf 'arm64\\n' ;;\n *) printf 'unexpected uname args: %s\\n' \"$*\" >&2; exit 1 ;;\nesac\n",
)
.unwrap();
fs::write(
fake_bin.join("curl"),
format!(
"#!/usr/bin/env bash\nset -euo pipefail\noutput=''\nprev=''\nurl=''\nfor arg in \"$@\"; do\n if [ \"$prev\" = '-o' ]; then\n output=\"$arg\"\n prev=''\n continue\n fi\n case \"$arg\" in\n -o) prev='-o' ;;\n http://*|https://*) url=\"$arg\" ;;\n esac\ndone\ncase \"$url\" in\n *nodus-v0.3.4-aarch64-pc-windows-msvc.zip)\n cp {} \"$output\"\n ;;\n *)\n printf 'unexpected curl url: %s\\n' \"$url\" >&2\n exit 1\n ;;\nesac\n",
shell_quote(&asset_path.to_string_lossy())
),
)
.unwrap();
fs::write(
fake_bin.join("cygpath"),
"#!/usr/bin/env bash\ncase \"$1\" in\n -w|-u) printf '%s\\n' \"$2\" ;;\n *) printf 'unexpected cygpath args: %s\\n' \"$*\" >&2; exit 1 ;;\nesac\n",
)
.unwrap();
write_fake_unzip(&fake_bin);
for helper in ["uname", "curl", "cygpath"] {
let status = ProcessCommand::new("chmod")
.args(["+x", fake_bin.join(helper).to_str().unwrap()])
.status()
.unwrap();
assert!(status.success());
}
let path = format!("{}:{}", fake_bin.display(), env::var("PATH").unwrap());
let install_output = ProcessCommand::new("bash")
.arg(script_path())
.args(["--version", "v0.3.4", "--install-dir"])
.arg(&install_dir)
.env("PATH", &path)
.output()
.unwrap();
assert!(
install_output.status.success(),
"{}",
String::from_utf8_lossy(&install_output.stderr)
);
let marker_path = install_dir.join(INSTALL_MARKER_FILE);
let marker: serde_json::Value =
serde_json::from_str(&fs::read_to_string(&marker_path).unwrap()).unwrap();
assert_eq!(marker["binary_name"], BIN_NAME);
let marker_binary_path = PathBuf::from(marker["binary_path"].as_str().unwrap());
assert_eq!(
canonicalize_or_identity(&marker_binary_path),
canonicalize_or_identity(&install_dir.join("nodus.exe"))
);
assert!(install_dir.join("nodus.exe").exists());
let uninstall_output = ProcessCommand::new("bash")
.arg(script_path())
.args(["--uninstall", "--install-dir"])
.arg(&install_dir)
.env("PATH", &path)
.output()
.unwrap();
assert!(
uninstall_output.status.success(),
"{}",
String::from_utf8_lossy(&uninstall_output.stderr)
);
assert!(!install_dir.join("nodus.exe").exists());
assert!(!marker_path.exists());
}
#[cfg(not(target_os = "windows"))]
#[test]
fn powershell_install_script_handles_windows_release_assets_from_flat_zip_root() {
if !pwsh_available() {
return;
}
let temp = tempfile::TempDir::new().unwrap();
let install_dir = temp.path().join("install");
let asset_dir = temp.path().join("assets");
fs::create_dir_all(&asset_dir).unwrap();
fs::write(asset_dir.join("nodus.exe"), "windows-binary").unwrap();
fs::write(asset_dir.join("README.md"), "readme").unwrap();
fs::write(asset_dir.join("LICENSE"), "license").unwrap();
let asset_name = "nodus-v0.3.4-aarch64-pc-windows-msvc.zip";
let asset_path = asset_dir.join(asset_name);
let compress_cmd = format!(
"Compress-Archive -Path 'nodus.exe','README.md','LICENSE' -DestinationPath '{}' -Force",
asset_path.to_string_lossy().replace('\'', "''")
);
let zip_status = ProcessCommand::new("pwsh")
.args([
"-NoProfile",
"-ExecutionPolicy",
"Bypass",
"-Command",
&compress_cmd,
])
.current_dir(&asset_dir)
.status()
.unwrap();
assert!(zip_status.success());
let script_body = fs::read_to_string(powershell_script_path()).unwrap();
let patched_script = script_body
.replacen(
"Invoke-WebRequest -Uri $Url -OutFile $OutputPath",
"Copy-Item -LiteralPath (Join-Path $env:NODUS_TEST_ASSET_DIR (Split-Path -Leaf $Url)) -Destination $OutputPath -Force",
1,
)
.replacen(
"$Target = Resolve-Target",
"$Target = 'aarch64-pc-windows-msvc'",
1,
);
assert_ne!(patched_script, script_body);
let test_script_path = temp.path().join("install-test.ps1");
fs::write(&test_script_path, patched_script).unwrap();
let install_output = ProcessCommand::new("pwsh")
.args([
"-NoProfile",
"-ExecutionPolicy",
"Bypass",
"-File",
&test_script_path.to_string_lossy(),
"-Version",
"v0.3.4",
"-InstallDir",
&install_dir.to_string_lossy(),
])
.env("NODUS_TEST_ASSET_DIR", &asset_dir)
.output()
.unwrap();
assert!(
install_output.status.success(),
"{}",
String::from_utf8_lossy(&install_output.stderr)
);
let marker_path = install_dir.join(INSTALL_MARKER_FILE);
let marker: serde_json::Value =
serde_json::from_str(&fs::read_to_string(&marker_path).unwrap()).unwrap();
assert_eq!(marker["binary_name"], BIN_NAME);
let marker_binary_path = PathBuf::from(marker["binary_path"].as_str().unwrap());
assert_eq!(
canonicalize_or_identity(&marker_binary_path),
canonicalize_or_identity(&install_dir.join("nodus.exe"))
);
assert!(install_dir.join("nodus.exe").exists());
let uninstall_output = ProcessCommand::new("pwsh")
.args([
"-NoProfile",
"-ExecutionPolicy",
"Bypass",
"-File",
&test_script_path.to_string_lossy(),
"-Uninstall",
"-InstallDir",
&install_dir.to_string_lossy(),
])
.output()
.unwrap();
assert!(
uninstall_output.status.success(),
"{}",
String::from_utf8_lossy(&uninstall_output.stderr)
);
assert!(!install_dir.join("nodus.exe").exists());
assert!(!marker_path.exists());
}
}