use crate::ChannelMessage;
use crate::util::UnwrapPoison;
use anyhow::{Context, Result, anyhow};
#[cfg(test)]
use directories::UserDirs;
use std::ffi::OsStr;
use std::fs::{self, File, OpenOptions};
use std::path::{Path, PathBuf};
use std::process::Stdio;
use std::sync::OnceLock;
use std::sync::atomic::{AtomicBool, Ordering};
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::util::lock::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::util::lock::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(crate) 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(crate) fn update_mode() -> UpdateMode {
classify_update_mode(Path::new(env!("CARGO_MANIFEST_DIR")))
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) struct UpdateAvailability {
pub(crate) available: bool,
pub(crate) in_progress: bool,
}
struct UpdateCache {
available: AtomicBool,
latest: std::sync::Mutex<Option<semver::Version>>,
in_progress: AtomicBool,
}
static UPDATE_CACHE: OnceLock<UpdateCache> = OnceLock::new();
fn update_cache() -> &'static UpdateCache {
UPDATE_CACHE.get_or_init(|| UpdateCache {
available: AtomicBool::new(update_mode() == UpdateMode::LocalCheckout),
latest: std::sync::Mutex::new(None),
in_progress: AtomicBool::new(false),
})
}
#[must_use]
pub(crate) fn update_availability() -> UpdateAvailability {
let cache = update_cache();
UpdateAvailability {
available: cache.available.load(Ordering::SeqCst),
in_progress: cache.in_progress.load(Ordering::SeqCst),
}
}
#[must_use]
pub(crate) fn update_latest() -> Option<semver::Version> {
update_cache().latest.lock().unwrap_poison().clone()
}
#[must_use]
pub(crate) fn update_in_progress() -> bool {
update_availability().in_progress
}
#[must_use]
pub(crate) fn should_show_update(availability: UpdateAvailability, is_admin: bool) -> bool {
is_admin && availability.available
}
#[cfg(test)]
pub(crate) struct UpdateCacheTestGuard {
previous: UpdateAvailability,
}
#[cfg(test)]
impl Drop for UpdateCacheTestGuard {
fn drop(&mut self) {
let cache = update_cache();
cache
.available
.store(self.previous.available, Ordering::SeqCst);
cache
.in_progress
.store(self.previous.in_progress, Ordering::SeqCst);
}
}
#[cfg(test)]
pub(crate) fn set_update_cache_for_test(
available: bool,
in_progress: bool,
) -> UpdateCacheTestGuard {
let previous = update_availability();
let cache = update_cache();
cache.available.store(available, Ordering::SeqCst);
cache.in_progress.store(in_progress, Ordering::SeqCst);
UpdateCacheTestGuard { previous }
}
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(crate) async fn check_registry_update() -> Result<Option<semver::Version>> {
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))
}
async fn refresh_update_cache() {
let cache = update_cache();
match update_mode() {
UpdateMode::LocalCheckout => {
cache.available.store(true, Ordering::SeqCst);
}
UpdateMode::Registry => {
if cache.in_progress.load(Ordering::SeqCst) {
return;
}
let result = check_registry_update().await;
if cache.in_progress.load(Ordering::SeqCst) {
return;
}
match result {
Ok(Some(latest)) => {
*cache.latest.lock().unwrap_poison() = Some(latest);
cache.available.store(true, Ordering::SeqCst);
}
Ok(None) => {
*cache.latest.lock().unwrap_poison() = None;
cache.available.store(false, Ordering::SeqCst);
}
Err(_) => {
}
}
}
}
}
pub async fn run_update_availability_refresh() {
loop {
refresh_update_cache().await;
if !crate::shutdown::sleep_or_shutdown_or_drain(Duration::from_mins(10)).await {
return;
}
}
}
static UPDATE_MUTEX: Mutex<()> = Mutex::const_new(());
static UPDATE_FINALIZING: AtomicBool = AtomicBool::new(false);
#[must_use]
pub fn update_is_finalizing() -> bool {
UPDATE_FINALIZING.load(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<()> {
let Some(_guard) = UPDATE_MUTEX.try_lock().ok() else {
anyhow::bail!("An update is already in progress. Please wait for it to complete.");
};
update_cache().in_progress.store(true, Ordering::SeqCst);
let result = match update_mode() {
UpdateMode::LocalCheckout => execute_local_update().await,
UpdateMode::Registry => execute_registry_update().await,
};
if result.is_err() {
update_cache().in_progress.store(false, Ordering::SeqCst);
}
result
}
async fn execute_local_update() -> Result<()> {
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 temp_install_root =
tempfile::tempdir().context("Failed to create temp install root for self-update")?;
let temp_build_dir =
tempfile::tempdir().context("Failed to create temp build dir for self-update")?;
let install_root = temp_install_root.path().to_path_buf();
let build_dir = temp_build_dir.path().to_path_buf();
run_cargo_with_timeout(
&[
OsStr::new("install"),
OsStr::new("--path"),
manifest_dir.as_os_str(),
OsStr::new("--root"),
install_root.as_os_str(),
OsStr::new("--locked"),
OsStr::new("--target-dir"),
build_dir.as_os_str(),
],
&crate::config::CONFIG.global_storage_root(),
Duration::from_hours(1),
"cargo install --path --locked",
"Build",
)
.await?;
let fresh_binary = temp_bin_path(&install_root);
finalize_install(
&fresh_binary,
admin_target.as_deref(),
"✅ Build complete. Restarting…",
vec![install_root, build_dir],
)
.await
}
async fn execute_registry_update() -> Result<()> {
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 temp_install_root =
tempfile::tempdir().context("Failed to create temp install root for self-update")?;
let install_root = temp_install_root.path().to_path_buf();
let crate_name = env!("CARGO_PKG_NAME");
run_cargo_with_timeout(
&[
OsStr::new("install"),
OsStr::new(crate_name),
OsStr::new("--root"),
install_root.as_os_str(),
OsStr::new("--force"),
],
&crate::config::CONFIG.global_storage_root(),
Duration::from_hours(1),
&format!("cargo install {crate_name} --force"),
"Update",
)
.await?;
let fresh_binary = temp_bin_path(&install_root);
finalize_install(
&fresh_binary,
admin_target.as_deref(),
"✅ Update installed from crates.io. Restarting…",
vec![install_root],
)
.await
}
fn temp_bin_path(install_root: &Path) -> PathBuf {
install_root
.join("bin")
.join(format!("mahbot{}", std::env::consts::EXE_SUFFIX))
}
async fn finalize_install(
fresh_binary: &Path,
admin_target: Option<&str>,
completion_msg: &str,
cleanup_paths: Vec<PathBuf>,
) -> Result<()> {
let len = fs::metadata(fresh_binary)
.with_context(|| format!("Freshly built binary missing at {}", fresh_binary.display()))?
.len();
if len == 0 {
anyhow::bail!(
"Freshly built binary at {} is empty",
fresh_binary.display()
);
}
let current_exe = std::env::current_exe().context("Failed to resolve current_exe()")?;
self_replace::self_replace(fresh_binary)
.with_context(|| format!("Failed to swap binary at {}", fresh_binary.display()))?;
notify_admin(completion_msg, admin_target).await;
refresh_cargo_bin(¤t_exe, admin_target).await;
notify_admin("🔄 Starting new instance…", admin_target).await;
finalize_update_and_restart(¤t_exe, cleanup_paths).await
}
async fn finalize_update_and_restart(spawn_path: &Path, cleanup_paths: Vec<PathBuf>) -> Result<()> {
UPDATE_FINALIZING.store(true, 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::db::checkpoint::checkpoint_all_databases().await;
for path in &cleanup_paths {
if let Err(e) = fs::remove_dir_all(path) {
warn!(
error = %e,
path = %path.display(),
"Could not remove temp update root"
);
}
}
release_instance_lock().await;
if let Err(e) = spawn_new_instance_from(spawn_path) {
UPDATE_FINALIZING.store(false, 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);
}
std::process::exit(0);
}
async fn run_cargo_with_timeout(
args: &[&OsStr],
cwd: &Path,
timeout: Duration,
label: &str,
failure_kind: &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)
.env_remove("CARGO_TARGET_DIR")
.kill_on_drop(true)
.stdin(Stdio::null())
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.output(),
)
.await;
match cargo_result {
Err(_elapsed) => {
anyhow::bail!(
"{failure_kind} failed: {label} timed out after {} minutes",
timeout.as_secs() / 60
);
}
Ok(Err(e)) => {
anyhow::bail!("{failure_kind} failed: could not start cargo: {e}");
}
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);
anyhow::bail!("{failure_kind} failed:\n```\n{truncated}\n```");
}
Ok(Ok(_)) => {
info!("{label} 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 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");
}
}
pub const ADMIN_ONLY_CMD_MSG: &str = "This command is only available to admin users.";
pub async fn handle_update_command(msg: &ChannelMessage) {
if !crate::users::is_admin(&msg.user_name).await {
crate::channels::telegram::send_reply(&msg.reply_target, ADMIN_ONLY_CMD_MSG).await;
return;
}
if update_availability().in_progress {
crate::channels::telegram::send_reply(
&msg.reply_target,
"An update is already in progress. Please wait for it to complete.",
)
.await;
return;
}
if !should_show_update(update_availability(), true) {
crate::channels::telegram::send_reply(
&msg.reply_target,
"No update is available at the moment.",
)
.await;
return;
}
if let Err(e) = verify_cargo_on_path("perform the update").await {
crate::channels::telegram::send_reply(
&msg.reply_target,
&format!("Cannot start the update: {e}"),
)
.await;
return;
}
if update_cache()
.in_progress
.compare_exchange(false, true, Ordering::SeqCst, Ordering::SeqCst)
.is_err()
{
crate::channels::telegram::send_reply(
&msg.reply_target,
"An update is already in progress. Please wait for it to complete.",
)
.await;
return;
}
crate::channels::telegram::send_reply(
&msg.reply_target,
"✅ Update triggered — it will build/install in the background and restart the daemon when complete.",
)
.await;
let invoker_target = msg.reply_target.clone();
tokio::spawn(async move {
if let Err(e) = execute_update().await {
let failure = format!("❌ Update failed:\n{e:#}");
let admin_target = resolve_update_admin_target().await;
notify_admin(&failure, admin_target.as_deref()).await;
if admin_target.as_deref() != Some(invoker_target.as_str()) {
crate::channels::telegram::send_reply(&invoker_target, &failure).await;
}
}
});
}
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))
}
async fn refresh_cargo_bin(current_exe: &Path, admin_target: Option<&str>) {
let Some(cargo_bin) = resolve_cargo_bin_path() else {
warn!("No cargo bin path resolved — PATH-visible binary not refreshed");
return;
};
if canonicalize_safe(current_exe) == canonicalize_safe(&cargo_bin) {
info!(
"Already running from cargo bin path `{}` — skipping install copy",
cargo_bin.display()
);
return;
}
copy_to_cargo_bin(current_exe, &cargo_bin, admin_target).await;
}
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>) {
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;
}
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;
}
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;
}
info!(path = %dest.display(), "Installed new binary to cargo bin path");
}
fn canonicalize_safe(path: &Path) -> PathBuf {
path.canonicalize().unwrap_or_else(|_| path.to_path_buf())
}
fn spawn_new_instance_from(binary_path: &Path) -> 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) => {
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::util::lock::{lock_file_path, try_flock};
use crate::util::test::make_executable;
#[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_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(),
);
}
#[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);
copy_to_cargo_bin(&source, &dest, None).await;
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");
copy_to_cargo_bin(&source, &dest, None).await;
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();
copy_to_cargo_bin(&source, &dest, None).await;
assert!(dest.is_file(), "Destination should exist");
assert!(
dest.parent().unwrap().is_dir(),
"Parent directory should exist"
);
}
#[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"));
}
}