#![allow(dead_code)]
use serde::{Deserialize, Serialize};
use std::path::{Path, PathBuf};
use std::process::{Command, Stdio};
const MIN_BD_VERSION: &str = "0.46.0";
const MAX_BD_VERSION_EXCLUSIVE: &str = "0.50.0";
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct BinaryVersion {
pub binary: String,
pub path: PathBuf,
pub version: String,
pub commit: Option<String>,
pub build_date: Option<String>,
#[serde(default)]
pub raw_output: String,
}
impl BinaryVersion {
pub fn to_json(&self) -> serde_json::Value {
serde_json::json!({
"binary": self.binary,
"path": self.path.display().to_string(),
"version": self.version,
"commit": self.commit,
"build_date": self.build_date,
})
}
}
#[derive(Debug, Clone)]
pub struct DiscoveredBinaries {
pub br: BinaryVersion,
pub bd: Option<BinaryVersion>,
}
impl DiscoveredBinaries {
pub const fn bd_available(&self) -> bool {
self.bd.is_some()
}
pub fn require_bd(&self) -> Result<&BinaryVersion, String> {
self.bd.as_ref().ok_or_else(|| {
"bd (Go beads) binary not found. Conformance tests require bd to be installed.\n\
Install from: https://github.com/steveyegge/beads\n\
Or set BD_BINARY env var to the path."
.to_string()
})
}
pub fn to_json(&self) -> serde_json::Value {
serde_json::json!({
"br": self.br.to_json(),
"bd": self.bd.as_ref().map(BinaryVersion::to_json),
"conformance_ready": self.bd_available(),
})
}
}
fn discover_br() -> Result<BinaryVersion, String> {
if let Ok(br_path) = std::env::var("BR_BINARY") {
let path = PathBuf::from(&br_path);
if path.exists() {
return probe_binary("br", &path);
}
return Err(format!("BR_BINARY={br_path} does not exist"));
}
let cargo_bin = assert_cmd::cargo::cargo_bin!("br");
if cargo_bin.exists() {
return probe_binary("br", cargo_bin);
}
let manifest_dir = PathBuf::from(env!("CARGO_MANIFEST_DIR"));
let release_bin = manifest_dir.join("target/release/br");
if release_bin.exists() {
return probe_binary("br", &release_bin);
}
if let Some(path) = which("br") {
return probe_binary("br", &path);
}
Err("br binary not found. Build with `cargo build` first.".to_string())
}
fn discover_bd() -> Option<BinaryVersion> {
if let Ok(bd_path) = std::env::var("BD_BINARY") {
let path = PathBuf::from(&bd_path);
if path.exists() {
return probe_binary("bd", &path).ok();
}
eprintln!("Warning: BD_BINARY={bd_path} does not exist");
return None;
}
let home = std::env::var("HOME")
.ok()
.map(PathBuf::from)
.unwrap_or_default();
let mut common_paths = vec![
PathBuf::from("/usr/local/bin/bd"),
PathBuf::from("/usr/bin/bd"),
];
if !home.as_os_str().is_empty() {
common_paths.push(home.join(".local/bin/bd"));
common_paths.push(home.join("go/bin/bd"));
}
for path in common_paths {
if path.exists()
&& let Ok(version) = probe_binary("bd", &path)
{
return Some(version);
}
}
if let Some(path) = which("bd") {
return probe_binary("bd", &path).ok();
}
None
}
fn probe_binary(name: &str, path: &Path) -> Result<BinaryVersion, String> {
if name == "bd"
&& let Some(output) = run_version_command(path, &["version"])
&& looks_like_br(&output)
{
return Err(format!(
"bd binary at {} appears to be br; set BD_BINARY to real bd",
path.display()
));
}
let json_output = run_version_command(path, &["version", "--json"]);
if let Some(output) = json_output
&& let Ok(parsed) = parse_json_version(&output)
{
return Ok(BinaryVersion {
binary: name.to_string(),
path: path.to_path_buf(),
version: parsed.version,
commit: parsed.commit,
build_date: parsed.build_date,
raw_output: output,
});
}
let plain_output = run_version_command(path, &["--version"]);
if let Some(output) = plain_output {
let version = parse_plain_version(&output);
return Ok(BinaryVersion {
binary: name.to_string(),
path: path.to_path_buf(),
version,
commit: None,
build_date: None,
raw_output: output,
});
}
let check_output = run_version_command(path, &["--help"]);
if check_output.is_some() {
return Ok(BinaryVersion {
binary: name.to_string(),
path: path.to_path_buf(),
version: "unknown".to_string(),
commit: None,
build_date: None,
raw_output: check_output.unwrap_or_default(),
});
}
Err(format!(
"Binary at {} does not respond to version commands",
path.display()
))
}
fn looks_like_br(output: &str) -> bool {
output.trim_start().starts_with("br ")
}
fn run_version_command(binary: &Path, args: &[&str]) -> Option<String> {
let output = Command::new(binary)
.args(args)
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.output()
.ok()?;
if output.status.success() {
Some(String::from_utf8_lossy(&output.stdout).to_string())
} else {
None
}
}
#[derive(Debug, Deserialize)]
struct JsonVersion {
version: String,
commit: Option<String>,
build_date: Option<String>,
}
fn parse_json_version(output: &str) -> Result<JsonVersion, serde_json::Error> {
let json_start = output.find('{').unwrap_or(0);
serde_json::from_str(&output[json_start..])
}
fn parse_plain_version(output: &str) -> String {
let output = output.trim();
for word in output.split_whitespace() {
if word.chars().next().is_some_and(|c| c.is_ascii_digit()) {
let version: String = word
.chars()
.take_while(|c| c.is_ascii_alphanumeric() || *c == '.' || *c == '-')
.collect();
if !version.is_empty() {
return version;
}
}
}
"unknown".to_string()
}
fn which(name: &str) -> Option<PathBuf> {
std::env::var_os("PATH").and_then(|paths| {
std::env::split_paths(&paths).find_map(|dir| {
let path = dir.join(name);
if path.exists() && path.is_file() {
Some(path)
} else {
None
}
})
})
}
pub fn discover_binaries() -> Result<DiscoveredBinaries, String> {
let br = discover_br()?;
let bd = discover_bd();
Ok(DiscoveredBinaries { br, bd })
}
pub fn check_bd_version(version: &BinaryVersion) -> Result<(), String> {
let current = &version.version;
if current == "unknown" || current.contains("dev") {
return Ok(());
}
if compare_versions(current, MIN_BD_VERSION).is_lt() {
return Err(format!(
"bd version {current} is below minimum required version {MIN_BD_VERSION}. Please upgrade bd."
));
}
Ok(())
}
pub fn bd_binary_name() -> String {
std::env::var("BD_BINARY").unwrap_or_else(|_| "bd".to_string())
}
pub fn bd_skip_reason() -> Option<String> {
let bd_bin = bd_binary_name();
let Some(stdout) = run_version_command(Path::new(&bd_bin), &["version"]) else {
return Some(format!(
"'{bd_bin}' did not respond to `bd version`. {REMEDY}"
));
};
if looks_like_br(&stdout) {
return Some(format!(
"'{bd_bin}' is br, not Go bd — conformance would compare br against itself. {REMEDY}"
));
}
let first_token = stdout
.split_whitespace()
.next()
.map(str::to_ascii_lowercase)
.unwrap_or_default();
if !matches!(first_token.as_str(), "bd" | "beads") {
return Some(format!(
"'{bd_bin}' does not identify itself as bd (`bd version` printed {stdout:?}). {REMEDY}"
));
}
let version = parse_plain_version(&stdout);
if version == "unknown" {
return None;
}
if compare_versions(&version, MIN_BD_VERSION).is_lt() {
return Some(format!(
"bd {version} at '{bd_bin}' predates the conformance baseline \
(minimum {MIN_BD_VERSION}). {REMEDY}"
));
}
if compare_versions(&version, MAX_BD_VERSION_EXCLUSIVE).is_ge() {
return Some(format!(
"bd {version} at '{bd_bin}' is past the classic architecture br is frozen against \
(requires < {MAX_BD_VERSION_EXCLUSIVE}). bd v0.50+ defaults to Dolt and to \
JSONL-only `no-db` mode, so it exposes no SQLite schema, writes a different \
config.yaml, and prints migration banners on every command. {REMEDY}"
));
}
None
}
const REMEDY: &str = "Point BD_BINARY at a classic Go bd, e.g. \
`git clone --depth 1 --branch v0.46.0 https://github.com/steveyegge/beads.git \
&& cd beads && go build -o bd ./cmd/bd`.";
pub fn bd_available() -> bool {
bd_skip_reason().is_none()
}
fn compare_versions(a: &str, b: &str) -> std::cmp::Ordering {
let parse = |s: &str| -> Vec<u32> {
s.split(|c: char| !c.is_ascii_digit())
.filter_map(|p| p.parse().ok())
.collect()
};
let av = parse(a);
let bv = parse(b);
av.cmp(&bv)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_discover_br() {
let result = discover_br();
assert!(result.is_ok(), "br should be discoverable: {result:?}");
let version = result.unwrap();
assert_eq!(version.binary, "br");
assert!(version.path.exists());
}
#[test]
fn test_discover_binaries() {
let result = discover_binaries();
assert!(result.is_ok(), "Binary discovery failed: {result:?}");
let binaries = result.unwrap();
assert_eq!(binaries.br.binary, "br");
if binaries.bd_available() {
let bd = binaries.bd.as_ref().unwrap();
assert_eq!(bd.binary, "bd");
}
}
#[test]
fn test_parse_plain_version() {
assert_eq!(parse_plain_version("br 0.1.0"), "0.1.0");
assert_eq!(parse_plain_version("beads 0.5.2"), "0.5.2");
assert_eq!(parse_plain_version("0.1.0-dev"), "0.1.0-dev");
assert_eq!(parse_plain_version("no version"), "unknown");
}
#[test]
fn test_compare_versions() {
use std::cmp::Ordering;
assert_eq!(compare_versions("0.1.0", "0.1.0"), Ordering::Equal);
assert_eq!(compare_versions("0.2.0", "0.1.0"), Ordering::Greater);
assert_eq!(compare_versions("0.1.0", "0.2.0"), Ordering::Less);
assert_eq!(compare_versions("1.0.0", "0.5.0"), Ordering::Greater);
}
#[test]
fn test_classic_bd_version_window() {
let in_window = |v: &str| {
compare_versions(v, MIN_BD_VERSION).is_ge()
&& compare_versions(v, MAX_BD_VERSION_EXCLUSIVE).is_lt()
};
assert!(in_window("0.46.0"), "minimum is inclusive");
assert!(in_window("0.47.2"));
assert!(in_window("0.49.6"), "last classic release");
assert!(!in_window("0.40.0"), "0.4x must not slip past the floor");
assert!(!in_window("0.45.0"));
assert!(!in_window("0.50.0"), "maximum is exclusive");
assert!(!in_window("0.50.3"), "upstream default install");
assert!(!in_window("0.59.0"));
assert!(!in_window("1.0.0"));
assert!(!in_window("0.4.9"));
}
#[test]
fn test_bd_skip_reason_messages_are_actionable() {
if let Some(reason) = bd_skip_reason() {
assert!(
reason.contains("BD_BINARY"),
"skip reason must name BD_BINARY: {reason}"
);
}
}
#[test]
fn test_discovered_binaries_json() {
let binaries = discover_binaries().expect("discovery failed");
let json = binaries.to_json();
assert!(json.get("br").is_some());
assert!(json.get("conformance_ready").is_some());
}
}