From 770bf9e63ac566b8cd2d71706f04be013dade824 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?C=C3=A9dric=20Halber?= Date: Fri, 29 May 2026 20:35:29 -0700 Subject: [PATCH 1/4] fix: --json flag doing nothing --- src/main.rs | 55 +++++++++++++++++++++++++++++++++- src/self_update.rs | 74 +++++++++++++++++++++++++++++++++++++++++----- 2 files changed, 121 insertions(+), 8 deletions(-) diff --git a/src/main.rs b/src/main.rs index 8de7269f..26881151 100644 --- a/src/main.rs +++ b/src/main.rs @@ -38,7 +38,7 @@ mod utils; use crate::args::{has_explicit_profile_arg, ArgValueSource, BaseArgs, CLIArgs}; const DEFAULT_CANARY_VERSION: &str = concat!(env!("CARGO_PKG_VERSION"), "-canary.dev"); -const CLI_VERSION: &str = match option_env!("BT_VERSION_STRING") { +pub(crate) const CLI_VERSION: &str = match option_env!("BT_VERSION_STRING") { Some(version) => version, None => DEFAULT_CANARY_VERSION, }; @@ -247,6 +247,23 @@ fn main() { std::process::exit(exit_code as i32); } +fn handle_version_json(argv: &[OsString]) -> bool { + let mut saw_version = false; + let mut saw_json = false; + for arg in argv.iter().skip(1).filter_map(|a| a.to_str()) { + if arg == "--" { + break; + } + saw_version |= arg == "--version" || arg == "-V"; + saw_json |= arg == "--json"; + } + if !(saw_version && saw_json) { + return false; + } + println!("{}", serde_json::json!({ "version": CLI_VERSION })); + true +} + fn apply_runtime_env_overrides(base: &BaseArgs) { // Apply the CLI-owned override once so reqwest and inherited child // commands consistently observe BRAINTRUST_CA_CERT/--ca-cert precedence @@ -260,6 +277,10 @@ fn try_main() -> Result<()> { let argv: Vec = std::env::args_os().collect(); env::bootstrap_from_args(&argv)?; + if handle_version_json(&argv) { + return Ok(()); + } + let matches = Cli::command().get_matches_from(&argv); let mut cli = Cli::from_arg_matches(&matches).expect("clap matches should parse"); apply_base_arg_sources(&matches, cli.command.base_mut()); @@ -588,4 +609,36 @@ mod tests { assert!(!cli.command.base().quiet); assert!(cli.command.base().verbose); } + + fn argv(parts: &[&str]) -> Vec { + parts.iter().map(OsString::from).collect() + } + + #[test] + fn handle_version_json_detects_long_form() { + assert!(handle_version_json(&argv(&["bt", "--version", "--json"]))); + assert!(handle_version_json(&argv(&["bt", "--json", "--version"]))); + } + + #[test] + fn handle_version_json_detects_short_form() { + assert!(handle_version_json(&argv(&["bt", "-V", "--json"]))); + } + + #[test] + fn handle_version_json_requires_both_flags() { + assert!(!handle_version_json(&argv(&["bt", "--version"]))); + assert!(!handle_version_json(&argv(&["bt", "--json", "status"]))); + } + + #[test] + fn handle_version_json_ignores_args_after_double_dash() { + assert!(!handle_version_json(&argv(&[ + "bt", + "eval", + "--", + "--version", + "--json", + ]))); + } } diff --git a/src/self_update.rs b/src/self_update.rs index bbac175f..4d0fb368 100644 --- a/src/self_update.rs +++ b/src/self_update.rs @@ -80,6 +80,8 @@ const BUILD_UPDATE_CHANNEL: Option<&str> = option_env!("BT_UPDATE_CHANNEL"); #[derive(Debug, Deserialize)] struct GitHubRelease { tag_name: String, + #[serde(default)] + target_commitish: Option, } pub async fn run(base: BaseArgs, args: SelfArgs) -> Result<()> { @@ -104,7 +106,7 @@ async fn run_update(base: &BaseArgs, args: UpdateArgs) -> Result<()> { Ok(release) => { let current = env!("CARGO_PKG_VERSION"); if stable_is_up_to_date(current, &release.tag_name) { - println!("{}", stable_check_message(current, &release.tag_name)); + print_stable_check(base, current, &release.tag_name); return Ok(()); } } @@ -140,17 +142,43 @@ async fn check_for_update(base: &BaseArgs, channel: UpdateChannel) -> Result<()> let current = env!("CARGO_PKG_VERSION"); match channel { - UpdateChannel::Stable => { - println!("{}", stable_check_message(current, &release.tag_name)); - } - UpdateChannel::Canary => { - println!("{}", canary_check_message(&release.tag_name)); - } + UpdateChannel::Stable => print_stable_check(base, current, &release.tag_name), + UpdateChannel::Canary => print_canary_check(base, &release), } Ok(()) } +fn print_stable_check(base: &BaseArgs, current: &str, release_tag: &str) { + if base.json { + let payload = serde_json::json!({ + "channel": "stable", + "current": current, + "latest": release_tag, + "up_to_date": stable_is_up_to_date(current, release_tag), + }); + println!("{payload}"); + } else { + println!("{}", stable_check_message(current, release_tag)); + } +} + +fn print_canary_check(base: &BaseArgs, release: &GitHubRelease) { + if base.json { + let payload = serde_json::json!({ + "channel": "canary", + "latest": release.tag_name, + "up_to_date": canary_is_up_to_date( + crate::CLI_VERSION, + release.target_commitish.as_deref(), + ), + }); + println!("{payload}"); + } else { + println!("{}", canary_check_message(&release.tag_name)); + } +} + async fn fetch_release(_base: &BaseArgs, channel: UpdateChannel) -> Result { let client = crate::http::build_http_client_from_builder( Client::builder() @@ -329,6 +357,16 @@ fn stable_check_message(current: &str, release_tag: &str) -> String { format!("update available on stable channel: current={current}, latest={release_tag}") } +fn canary_is_up_to_date(current_version: &str, target_commitish: Option<&str>) -> bool { + let Some((_, local_sha)) = current_version.rsplit_once("-canary.") else { + return false; + }; + if local_sha.is_empty() || local_sha == "dev" { + return false; + } + target_commitish.is_some_and(|target| target.starts_with(local_sha)) +} + fn stable_is_up_to_date(current: &str, release_tag: &str) -> bool { let latest = release_tag.trim_start_matches('v'); latest == current @@ -432,6 +470,28 @@ mod tests { assert!(msg.contains("latest=v0.2.0")); } + #[test] + fn canary_up_to_date_matches_target_commitish() { + assert!(canary_is_up_to_date( + "0.1.0-canary.abc123def456", + Some("abc123def456789012345678901234567890aaaa"), + )); + assert!(!canary_is_up_to_date( + "0.1.0-canary.abc123def456", + Some("ffffffffffffffffffffffffffffffffffffffff"), + )); + } + + #[test] + fn canary_up_to_date_false_for_dev_or_stable_builds() { + assert!(!canary_is_up_to_date("0.1.0-canary.dev", Some("abc"))); + assert!(!canary_is_up_to_date( + "0.1.0", + Some("abc123def456789012345678901234567890aaaa"), + )); + assert!(!canary_is_up_to_date("0.1.0-canary.abc123def456", None)); + } + #[test] fn canary_check_message_contains_guidance() { let msg = canary_check_message("canary-deadbeef"); From c093a50a53298f0a57e03914b6456f37283a6ae0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?C=C3=A9dric=20Halber?= Date: Fri, 29 May 2026 20:42:51 -0700 Subject: [PATCH 2/4] fix: init and switch not respecting --json flag --- src/init.rs | 42 +++++++++++++++++++++++++++++++++++------- src/switch.rs | 40 +++++++++++++++++++++++++++++++--------- 2 files changed, 66 insertions(+), 16 deletions(-) diff --git a/src/init.rs b/src/init.rs index 9f1e3b41..47e6659f 100644 --- a/src/init.rs +++ b/src/init.rs @@ -19,8 +19,23 @@ pub struct InitArgs {} pub async fn run(base: BaseArgs, _args: InitArgs) -> Result<()> { let bt_dir = std::env::current_dir()?.join(".bt"); - if bt_dir.join("config.json").exists() { - print_command_status(CommandStatus::Warning, "Already Initialized"); + let config_path = bt_dir.join("config.json"); + if config_path.exists() { + if base.json { + let existing = config::load_file(&config_path); + println!( + "{}", + serde_json::json!({ + "initialized": false, + "status": "already-initialized", + "org": existing.org, + "project": existing.project, + "path": config_path.display().to_string(), + }) + ); + } else { + print_command_status(CommandStatus::Warning, "Already Initialized"); + } return Ok(()); } @@ -61,11 +76,24 @@ pub async fn run(base: BaseArgs, _args: InitArgs) -> Result<()> { config::save_local(&cfg, true)?; - print_command_status( - CommandStatus::Success, - &format!("Project linked to {org}/{project}"), - ); - print_command_status(CommandStatus::Success, "Created .bt/config.json"); + if base.json { + println!( + "{}", + serde_json::json!({ + "initialized": true, + "status": "created", + "org": org, + "project": project, + "path": config_path.display().to_string(), + }) + ); + } else { + print_command_status( + CommandStatus::Success, + &format!("Project linked to {org}/{project}"), + ); + print_command_status(CommandStatus::Success, "Created .bt/config.json"); + } Ok(()) } diff --git a/src/switch.rs b/src/switch.rs index 127455cd..fe35fa79 100644 --- a/src/switch.rs +++ b/src/switch.rs @@ -129,18 +129,27 @@ pub async fn run(base: BaseArgs, args: SwitchArgs) -> Result<()> { } }; - let path = if args.local { - config::local_path().ok_or_else(|| { - anyhow::anyhow!( - "No local .bt directory found. Use bt init to initialize this directory." - ) - })? + let (path, scope) = if args.local { + ( + config::local_path().ok_or_else(|| { + anyhow::anyhow!( + "No local .bt directory found. Use bt init to initialize this directory." + ) + })?, + "local", + ) } else if args.global { - config::global_path()? + (config::global_path()?, "global") } else if interactive && config::local_path().is_some() { - select_scope()? + let chosen = select_scope()?; + let scope = if chosen == config::global_path()? { + "global" + } else { + "local" + }; + (chosen, scope) } else { - config::global_path()? + (config::global_path()?, "global") }; let mut cfg = config::load_file(&path); @@ -151,6 +160,19 @@ pub async fn run(base: BaseArgs, args: SwitchArgs) -> Result<()> { config::save_file(&path, &cfg) .context(format!("Could not save config to {}", path.display()))?; + if base.json { + let payload = serde_json::json!({ + "org": org_name, + "project": project.name, + "project_id": project.id, + "profile": config_profile, + "scope": scope, + "path": path.display().to_string(), + }); + println!("{payload}"); + return Ok(()); + } + let display = format!("{org_name}/{}", project.name); print_command_status(CommandStatus::Success, &format!("Switched to {display}")); if base.verbose { From 47fd220f51c3dcf7c72dc881743cba7bcb894747 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?C=C3=A9dric=20Halber?= Date: Mon, 1 Jun 2026 13:54:46 -0700 Subject: [PATCH 3/4] fix: various bugs --- src/config/mod.rs | 14 ++++++++---- src/init.rs | 41 +++++++++++++++------------------ src/main.rs | 57 ++++++++++++++++++++++++---------------------- src/self_update.rs | 47 ++++++++++++++++++++++++++------------ src/setup/mod.rs | 2 +- src/switch.rs | 16 ++++--------- 6 files changed, 97 insertions(+), 80 deletions(-) diff --git a/src/config/mod.rs b/src/config/mod.rs index adc9246a..779499d5 100644 --- a/src/config/mod.rs +++ b/src/config/mod.rs @@ -273,12 +273,18 @@ pub fn resolve_write_path(global: bool, local: bool) -> Result { } } -pub fn save_local(config: &Config, create_dir: bool) -> Result<()> { - let dir = std::env::current_dir()?.join(".bt"); +pub fn local_save_path() -> Result { + Ok(std::env::current_dir()?.join(".bt").join("config.json")) +} + +pub fn save_local(config: &Config, create_dir: bool) -> Result { + let path = local_save_path()?; + let dir = path.parent().expect(".bt parent directory"); if create_dir && !dir.exists() { - fs::create_dir_all(&dir)?; + fs::create_dir_all(dir)?; } - save_file(&dir.join("config.json"), config) + save_file(&path, config)?; + Ok(path) } // --- CLI commands --- diff --git a/src/init.rs b/src/init.rs index 47e6659f..e9976c1b 100644 --- a/src/init.rs +++ b/src/init.rs @@ -18,21 +18,18 @@ Examples: pub struct InitArgs {} pub async fn run(base: BaseArgs, _args: InitArgs) -> Result<()> { - let bt_dir = std::env::current_dir()?.join(".bt"); - let config_path = bt_dir.join("config.json"); + let config_path = config::local_save_path()?; if config_path.exists() { if base.json { let existing = config::load_file(&config_path); - println!( - "{}", - serde_json::json!({ - "initialized": false, - "status": "already-initialized", - "org": existing.org, - "project": existing.project, - "path": config_path.display().to_string(), - }) - ); + let payload = serde_json::json!({ + "initialized": false, + "status": "already-initialized", + "org": existing.org, + "project": existing.project, + "path": config_path.display().to_string(), + }); + println!("{}", serde_json::to_string(&payload)?); } else { print_command_status(CommandStatus::Warning, "Already Initialized"); } @@ -74,19 +71,17 @@ pub async fn run(base: BaseArgs, _args: InitArgs) -> Result<()> { ..Default::default() }; - config::save_local(&cfg, true)?; + let written_path = config::save_local(&cfg, true)?; if base.json { - println!( - "{}", - serde_json::json!({ - "initialized": true, - "status": "created", - "org": org, - "project": project, - "path": config_path.display().to_string(), - }) - ); + let payload = serde_json::json!({ + "initialized": true, + "status": "created", + "org": org, + "project": project, + "path": written_path.display().to_string(), + }); + println!("{}", serde_json::to_string(&payload)?); } else { print_command_status( CommandStatus::Success, diff --git a/src/main.rs b/src/main.rs index 26881151..68807d66 100644 --- a/src/main.rs +++ b/src/main.rs @@ -247,21 +247,28 @@ fn main() { std::process::exit(exit_code as i32); } -fn handle_version_json(argv: &[OsString]) -> bool { - let mut saw_version = false; - let mut saw_json = false; - for arg in argv.iter().skip(1).filter_map(|a| a.to_str()) { - if arg == "--" { - break; - } - saw_version |= arg == "--version" || arg == "-V"; - saw_json |= arg == "--json"; - } - if !(saw_version && saw_json) { - return false; +fn handle_version_json(argv: &[OsString]) -> Result { + use clap::{Arg, ArgAction, Command}; + let preflight = Command::new("bt-version-preflight") + .ignore_errors(true) + .disable_help_flag(true) + .disable_version_flag(true) + .arg( + Arg::new("version") + .long("version") + .short('V') + .action(ArgAction::Count), + ) + .arg(Arg::new("json").long("json").action(ArgAction::Count)); + let Ok(matches) = preflight.try_get_matches_from(argv) else { + return Ok(false); + }; + if matches.get_count("version") == 0 || matches.get_count("json") == 0 { + return Ok(false); } - println!("{}", serde_json::json!({ "version": CLI_VERSION })); - true + let payload = serde_json::json!({ "version": CLI_VERSION }); + println!("{}", serde_json::to_string(&payload)?); + Ok(true) } fn apply_runtime_env_overrides(base: &BaseArgs) { @@ -277,7 +284,7 @@ fn try_main() -> Result<()> { let argv: Vec = std::env::args_os().collect(); env::bootstrap_from_args(&argv)?; - if handle_version_json(&argv) { + if handle_version_json(&argv)? { return Ok(()); } @@ -616,29 +623,25 @@ mod tests { #[test] fn handle_version_json_detects_long_form() { - assert!(handle_version_json(&argv(&["bt", "--version", "--json"]))); - assert!(handle_version_json(&argv(&["bt", "--json", "--version"]))); + assert!(handle_version_json(&argv(&["bt", "--version", "--json"])).unwrap()); + assert!(handle_version_json(&argv(&["bt", "--json", "--version"])).unwrap()); } #[test] fn handle_version_json_detects_short_form() { - assert!(handle_version_json(&argv(&["bt", "-V", "--json"]))); + assert!(handle_version_json(&argv(&["bt", "-V", "--json"])).unwrap()); } #[test] fn handle_version_json_requires_both_flags() { - assert!(!handle_version_json(&argv(&["bt", "--version"]))); - assert!(!handle_version_json(&argv(&["bt", "--json", "status"]))); + assert!(!handle_version_json(&argv(&["bt", "--version"])).unwrap()); + assert!(!handle_version_json(&argv(&["bt", "--json", "status"])).unwrap()); } #[test] fn handle_version_json_ignores_args_after_double_dash() { - assert!(!handle_version_json(&argv(&[ - "bt", - "eval", - "--", - "--version", - "--json", - ]))); + assert!( + !handle_version_json(&argv(&["bt", "eval", "--", "--version", "--json",])).unwrap() + ); } } diff --git a/src/self_update.rs b/src/self_update.rs index 4d0fb368..e0993c33 100644 --- a/src/self_update.rs +++ b/src/self_update.rs @@ -106,7 +106,7 @@ async fn run_update(base: &BaseArgs, args: UpdateArgs) -> Result<()> { Ok(release) => { let current = env!("CARGO_PKG_VERSION"); if stable_is_up_to_date(current, &release.tag_name) { - print_stable_check(base, current, &release.tag_name); + print_stable_check(base, current, &release.tag_name)?; return Ok(()); } } @@ -118,7 +118,14 @@ async fn run_update(base: &BaseArgs, args: UpdateArgs) -> Result<()> { } } - run_installer(channel)?; + run_installer(base, channel)?; + if base.json { + let payload = serde_json::json!({ + "channel": channel.name(), + "status": "completed", + }); + println!("{}", serde_json::to_string(&payload)?); + } Ok(()) } @@ -142,14 +149,14 @@ async fn check_for_update(base: &BaseArgs, channel: UpdateChannel) -> Result<()> let current = env!("CARGO_PKG_VERSION"); match channel { - UpdateChannel::Stable => print_stable_check(base, current, &release.tag_name), - UpdateChannel::Canary => print_canary_check(base, &release), + UpdateChannel::Stable => print_stable_check(base, current, &release.tag_name)?, + UpdateChannel::Canary => print_canary_check(base, &release)?, } Ok(()) } -fn print_stable_check(base: &BaseArgs, current: &str, release_tag: &str) { +fn print_stable_check(base: &BaseArgs, current: &str, release_tag: &str) -> Result<()> { if base.json { let payload = serde_json::json!({ "channel": "stable", @@ -157,13 +164,14 @@ fn print_stable_check(base: &BaseArgs, current: &str, release_tag: &str) { "latest": release_tag, "up_to_date": stable_is_up_to_date(current, release_tag), }); - println!("{payload}"); + println!("{}", serde_json::to_string(&payload)?); } else { println!("{}", stable_check_message(current, release_tag)); } + Ok(()) } -fn print_canary_check(base: &BaseArgs, release: &GitHubRelease) { +fn print_canary_check(base: &BaseArgs, release: &GitHubRelease) -> Result<()> { if base.json { let payload = serde_json::json!({ "channel": "canary", @@ -173,10 +181,11 @@ fn print_canary_check(base: &BaseArgs, release: &GitHubRelease) { release.target_commitish.as_deref(), ), }); - println!("{payload}"); + println!("{}", serde_json::to_string(&payload)?); } else { println!("{}", canary_check_message(&release.tag_name)); } + Ok(()) } async fn fetch_release(_base: &BaseArgs, channel: UpdateChannel) -> Result { @@ -213,12 +222,21 @@ async fn fetch_release(_base: &BaseArgs, channel: UpdateChannel) -> Result Result<()> { +fn run_installer(base: &BaseArgs, channel: UpdateChannel) -> Result<()> { + let status_line = |msg: &str| { + if base.json { + eprintln!("{msg}"); + } else { + println!("{msg}"); + } + }; + let redirect_stdout = if base.json { " 1>&2" } else { "" }; + #[cfg(not(windows))] { let installer_url = channel.installer_url(); - println!("updating bt from {} channel...", channel.name()); - let cmd = format!("curl -fsSL '{installer_url}' | sh"); + status_line(&format!("updating bt from {} channel...", channel.name())); + let cmd = format!("curl -fsSL '{installer_url}' | sh{redirect_stdout}"); let mut command = Command::new("sh"); command.arg("-c").arg(cmd); let status = command.status().context("failed to execute installer")?; @@ -227,7 +245,7 @@ fn run_installer(channel: UpdateChannel) -> Result<()> { anyhow::bail!("installer exited with status {status}"); } - println!("update completed"); + status_line("update completed"); Ok(()) } @@ -241,7 +259,8 @@ fn run_installer(channel: UpdateChannel) -> Result<()> { "https://github.com/braintrustdata/bt/releases/download/canary/bt-installer.ps1" } }; - let script = format!("irm {installer_url} | iex"); + status_line(&format!("updating bt from {} channel...", channel.name())); + let script = format!("irm {installer_url} | iex{redirect_stdout}"); let status = Command::new("powershell") .args([ "-NoProfile", @@ -256,7 +275,7 @@ fn run_installer(channel: UpdateChannel) -> Result<()> { anyhow::bail!("installer exited with status {status}"); } - println!("update completed"); + status_line("update completed"); return Ok(()); } } diff --git a/src/setup/mod.rs b/src/setup/mod.rs index 8ca28d61..7934899f 100644 --- a/src/setup/mod.rs +++ b/src/setup/mod.rs @@ -2352,7 +2352,7 @@ async fn select_project_for_auth_context( } fn maybe_init(org: &str, project: &crate::projects::api::Project) -> Result<()> { - let config_path = std::env::current_dir()?.join(".bt").join("config.json"); + let config_path = config::local_save_path()?; let mut cfg = if config_path.exists() { let existing = config::load_file(&config_path); let matches = existing.org.as_deref() == Some(org) diff --git a/src/switch.rs b/src/switch.rs index fe35fa79..2b340462 100644 --- a/src/switch.rs +++ b/src/switch.rs @@ -141,13 +141,7 @@ pub async fn run(base: BaseArgs, args: SwitchArgs) -> Result<()> { } else if args.global { (config::global_path()?, "global") } else if interactive && config::local_path().is_some() { - let chosen = select_scope()?; - let scope = if chosen == config::global_path()? { - "global" - } else { - "local" - }; - (chosen, scope) + select_scope()? } else { (config::global_path()?, "global") }; @@ -169,7 +163,7 @@ pub async fn run(base: BaseArgs, args: SwitchArgs) -> Result<()> { "scope": scope, "path": path.display().to_string(), }); - println!("{payload}"); + println!("{}", serde_json::to_string(&payload)?); return Ok(()); } @@ -182,7 +176,7 @@ pub async fn run(base: BaseArgs, args: SwitchArgs) -> Result<()> { Ok(()) } -fn select_scope() -> Result { +fn select_scope() -> Result<(std::path::PathBuf, &'static str)> { let global = config::global_path()?; let local = config::local_path().unwrap(); let options = [ @@ -225,9 +219,9 @@ fn select_scope() -> Result { .default(1) .interact()?; if idx == 0 { - Ok(global) + Ok((global, "global")) } else { - Ok(local) + Ok((local, "local")) } } From 91dc096a628b190ecfb146e3734023c00e46e5e2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?C=C3=A9dric=20Halber?= Date: Mon, 1 Jun 2026 15:10:09 -0700 Subject: [PATCH 4/4] chore: align latest and current field for bt self update --check --json --- src/self_update.rs | 112 ++++++++++++++++++++------------------------- 1 file changed, 49 insertions(+), 63 deletions(-) diff --git a/src/self_update.rs b/src/self_update.rs index e0993c33..f12f1f94 100644 --- a/src/self_update.rs +++ b/src/self_update.rs @@ -104,9 +104,8 @@ async fn run_update(base: &BaseArgs, args: UpdateArgs) -> Result<()> { if channel == UpdateChannel::Stable { match fetch_release(base, channel).await { Ok(release) => { - let current = env!("CARGO_PKG_VERSION"); - if stable_is_up_to_date(current, &release.tag_name) { - print_stable_check(base, current, &release.tag_name)?; + if stable_is_up_to_date(env!("CARGO_PKG_VERSION"), &release.tag_name) { + print_check(base, channel, &release)?; return Ok(()); } } @@ -146,44 +145,41 @@ fn ensure_installer_managed_install() -> Result<()> { async fn check_for_update(base: &BaseArgs, channel: UpdateChannel) -> Result<()> { let release = fetch_release(base, channel).await?; - let current = env!("CARGO_PKG_VERSION"); - - match channel { - UpdateChannel::Stable => print_stable_check(base, current, &release.tag_name)?, - UpdateChannel::Canary => print_canary_check(base, &release)?, - } - - Ok(()) + print_check(base, channel, &release) } -fn print_stable_check(base: &BaseArgs, current: &str, release_tag: &str) -> Result<()> { - if base.json { - let payload = serde_json::json!({ - "channel": "stable", - "current": current, - "latest": release_tag, - "up_to_date": stable_is_up_to_date(current, release_tag), - }); - println!("{}", serde_json::to_string(&payload)?); - } else { - println!("{}", stable_check_message(current, release_tag)); - } - Ok(()) -} +fn print_check(base: &BaseArgs, channel: UpdateChannel, release: &GitHubRelease) -> Result<()> { + let (current, latest, up_to_date, message) = match channel { + UpdateChannel::Stable => { + let current = env!("CARGO_PKG_VERSION").to_string(); + let latest = release.tag_name.clone(); + let up_to_date = stable_is_up_to_date(¤t, &latest); + let message = stable_check_message(¤t, &latest); + (current, latest, up_to_date, message) + } + UpdateChannel::Canary => { + // `current` is built by build.rs as `{CARGO_PKG_VERSION}-canary.{short_sha}`. + // Construct `latest` in the same shape so the two are comparable. + // The canary tag itself is always literally "canary"; the meaningful + // identifier is the commit it points at (target_commitish). + let current = crate::CLI_VERSION.to_string(); + let latest = format_canary_version(release.target_commitish.as_deref()); + let up_to_date = current == latest; + let message = canary_check_message(&latest); + (current, latest, up_to_date, message) + } + }; -fn print_canary_check(base: &BaseArgs, release: &GitHubRelease) -> Result<()> { if base.json { let payload = serde_json::json!({ - "channel": "canary", - "latest": release.tag_name, - "up_to_date": canary_is_up_to_date( - crate::CLI_VERSION, - release.target_commitish.as_deref(), - ), + "channel": channel.name(), + "current": current, + "latest": latest, + "up_to_date": up_to_date, }); println!("{}", serde_json::to_string(&payload)?); } else { - println!("{}", canary_check_message(&release.tag_name)); + println!("{message}"); } Ok(()) } @@ -376,14 +372,15 @@ fn stable_check_message(current: &str, release_tag: &str) -> String { format!("update available on stable channel: current={current}, latest={release_tag}") } -fn canary_is_up_to_date(current_version: &str, target_commitish: Option<&str>) -> bool { - let Some((_, local_sha)) = current_version.rsplit_once("-canary.") else { - return false; - }; - if local_sha.is_empty() || local_sha == "dev" { - return false; - } - target_commitish.is_some_and(|target| target.starts_with(local_sha)) +/// Format a canary version string to match the shape that `build.rs` bakes into +/// `CLI_VERSION`: `{CARGO_PKG_VERSION}-canary.{short_sha}`. Falls back to a +/// `dev`-style suffix when `target_commitish` is missing so an unparseable +/// release never accidentally compares equal to a local canary build. +fn format_canary_version(target_commitish: Option<&str>) -> String { + let short_sha = target_commitish + .map(|sha| &sha[..sha.len().min(12)]) + .unwrap_or("unknown"); + format!("{}-canary.{}", env!("CARGO_PKG_VERSION"), short_sha) } fn stable_is_up_to_date(current: &str, release_tag: &str) -> bool { @@ -391,10 +388,8 @@ fn stable_is_up_to_date(current: &str, release_tag: &str) -> bool { latest == current } -fn canary_check_message(release_tag: &str) -> String { - format!( - "latest canary release tag: {release_tag}\nrun `bt self update --channel canary` to install it" - ) +fn canary_check_message(latest: &str) -> String { + format!("latest canary release: {latest}\nrun `bt self update --channel canary` to install it") } fn parse_update_channel(raw: Option<&str>) -> Option { @@ -490,31 +485,22 @@ mod tests { } #[test] - fn canary_up_to_date_matches_target_commitish() { - assert!(canary_is_up_to_date( - "0.1.0-canary.abc123def456", - Some("abc123def456789012345678901234567890aaaa"), - )); - assert!(!canary_is_up_to_date( - "0.1.0-canary.abc123def456", - Some("ffffffffffffffffffffffffffffffffffffffff"), - )); + fn format_canary_version_matches_cli_version_shape() { + let pkg = env!("CARGO_PKG_VERSION"); + let formatted = format_canary_version(Some("abc123def456789012345678901234567890aaaa")); + assert_eq!(formatted, format!("{pkg}-canary.abc123def456")); } #[test] - fn canary_up_to_date_false_for_dev_or_stable_builds() { - assert!(!canary_is_up_to_date("0.1.0-canary.dev", Some("abc"))); - assert!(!canary_is_up_to_date( - "0.1.0", - Some("abc123def456789012345678901234567890aaaa"), - )); - assert!(!canary_is_up_to_date("0.1.0-canary.abc123def456", None)); + fn format_canary_version_falls_back_when_commitish_missing() { + let pkg = env!("CARGO_PKG_VERSION"); + assert_eq!(format_canary_version(None), format!("{pkg}-canary.unknown")); } #[test] fn canary_check_message_contains_guidance() { - let msg = canary_check_message("canary-deadbeef"); - assert!(msg.contains("canary-deadbeef")); + let msg = canary_check_message("0.10.0-canary.abc123def456"); + assert!(msg.contains("0.10.0-canary.abc123def456")); assert!(msg.contains("bt self update --channel canary")); }