use crate::util::is_executable;
use anyhow::{Context, Result, anyhow};
#[cfg(test)]
use directories::UserDirs;
use std::fs::{self, File, OpenOptions};
use std::path::{Path, PathBuf};
use std::process::Stdio;
use std::sync::OnceLock;
use std::time::Duration;
use tokio::sync::Mutex;
use tracing::{error, info, warn};
pub const VERSION: &str = env!("CARGO_PKG_VERSION");
pub fn acquire_lock(storage_root: &Path) -> Result<()> {
let lock_path = crate::lock_utils::lock_file_path(storage_root);
if let Some(parent) = lock_path.parent() {
fs::create_dir_all(parent)
.with_context(|| format!("failed to create directory {}", parent.display()))?;
}
match try_acquire_lock(&lock_path)? {
Some(file) => {
info!(path = %lock_path.display(), "Acquired instance lock");
let guard = FlockGuard {
file: Some(file),
lock_path,
};
INSTANCE_LOCK
.set(Mutex::new(guard))
.expect("acquire_lock called more than once");
Ok(())
}
None => Err(anyhow!(
"Another instance of mahbot is already running (lock file: {}). \
If no other instance is running, delete this file manually.",
lock_path.display()
)),
}
}
fn try_acquire_lock(path: &Path) -> Result<Option<File>> {
let file = open_lock_file(path)
.with_context(|| format!("failed to open lock file {}", path.display()))?;
if crate::lock_utils::try_flock(&file)
.with_context(|| format!("flock failed on lock file {}", path.display()))?
{
Ok(Some(file))
} else {
Ok(None)
}
}
fn open_lock_file(path: &Path) -> std::io::Result<File> {
OpenOptions::new()
.read(true)
.write(true)
.create(true)
.truncate(false)
.open(path)
}
struct FlockGuard {
file: Option<File>,
lock_path: PathBuf,
}
impl std::fmt::Debug for FlockGuard {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("FlockGuard")
.field("held", &self.file.is_some())
.field("lock_path", &self.lock_path)
.finish()
}
}
impl FlockGuard {
fn release(&mut self) {
if self.file.take().is_some() {
info!(path = %self.lock_path.display(), "Released instance lock");
}
}
}
static INSTANCE_LOCK: OnceLock<Mutex<FlockGuard>> = OnceLock::new();
async fn release_instance_lock() {
if let Some(mutex) = INSTANCE_LOCK.get() {
let mut guard = mutex.lock().await;
guard.release();
}
}
async fn reacquire_instance_lock() -> Result<()> {
let mutex = INSTANCE_LOCK
.get()
.context("Instance lock not initialized")?;
let lock_path = {
let guard = mutex.lock().await;
if guard.file.is_some() {
return Ok(()); }
guard.lock_path.clone()
};
let file = try_acquire_lock(&lock_path)?;
let mut guard = mutex.lock().await;
match file {
Some(file) => {
info!(path = %guard.lock_path.display(), "Re-acquired instance lock");
guard.file = Some(file);
Ok(())
}
None => Err(anyhow!(
"Failed to re-acquire instance lock — another instance may have started"
)),
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum UpdateMode {
LocalCheckout,
Registry,
}
fn classify_update_mode(manifest_dir: &Path) -> UpdateMode {
let mut prev: Option<&std::ffi::OsStr> = None;
for component in manifest_dir.components() {
if let (Some(a), std::path::Component::Normal(b)) = (prev, component)
&& ((a == "registry" && b == "src") || (a == "git" && b == "checkouts"))
{
return UpdateMode::Registry;
}
prev = match component {
std::path::Component::Normal(os) => Some(os),
_ => None,
};
}
if manifest_dir.join(".git").exists() {
return UpdateMode::LocalCheckout;
}
if manifest_dir.join("Cargo.toml").is_file() {
UpdateMode::LocalCheckout
} else {
UpdateMode::Registry
}
}
#[must_use]
pub fn update_mode() -> UpdateMode {
classify_update_mode(Path::new(env!("CARGO_MANIFEST_DIR")))
}
#[must_use]
pub fn is_update_available() -> bool {
match update_mode() {
UpdateMode::LocalCheckout => Path::new(env!("CARGO_MANIFEST_DIR"))
.join("Cargo.toml")
.is_file(),
UpdateMode::Registry => !cfg!(windows),
}
}
fn registry_http_client() -> Result<&'static reqwest::Client> {
static CLIENT: OnceLock<Result<reqwest::Client, String>> = OnceLock::new();
CLIENT
.get_or_init(|| {
crate::util::http::install_ring_provider();
reqwest::Client::builder()
.user_agent(format!("mahbot/{VERSION} (self-update check)"))
.timeout(Duration::from_secs(15))
.build()
.map_err(|e| format!("failed to build crates.io registry HTTP client: {e}"))
})
.as_ref()
.map_err(|e| anyhow!("{e}"))
}
fn sparse_index_path(name: &str) -> String {
let len = name.len();
match len {
1 => format!("1/{name}"),
2 => format!("2/{name}"),
3 => format!("3/{}/{name}", &name[..1]),
_ => format!("{}/{}/{}", &name[..2], &name[2..4], name),
}
}
fn latest_stable_version(index_body: &str) -> Option<semver::Version> {
let mut yanked: std::collections::HashMap<String, bool> = std::collections::HashMap::new();
for line in index_body.lines() {
let line = line.trim();
if line.is_empty() {
continue;
}
let Ok(record) = serde_json::from_str::<serde_json::Value>(line) else {
continue;
};
let Some(vers) = record.get("vers").and_then(serde_json::Value::as_str) else {
continue;
};
let is_yanked = record
.get("yanked")
.and_then(serde_json::Value::as_bool)
.unwrap_or(false);
yanked.insert(vers.to_string(), is_yanked);
}
yanked
.into_iter()
.filter(|(_, is_yanked)| !is_yanked)
.filter_map(|(vers, _)| {
let version = semver::Version::parse(&vers).ok()?;
if !version.pre.is_empty() {
return None;
}
Some(version)
})
.max()
}
async fn fetch_latest_stable_version() -> Result<Option<semver::Version>> {
let name = env!("CARGO_PKG_NAME");
let url = format!("https://index.crates.io/{}", sparse_index_path(name));
let response = registry_http_client()?
.get(&url)
.send()
.await
.with_context(|| format!("failed to query crates.io index for {name}"))?;
if !response.status().is_success() {
anyhow::bail!(
"crates.io index returned HTTP {} for {name}",
response.status()
);
}
let body = response
.text()
.await
.with_context(|| format!("failed to read crates.io index response for {name}"))?;
Ok(latest_stable_version(&body))
}
pub async fn check_registry_update() -> Result<Option<semver::Version>> {
if cfg!(windows) {
return Ok(None);
}
let Some(latest) = fetch_latest_stable_version().await? else {
return Ok(None);
};
let current = semver::Version::parse(VERSION)
.with_context(|| format!("embedded version {VERSION} is not valid semver"))?;
Ok((latest > current).then_some(latest))
}
static UPDATE_MUTEX: Mutex<()> = Mutex::const_new(());
static UPDATE_FINALIZING: std::sync::atomic::AtomicBool = std::sync::atomic::AtomicBool::new(false);
#[must_use]
pub fn update_is_finalizing() -> bool {
UPDATE_FINALIZING.load(std::sync::atomic::Ordering::SeqCst)
}
async fn verify_cargo_on_path(action: &str) -> Result<()> {
match tokio::process::Command::new("cargo")
.arg("--version")
.stdin(Stdio::null())
.stdout(Stdio::null())
.stderr(Stdio::null())
.status()
.await
{
Ok(status) if status.success() => Ok(()),
_ => anyhow::bail!("cargo not found on PATH — cannot {action}"),
}
}
async fn resolve_update_admin_target() -> Option<String> {
let admin_target = resolve_admin_telegram_target().await;
if admin_target.is_none() {
if crate::config::CONFIG.telegram_bot_token().is_none() {
info!("No Telegram bot token configured — skipping update notifications");
} else {
warn!(
"Admin user 'admin' has no Telegram channel binding with a reply_target. \
Update notifications will be skipped. \
Bind a Telegram channel to the admin user to receive update notifications."
);
}
}
admin_target
}
pub(crate) async fn execute_update() -> Result<()> {
match update_mode() {
UpdateMode::LocalCheckout => execute_local_update().await,
UpdateMode::Registry => execute_registry_update().await,
}
}
async fn execute_local_update() -> Result<()> {
let Some(_guard) = UPDATE_MUTEX.try_lock().ok() else {
anyhow::bail!("An update is already in progress. Please wait for it to complete.");
};
let manifest_dir = Path::new(env!("CARGO_MANIFEST_DIR"));
let cargo_toml = manifest_dir.join("Cargo.toml");
if !cargo_toml.is_file() {
anyhow::bail!(
"Self-update is not available on this installation. \
Cargo.toml not found at {}. \
Self-update only works when running from the original build checkout directory.",
cargo_toml.display()
);
}
verify_cargo_on_path("build from source").await?;
let admin_target = resolve_update_admin_target().await;
notify_admin(
"🔄 Update started — building from source…",
admin_target.as_deref(),
)
.await;
let binary_path = manifest_dir
.join("target")
.join("release")
.join(format!("mahbot{}", std::env::consts::EXE_SUFFIX));
let cargo_bin_path = resolve_cargo_bin_path();
run_cargo_with_timeout(
&["build", "--release", "--locked"],
manifest_dir,
std::time::Duration::from_mins(30),
"cargo build --release",
"Build",
admin_target.as_deref(),
)
.await?;
self_replace::self_replace(&binary_path)
.with_context(|| format!("Failed to swap binary at {}", binary_path.display()))?;
let spawn_path = resolve_spawn_path(
&binary_path,
cargo_bin_path.as_deref(),
admin_target.as_deref(),
)
.await?;
notify_admin("✅ Build complete. Restarting…", admin_target.as_deref()).await;
notify_admin("🔄 Starting new instance…", admin_target.as_deref()).await;
let current_exe_path = std::env::current_exe().context("Failed to resolve current_exe()")?;
finalize_update_and_restart(&spawn_path, admin_target.as_deref(), || {
let should_delete = should_delete_build_artifact(
&binary_path,
¤t_exe_path,
cargo_bin_path.as_deref(),
);
if should_delete {
if let Err(e) = fs::remove_file(&binary_path) {
warn!(
error = %e,
path = %binary_path.display(),
"Could not remove build artifact after successful spawn"
);
}
} else {
info!(
path = %binary_path.display(),
"Skipping deletion of build artifact (matches current_exe or cargo bin path)"
);
}
})
.await
}
async fn execute_registry_update() -> Result<()> {
let Some(_guard) = UPDATE_MUTEX.try_lock().ok() else {
anyhow::bail!("An update is already in progress. Please wait for it to complete.");
};
if cfg!(windows) {
anyhow::bail!("Registry self-update is not supported on Windows");
}
verify_cargo_on_path("install from crates.io").await?;
let admin_target = resolve_update_admin_target().await;
notify_admin(
"🔄 Update started — installing from crates.io…",
admin_target.as_deref(),
)
.await;
let crate_name = env!("CARGO_PKG_NAME");
let install_cwd = crate::config::CONFIG.global_storage_root();
run_cargo_with_timeout(
&["install", crate_name, "--force"],
&install_cwd,
Duration::from_hours(1),
&format!("cargo install {crate_name} --force"),
"Update",
admin_target.as_deref(),
)
.await?;
let spawn_path = resolve_registry_spawn_path(admin_target.as_deref()).await?;
notify_admin(
"✅ Update installed from crates.io. Restarting…",
admin_target.as_deref(),
)
.await;
notify_admin("🔄 Starting new instance…", admin_target.as_deref()).await;
finalize_update_and_restart(&spawn_path, admin_target.as_deref(), || {}).await
}
async fn finalize_update_and_restart(
spawn_path: &Path,
admin_target: Option<&str>,
after_spawn: impl FnOnce(),
) -> Result<()> {
UPDATE_FINALIZING.store(true, std::sync::atomic::Ordering::SeqCst);
crate::shutdown::drain_begin();
while !crate::shutdown::shutdown_token().is_cancelled() {
tokio::time::sleep(std::time::Duration::from_millis(500)).await;
}
crate::tools::browser::close_all_browser_sessions().await;
crate::checkpoint::checkpoint_all_databases().await;
release_instance_lock().await;
if let Err(e) = spawn_new_instance_from(spawn_path, admin_target).await {
UPDATE_FINALIZING.store(false, std::sync::atomic::Ordering::SeqCst);
if let Err(lock_err) = reacquire_instance_lock().await {
error!(%lock_err, "Failed to re-acquire instance lock after spawn failure");
}
return Err(e);
}
after_spawn();
std::process::exit(0);
}
async fn run_cargo_with_timeout(
args: &[&str],
cwd: &Path,
timeout: Duration,
label: &str,
failure_kind: &str,
admin_target: Option<&str>,
) -> Result<()> {
info!("Starting {label} in {}", cwd.display());
let cargo_result = tokio::time::timeout(
timeout,
tokio::process::Command::new("cargo")
.args(args)
.current_dir(cwd)
.kill_on_drop(true)
.stdin(Stdio::null())
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.output(),
)
.await;
match cargo_result {
Err(_elapsed) => {
let msg = format!(
"❌ {failure_kind} failed: {label} timed out after {} minutes",
timeout.as_secs() / 60
);
notify_admin(&msg, admin_target).await;
anyhow::bail!(msg);
}
Ok(Err(e)) => {
let msg = format!("❌ {failure_kind} failed: could not start cargo: {e}");
notify_admin(&msg, admin_target).await;
anyhow::bail!(msg);
}
Ok(Ok(output)) if !output.status.success() => {
let stderr = String::from_utf8_lossy(&output.stderr);
let stdout = String::from_utf8_lossy(&output.stdout);
let combined = format!("stdout:\n{stdout}\nstderr:\n{stderr}");
let truncated = truncate_to_last_64k(&combined);
let msg = format!("❌ {failure_kind} failed:\n```\n{truncated}\n```");
notify_admin(&msg, admin_target).await;
anyhow::bail!("{label} failed with exit status: {}", output.status);
}
Ok(Ok(_)) => {
info!("{label} completed successfully");
Ok(())
}
}
}
async fn resolve_registry_spawn_path(admin_target: Option<&str>) -> Result<PathBuf> {
let current_exe = std::env::current_exe()
.context("Failed to resolve current_exe() for registry update restart")?;
let cargo_bin = resolve_cargo_bin_path();
if let Some(cargo_bin) = &cargo_bin
&& canonicalize_safe(¤t_exe) == canonicalize_safe(cargo_bin)
{
info!(
"cargo install updated the running binary in place at `{}` — restarting from current_exe",
current_exe.display()
);
return Ok(current_exe);
}
let mtime =
|path: &Path| -> Option<std::time::SystemTime> { fs::metadata(path).ok()?.modified().ok() };
let current_mtime = mtime(¤t_exe);
let mut spawn = current_exe.clone();
let mut fresh_elsewhere = false;
if let Some(cargo_bin) = &cargo_bin
&& is_executable(cargo_bin)
{
let cargo_mtime = mtime(cargo_bin);
let cargo_fresher = match (cargo_mtime, current_mtime) {
(Some(c), Some(s)) => c > s,
(Some(_), None) => true,
(None, _) => false,
};
if cargo_fresher {
spawn = cargo_bin.clone();
fresh_elsewhere = true;
}
}
if !is_executable(&spawn) {
return executable_or_current_exe(&spawn, ¤t_exe, "Registry");
}
if fresh_elsewhere {
notify_admin(
&format!(
"⚠️ Update installed to `{}`. The previously running copy at `{}` \
was not updated in place (different install root). \
Restarting from the freshly installed binary.",
spawn.display(),
current_exe.display(),
),
admin_target,
)
.await;
info!(
path = %spawn.display(),
"Restarting from freshly installed binary (running copy was stale)"
);
} else {
info!(
path = %spawn.display(),
"Restarting from current_exe (no fresher binary at the cargo bin path)"
);
}
Ok(spawn)
}
pub async fn resolve_admin_telegram_target() -> Option<String> {
let _ = crate::config::CONFIG.telegram_bot_token()?;
let store = crate::users::store();
let admin = store.find_admin().await.ok()??;
let bindings = store.get_user_channels(&admin.name).await.ok()?;
bindings
.into_iter()
.find(|b| b.channel == "telegram" && b.reply_target.is_some())
.and_then(|b| b.reply_target)
}
pub async fn notify_admin(message: &str, target: Option<&str>) {
let Some(recipient) = target else {
return;
};
if crate::channel_registry().get("telegram").is_none() {
warn!("Telegram channel not found in registry — cannot send update notification");
return;
}
if let Err(e) =
crate::channels::telegram::send_direct(recipient, message.to_string(), None).await
{
error!(error = %e, "Failed to send update notification to admin");
}
}
fn resolve_cargo_bin_path() -> Option<PathBuf> {
let exe_name = format!("mahbot{}", std::env::consts::EXE_SUFFIX);
Some(crate::util::cargo_bin_dir()?.join(exe_name))
}
fn stale_binary_notification(reason: &str, source: &Path, dest: &Path) -> String {
format!(
"⚠️ {reason}. \
The running binary is updated, but the PATH-visible binary \
remains stale. Manually copy `{}` to `{}`.",
source.display(),
dest.display(),
)
}
async fn copy_to_cargo_bin(
source: &Path,
dest: &Path,
admin_target: Option<&str>,
) -> Option<PathBuf> {
if let Some(parent) = dest.parent()
&& let Err(e) = fs::create_dir_all(parent)
{
warn!(
error = %e,
path = %parent.display(),
"Failed to create cargo bin directory"
);
notify_admin(
&stale_binary_notification(
&format!(
"Could not create cargo bin directory `{}`",
parent.display()
),
source,
dest,
),
admin_target,
)
.await;
return None;
}
let tmp_path = dest.with_extension("mahbot_update_tmp");
let _ = fs::remove_file(&tmp_path);
if let Err(e) = fs::copy(source, &tmp_path) {
warn!(
error = %e,
path = %dest.display(),
"Failed to copy binary to cargo bin temp path"
);
let _ = fs::remove_file(&tmp_path);
notify_admin(
&stale_binary_notification(
&format!("Could not install updated binary to `{}`", dest.display()),
source,
dest,
),
admin_target,
)
.await;
return None;
}
if let Err(e) = fs::rename(&tmp_path, dest) {
warn!(
error = %e,
path = %dest.display(),
source = %tmp_path.display(),
"Failed to rename temp binary to final path"
);
let _ = fs::remove_file(&tmp_path);
notify_admin(
&format!(
"⚠️ Could not install updated binary to `{}`: rename failed: {e}. \
The temp file is at `{}`. Manually rename it to complete installation.",
dest.display(),
tmp_path.display(),
),
admin_target,
)
.await;
return None;
}
info!(path = %dest.display(), "Installed new binary to cargo bin path");
Some(dest.to_path_buf())
}
fn should_delete_build_artifact(
binary_path: &Path,
current_exe_path: &Path,
cargo_bin_path: Option<&Path>,
) -> bool {
let binary_canon = canonicalize_safe(binary_path);
let current_exe_canon = canonicalize_safe(current_exe_path);
let cargo_bin_canon = cargo_bin_path.map(canonicalize_safe);
binary_canon != current_exe_canon && (cargo_bin_canon.as_ref() != Some(&binary_canon))
}
fn canonicalize_safe(path: &Path) -> PathBuf {
path.canonicalize().unwrap_or_else(|_| path.to_path_buf())
}
async fn resolve_spawn_path(
built_binary: &Path,
cargo_bin: Option<&Path>,
admin_target: Option<&str>,
) -> Result<PathBuf> {
let current_exe = std::env::current_exe()
.context("Failed to resolve current_exe() for spawn path resolution")?;
let candidate = if let Some(cargo_bin) = cargo_bin {
if canonicalize_safe(¤t_exe) == canonicalize_safe(cargo_bin) {
info!(
"Already running from cargo bin path `{}` — skipping install copy",
cargo_bin.display()
);
cargo_bin.to_path_buf()
} else {
copy_to_cargo_bin(built_binary, cargo_bin, admin_target)
.await
.unwrap_or_else(|| current_exe.clone())
}
} else {
current_exe.clone()
};
executable_or_current_exe(&candidate, ¤t_exe, "Primary")
}
fn executable_or_current_exe(
candidate: &Path,
current_exe: &Path,
warn_label: &str,
) -> Result<PathBuf> {
if !is_executable(candidate) {
warn!(
path = %candidate.display(),
"{warn_label} spawn target not executable — falling back to current_exe()"
);
if !is_executable(current_exe) {
anyhow::bail!(
"Neither cargo bin path `{}` nor current_exe `{}` is executable",
candidate.display(),
current_exe.display(),
);
}
return Ok(current_exe.to_path_buf());
}
Ok(candidate.to_path_buf())
}
async fn spawn_new_instance_from(binary_path: &Path, admin_target: Option<&str>) -> Result<()> {
let args: Vec<_> = std::env::args_os().skip(1).collect();
info!(
program = %binary_path.display(),
args = ?args,
"Spawning new mahbot instance"
);
let mut cmd = std::process::Command::new(binary_path);
cmd.args(&args);
let update_log = OpenOptions::new()
.create(true)
.append(true)
.open(
crate::config::CONFIG
.global_storage_root()
.join("update.log"),
)
.context("Failed to open update.log for child stderr")?;
cmd.stdin(Stdio::null()).stdout(Stdio::null());
#[cfg(windows)]
{
use std::os::windows::process::CommandExt;
const DETACHED_PROCESS: u32 = 0x0000_0008;
const CREATE_NO_WINDOW: u32 = 0x0800_0000;
cmd.creation_flags(DETACHED_PROCESS | CREATE_NO_WINDOW);
}
cmd.stderr(Stdio::from(update_log));
match cmd.spawn() {
Ok(child) => {
info!(pid = child.id(), "Spawned new mahbot instance");
Ok(())
}
Err(e) => {
let msg = format!("❌ Failed to start new instance: {e}");
notify_admin(&msg, admin_target).await;
warn!(
error = %e,
"New instance spawn failed — keeping current instance alive"
);
Err(anyhow::Error::from(e).context("Failed to spawn new instance after update"))
}
}
}
fn truncate_to_last_64k(s: &str) -> String {
const MAX: usize = 64 * 1024;
if s.len() <= MAX {
return s.to_string();
}
let start = s.ceil_char_boundary(s.len() - MAX);
format!(
"[…output truncated; showing last {} bytes…]\n{}",
MAX,
&s[start..]
)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::lock_utils::{lock_file_path, try_flock};
fn make_executable(path: &Path) {
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
std::fs::set_permissions(path, PermissionsExt::from_mode(0o755)).unwrap();
}
#[cfg(not(unix))]
let _ = path;
}
#[test]
fn test_truncate_to_last_64k_no_truncation() {
let s = "hello world";
assert_eq!(truncate_to_last_64k(s), "hello world");
}
#[test]
fn test_truncate_to_last_64k_large_input() {
let big = "X".repeat(70_000);
let result = truncate_to_last_64k(&big);
assert!(result.starts_with("[…output truncated;"));
let x_count = result.chars().filter(|c| *c == 'X').count();
assert_eq!(x_count, 64 * 1024);
}
#[test]
fn test_lock_acquire_and_release_with_temp_dir() {
let dir = tempfile::tempdir().unwrap();
let lock_path = dir.path().join("mahbot.lock");
let file1 = open_lock_file(&lock_path).unwrap();
assert!(try_flock(&file1).unwrap(), "First flock should succeed");
let file2 = OpenOptions::new()
.read(true)
.write(true)
.open(&lock_path)
.unwrap();
assert!(
!try_flock(&file2).unwrap(),
"Second flock should fail (already locked)"
);
drop(file1);
assert!(
try_flock(&file2).unwrap(),
"After release, flock should succeed"
);
}
#[test]
fn test_try_acquire_lock_held_free() {
let dir = tempfile::tempdir().unwrap();
let lock_path = dir.path().join("mahbot.lock");
let holder = open_lock_file(&lock_path).unwrap();
assert!(try_flock(&holder).unwrap(), "First flock should succeed");
assert!(
try_acquire_lock(&lock_path).unwrap().is_none(),
"Should return None when lock is held"
);
drop(holder);
let result = try_acquire_lock(&lock_path).unwrap();
assert!(result.is_some(), "After release, lock should be acquirable");
}
#[test]
fn test_lock_file_path_suffix() {
let dir = tempfile::tempdir().unwrap();
let path = lock_file_path(dir.path());
assert!(
path.ends_with("mahbot.lock"),
"Lock file path must end with mahbot.lock, got: {}",
path.display(),
);
}
#[test]
fn test_is_update_available() {
assert!(
is_update_available(),
"Self-update should be available when running from repo"
);
assert_eq!(
update_mode(),
UpdateMode::LocalCheckout,
"The repo checkout must classify as LocalCheckout"
);
}
#[test]
fn test_update_mode_detection() {
let dir = tempfile::tempdir().unwrap();
let checkout = dir.path().join("checkout");
std::fs::create_dir_all(checkout.join(".git")).unwrap();
std::fs::write(checkout.join("Cargo.toml"), "").unwrap();
assert_eq!(classify_update_mode(&checkout), UpdateMode::LocalCheckout);
let registry = dir
.path()
.join(".cargo")
.join("registry")
.join("src")
.join("index.crates.io-6f17d22bba3b01f9")
.join("mahbot-0.3.0");
std::fs::create_dir_all(®istry).unwrap();
std::fs::write(registry.join("Cargo.toml"), "").unwrap();
assert_eq!(classify_update_mode(®istry), UpdateMode::Registry);
let git_checkout = dir
.path()
.join(".cargo")
.join("git")
.join("checkouts")
.join("mahbot-1a2b3c")
.join("main");
std::fs::create_dir_all(git_checkout.join(".git")).unwrap();
std::fs::write(git_checkout.join("Cargo.toml"), "").unwrap();
assert_eq!(classify_update_mode(&git_checkout), UpdateMode::Registry);
let custom = dir
.path()
.join("custom-cargo")
.join("registry")
.join("src")
.join("index.crates.io-hash")
.join("mahbot-0.3.0");
std::fs::create_dir_all(&custom).unwrap();
std::fs::write(custom.join("Cargo.toml"), "").unwrap();
assert_eq!(classify_update_mode(&custom), UpdateMode::Registry);
let plain = dir.path().join("plain-src");
std::fs::create_dir_all(&plain).unwrap();
std::fs::write(plain.join("Cargo.toml"), "").unwrap();
assert_eq!(classify_update_mode(&plain), UpdateMode::LocalCheckout);
let bare = dir.path().join("bare");
std::fs::create_dir_all(&bare).unwrap();
assert_eq!(classify_update_mode(&bare), UpdateMode::Registry);
}
#[test]
fn test_sparse_index_path() {
assert_eq!(sparse_index_path("a"), "1/a");
assert_eq!(sparse_index_path("ab"), "2/ab");
assert_eq!(sparse_index_path("abc"), "3/a/abc");
assert_eq!(sparse_index_path("mahbot"), "ma/hb/mahbot");
assert_eq!(sparse_index_path("serde"), "se/rd/serde");
}
#[test]
fn test_latest_stable_version_filters_yanked_and_prerelease() {
let body = "\
{\"name\":\"mahbot\",\"vers\":\"0.2.0\",\"yanked\":false}
{\"name\":\"mahbot\",\"vers\":\"0.3.0\",\"yanked\":false}
{\"name\":\"mahbot\",\"vers\":\"0.4.0\",\"yanked\":true}
{\"name\":\"mahbot\",\"vers\":\"0.3.1-beta.1\",\"yanked\":false}
{\"name\":\"mahbot\",\"vers\":\"0.4.0-rc.1\",\"yanked\":false}
";
let latest = latest_stable_version(body).expect("a stable non-yanked version exists");
assert_eq!(latest.to_string(), "0.3.0");
}
#[test]
fn test_latest_stable_version_empty_and_malformed() {
assert_eq!(latest_stable_version(""), None);
assert_eq!(latest_stable_version("not json\n"), None);
assert_eq!(
latest_stable_version("{\"vers\":\"1.0.0\"}\n"),
Some(semver::Version::new(1, 0, 0))
);
assert_eq!(
latest_stable_version("{\"vers\":\"1.0.0\",\"yanked\":true}\n"),
None
);
}
#[test]
fn test_latest_stable_version_semver_ordering() {
let body = "\
{\"name\":\"mahbot\",\"vers\":\"0.9.0\",\"yanked\":false}
{\"name\":\"mahbot\",\"vers\":\"0.10.0\",\"yanked\":false}
";
assert_eq!(
latest_stable_version(body).map(|v| v.to_string()),
Some("0.10.0".to_string())
);
}
#[test]
fn test_latest_stable_version_last_line_wins_for_yank_state() {
let re_yanked = "\
{\"name\":\"mahbot\",\"vers\":\"0.4.0\",\"yanked\":false}
{\"name\":\"mahbot\",\"vers\":\"0.4.0\",\"yanked\":true}
{\"name\":\"mahbot\",\"vers\":\"0.4.0\",\"yanked\":false}
{\"name\":\"mahbot\",\"vers\":\"0.4.0\",\"yanked\":true}
{\"name\":\"mahbot\",\"vers\":\"0.3.0\",\"yanked\":false}
";
assert_eq!(
latest_stable_version(re_yanked).map(|v| v.to_string()),
Some("0.3.0".to_string())
);
let unyanked = "\
{\"name\":\"mahbot\",\"vers\":\"0.4.0\",\"yanked\":true}
{\"name\":\"mahbot\",\"vers\":\"0.4.0\",\"yanked\":false}
";
assert_eq!(
latest_stable_version(unyanked).map(|v| v.to_string()),
Some("0.4.0".to_string())
);
}
use crate::util::test::set_env_var;
#[test]
fn test_resolve_cargo_bin_path_cargo_home() {
let path_with = {
let _guard = set_env_var("CARGO_HOME", Some("/custom/cargo"));
resolve_cargo_bin_path()
};
let path_empty = {
let _guard = set_env_var("CARGO_HOME", Some(""));
resolve_cargo_bin_path()
};
assert!(
path_with.is_some(),
"resolve_cargo_bin_path should return Some with CARGO_HOME set"
);
let path = path_with.unwrap();
assert!(
path.starts_with("/custom/cargo/bin/mahbot"),
"Expected path to start with /custom/cargo/bin/mahbot, got {}",
path.display(),
);
let file_name = path.file_name().unwrap().to_string_lossy();
assert!(
file_name.starts_with("mahbot"),
"Expected file name to start with 'mahbot', got '{file_name}'"
);
let dirs = UserDirs::new();
if let Some(dirs) = dirs {
assert!(
path_empty.is_some(),
"Expected a path when CARGO_HOME is empty"
);
let path = path_empty.unwrap();
let expected_prefix = dirs.home_dir().join(".cargo").join("bin");
assert!(
path.starts_with(&expected_prefix),
"Expected path to start with {}, got {}",
expected_prefix.display(),
path.display(),
);
}
}
#[test]
fn test_canonicalize_safe_nonexistent_path() {
let dir = tempfile::tempdir().unwrap();
let nonexistent = dir.path().join("does_not_exist");
let result = canonicalize_safe(&nonexistent);
assert_eq!(result, nonexistent);
}
#[test]
fn test_canonicalize_safe_existing_path() {
let dir = tempfile::tempdir().unwrap();
let file_path = dir.path().join("test_file.txt");
std::fs::write(&file_path, "hello").unwrap();
let result = canonicalize_safe(&file_path);
assert!(
result.ends_with("test_file.txt"),
"Canonicalized path should end with test_file.txt, got {}",
result.display(),
);
}
#[cfg(unix)]
#[test]
fn test_is_executable_on_unix() {
use std::os::unix::fs::PermissionsExt;
let dir = tempfile::tempdir().unwrap();
let file_path = dir.path().join("test_exe");
assert!(!is_executable(&file_path));
std::fs::write(&file_path, "content").unwrap();
std::fs::set_permissions(&file_path, PermissionsExt::from_mode(0o644)).unwrap();
assert!(
!is_executable(&file_path),
"File with mode 644 should not be executable"
);
make_executable(&file_path);
assert!(
is_executable(&file_path),
"File with mode 755 should be executable"
);
std::fs::set_permissions(&file_path, PermissionsExt::from_mode(0o100)).unwrap();
assert!(
is_executable(&file_path),
"File with mode 100 should be executable"
);
}
#[cfg(windows)]
#[test]
fn test_is_executable_on_windows() {
let dir = tempfile::tempdir().unwrap();
let file_path = dir.path().join("test_exe.exe");
assert!(!is_executable(&file_path));
std::fs::write(&file_path, "content").unwrap();
assert!(
is_executable(&file_path),
"File with .exe extension should be executable"
);
let txt_path = dir.path().join("test.txt");
std::fs::write(&txt_path, "content").unwrap();
assert!(
!is_executable(&txt_path),
"File with .txt extension should not be executable"
);
}
#[tokio::test]
async fn test_copy_to_cargo_bin_success() {
let dir = tempfile::tempdir().unwrap();
let source = dir.path().join("source_bin");
let dest = dir.path().join("subdir").join("installed_bin");
std::fs::write(&source, "binary content").unwrap();
make_executable(&source);
let result = copy_to_cargo_bin(&source, &dest, None).await;
assert!(result.is_some(), "Copy should succeed");
assert_eq!(result.unwrap(), dest);
assert!(dest.is_file(), "Destination should exist");
assert_eq!(std::fs::read_to_string(&dest).unwrap(), "binary content");
let tmp_path = dest.with_extension("mahbot_update_tmp");
assert!(!tmp_path.exists(), "Temp file should be cleaned up");
}
#[tokio::test]
async fn test_copy_to_cargo_bin_source_not_found() {
let dir = tempfile::tempdir().unwrap();
let source = dir.path().join("nonexistent_source");
let dest = dir.path().join("dest_bin");
let result = copy_to_cargo_bin(&source, &dest, None).await;
assert!(result.is_none(), "Copy should return None on failure");
assert!(!dest.exists(), "Destination should not be created");
}
#[tokio::test]
async fn test_copy_to_cargo_bin_creates_parent_dir() {
let dir = tempfile::tempdir().unwrap();
let source = dir.path().join("source_bin");
let dest = dir.path().join("deep").join("nested").join("installed_bin");
std::fs::write(&source, "content").unwrap();
let result = copy_to_cargo_bin(&source, &dest, None).await;
assert!(
result.is_some(),
"Copy should create parent dirs and succeed"
);
assert!(dest.is_file(), "Destination should exist");
assert!(
dest.parent().unwrap().is_dir(),
"Parent directory should exist"
);
}
#[test]
fn test_should_delete_build_artifact() {
let cases: &[(&str, &str, Option<&str>, bool)] = &[
(
"/usr/local/bin/mahbot",
"/usr/local/bin/mahbot",
Some("/usr/local/bin/mahbot"),
false,
), (
"/build/target/release/mahbot",
"/usr/local/bin/mahbot",
Some("/home/user/.cargo/bin/mahbot"),
true,
), (
"/home/user/.cargo/bin/mahbot",
"/home/user/dev/mahbot/target/release/mahbot",
Some("/home/user/.cargo/bin/mahbot"),
false,
), (
"/usr/local/bin/mahbot",
"/usr/local/bin/mahbot",
Some("/home/user/.cargo/bin/mahbot"),
false,
), (
"/build/target/release/mahbot",
"/usr/local/bin/mahbot",
None,
true,
), (
"/usr/local/bin/mahbot",
"/usr/local/bin/mahbot",
None,
false,
), ];
for &(binary, current, cargo_bin, expected) in cases {
assert_eq!(
should_delete_build_artifact(
Path::new(binary),
Path::new(current),
cargo_bin.map(Path::new)
),
expected,
"binary={binary}, current={current}, cargo_bin={cargo_bin:?}"
);
}
}
#[tokio::test]
async fn test_resolve_spawn_path_falls_back_to_current_exe_on_copy_failure() {
let dir = tempfile::tempdir().unwrap();
let source = dir.path().join("nonexistent_source"); let dest = dir.path().join("install").join("mahbot");
let current_exe = std::env::current_exe().unwrap();
let result = resolve_spawn_path(&source, Some(dest.as_path()), None).await;
assert!(
result.is_ok(),
"Should fall back to current_exe on copy failure"
);
assert_eq!(
result.unwrap(),
current_exe,
"Should return current_exe when copy fails"
);
}
#[tokio::test]
async fn test_resolve_spawn_path_no_cargo_bin() {
let source = Path::new("/tmp/nonexistent_binary");
let current_exe = std::env::current_exe().unwrap();
let result = resolve_spawn_path(source, None, None).await;
assert!(
result.is_ok(),
"Should return current_exe when no cargo bin path"
);
assert_eq!(result.unwrap(), current_exe);
}
#[tokio::test]
async fn test_resolve_spawn_path_copy_success() {
let dir = tempfile::tempdir().unwrap();
let source = dir.path().join("built_bin");
let dest = dir.path().join("cargo_bin").join("mahbot");
std::fs::write(&source, "binary payload").unwrap();
make_executable(&source);
let result = resolve_spawn_path(&source, Some(dest.as_path()), None).await;
assert!(result.is_ok(), "resolve_spawn_path should succeed");
let path = result.unwrap();
assert_eq!(
path, dest,
"Should return the cargo bin path on successful copy"
);
assert!(dest.is_file(), "Destination should exist");
assert_eq!(std::fs::read_to_string(&dest).unwrap(), "binary payload");
}
#[test]
fn test_stale_binary_notification_format() {
let msg = stale_binary_notification(
"Test error",
Path::new("/src/mahbot"),
Path::new("/dest/mahbot"),
);
assert!(msg.contains("⚠️ Test error"));
assert!(msg.contains("Manually copy"));
assert!(msg.contains("/src/mahbot"));
assert!(msg.contains("/dest/mahbot"));
assert!(msg.contains("PATH-visible binary remains stale"));
}
}