diff --git a/src/init.rs b/src/init.rs index 9f1e3b4..47e6659 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/main.rs b/src/main.rs index 8de7269..2688115 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 bbac175..4d0fb36 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"); diff --git a/src/switch.rs b/src/switch.rs index 127455c..fe35fa7 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 {