use colored::Colorize;
use self_update::backends::github::ReleaseList;
use self_update::cargo_crate_version;
use semver::Version;
use std::sync::mpsc;
use std::thread;
use std::time::Duration;
const REPO_OWNER: &str = "iyulab";
const REPO_NAME: &str = "undoc";
const BIN_NAME: &str = "undoc";
const CLI_CRATE_NAME: &str = "undoc-cli";
struct PlatformInfo {
os_name: &'static str,
arch_name: &'static str,
target_triple: &'static str,
archive_ext: &'static str,
}
fn get_platform_info() -> PlatformInfo {
platform_for(
std::env::consts::OS,
std::env::consts::ARCH,
cfg!(target_env = "musl"),
)
}
fn platform_for(os: &'static str, arch: &'static str, musl: bool) -> PlatformInfo {
let (os_name, arch_name, target_triple, archive_ext) = match (os, arch, musl) {
("windows", "x86_64", _) => ("windows", "x86_64", "x86_64-pc-windows-msvc", "zip"),
("linux", "x86_64", false) => ("linux", "x86_64", "x86_64-unknown-linux-gnu", "tar.gz"),
("linux", "x86_64", true) => (
"linux",
"x86_64-musl",
"x86_64-unknown-linux-musl",
"tar.gz",
),
("macos", "x86_64", _) => ("macos", "x86_64", "x86_64-apple-darwin", "tar.gz"),
("macos", "aarch64", _) => ("macos", "aarch64", "aarch64-apple-darwin", "tar.gz"),
(os, arch, _) => (os, arch, "unknown", "tar.gz"),
};
PlatformInfo {
os_name,
arch_name,
target_triple,
archive_ext,
}
}
fn get_asset_patterns(platform: &PlatformInfo, version: &str) -> Vec<String> {
let v = version.trim_start_matches('v');
vec![
format!(
"undoc-{}-{}-v{}.{}",
platform.os_name, platform.arch_name, v, platform.archive_ext
),
format!(
"undoc-{}-{}-{}.{}",
platform.os_name, platform.arch_name, v, platform.archive_ext
),
format!(
"undoc-{}-v{}.{}",
platform.target_triple, v, platform.archive_ext
),
format!(
"undoc-{}-{}.{}",
platform.target_triple, v, platform.archive_ext
),
]
}
fn find_matching_asset(asset_names: &[String], patterns: &[String]) -> Option<String> {
for pattern in patterns {
if asset_names.iter().any(|name| name == pattern) {
return Some(pattern.clone());
}
}
None
}
fn is_cargo_install() -> bool {
if let Ok(exe_path) = std::env::current_exe() {
let path_str = exe_path.to_string_lossy();
path_str.contains(".cargo") && path_str.contains("bin")
} else {
false
}
}
pub struct UpdateCheckResult {
pub has_update: bool,
pub latest_version: String,
pub current_version: String,
}
pub fn check_update_async() -> mpsc::Receiver<Option<UpdateCheckResult>> {
let (tx, rx) = mpsc::channel();
thread::spawn(move || {
let result = check_latest_version();
let _ = tx.send(result);
});
rx
}
fn check_latest_version() -> Option<UpdateCheckResult> {
let current_version = cargo_crate_version!();
let releases = ReleaseList::configure()
.repo_owner(REPO_OWNER)
.repo_name(REPO_NAME)
.build()
.ok()?
.fetch()
.ok()?;
let latest = releases.latest()?;
let latest_version = latest.version().trim_start_matches('v');
let current = Version::parse(current_version).ok()?;
let latest_ver = Version::parse(latest_version).ok()?;
Some(UpdateCheckResult {
has_update: latest_ver > current,
latest_version: latest_version.to_string(),
current_version: current_version.to_string(),
})
}
pub fn try_get_update_result(
rx: &mpsc::Receiver<Option<UpdateCheckResult>>,
) -> Option<UpdateCheckResult> {
rx.recv_timeout(Duration::from_millis(500)).ok().flatten()
}
pub fn print_update_notification(result: &UpdateCheckResult) {
if result.has_update {
eprintln!();
eprintln!(
"{} {} → {} available! Run '{}' to update.",
"Update:".yellow().bold(),
result.current_version,
result.latest_version.green(),
"undoc update".cyan()
);
}
}
pub fn run_update(check_only: bool, force: bool) -> Result<(), Box<dyn std::error::Error>> {
let current_version = cargo_crate_version!();
println!("{} {}", "Current version:".cyan().bold(), current_version);
println!("{}", "Checking for updates...".cyan());
let releases = ReleaseList::configure()
.repo_owner(REPO_OWNER)
.repo_name(REPO_NAME)
.build()?
.fetch()?;
if releases.is_empty() {
println!("{}", "No releases found on GitHub.".yellow());
return Ok(());
}
let latest = releases.latest().ok_or("No releases found on GitHub.")?;
let latest_version = latest.version().trim_start_matches('v');
println!("{} {}", "Latest version:".cyan().bold(), latest_version);
let current = semver::Version::parse(current_version)?;
let latest_ver = semver::Version::parse(latest_version)?;
if current >= latest_ver && !force {
println!();
println!("{} You are running the latest version!", "✓".green().bold());
return Ok(());
}
if current < latest_ver {
println!();
println!(
"{} New version available: {} → {}",
"↑".yellow().bold(),
current_version.yellow(),
latest_version.green().bold()
);
}
if check_only {
println!();
if is_cargo_install() {
println!(
"Run '{}' to update.",
format!("cargo install {}", CLI_CRATE_NAME).cyan()
);
} else {
println!("Run '{}' to update.", "undoc update".cyan());
}
return Ok(());
}
if is_cargo_install() {
println!();
println!(
"{} Installed via cargo. Please run:",
"Note:".yellow().bold()
);
println!(
" {}",
format!("cargo install {}", CLI_CRATE_NAME).cyan().bold()
);
println!();
println!(
"{}",
"This ensures proper integration with your Rust toolchain.".dimmed()
);
return Ok(());
}
println!();
println!("{}", "Downloading update...".cyan());
let platform = get_platform_info();
let patterns = get_asset_patterns(&platform, latest_version);
let asset_names: Vec<String> = latest
.assets()
.iter()
.map(|a| a.name().to_string())
.collect();
let asset_name = find_matching_asset(&asset_names, &patterns);
if asset_name.is_none() {
println!("{}", "No matching asset found.".red());
println!("{}", "Searched for:".dimmed());
for p in &patterns {
println!(" - {}", p.dimmed());
}
println!();
println!(
"{} {}",
"Available assets:".dimmed(),
latest
.assets()
.iter()
.map(|a| a.name())
.collect::<Vec<_>>()
.join(", ")
);
return Err("No compatible binary found for this platform".into());
}
let asset_name = asset_name.unwrap();
println!("{} {}", "Found asset:".dimmed(), asset_name.dimmed());
let download_url = format!(
"https://github.com/{}/{}/releases/download/v{}/{}",
REPO_OWNER, REPO_NAME, latest_version, asset_name
);
let tmp_dir = tempfile::TempDir::new()?;
let tmp_archive_path = tmp_dir.path().join(&asset_name);
let mut tmp_archive = std::fs::File::create(&tmp_archive_path)?;
let mut download = self_update::Download::from_url(&download_url);
download.show_download_progress(true);
download.download_to(&mut tmp_archive)?;
print!("Extracting archive... ");
std::io::Write::flush(&mut std::io::stdout())?;
let bin_name = format!("{}{}", BIN_NAME, std::env::consts::EXE_SUFFIX);
self_update::Extract::from_source(&tmp_archive_path).extract_file(tmp_dir.path(), &bin_name)?;
println!("Done");
print!("Replacing binary file... ");
std::io::Write::flush(&mut std::io::stdout())?;
let new_exe = tmp_dir.path().join(&bin_name);
self_replace::self_replace(new_exe)?;
println!("Done");
println!();
println!(
"{} Successfully updated to v{}!",
"✓".green().bold(),
latest_version
);
println!();
println!("Restart undoc to use the new version.");
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
fn windows_x86_64() -> PlatformInfo {
platform_for("windows", "x86_64", false)
}
#[test]
fn cli_archive_is_chosen_over_the_library_archive() {
let patterns = get_asset_patterns(&windows_x86_64(), "0.9.0");
let release = vec![
"libundoc-windows-x86_64-v0.9.0.zip".to_string(),
"undoc-windows-x86_64-v0.9.0.zip".to_string(),
];
assert_eq!(
find_matching_asset(&release, &patterns).as_deref(),
Some("undoc-windows-x86_64-v0.9.0.zip")
);
}
#[test]
fn a_musl_build_updates_to_the_musl_archive() {
let release = vec![
"undoc-linux-x86_64-v0.9.0.tar.gz".to_string(),
"undoc-linux-x86_64-musl-v0.9.0.tar.gz".to_string(),
"libundoc-linux-x86_64-musl-v0.9.0.tar.gz".to_string(),
];
let musl = get_asset_patterns(&platform_for("linux", "x86_64", true), "0.9.0");
assert_eq!(
find_matching_asset(&release, &musl).as_deref(),
Some("undoc-linux-x86_64-musl-v0.9.0.tar.gz")
);
let gnu = get_asset_patterns(&platform_for("linux", "x86_64", false), "0.9.0");
assert_eq!(
find_matching_asset(&release, &gnu).as_deref(),
Some("undoc-linux-x86_64-v0.9.0.tar.gz")
);
}
#[test]
fn a_musl_build_never_falls_back_to_the_glibc_archive() {
let release = vec!["undoc-linux-x86_64-v0.9.0.tar.gz".to_string()];
let musl = get_asset_patterns(&platform_for("linux", "x86_64", true), "0.9.0");
assert_eq!(find_matching_asset(&release, &musl), None);
}
#[test]
fn each_released_platform_names_its_own_archive() {
for (os, arch, musl, expected) in [
(
"windows",
"x86_64",
false,
"undoc-windows-x86_64-v0.9.0.zip",
),
("linux", "x86_64", false, "undoc-linux-x86_64-v0.9.0.tar.gz"),
(
"linux",
"x86_64",
true,
"undoc-linux-x86_64-musl-v0.9.0.tar.gz",
),
("macos", "x86_64", false, "undoc-macos-x86_64-v0.9.0.tar.gz"),
(
"macos",
"aarch64",
false,
"undoc-macos-aarch64-v0.9.0.tar.gz",
),
] {
let patterns = get_asset_patterns(&platform_for(os, arch, musl), "0.9.0");
assert_eq!(patterns[0], expected, "{os} {arch} musl={musl}");
}
}
#[test]
fn a_library_only_release_matches_nothing() {
let patterns = get_asset_patterns(&windows_x86_64(), "0.9.0");
let release = vec!["libundoc-windows-x86_64-v0.9.0.zip".to_string()];
assert_eq!(find_matching_asset(&release, &patterns), None);
}
#[test]
fn naming_variants_resolve_in_preference_order() {
let patterns = get_asset_patterns(&windows_x86_64(), "0.9.0");
let triple_only = vec!["undoc-x86_64-pc-windows-msvc-0.9.0.zip".to_string()];
assert_eq!(
find_matching_asset(&triple_only, &patterns).as_deref(),
Some("undoc-x86_64-pc-windows-msvc-0.9.0.zip")
);
let several = vec![
"undoc-x86_64-pc-windows-msvc-v0.9.0.zip".to_string(),
"undoc-windows-x86_64-v0.9.0.zip".to_string(),
];
assert_eq!(
find_matching_asset(&several, &patterns).as_deref(),
Some("undoc-windows-x86_64-v0.9.0.zip"),
"the human-friendly name is the preferred one"
);
}
#[test]
fn a_v_prefixed_tag_produces_the_same_patterns_as_a_bare_version() {
let platform = windows_x86_64();
assert_eq!(
get_asset_patterns(&platform, "v0.9.0"),
get_asset_patterns(&platform, "0.9.0")
);
}
#[test]
fn another_platforms_archive_is_not_matched() {
let patterns = get_asset_patterns(&windows_x86_64(), "0.9.0");
let release = vec![
"undoc-linux-x86_64-v0.9.0.tar.gz".to_string(),
"undoc-macos-aarch64-v0.9.0.tar.gz".to_string(),
];
assert_eq!(find_matching_asset(&release, &patterns), None);
}
#[test]
fn a_different_version_is_not_matched() {
let patterns = get_asset_patterns(&windows_x86_64(), "0.9.0");
let release = vec!["undoc-windows-x86_64-v0.8.0.zip".to_string()];
assert_eq!(find_matching_asset(&release, &patterns), None);
}
}