use anyhow::{Context, Result, anyhow};
use directories::UserDirs;
use std::fs::{self, File, OpenOptions};
use std::io::Write;
use std::path::{Path, PathBuf};
use std::process::Stdio;
use std::sync::OnceLock;
use tokio::sync::Mutex;
use tracing::{error, info, warn};
pub fn acquire_lock(storage_root: &Path) -> Result<()> {
let lock_path = 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(mut file) => {
let _ = write!(file, "{}", std::process::id());
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>> {
for attempt in 0..3 {
let file = OpenOptions::new()
.read(true)
.write(true)
.create(true)
.truncate(false)
.open(path)
.with_context(|| format!("failed to open lock file {}", path.display()))?;
if try_flock(&file)? {
return Ok(Some(file));
}
drop(file);
if attempt < 2 {
std::thread::sleep(std::time::Duration::from_millis(100));
}
}
Ok(None)
}
fn lock_file_path(storage_root: &Path) -> PathBuf {
storage_root.join("mahbot.lock")
}
#[cfg(unix)]
fn try_flock(file: &File) -> Result<bool> {
use std::os::unix::io::AsRawFd;
let fd = file.as_raw_fd();
let flags = unsafe { libc::fcntl(fd, libc::F_GETFD) };
if flags != -1 {
unsafe {
libc::fcntl(fd, libc::F_SETFD, flags | libc::FD_CLOEXEC);
}
}
let result = unsafe { libc::flock(fd, libc::LOCK_EX | libc::LOCK_NB) };
if result == 0 {
Ok(true)
} else {
let err = std::io::Error::last_os_error();
match err.raw_os_error() {
Some(libc::EAGAIN) => Ok(false), _ => Err(anyhow::Error::from(err).context("flock failed on lock file")),
}
}
}
#[cfg(windows)]
fn try_flock(file: &File) -> Result<bool> {
use std::os::windows::io::AsRawHandle;
use windows_sys::Win32::Foundation::HANDLE;
use windows_sys::Win32::Storage::FileSystem::{
LOCKFILE_EXCLUSIVE_LOCK, LOCKFILE_FAIL_IMMEDIATELY, LockFileEx,
};
let handle = file.as_raw_handle() as HANDLE;
unsafe {
windows_sys::Win32::Foundation::SetHandleInformation(handle, 1, 0);
}
let mut overlapped = std::mem::zeroed::<windows_sys::Win32::System::IO::OVERLAPPED>();
let locked = unsafe {
LockFileEx(
handle,
LOCKFILE_EXCLUSIVE_LOCK | LOCKFILE_FAIL_IMMEDIATELY,
0,
0,
0,
&mut overlapped,
)
};
if locked != 0 {
Ok(true)
} else {
let err = std::io::Error::last_os_error();
match err.raw_os_error() {
Some(33) | Some(36) => Ok(false), _ => Err(anyhow::Error::from(err).context("LockFileEx failed on lock file")),
}
}
}
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");
}
}
fn reacquire(&mut self) -> Result<()> {
if self.file.is_some() {
return Ok(()); }
match try_acquire_lock(&self.lock_path)? {
Some(file) => {
info!(path = %self.lock_path.display(), "Re-acquired instance lock");
self.file = Some(file);
Ok(())
}
None => Err(anyhow!(
"Failed to re-acquire instance lock — another instance may have started"
)),
}
}
}
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 mut guard = mutex.lock().await;
guard.reacquire()
}
#[inline]
#[must_use]
pub fn is_update_available() -> bool {
Path::new(env!("CARGO_MANIFEST_DIR"))
.join("Cargo.toml")
.is_file()
}
async fn checkpoint_all_databases() {
let stores: [(&str, Option<&crate::turso::Connection>); 8] = [
("board", crate::board::BOARD.get().map(|s| &s.conn)),
("sessions", crate::session::SESSIONS.get().map(|s| &s.conn)),
(
"workspaces",
crate::workspace::WORKSPACES.get().map(|s| &s.conn),
),
(
"chat_history",
crate::chat_history::CHAT_HISTORY.get().map(|s| &s.conn),
),
("stats", crate::stats::STATS_STORE.get().map(|s| &s.conn)),
(
"config_db",
crate::config_db::CONFIG_STORE.get().map(|s| &s.conn),
),
("users", crate::users::USER_STORE.get().map(|s| &s.conn)),
("logs", crate::logs::LOG_STORE.get().map(|s| &s.conn)),
];
for (name, conn_opt) in &stores {
let Some(conn) = conn_opt else {
continue;
};
match conn.checkpoint().await {
Ok(()) => info!(db = %name, "Database WAL checkpointed"),
Err(e) => warn!(error = %e, db = %name, "Failed to checkpoint database WAL"),
}
}
}
static UPDATE_MUTEX: OnceLock<Mutex<()>> = OnceLock::new();
fn update_mutex() -> &'static Mutex<()> {
UPDATE_MUTEX.get_or_init(|| Mutex::new(()))
}
pub async fn execute_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()
);
}
match tokio::process::Command::new("cargo")
.arg("--version")
.stdin(Stdio::null())
.stdout(Stdio::null())
.stderr(Stdio::null())
.status()
.await
{
Ok(status) if status.success() => {}
_ => anyhow::bail!("cargo not found on PATH — cannot build from source"),
}
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."
);
}
}
notify_admin(
"🔄 Update started — building from source…",
admin_target.as_ref(),
)
.await;
let binary_path = manifest_dir.join("target").join("release").join({
let exe_suffix = std::env::consts::EXE_SUFFIX;
if exe_suffix.is_empty() {
"mahbot".to_string()
} else {
format!("mahbot{exe_suffix}")
}
});
let cargo_bin_path = resolve_cargo_bin_path();
run_cargo_build(manifest_dir, admin_target.as_ref()).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_ref(),
)
.await?;
notify_admin("✅ Build complete. Restarting…", admin_target.as_ref()).await;
notify_admin("🔄 Starting new instance…", admin_target.as_ref()).await;
crate::registry::AGENT_REGISTRY.shutdown_all();
crate::tools::browser::close_all_browser_sessions().await;
crate::shutdown::shutdown();
release_instance_lock().await;
if let Err(e) = spawn_new_instance_from(&spawn_path, admin_target.as_ref()).await {
if let Err(lock_err) = reacquire_instance_lock().await {
error!(%lock_err, "Failed to re-acquire instance lock after spawn failure");
}
return Err(e);
}
let current_exe_path = std::env::current_exe().context("Failed to resolve current_exe()")?;
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)"
);
}
checkpoint_all_databases().await;
std::process::exit(0);
}
async fn run_cargo_build(manifest_dir: &Path, admin_target: Option<&String>) -> Result<()> {
info!(
"Starting cargo build --release in {}",
manifest_dir.display()
);
let build_result = tokio::time::timeout(
std::time::Duration::from_mins(30),
tokio::process::Command::new("cargo")
.args(["build", "--release", "--locked"])
.current_dir(manifest_dir)
.stdin(Stdio::null())
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.output(),
)
.await;
match build_result {
Err(_elapsed) => {
let msg = "❌ Build failed: timed out after 30 minutes";
notify_admin(msg, admin_target).await;
anyhow::bail!(msg);
}
Ok(Err(e)) => {
let msg = format!("❌ Build 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!("❌ Build failed:\n```\n{truncated}\n```");
notify_admin(&msg, admin_target).await;
anyhow::bail!("Build failed with exit status: {}", output.status);
}
Ok(Ok(_)) => {
info!("cargo build --release completed successfully");
Ok(())
}
}
}
pub async fn resolve_admin_telegram_target() -> Option<String> {
let _ = crate::config::CONFIG.telegram_bot_token()?;
let store = crate::users::store();
let bindings = store.get_user_channels("admin").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<&String>) {
let Some(recipient) = target else {
return;
};
let Some(channel) = crate::channel_registry().get("telegram") else {
warn!("Telegram channel not found in registry — cannot send update notification");
return;
};
let reply = crate::SendMessage {
content: message.to_string(),
recipient: recipient.clone(),
reply_markup: None,
agent_role: None,
workspace: String::new(),
};
if let Err(e) = channel.send(&reply).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);
if let Ok(cargo_home) = std::env::var("CARGO_HOME")
&& !cargo_home.is_empty()
{
return Some(PathBuf::from(cargo_home).join("bin").join(&exe_name));
}
let dirs = UserDirs::new()?;
Some(dirs.home_dir().join(".cargo").join("bin").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<&String>,
) -> 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;
}
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
if let Err(e) = fs::set_permissions(&tmp_path, PermissionsExt::from_mode(0o755)) {
warn!(
error = %e,
path = %tmp_path.display(),
"Failed to set executable permissions on temp binary \
(permissions likely preserved by fs::copy)"
);
}
}
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())
}
#[cfg(unix)]
fn is_executable(path: &Path) -> bool {
use std::os::unix::fs::PermissionsExt;
path.is_file() && fs::metadata(path).is_ok_and(|m| m.permissions().mode() & 0o111 != 0)
}
#[cfg(windows)]
fn is_executable(path: &Path) -> bool {
path.is_file()
&& path
.extension()
.map_or(false, |ext| ext.eq_ignore_ascii_case("exe"))
}
async fn resolve_spawn_path(
built_binary: &Path,
cargo_bin: Option<&Path>,
admin_target: Option<&String>,
) -> 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()
};
if !is_executable(&candidate) {
warn!(
path = %candidate.display(),
"Primary spawn target not executable — falling back to current_exe()"
);
if !is_executable(¤t_exe) {
anyhow::bail!(
"Neither cargo bin path `{}` nor current_exe `{}` is executable",
candidate.display(),
current_exe.display(),
);
}
return Ok(current_exe);
}
Ok(candidate)
}
async fn spawn_new_instance_from(binary_path: &Path, admin_target: Option<&String>) -> 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")?;
#[cfg(unix)]
{
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.stdin(Stdio::null())
.stdout(Stdio::null())
.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::*;
#[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_path_construction_with_exe_suffix() {
let suffix = std::env::consts::EXE_SUFFIX;
let exe_name = if suffix.is_empty() {
"mahbot".to_string()
} else {
format!("mahbot{suffix}")
};
assert!(exe_name.starts_with("mahbot"));
}
#[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 = OpenOptions::new()
.read(true)
.write(true)
.create(true)
.truncate(false)
.open(&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_exhaustion() {
let dir = tempfile::tempdir().unwrap();
let lock_path = dir.path().join("mahbot.lock");
let holder = OpenOptions::new()
.read(true)
.write(true)
.create(true)
.truncate(false)
.open(&lock_path)
.unwrap();
assert!(try_flock(&holder).unwrap(), "First flock should succeed");
let result = try_acquire_lock(&lock_path).unwrap();
assert!(result.is_none(), "Should exhaust retries when lock is held");
drop(holder);
let result = try_acquire_lock(&lock_path).unwrap();
assert!(
result.is_some(),
"Should acquire lock after previous holder releases"
);
}
#[test]
fn test_is_update_available() {
assert!(
is_update_available(),
"Self-update should be available when running from repo"
);
}
static ENV_LOCK: std::sync::OnceLock<std::sync::Mutex<()>> = std::sync::OnceLock::new();
fn env_lock() -> &'static std::sync::Mutex<()> {
ENV_LOCK.get_or_init(|| std::sync::Mutex::new(()))
}
#[test]
fn test_resolve_cargo_bin_path_cargo_home() {
let _guard = env_lock().lock().unwrap();
unsafe {
std::env::set_var("CARGO_HOME", "/custom/cargo");
}
let path_with = resolve_cargo_bin_path();
unsafe {
std::env::set_var("CARGO_HOME", "");
}
let path_empty = resolve_cargo_bin_path();
unsafe {
std::env::remove_var("CARGO_HOME");
}
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"
);
std::fs::set_permissions(&file_path, PermissionsExt::from_mode(0o755)).unwrap();
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();
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
std::fs::set_permissions(&source, PermissionsExt::from_mode(0o755)).unwrap();
}
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();
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
std::fs::set_permissions(&source, PermissionsExt::from_mode(0o755)).unwrap();
}
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"));
}
}