use crate::cli::UpgradeArgs;
use crate::error::{BeadsError, Result};
use crate::output::{OutputContext, OutputMode};
use rich_rust::prelude::*;
use self_update::backends::github;
use self_update::cargo_crate_version;
use self_update::update::ReleaseUpdate;
use serde::Serialize;
use std::env;
const REPO_OWNER: &str = "Dicklesworthstone";
const REPO_NAME: &str = "beads_rust";
const BIN_NAME: &str = "br";
#[derive(Serialize)]
struct UpdateCheckResult {
current_version: String,
latest_version: String,
update_available: bool,
#[serde(skip_serializing_if = "Option::is_none")]
download_url: Option<String>,
}
#[derive(Serialize)]
struct UpdateResult {
current_version: String,
new_version: String,
updated: bool,
#[serde(skip_serializing_if = "Option::is_none")]
message: Option<String>,
}
pub fn execute(args: &UpgradeArgs, ctx: &OutputContext) -> Result<()> {
let current_version = cargo_crate_version!();
if args.dry_run {
return execute_dry_run(args, current_version, ctx);
}
if args.check {
return execute_check(current_version, ctx);
}
execute_upgrade(args, current_version, ctx)
}
fn execute_check(current_version: &str, ctx: &OutputContext) -> Result<()> {
tracing::info!("Checking for updates...");
let updater = build_updater(current_version)?;
let latest = updater.get_latest_release().map_err(map_update_error)?;
let latest_version = &latest.version;
let update_available = version_newer(latest_version, current_version);
let download_url = latest.assets.first().map(|a| a.download_url.clone());
let result = UpdateCheckResult {
current_version: current_version.to_string(),
latest_version: latest_version.clone(),
update_available,
download_url,
};
if ctx.is_json() {
ctx.json_pretty(&result);
} else if ctx.is_quiet() {
} else if matches!(ctx.mode(), OutputMode::Rich) {
render_check_rich(&result, ctx);
} else {
println!("Current version: {current_version}");
println!("Latest version: {latest_version}");
if update_available {
println!("\n\u{2191} Update available! Run `br upgrade` to install.");
} else {
println!("\n\u{2713} Already up to date");
}
}
Ok(())
}
fn execute_dry_run(args: &UpgradeArgs, current_version: &str, ctx: &OutputContext) -> Result<()> {
tracing::info!("Dry-run mode: checking what would happen...");
let target_version = args.version.as_deref();
let updater = build_updater(current_version)?;
let latest = updater.get_latest_release().map_err(map_update_error)?;
let latest_version = &latest.version;
let install_version = target_version.unwrap_or(latest_version);
let would_update = args.force || version_newer(install_version, current_version);
let download_url = latest
.assets
.first()
.map_or_else(|| "N/A".to_string(), |a| a.download_url.clone());
if ctx.is_json() {
let result = serde_json::json!({
"dry_run": true,
"current_version": current_version,
"target_version": install_version,
"would_download": download_url,
"would_update": would_update,
});
ctx.json_pretty(&result);
} else if ctx.is_quiet() {
} else if matches!(ctx.mode(), OutputMode::Rich) {
render_dry_run_rich(
current_version,
install_version,
&download_url,
would_update,
ctx,
);
} else {
println!("Dry-run mode (no changes will be made)\n");
println!("Current version: {current_version}");
println!("Target version: {install_version}");
println!("Would download: {download_url}");
println!(
"Would install: {}",
if would_update {
"yes"
} else {
"no (already up to date)"
}
);
println!("\nNo changes made.");
}
Ok(())
}
fn execute_upgrade(args: &UpgradeArgs, current_version: &str, ctx: &OutputContext) -> Result<()> {
tracing::info!(current = %current_version, "Starting upgrade...");
let is_json = ctx.is_json();
let is_quiet = ctx.is_quiet();
let is_rich = matches!(ctx.mode(), OutputMode::Rich);
if !is_json && !is_quiet && !is_rich {
println!("Checking for updates...");
println!("Current version: {current_version}");
} else if is_rich {
ctx.info(&format!(
"Checking for updates (current: {current_version})..."
));
}
let updater = if let Some(ref target_version) = args.version {
build_updater_with_target(target_version, current_version, !is_json && !is_rich)?
} else {
build_updater(current_version)?
};
let latest = updater.get_latest_release().map_err(map_update_error)?;
let latest_version = &latest.version;
if !is_json && !is_quiet && !is_rich {
println!("Latest version: {latest_version}");
}
let update_available = args.force || version_newer(latest_version, current_version);
if !update_available {
let result = UpdateResult {
current_version: current_version.to_string(),
new_version: latest_version.clone(),
updated: false,
message: Some("Already up to date".to_string()),
};
if is_json {
ctx.json_pretty(&result);
} else if is_quiet {
} else if is_rich {
render_up_to_date_rich(current_version, latest_version, ctx);
} else {
println!("\n\u{2713} Already up to date");
}
return Ok(());
}
if !is_json && !is_quiet && !is_rich {
println!("\nDownloading {latest_version}...");
} else if is_rich {
ctx.info(&format!("Downloading {latest_version}..."));
}
let status = updater.update().map_err(|e| {
let msg = e.to_string();
if msg.contains("archive-tar") || msg.contains("ArchiveNotEnabled") || msg.contains("tar") {
BeadsError::Other(anyhow::anyhow!(
"{msg}\n\n\
This binary was built without archive support for the required format (e.g., .tar.gz).\n\
This is a known issue in some older versions (e.g., 0.1.21 - 0.1.26). Version 0.1.27 and later include the correct 'archive-tar' linkage.\n\n\
Please upgrade manually by running:\n\n \
curl -fsSL https://raw.githubusercontent.com/Dicklesworthstone/beads_rust/main/install.sh | bash\n\n\
Or by downloading the release from:\n \
https://github.com/Dicklesworthstone/beads_rust/releases\n\n\
After that, `br upgrade` will work correctly for future updates."
))
} else {
map_update_error(e)
}
})?;
let result = UpdateResult {
current_version: current_version.to_string(),
new_version: status.version().to_string(),
updated: status.updated(),
message: if status.updated() {
Some(format!("Updated to {}", status.version()))
} else {
Some("No update performed".to_string())
},
};
if is_json {
ctx.json_pretty(&result);
} else if is_quiet {
} else if is_rich {
render_upgrade_result_rich(&result, current_version, ctx);
} else if status.updated() {
println!(
"\n\u{2713} Updated br from {current_version} to {}",
status.version()
);
} else {
println!("\n\u{2713} Already up to date");
}
Ok(())
}
fn resolve_auth_token() -> Option<String> {
env::var("GITHUB_TOKEN")
.or_else(|_| env::var("GH_TOKEN"))
.ok()
.map(|t| t.trim().to_string())
.filter(|t| !t.is_empty())
}
fn asset_target_name() -> &'static str {
match self_update::get_target() {
"x86_64-apple-darwin" => "darwin_amd64",
"aarch64-apple-darwin" => "darwin_arm64",
"x86_64-unknown-linux-gnu" => "linux_amd64",
"x86_64-unknown-linux-musl" => "linux_musl_amd64",
"aarch64-unknown-linux-gnu" => "linux_arm64",
"aarch64-unknown-linux-musl" => "linux_musl_arm64",
"x86_64-pc-windows-msvc" | "x86_64-pc-windows-gnu" => "windows_amd64",
other => other, }
}
fn build_updater(current_version: &str) -> Result<Box<dyn ReleaseUpdate>> {
let mut builder = github::Update::configure();
builder
.repo_owner(REPO_OWNER)
.repo_name(REPO_NAME)
.bin_name(BIN_NAME)
.target(asset_target_name())
.show_download_progress(true)
.no_confirm(true)
.current_version(current_version);
if let Some(token) = resolve_auth_token() {
tracing::debug!("Using GitHub auth token from environment");
builder.auth_token(&token);
}
builder.build().map_err(map_update_error)
}
fn build_updater_with_target(
target_version: &str,
current_version: &str,
show_progress: bool,
) -> Result<Box<dyn ReleaseUpdate>> {
let mut builder = github::Update::configure();
builder
.repo_owner(REPO_OWNER)
.repo_name(REPO_NAME)
.bin_name(BIN_NAME)
.target(asset_target_name())
.show_download_progress(show_progress)
.no_confirm(true)
.current_version(current_version)
.target_version_tag(target_version);
if let Some(token) = resolve_auth_token() {
tracing::debug!("Using GitHub auth token from environment");
builder.auth_token(&token);
}
builder.build().map_err(map_update_error)
}
fn map_update_error<E: std::error::Error + Send + Sync + 'static>(err: E) -> BeadsError {
BeadsError::Other(anyhow::Error::from(err))
}
fn version_newer(new: &str, current: &str) -> bool {
let parse_version = |v: &str| -> Vec<u32> {
v.trim_start_matches('v')
.split('.')
.filter_map(|s| s.parse().ok())
.collect()
};
let new_parts = parse_version(new);
let current_parts = parse_version(current);
for (n, c) in new_parts.iter().zip(current_parts.iter()) {
match n.cmp(c) {
std::cmp::Ordering::Greater => return true,
std::cmp::Ordering::Less => return false,
std::cmp::Ordering::Equal => {} }
}
new_parts.len() > current_parts.len()
}
fn render_check_rich(result: &UpdateCheckResult, ctx: &OutputContext) {
let console = Console::default();
let theme = ctx.theme();
let width = ctx.width();
let mut content = Text::new("");
content.append_styled("Current version: ", theme.dimmed.clone());
content.append_styled(&result.current_version, theme.emphasis.clone());
content.append("\n");
content.append_styled("Latest version: ", theme.dimmed.clone());
if result.update_available {
content.append_styled(&result.latest_version, theme.success.clone());
content.append_styled(" ✓ NEW", theme.success.clone());
} else {
content.append_styled(&result.latest_version, theme.emphasis.clone());
}
content.append("\n\n");
if result.update_available {
content.append_styled("↑ ", theme.success.clone());
content.append_styled("Update available! ", theme.success.clone());
content.append("Run ");
content.append_styled("`br upgrade`", theme.accent.clone());
content.append(" to install.\n");
} else {
content.append_styled("✓ ", theme.success.clone());
content.append("Already up to date\n");
}
let panel = Panel::from_rich_text(&content, width)
.title(Text::styled("Upgrade Check", theme.panel_title.clone()))
.box_style(theme.box_style);
console.print_renderable(&panel);
}
fn render_dry_run_rich(
current_version: &str,
target_version: &str,
download_url: &str,
would_update: bool,
ctx: &OutputContext,
) {
let console = Console::default();
let theme = ctx.theme();
let width = ctx.width();
let mut content = Text::new("");
content.append_styled("⚡ Dry-run mode ", theme.warning.clone());
content.append_styled("(no changes will be made)\n\n", theme.dimmed.clone());
content.append_styled("Current version: ", theme.dimmed.clone());
content.append_styled(current_version, theme.emphasis.clone());
content.append("\n");
content.append_styled("Target version: ", theme.dimmed.clone());
content.append_styled(target_version, theme.emphasis.clone());
content.append("\n");
content.append_styled("Would download: ", theme.dimmed.clone());
content.append_styled(download_url, theme.accent.clone());
content.append("\n");
content.append_styled("Would install: ", theme.dimmed.clone());
if would_update {
content.append_styled("yes", theme.success.clone());
} else {
content.append_styled("no (already up to date)", theme.warning.clone());
}
content.append("\n\n");
content.append_styled("No changes made.", theme.dimmed.clone());
content.append("\n");
let panel = Panel::from_rich_text(&content, width)
.title(Text::styled("Upgrade Dry Run", theme.panel_title.clone()))
.box_style(theme.box_style);
console.print_renderable(&panel);
}
fn render_up_to_date_rich(current_version: &str, latest_version: &str, ctx: &OutputContext) {
let console = Console::default();
let theme = ctx.theme();
let width = ctx.width();
let mut content = Text::new("");
content.append_styled("Current version: ", theme.dimmed.clone());
content.append_styled(current_version, theme.emphasis.clone());
content.append("\n");
content.append_styled("Latest version: ", theme.dimmed.clone());
content.append_styled(latest_version, theme.emphasis.clone());
content.append("\n\n");
content.append_styled("✓ ", theme.success.clone());
content.append("Already up to date\n");
let panel = Panel::from_rich_text(&content, width)
.title(Text::styled("Upgrade Status", theme.panel_title.clone()))
.box_style(theme.box_style);
console.print_renderable(&panel);
}
fn render_upgrade_result_rich(result: &UpdateResult, current_version: &str, ctx: &OutputContext) {
let console = Console::default();
let theme = ctx.theme();
let width = ctx.width();
let mut content = Text::new("");
content.append_styled("✓ ", theme.success.clone());
if result.updated {
content.append_styled("Upgraded ", theme.success.clone());
content.append("br from ");
content.append_styled(current_version, theme.dimmed.clone());
content.append(" to ");
content.append_styled(&result.new_version, theme.success.clone());
content.append("\n");
} else {
content.append("Already up to date\n");
}
let panel = Panel::from_rich_text(&content, width)
.title(Text::styled("Upgrade Complete", theme.panel_title.clone()))
.box_style(theme.box_style);
console.print_renderable(&panel);
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_version_comparison_basic() {
assert!(version_newer("0.2.0", "0.1.0"));
assert!(version_newer("1.0.0", "0.9.0"));
assert!(version_newer("0.1.1", "0.1.0"));
}
#[test]
fn test_version_comparison_double_digits() {
assert!(version_newer("0.10.0", "0.9.0"));
assert!(version_newer("0.10.0", "0.2.0"));
assert!(version_newer("1.0.0", "0.99.99"));
}
#[test]
fn test_version_comparison_equal() {
assert!(!version_newer("0.1.0", "0.1.0"));
assert!(!version_newer("1.0.0", "1.0.0"));
}
#[test]
fn test_version_comparison_older() {
assert!(!version_newer("0.1.0", "0.2.0"));
assert!(!version_newer("0.9.0", "1.0.0"));
}
#[test]
fn test_version_with_v_prefix() {
assert!(version_newer("v0.2.0", "v0.1.0"));
assert!(version_newer("v0.2.0", "0.1.0"));
assert!(version_newer("0.2.0", "v0.1.0"));
}
#[test]
fn test_version_more_parts() {
assert!(version_newer("0.1.0.1", "0.1.0"));
assert!(!version_newer("0.1.0", "0.1.0.1"));
}
}