use anyhow::{Context, Result, anyhow};
use serde::{Deserialize, Serialize};
use std::path::{Path, PathBuf};
use std::time::{Duration, SystemTime};
#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq)]
pub struct LatestRelease {
pub tag_name: String,
#[serde(default)]
pub html_url: String,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum InstallMethod {
CargoInstall,
DevBuild,
DirectBinary,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct UpdateCheckState {
pub last_checked_unix: u64,
pub last_known_latest: Option<String>,
pub last_known_url: Option<String>,
}
#[derive(Debug, Clone)]
pub struct KaishinOptions {
pub owner: String,
pub repo: String,
pub bin_name: String,
pub current_version: String,
pub crate_name: Option<String>,
}
impl KaishinOptions {
pub fn new(owner: &str, repo: &str, bin_name: &str, current_version: &str) -> Self {
Self {
owner: owner.to_string(),
repo: repo.to_string(),
bin_name: bin_name.to_string(),
current_version: current_version.to_string(),
crate_name: None,
}
}
pub fn crate_name(mut self, crate_name: &str) -> Self {
self.crate_name = Some(crate_name.to_string());
self
}
pub fn cargo_crate_name(&self) -> &str {
self.crate_name.as_deref().unwrap_or(&self.bin_name)
}
}
#[derive(Debug, Clone)]
pub struct UpdateOptions {
pub yes: bool,
pub check_only: bool,
pub non_interactive: bool,
pub prefer_github_release: bool,
}
impl Default for UpdateOptions {
fn default() -> Self {
Self {
yes: false,
check_only: false,
non_interactive: false,
prefer_github_release: true,
}
}
}
impl UpdateOptions {
pub fn new() -> Self {
Self::default()
}
pub fn yes(mut self, yes: bool) -> Self {
self.yes = yes;
self
}
pub fn check_only(mut self, check_only: bool) -> Self {
self.check_only = check_only;
self
}
pub fn non_interactive(mut self, non_interactive: bool) -> Self {
self.non_interactive = non_interactive;
self
}
pub fn prefer_github_release(mut self, prefer_github_release: bool) -> Self {
self.prefer_github_release = prefer_github_release;
self
}
}
#[derive(Debug, Clone)]
pub struct Checker {
opts: KaishinOptions,
interval: Duration,
state_path: PathBuf,
}
impl Checker {
pub fn new(app_name: &str, opts: KaishinOptions) -> Self {
let state_path = default_state_path(app_name)
.expect("failed to resolve default state path (dirs::data_dir() failed)");
Self {
opts,
interval: Duration::from_secs(86400),
state_path,
}
}
pub fn interval(mut self, interval: Duration) -> Self {
self.interval = interval;
self
}
pub fn state_path(mut self, path: impl Into<PathBuf>) -> Self {
self.state_path = path.into();
self
}
pub fn should_check(&self) -> bool {
let state = self.load_state();
should_auto_check(state.as_ref(), self.interval, SystemTime::now())
}
pub async fn check_and_save(&self) -> Result<Option<LatestRelease>> {
let latest = check_latest_release(&self.opts).await?;
let now_unix = SystemTime::now()
.duration_since(SystemTime::UNIX_EPOCH)
.map(|d| d.as_secs())
.unwrap_or(0);
let state = UpdateCheckState {
last_checked_unix: now_unix,
last_known_latest: Some(latest.tag_name.clone()),
last_known_url: Some(latest.html_url.clone()),
};
let _ = self.save_state(&state);
if is_update_available(&self.opts.current_version, &latest.tag_name)? {
Ok(Some(latest))
} else {
Ok(None)
}
}
pub fn cached_update(&self) -> Option<LatestRelease> {
let state = self.load_state()?;
let latest_tag = state.last_known_latest?;
if is_update_available(&self.opts.current_version, &latest_tag).unwrap_or(false) {
Some(LatestRelease {
tag_name: latest_tag,
html_url: state.last_known_url.unwrap_or_default(),
})
} else {
None
}
}
pub fn format_banner(&self, latest: &LatestRelease) -> String {
format_update_banner(&self.opts, latest)
}
pub async fn auto_update(&self) -> Result<Option<LatestRelease>> {
if !self.should_check() {
return Ok(None);
}
let lock_path = self.state_path.with_extension("lock");
let Some(_lock) = UpdateLock::acquire(&lock_path) else {
return Ok(None);
};
let Some(latest) = self.check_and_save().await? else {
return Ok(None);
};
let opts = self.opts.clone();
let latest_for_thread = latest.clone();
let (tx, rx) = tokio::sync::oneshot::channel();
std::thread::Builder::new()
.name(format!("{}-auto-update", opts.bin_name))
.spawn(move || {
let _ = tx.send(run_silent_update_blocking(&opts, &latest_for_thread));
})
.context("failed to spawn auto-update worker thread")?;
let installed = rx
.await
.context("auto-update worker thread exited without reporting a result")??;
Ok(installed.then_some(latest))
}
pub fn spawn_auto_update(&self) {
let this = self.clone();
tokio::spawn(async move {
let _ = this.auto_update().await;
});
}
fn load_state(&self) -> Option<UpdateCheckState> {
load_check_state(&self.state_path)
}
fn save_state(&self, state: &UpdateCheckState) -> Result<()> {
save_check_state(&self.state_path, state)
}
}
pub fn default_interval() -> Duration {
Duration::from_secs(86400)
}
pub fn parse_interval(s: &str) -> Result<Duration> {
Ok(humantime::parse_duration(s)?)
}
pub fn detect_install_method(exe: &Path) -> InstallMethod {
let s = exe.to_string_lossy().replace('\\', "/").to_lowercase();
if s.contains("/target/debug/") || s.contains("/target/release/") {
return InstallMethod::DevBuild;
}
let cargo_bin = std::env::var("CARGO_HOME")
.ok()
.map(PathBuf::from)
.or_else(|| dirs::home_dir().map(|h| h.join(".cargo")))
.map(|p| {
p.join("bin")
.to_string_lossy()
.replace('\\', "/")
.to_lowercase()
});
if let Some(bin) = cargo_bin {
if s.starts_with(&format!("{}/", bin)) {
return InstallMethod::CargoInstall;
}
}
if s.contains("/.cargo/bin/") || s.contains("/cargo/bin/") {
return InstallMethod::CargoInstall;
}
InstallMethod::DirectBinary
}
pub fn is_update_available(current: &str, latest_tag: &str) -> Result<bool> {
let cur = semver::Version::parse(current)
.map_err(|e| anyhow!("invalid current version `{}`: {}", current, e))?;
let lat_str = latest_tag.trim_start_matches('v');
let lat = semver::Version::parse(lat_str)
.map_err(|e| anyhow!("invalid latest tag `{}`: {}", latest_tag, e))?;
Ok(lat > cur)
}
pub async fn check_latest_release(opts: &KaishinOptions) -> Result<LatestRelease> {
let url = format!(
"https://api.github.com/repos/{}/{}/releases/latest",
opts.owner, opts.repo
);
let client = reqwest::Client::builder()
.user_agent(format!("{}/{}", opts.bin_name, opts.current_version))
.timeout(Duration::from_secs(5))
.build()?;
let res = client.get(url).send().await?;
if !res.status().is_success() {
return Err(anyhow!("GitHub releases API returned {}", res.status()));
}
let release: LatestRelease = res.json().await?;
Ok(release)
}
pub fn default_state_path(app_name: &str) -> Option<PathBuf> {
dirs::data_dir().map(|d| d.join(app_name).join("last_update_check.json"))
}
pub fn load_check_state(path: &Path) -> Option<UpdateCheckState> {
let content = std::fs::read_to_string(path).ok()?;
serde_json::from_str(&content).ok()
}
pub fn save_check_state(path: &Path, state: &UpdateCheckState) -> Result<()> {
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent)?;
let json = serde_json::to_string(state)?;
let mut tmp = tempfile::NamedTempFile::new_in(parent)?;
use std::io::Write;
tmp.write_all(json.as_bytes())?;
tmp.persist(path)?;
}
Ok(())
}
pub fn should_auto_check(
state: Option<&UpdateCheckState>,
interval: Duration,
now: SystemTime,
) -> bool {
let Some(state) = state else {
return true;
};
let Ok(now_unix) = now.duration_since(SystemTime::UNIX_EPOCH) else {
return true;
};
let elapsed = now_unix.as_secs().saturating_sub(state.last_checked_unix);
elapsed >= interval.as_secs()
}
pub fn format_update_banner(opts: &KaishinOptions, latest: &LatestRelease) -> String {
let tag = latest.tag_name.trim_start_matches('v');
let mut s = format!(
"\u{2699} {} {} available (current {}) — run `{} self-update` to upgrade",
opts.bin_name, tag, opts.current_version, opts.bin_name
);
if !latest.html_url.is_empty() {
s.push_str(&format!("\n release notes: {}", latest.html_url));
}
s
}
pub async fn run_self_update(opts: &KaishinOptions, upd_opts: UpdateOptions) -> Result<()> {
let latest = check_latest_release(opts)
.await
.context("failed to fetch latest release from GitHub")?;
let available = is_update_available(&opts.current_version, &latest.tag_name)?;
if !available {
println!(
"\u{2713} {} {} is already up to date.",
opts.bin_name, opts.current_version
);
return Ok(());
}
let latest_clean = latest.tag_name.trim_start_matches('v');
if upd_opts.check_only {
println!(
"\u{2699} {} {} available (current {}). Run `{} self-update` to install.",
opts.bin_name, latest_clean, opts.current_version, opts.bin_name
);
if !latest.html_url.is_empty() {
println!(" release notes: {}", latest.html_url);
}
return Ok(());
}
let opts = opts.clone();
let (tx, rx) = tokio::sync::oneshot::channel();
std::thread::Builder::new()
.name(format!("{}-self-update", opts.bin_name))
.spawn(move || {
let _ = tx.send(run_self_update_blocking(&opts, &latest, upd_opts));
})
.context("failed to spawn self-update worker thread")?;
rx.await
.context("self-update worker thread exited without reporting a result")?
}
#[cfg(windows)]
mod cooked_input {
use windows_sys::Win32::Foundation::HANDLE;
use windows_sys::Win32::System::Console::{
CONSOLE_MODE, ENABLE_ECHO_INPUT, ENABLE_LINE_INPUT, ENABLE_PROCESSED_INPUT, GetConsoleMode,
GetStdHandle, STD_INPUT_HANDLE, SetConsoleMode,
};
const COOKED: CONSOLE_MODE = ENABLE_PROCESSED_INPUT | ENABLE_LINE_INPUT | ENABLE_ECHO_INPUT;
pub(crate) struct CookedInput {
handle: HANDLE,
prev: CONSOLE_MODE,
restore: bool,
}
impl CookedInput {
pub(crate) fn guard() -> Self {
unsafe {
let handle = GetStdHandle(STD_INPUT_HANDLE);
let mut prev: CONSOLE_MODE = 0;
if handle.is_null() || GetConsoleMode(handle, &mut prev) == 0 {
return Self {
handle,
prev: 0,
restore: false,
};
}
let cooked = prev | COOKED;
if cooked != prev && SetConsoleMode(handle, cooked) != 0 {
Self {
handle,
prev,
restore: true,
}
} else {
Self {
handle,
prev,
restore: false,
}
}
}
}
}
impl Drop for CookedInput {
fn drop(&mut self) {
if self.restore {
unsafe {
SetConsoleMode(self.handle, self.prev);
}
}
}
}
}
fn run_self_update_blocking(
opts: &KaishinOptions,
latest: &LatestRelease,
upd_opts: UpdateOptions,
) -> Result<()> {
let latest_clean = latest.tag_name.trim_start_matches('v');
if !upd_opts.yes {
use std::io::IsTerminal;
if upd_opts.non_interactive || !std::io::stdin().is_terminal() {
anyhow::bail!(
"non-interactive mode: use `--yes` to proceed with update to v{}",
latest_clean
);
}
eprint!("Update to v{}? [y/N] ", latest_clean);
use std::io::Write;
std::io::stderr().flush().ok();
#[cfg(windows)]
let _cooked_input = cooked_input::CookedInput::guard();
let mut answer = String::new();
std::io::stdin().read_line(&mut answer)?;
let answer = answer.trim().to_ascii_lowercase();
if answer != "y" && answer != "yes" {
eprintln!("aborted.");
return Ok(());
}
}
let exe = std::env::current_exe().context("failed to resolve current_exe()")?;
let method = detect_install_method(&exe);
match method {
InstallMethod::DevBuild => {
return Err(anyhow!(
"\u{26a0} `{}` looks like a development build. Refusing to self-update.",
exe.display()
));
}
InstallMethod::CargoInstall => {
if upd_opts.prefer_github_release {
match update_via_github_release(opts, latest, true) {
Ok(()) => return Ok(()),
Err(e) => {
eprintln!(
"GitHub release download failed: {e:#}. Falling back to `cargo install`."
);
}
}
}
update_via_cargo_install(opts, latest_clean)?;
}
InstallMethod::DirectBinary => {
update_via_github_release(opts, latest, true)?;
}
}
Ok(())
}
fn run_silent_update_blocking(opts: &KaishinOptions, latest: &LatestRelease) -> Result<bool> {
let exe = std::env::current_exe().context("failed to resolve current_exe()")?;
match detect_install_method(&exe) {
InstallMethod::DevBuild => Ok(false),
InstallMethod::CargoInstall | InstallMethod::DirectBinary => {
update_via_github_release(opts, latest, false)?;
Ok(true)
}
}
}
struct UpdateLock {
_file: std::fs::File,
}
impl UpdateLock {
fn acquire(path: &Path) -> Option<Self> {
if let Some(parent) = path.parent() {
let _ = std::fs::create_dir_all(parent);
}
let file = std::fs::OpenOptions::new()
.create(true)
.read(true)
.write(true)
.truncate(false)
.open(path)
.ok()?;
use fs2::FileExt;
match file.try_lock_exclusive() {
Ok(()) => Some(Self { _file: file }),
Err(_) => None,
}
}
}
fn update_via_cargo_install(opts: &KaishinOptions, latest_clean: &str) -> Result<()> {
let tmp = tempfile::Builder::new()
.prefix(&format!("{}-self-update-", opts.bin_name))
.tempdir()?;
let tmp_root = tmp.path().to_path_buf();
println!(
"running: cargo install {} --version {} --locked --force --root {}",
opts.cargo_crate_name(),
latest_clean,
tmp_root.display()
);
let status = std::process::Command::new("cargo")
.arg("install")
.arg(opts.cargo_crate_name())
.arg("--version")
.arg(latest_clean)
.arg("--locked")
.arg("--force")
.arg("--root")
.arg(&tmp_root)
.status()?;
if !status.success() {
anyhow::bail!("cargo install failed");
}
let bin_exe_name = if cfg!(windows) {
format!("{}.exe", opts.bin_name)
} else {
opts.bin_name.clone()
};
let new_exe = tmp_root.join("bin").join(bin_exe_name);
self_update::self_replace::self_replace(&new_exe)?;
println!("\u{2713} {} v{} installed.", opts.bin_name, latest_clean);
Ok(())
}
fn update_via_github_release(
opts: &KaishinOptions,
latest: &LatestRelease,
show_progress: bool,
) -> Result<()> {
let err = match try_github_release_with_target(opts, latest, None, show_progress) {
Ok(()) => return Ok(()),
Err(e) => e,
};
#[cfg(all(target_os = "linux", target_arch = "x86_64"))]
{
if cfg!(target_env = "musl") {
let has_glibc = std::path::Path::new("/lib/x86_64-linux-gnu/libc.so.6").exists()
|| std::path::Path::new("/lib64/ld-linux-x86-64.so.2").exists()
|| std::path::Path::new("/lib/ld-linux-x86-64.so.2").exists();
if has_glibc {
if let Ok(()) = try_github_release_with_target(
opts,
latest,
Some("x86_64-unknown-linux-gnu"),
show_progress,
) {
return Ok(());
}
}
} else {
if let Ok(()) = try_github_release_with_target(
opts,
latest,
Some("x86_64-unknown-linux-musl"),
show_progress,
) {
return Ok(());
}
}
}
#[cfg(all(target_os = "windows", target_arch = "x86_64"))]
{
let alt = if cfg!(target_env = "gnu") {
"x86_64-pc-windows-msvc"
} else {
"x86_64-pc-windows-gnu"
};
if let Ok(()) = try_github_release_with_target(opts, latest, Some(alt), show_progress) {
return Ok(());
}
}
Err(err)
}
fn bin_path_in_archive_candidates(bin_name: &str, target: &str) -> [String; 2] {
let exe = std::env::consts::EXE_SUFFIX;
[
format!("{bin_name}-{target}{exe}"),
format!("{bin_name}{exe}"),
]
}
fn strip_exe_suffix<'a>(bin_name: &'a str, exe: &str) -> &'a str {
if exe.is_empty() {
bin_name
} else {
bin_name.strip_suffix(exe).unwrap_or(bin_name)
}
}
fn try_github_release_with_target(
opts: &KaishinOptions,
latest: &LatestRelease,
target_override: Option<&str>,
show_progress: bool,
) -> Result<()> {
let effective_target = target_override.unwrap_or_else(|| self_update::get_target());
let bin_name = strip_exe_suffix(&opts.bin_name, std::env::consts::EXE_SUFFIX);
let identifier = asset_identifier(bin_name, effective_target);
let mut last_err: Option<anyhow::Error> = None;
for bin_path in bin_path_in_archive_candidates(bin_name, effective_target) {
match try_github_release_once(
opts,
latest,
target_override,
show_progress,
&identifier,
&bin_path,
) {
Ok(()) => return Ok(()),
Err(e) => last_err = Some(e),
}
}
Err(last_err.unwrap_or_else(|| anyhow!("no bin-path-in-archive candidates")))
}
fn asset_identifier(bin_name: &str, target: &str) -> String {
format!("{bin_name}-{target}")
}
fn try_github_release_once(
opts: &KaishinOptions,
latest: &LatestRelease,
target_override: Option<&str>,
show_progress: bool,
identifier: &str,
bin_path_in_archive: &str,
) -> Result<()> {
let mut builder = self_update::backends::github::Update::configure();
builder
.repo_owner(&opts.owner)
.repo_name(&opts.repo)
.bin_name(&opts.bin_name)
.identifier(identifier)
.bin_path_in_archive(bin_path_in_archive)
.show_download_progress(show_progress)
.show_output(show_progress)
.current_version(&opts.current_version)
.target_version_tag(&latest.tag_name)
.no_confirm(true);
if let Some(t) = target_override {
builder.target(t);
}
let status = builder
.build()
.context("build")?
.update()
.context("update")?;
if show_progress {
match status {
self_update::Status::UpToDate(v) => {
println!("\u{2713} {} {} is already up to date.", opts.bin_name, v)
}
self_update::Status::Updated(v) => {
println!("\u{2713} {} v{} installed.", opts.bin_name, v)
}
}
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
use tempfile::tempdir;
#[test]
fn test_detect_install_method() {
let p = PathBuf::from("/home/u/.cargo/bin/kaishin");
assert_eq!(detect_install_method(&p), InstallMethod::CargoInstall);
let p = PathBuf::from(
r"C:\Users\yukimemi\src\github.com\yukimemi\kaishin\target\debug\kaishin.exe",
);
assert_eq!(detect_install_method(&p), InstallMethod::DevBuild);
let p = PathBuf::from("/opt/kaishin-bin/kaishin");
assert_eq!(detect_install_method(&p), InstallMethod::DirectBinary);
}
#[test]
fn test_is_update_available() {
assert!(is_update_available("0.1.0", "v0.1.1").unwrap());
assert!(!is_update_available("0.1.1", "v0.1.1").unwrap());
assert!(!is_update_available("0.1.2", "v0.1.1").unwrap());
assert!(is_update_available("0.1.0", "0.1.1").unwrap());
}
#[test]
fn test_cargo_crate_name_defaults_to_bin_name() {
let opts = KaishinOptions::new("u", "r", "app", "1.0.0");
assert_eq!(opts.cargo_crate_name(), "app");
}
#[test]
fn test_cargo_crate_name_uses_explicit_crate_name() {
let opts = KaishinOptions::new("yukimemi", "kotonoha", "kotonoha", "0.3.0")
.crate_name("kotonoha-server");
assert_eq!(opts.cargo_crate_name(), "kotonoha-server");
assert_eq!(opts.bin_name, "kotonoha");
}
#[test]
fn test_asset_identifier_disambiguates_sibling_binaries() {
let target = "x86_64-pc-windows-msvc";
let id = asset_identifier("kanade", target);
let right = format!("kanade-{target}.zip");
let sibling = format!("kanade-agent-{target}.zip");
assert!(right.contains(&id));
assert!(sibling.contains(target));
assert!(
!sibling.contains(&id),
"sibling `{sibling}` must not match identifier `{id}`"
);
}
#[test]
fn test_bin_path_candidates_try_kata_layout_first() {
let target = "x86_64-pc-windows-msvc";
let [first, second] = bin_path_in_archive_candidates("kanade", target);
let exe = std::env::consts::EXE_SUFFIX;
assert_eq!(first, format!("kanade-{target}{exe}"));
assert_eq!(second, format!("kanade{exe}"));
assert!(!first.contains("{{") && !second.contains("{{"));
}
#[test]
fn test_strip_exe_suffix_prevents_double_suffix() {
let target = "x86_64-pc-windows-msvc";
assert_eq!(strip_exe_suffix("kanade.exe", ".exe"), "kanade");
assert_eq!(strip_exe_suffix("kanade", ".exe"), "kanade");
assert_eq!(strip_exe_suffix("kanade", ""), "kanade");
assert_eq!(strip_exe_suffix("kanade.exe", ""), "kanade.exe");
let bare = strip_exe_suffix("kanade.exe", ".exe");
let kata = format!("{bare}-{target}.exe");
assert_eq!(kata, format!("kanade-{target}.exe"));
assert!(!kata.contains(".exe-"), "must not embed `.exe` mid-path");
}
#[test]
fn test_should_auto_check() {
let now = SystemTime::now();
let now_unix = now
.duration_since(SystemTime::UNIX_EPOCH)
.unwrap()
.as_secs();
assert!(should_auto_check(None, Duration::from_secs(86400), now));
let state = UpdateCheckState {
last_checked_unix: now_unix - 3600,
last_known_latest: None,
last_known_url: None,
};
assert!(!should_auto_check(
Some(&state),
Duration::from_secs(86400),
now
));
let state = UpdateCheckState {
last_checked_unix: now_unix - 100000,
last_known_latest: None,
last_known_url: None,
};
assert!(should_auto_check(
Some(&state),
Duration::from_secs(86400),
now
));
}
#[test]
fn test_update_options_defaults() {
let opts = UpdateOptions::new();
assert!(!opts.yes);
assert!(!opts.check_only);
assert!(!opts.non_interactive);
assert!(opts.prefer_github_release);
}
#[test]
fn test_update_options_prefer_github_release_builder() {
let opts = UpdateOptions::new().prefer_github_release(false);
assert!(!opts.prefer_github_release);
let opts = opts.prefer_github_release(true);
assert!(opts.prefer_github_release);
}
#[test]
fn test_format_update_banner() {
let opts = KaishinOptions::new("u", "r", "app", "1.0.0");
let release = LatestRelease {
tag_name: "v1.1.0".to_string(),
html_url: "https://example.com".to_string(),
};
let banner = format_update_banner(&opts, &release);
assert!(banner.contains("app 1.1.0 available"));
assert!(banner.contains("(current 1.0.0)"));
assert!(banner.contains("run `app self-update`"));
assert!(banner.contains("https://example.com"));
}
#[test]
fn test_checker_state_management() {
let tmp = tempdir().unwrap();
let state_path = tmp.path().join("state.json");
let opts = KaishinOptions::new("u", "r", "app", "1.0.0");
let checker = Checker::new("app", opts).state_path(&state_path);
assert!(checker.should_check());
assert!(checker.cached_update().is_none());
let now_unix = SystemTime::now()
.duration_since(SystemTime::UNIX_EPOCH)
.unwrap()
.as_secs();
let state = UpdateCheckState {
last_checked_unix: now_unix,
last_known_latest: Some("v1.2.0".to_string()),
last_known_url: Some("https://rel".to_string()),
};
checker.save_state(&state).unwrap();
assert!(!checker.should_check()); let cached = checker.cached_update().unwrap();
assert_eq!(cached.tag_name, "v1.2.0");
assert_eq!(cached.html_url, "https://rel");
let banner = checker.format_banner(&cached);
assert!(banner.contains("app 1.2.0 available"));
}
#[test]
fn test_update_lock_mutual_exclusion() {
let tmp = tempdir().unwrap();
let path = tmp.path().join("nested").join("update.lock");
let lock = UpdateLock::acquire(&path).expect("first acquire should win");
assert!(path.exists());
assert!(UpdateLock::acquire(&path).is_none());
drop(lock);
let lock = UpdateLock::acquire(&path).expect("acquire after release");
drop(lock);
}
}