use crate::config::Config;
#[cfg(target_os = "macos")]
use crate::error::Error as CliError;
use crate::Result;
use anyhow::anyhow;
use serde::{Deserialize, Serialize};
use std::collections::{HashMap, HashSet};
use std::env;
#[cfg(target_os = "macos")]
use std::io::Write;
#[cfg(target_os = "macos")]
use std::os::unix::fs::PermissionsExt;
use std::path::{Path, PathBuf};
use std::process::Command;
#[cfg(target_os = "macos")]
use std::sync::{Arc, OnceLock};
#[cfg(target_os = "macos")]
use tempfile::NamedTempFile;
#[cfg(target_os = "macos")]
type HomebrewLogger = Arc<dyn Fn(&str) + Send + Sync>;
#[cfg(target_os = "macos")]
static HOMEBREW_INSTALL_LOGGER: OnceLock<HomebrewLogger> = OnceLock::new();
#[cfg(target_os = "macos")]
const BREW_COMMON_PATHS: [&str; 3] = [
"/opt/homebrew/bin/brew",
"/usr/local/bin/brew",
"/home/linuxbrew/.linuxbrew/bin/brew",
];
#[cfg(target_os = "macos")]
const OPENJDK_OPT_BASES: [&str; 3] = [
"/opt/homebrew/opt",
"/usr/local/opt",
"/home/linuxbrew/.linuxbrew/opt",
];
#[cfg(target_os = "macos")]
fn resolve_brew_command() -> Option<String> {
if let Ok(path) = which::which("brew") {
return Some(path.display().to_string());
}
BREW_COMMON_PATHS
.iter()
.map(Path::new)
.find(|candidate| candidate.exists())
.map(|path| path.display().to_string())
}
#[cfg(target_os = "macos")]
fn java_bin_from_prefix(prefix: &str) -> Option<String> {
let prefix_path = Path::new(prefix);
let direct = prefix_path.join("bin/java");
if direct.exists() {
return Some(prefix_path.join("bin").display().to_string());
}
let alt = prefix_path
.join("libexec")
.join("openjdk.jdk")
.join("Contents")
.join("Home")
.join("bin/java");
if alt.exists() {
let bin_dir = alt.parent()?;
return Some(bin_dir.display().to_string());
}
None
}
fn is_valid_java_binary(path: &str) -> bool {
Command::new(path)
.arg("-version")
.output()
.map(|output| {
if !output.status.success() {
return false;
}
let stderr = String::from_utf8_lossy(&output.stderr);
let stdout = String::from_utf8_lossy(&output.stdout);
let combined = format!("{}\n{}", stderr, stdout);
combined.contains("version")
&& (combined.contains("java") || combined.contains("openjdk"))
&& !combined.contains("Unable to locate a Java Runtime")
})
.unwrap_or(false)
}
fn adjust_java_binary(mut current: Option<String>) -> Option<String> {
if let Some(ref path) = current {
if !is_valid_java_binary(path) {
current = None;
}
}
#[cfg(target_os = "macos")]
{
if current.is_none() {
if let Some(brew_bin) = check_java_in_brew_not_in_path() {
let candidate = format!("{}/java", brew_bin);
if is_valid_java_binary(&candidate) {
return Some(candidate);
}
}
if let Ok(output) = Command::new("/usr/libexec/java_home").output() {
if output.status.success() {
let path = String::from_utf8_lossy(&output.stdout).trim().to_string();
if !path.is_empty() {
let candidate = format!("{}/bin/java", path);
if is_valid_java_binary(&candidate) {
return Some(candidate);
}
}
}
}
let fallback_paths = [
"/Library/Internet Plug-Ins/JavaAppletPlugin.plugin/Contents/Home/bin/java",
"/Library/Java/JavaVirtualMachines/openjdk.jdk/Contents/Home/bin/java",
"/Library/Java/JavaVirtualMachines/temurin-21.jdk/Contents/Home/bin/java",
"/Library/Java/JavaVirtualMachines/temurin-17.jdk/Contents/Home/bin/java",
];
for path in fallback_paths.iter() {
if Path::new(path).exists() && is_valid_java_binary(path) {
return Some(path.to_string());
}
}
}
}
current
}
fn version_path_from_sources<'a>(
primary: &'a Option<String>,
fallback: &'a Option<String>,
) -> Option<&'a str> {
primary
.as_ref()
.map(|s| s.as_str())
.or_else(|| fallback.as_ref().map(|s| s.as_str()))
}
fn get_java_version_string(path: Option<&str>) -> Option<String> {
let command = path.unwrap_or("java");
let output = Command::new(command).arg("-version").output().ok()?;
if !output.status.success() {
return None;
}
let version_str = String::from_utf8_lossy(&output.stderr);
parse_java_version(&version_str).map(|v| v.to_string())
}
fn check_java_version_with_info_at(path: Option<&str>, min_version: u32) -> (bool, Option<String>) {
let command = path.unwrap_or("java");
let output = Command::new(command).arg("-version").output();
match output {
Ok(output) => {
let version_str = String::from_utf8_lossy(&output.stderr);
if let Some(version) = parse_java_version(&version_str) {
(version >= min_version, Some(version.to_string()))
} else {
(false, None)
}
}
Err(_) => (false, None),
}
}
pub fn set_homebrew_install_logger<F>(logger: F)
where
F: Fn(&str) + Send + Sync + 'static,
{
#[cfg(target_os = "macos")]
{
let _ = HOMEBREW_INSTALL_LOGGER.set(Arc::new(logger));
}
#[cfg(not(target_os = "macos"))]
{
let _ = logger;
}
}
#[cfg(target_os = "macos")]
fn log_homebrew_install(message: &str) {
if let Some(callback) = HOMEBREW_INSTALL_LOGGER.get() {
callback(message);
} else {
eprintln!("🍺 {message}");
}
}
#[derive(Debug, Serialize, Deserialize)]
pub struct DependencyConfig {
pub dependencies: Vec<Dependency>,
}
#[derive(Debug, Serialize, Deserialize)]
pub struct Dependency {
pub name: String,
pub check_running: bool,
#[serde(default)]
pub min_version: Option<u32>,
pub install_instructions: String,
pub description: String,
#[serde(default)]
pub website: Option<String>,
#[serde(default)]
pub environments: Option<HashMap<String, EnvironmentConfig>>,
#[serde(default)]
pub package_manager: bool,
#[serde(default)]
pub os_filter: Option<Vec<String>>,
}
#[derive(Debug, Serialize, Deserialize)]
pub struct EnvironmentConfig {
#[serde(default)]
pub install_commands: Option<Vec<String>>,
#[serde(default)]
pub verify_command: Option<String>,
#[serde(default)]
pub skip: bool,
#[serde(default)]
pub skip_reason: Option<String>,
}
#[derive(Debug, Serialize, Deserialize)]
pub struct DependencyCheckResult {
pub dependencies: Vec<DependencyResult>,
pub all_satisfied: bool,
}
#[derive(Debug, Serialize, Deserialize)]
pub struct DependencyResult {
pub name: String,
pub found: bool,
pub path: Option<String>,
pub version: Option<String>,
pub running: Option<bool>,
pub skipped: bool,
pub skip_reason: Option<String>,
pub description: Option<String>,
pub website: Option<String>,
pub install_instructions: Option<String>,
}
pub fn check_single_dependency(
name: &str,
custom_path: Option<String>,
) -> Result<DependencyResult> {
let deps_yaml = include_str!("../../deps.yaml");
let config: DependencyConfig = serde_yaml::from_str(deps_yaml)?;
let dep = config
.dependencies
.iter()
.find(|d| d.name == name)
.ok_or_else(|| anyhow!("Dependency '{}' not found", name))?;
let is_colab = is_google_colab();
if is_colab {
if let Some(environments) = &dep.environments {
if let Some(colab_config) = environments.get("google_colab") {
if colab_config.skip {
return Ok(DependencyResult {
name: dep.name.clone(),
found: false,
path: None,
version: None,
running: None,
skipped: true,
skip_reason: colab_config.skip_reason.clone(),
description: Some(dep.description.clone()),
website: dep.website.clone(),
install_instructions: Some(dep.install_instructions.clone()),
});
}
}
}
}
let forced_missing: Option<HashSet<String>> = std::env::var("BIOVAULT_FORCE_MISSING_DEPS")
.ok()
.map(|value| {
value
.split(',')
.map(|item| item.trim().to_lowercase())
.filter(|item| !item.is_empty())
.collect()
});
let should_force_missing = forced_missing.as_ref().is_some_and(|set| {
set.contains("all") || set.contains("*") || set.contains(&dep.name.to_lowercase())
});
if should_force_missing {
eprintln!(
"🔧 Forcing dependency '{}' to appear missing (BIOVAULT_FORCE_MISSING_DEPS)",
dep.name
);
return Ok(DependencyResult {
name: dep.name.clone(),
found: false,
path: custom_path.clone(),
version: None,
running: None,
skipped: false,
skip_reason: None,
description: Some(dep.description.clone()),
website: dep.website.clone(),
install_instructions: Some(dep.install_instructions.clone()),
});
}
let mut fallback_path: Option<String> = custom_path.clone();
let direct_path = if let Some(custom) = custom_path.as_ref() {
if Path::new(custom).exists() {
let is_valid = match dep.name.as_str() {
"java" => is_valid_java_binary(custom),
"docker" => Command::new(custom)
.arg("--version")
.output()
.map(|output| {
let version_str = String::from_utf8_lossy(&output.stdout);
version_str.contains("Docker")
})
.unwrap_or(false),
"nextflow" => Command::new(custom)
.arg("-version")
.output()
.map(|output| {
let version_str = String::from_utf8_lossy(&output.stdout);
version_str.contains("nextflow") || version_str.contains("version")
})
.unwrap_or(false),
"syftbox" => Command::new(custom)
.arg("--version")
.output()
.map(|output| {
let version_str = String::from_utf8_lossy(&output.stdout);
version_str.contains("syftbox") || version_str.contains("version")
})
.unwrap_or(false),
_ => true,
};
if is_valid {
Some(custom.clone())
} else {
None
}
} else {
None
}
} else {
let bv_config = Config::load().ok();
if let Some(config_path) = bv_config
.as_ref()
.and_then(|cfg| cfg.get_binary_path(&dep.name))
{
fallback_path = Some(config_path.clone());
if Path::new(&config_path).exists() {
Some(config_path)
} else {
None
}
} else {
None
}
};
let mut binary_path = direct_path
.or_else(|| {
which::which(&dep.name)
.ok()
.map(|p| p.display().to_string())
})
.or_else(|| find_in_well_known_locations(&dep.name));
if dep.name == "java" {
binary_path = adjust_java_binary(binary_path);
if binary_path.is_some() {
fallback_path = binary_path.clone();
}
}
#[cfg(target_os = "macos")]
let docker_desktop_installed = dep.name == "docker" && is_docker_desktop_installed();
#[cfg(not(target_os = "macos"))]
let docker_desktop_installed = false;
let mut syftbox_sbenv_path: Option<String> = None;
if binary_path.is_none() && dep.name == "syftbox" {
syftbox_sbenv_path = check_syftbox_in_sbenv();
}
if binary_path.is_none() && !docker_desktop_installed && syftbox_sbenv_path.is_none() {
return Ok(DependencyResult {
name: dep.name.clone(),
found: false,
path: fallback_path,
version: None,
running: None,
skipped: false,
skip_reason: None,
description: Some(dep.description.clone()),
website: dep.website.clone(),
install_instructions: Some(dep.install_instructions.clone()),
});
} else if docker_desktop_installed && binary_path.is_none() {
return Ok(DependencyResult {
name: dep.name.clone(),
found: true,
path: Some("/Applications/Docker.app".to_string()),
version: None,
running: Some(false),
skipped: false,
skip_reason: None,
description: Some(dep.description.clone()),
website: dep.website.clone(),
install_instructions: Some("Docker Desktop is installed but not running. Please start Docker Desktop from your Applications folder.".to_string()),
});
} else if let Some(sbenv_path) = syftbox_sbenv_path {
let version = Command::new(&sbenv_path)
.arg("--version")
.output()
.ok()
.filter(|output| output.status.success())
.and_then(|output| {
let version_str = String::from_utf8_lossy(&output.stdout);
version_str.split_whitespace().nth(2).map(|v| v.to_string())
});
return Ok(DependencyResult {
name: dep.name.clone(),
found: true,
path: Some(sbenv_path),
version,
running: None,
skipped: false,
skip_reason: None,
description: Some(dep.description.clone()),
website: dep.website.clone(),
install_instructions: Some(dep.install_instructions.clone()),
});
} else if let Some(ref path) = binary_path {
let version_source = version_path_from_sources(&binary_path, &fallback_path);
let (version_ok, version_str) = if let Some(min_version) = dep.min_version {
if dep.name == "java" {
check_java_version_with_info_at(version_source, min_version)
} else {
check_version_with_info(&dep.name, min_version, version_source)
}
} else if dep.name == "java" {
(true, get_java_version_string(version_source))
} else {
(true, get_version_string(&dep.name, version_source))
};
let is_running = if dep.check_running {
Some(check_if_running(&dep.name))
} else {
None
};
return Ok(DependencyResult {
name: dep.name.clone(),
found: version_ok,
path: Some(path.clone()),
version: version_str,
running: is_running,
skipped: false,
skip_reason: None,
description: Some(dep.description.clone()),
website: dep.website.clone(),
install_instructions: Some(dep.install_instructions.clone()),
});
}
Ok(DependencyResult {
name: dep.name.clone(),
found: false,
path: None,
version: None,
running: None,
skipped: false,
skip_reason: None,
description: Some(dep.description.clone()),
website: dep.website.clone(),
install_instructions: Some(dep.install_instructions.clone()),
})
}
pub fn check_dependencies_result() -> Result<DependencyCheckResult> {
let deps_yaml = include_str!("../../deps.yaml");
let config: DependencyConfig = serde_yaml::from_str(deps_yaml)?;
let is_colab = is_google_colab();
let bv_config = Config::load().ok();
let mut all_found = true;
let mut all_running = true;
let mut results: Vec<DependencyResult> = Vec::new();
let current_os = std::env::consts::OS;
for dep in &config.dependencies {
if dep.package_manager {
continue;
}
if let Some(os_filter) = &dep.os_filter {
if !os_filter.contains(¤t_os.to_string()) {
continue;
}
}
if is_colab {
if let Some(environments) = &dep.environments {
if let Some(colab_config) = environments.get("google_colab") {
if colab_config.skip {
results.push(DependencyResult {
name: dep.name.clone(),
found: false,
path: None,
version: None,
running: None,
skipped: true,
skip_reason: colab_config.skip_reason.clone(),
description: Some(dep.description.clone()),
website: dep.website.clone(),
install_instructions: Some(dep.install_instructions.clone()),
});
continue;
}
}
}
}
let custom_path = bv_config
.as_ref()
.and_then(|cfg| cfg.get_binary_path(&dep.name));
let mut binary_path = if custom_path
.as_ref()
.map(|p| Path::new(p).exists())
.unwrap_or(false)
{
custom_path.clone()
} else {
which::which(&dep.name)
.ok()
.map(|p| p.display().to_string())
}
.or_else(|| find_in_well_known_locations(&dep.name));
if dep.name == "java" {
binary_path = adjust_java_binary(binary_path);
}
#[cfg(target_os = "macos")]
let docker_desktop_installed = dep.name == "docker" && is_docker_desktop_installed();
#[cfg(not(target_os = "macos"))]
let docker_desktop_installed = false;
let mut syftbox_sbenv_path: Option<String> = None;
if binary_path.is_none() && dep.name == "syftbox" {
syftbox_sbenv_path = check_syftbox_in_sbenv();
}
if binary_path.is_none() && !docker_desktop_installed && syftbox_sbenv_path.is_none() {
all_found = false;
results.push(DependencyResult {
name: dep.name.clone(),
found: false,
path: custom_path.clone(),
version: None,
running: None,
skipped: false,
skip_reason: None,
description: Some(dep.description.clone()),
website: dep.website.clone(),
install_instructions: Some(dep.install_instructions.clone()),
});
} else if docker_desktop_installed && binary_path.is_none() {
all_running = false; results.push(DependencyResult {
name: dep.name.clone(),
found: true,
path: Some("/Applications/Docker.app".to_string()),
version: None,
running: Some(false),
skipped: false,
skip_reason: None,
description: Some(dep.description.clone()),
website: dep.website.clone(),
install_instructions: Some("Docker Desktop is installed but not running. Please start Docker Desktop from your Applications folder.".to_string()),
});
} else if let Some(sbenv_path) = syftbox_sbenv_path {
let version = Command::new(&sbenv_path)
.arg("--version")
.output()
.ok()
.filter(|output| output.status.success())
.and_then(|output| {
let version_str = String::from_utf8_lossy(&output.stdout);
version_str.split_whitespace().nth(2).map(|v| v.to_string())
});
results.push(DependencyResult {
name: dep.name.clone(),
found: true,
path: Some(sbenv_path),
version,
running: None,
skipped: false,
skip_reason: None,
description: Some(dep.description.clone()),
website: dep.website.clone(),
install_instructions: Some(dep.install_instructions.clone()),
});
} else if let Some(ref path) = binary_path {
let version_source = Some(path.as_str());
let (version_ok, version_str) = if let Some(min_version) = dep.min_version {
if dep.name == "java" {
check_java_version_with_info_at(version_source, min_version)
} else {
check_version_with_info(&dep.name, min_version, version_source)
}
} else if dep.name == "java" {
(true, get_java_version_string(version_source))
} else {
(true, get_version_string(&dep.name, version_source))
};
if !version_ok {
all_found = false;
}
let is_running = if dep.check_running {
Some(check_if_running(&dep.name))
} else {
None
};
if let Some(running) = is_running {
if !running {
all_running = false;
}
}
results.push(DependencyResult {
name: dep.name.clone(),
found: version_ok,
path: Some(path.clone()),
version: version_str,
running: is_running,
skipped: false,
skip_reason: None,
description: Some(dep.description.clone()),
website: dep.website.clone(),
install_instructions: Some(dep.install_instructions.clone()),
});
}
}
Ok(DependencyCheckResult {
dependencies: results,
all_satisfied: all_found && all_running,
})
}
fn find_in_well_known_locations(executable: &str) -> Option<String> {
let mut search_dirs: Vec<PathBuf> = Vec::new();
#[cfg(target_os = "macos")]
{
search_dirs.extend([
PathBuf::from("/opt/homebrew/bin"),
PathBuf::from("/opt/homebrew/sbin"),
PathBuf::from("/usr/local/bin"),
PathBuf::from("/usr/local/sbin"),
PathBuf::from("/usr/bin"),
PathBuf::from("/usr/sbin"),
PathBuf::from("/bin"),
PathBuf::from("/sbin"),
]);
if executable == "docker" {
search_dirs.extend([
PathBuf::from("/Applications/Docker.app/Contents/Resources/bin"),
PathBuf::from("/usr/local/bin"), PathBuf::from("/opt/homebrew/bin"), ]);
}
}
#[cfg(target_os = "linux")]
{
search_dirs.extend([
PathBuf::from("/usr/local/bin"),
PathBuf::from("/usr/bin"),
PathBuf::from("/bin"),
PathBuf::from("/usr/local/sbin"),
PathBuf::from("/usr/sbin"),
PathBuf::from("/sbin"),
PathBuf::from("/snap/bin"),
PathBuf::from("/var/lib/snapd/snap/bin"),
]);
}
#[cfg(target_os = "windows")]
{
search_dirs.extend([
PathBuf::from("C:/Program Files"),
PathBuf::from("C:/Program Files (x86)"),
]);
if executable == "docker" {
search_dirs.push(PathBuf::from(
"C:/Program Files/Docker/Docker/resources/bin",
));
}
}
if let Some(home) = dirs::home_dir() {
search_dirs.push(home.join(".local/bin"));
search_dirs.push(home.join(".cargo/bin"));
search_dirs.push(home.join("bin"));
}
let binary_name = if cfg!(target_os = "windows") {
format!("{}.exe", executable)
} else {
executable.to_string()
};
for dir in search_dirs {
let candidate = dir.join(&binary_name);
if candidate.is_file() {
return Some(candidate.display().to_string());
}
}
None
}
pub async fn execute(json: bool) -> Result<()> {
let deps_yaml = include_str!("../../deps.yaml");
let config: DependencyConfig = serde_yaml::from_str(deps_yaml)?;
if !json {
println!("BioVault Dependency Check");
println!("=========================\n");
}
let is_colab = is_google_colab();
if is_colab && !json {
println!("ℹ️ Google Colab environment detected\n");
}
let is_ci = env::var("CI").is_ok() || env::var("GITHUB_ACTIONS").is_ok();
let bv_config = Config::load().ok();
let mut all_found = true;
let mut all_running = true;
let mut results: Vec<DependencyResult> = Vec::new();
let current_os = std::env::consts::OS;
for dep in &config.dependencies {
if dep.package_manager {
continue;
}
if let Some(os_filter) = &dep.os_filter {
if !os_filter.contains(¤t_os.to_string()) {
continue;
}
}
if is_colab {
if let Some(environments) = &dep.environments {
if let Some(colab_config) = environments.get("google_colab") {
if colab_config.skip {
if !json {
println!("Checking {}... ⏭️ SKIPPED", dep.name);
println!(
" Reason: {}",
colab_config
.skip_reason
.as_ref()
.unwrap_or(&"Not available in Colab".to_string())
);
println!();
}
results.push(DependencyResult {
name: dep.name.clone(),
found: false,
path: None,
version: None,
running: None,
skipped: true,
skip_reason: colab_config.skip_reason.clone(),
description: Some(dep.description.clone()),
website: dep.website.clone(),
install_instructions: Some(dep.install_instructions.clone()),
});
continue;
}
}
}
}
if !json {
print!("Checking {}... ", dep.name);
}
let custom_path = bv_config
.as_ref()
.and_then(|cfg| cfg.binary_paths.as_ref())
.and_then(|bp| match dep.name.as_str() {
"java" => bp.java.clone(),
"docker" => bp.docker.clone(),
"nextflow" => bp.nextflow.clone(),
_ => None,
});
let mut binary_path = if let Some(custom) = custom_path {
if std::path::Path::new(&custom).exists() {
Some(custom)
} else {
None
}
} else {
which::which(&dep.name)
.ok()
.map(|p| p.display().to_string())
};
if dep.name == "java" {
binary_path = adjust_java_binary(binary_path);
}
let mut uv_windows_path: Option<String> = None;
if binary_path.is_none() && dep.name == "uv" && std::env::consts::OS == "windows" {
uv_windows_path = check_uv_in_windows_not_in_path();
}
#[cfg(target_os = "macos")]
let docker_desktop_installed = dep.name == "docker" && is_docker_desktop_installed();
#[cfg(not(target_os = "macos"))]
let docker_desktop_installed = false;
let mut syftbox_sbenv_path: Option<String> = None;
if binary_path.is_none() && dep.name == "syftbox" {
syftbox_sbenv_path = check_syftbox_in_sbenv();
}
if binary_path.is_none()
&& uv_windows_path.is_none()
&& !docker_desktop_installed
&& syftbox_sbenv_path.is_none()
{
all_found = false;
if !json {
println!("❌ NOT FOUND");
println!(" Description: {}", dep.description);
println!(" Installation instructions:");
for line in dep.install_instructions.lines() {
if !line.trim().is_empty() {
println!(" {}", line);
}
}
println!();
}
results.push(DependencyResult {
name: dep.name.clone(),
found: false,
path: None,
version: None,
running: None,
skipped: false,
skip_reason: None,
description: Some(dep.description.clone()),
website: dep.website.clone(),
install_instructions: Some(dep.install_instructions.clone()),
});
} else if docker_desktop_installed && binary_path.is_none() {
all_running = false;
if !json {
println!("⚠️ Found (Docker Desktop installed but not running)");
println!(" Docker Desktop is installed on your system.");
println!(" To start Docker, open Docker Desktop from your Applications folder.");
println!(" Location: /Applications/Docker.app");
println!();
}
results.push(DependencyResult {
name: dep.name.clone(),
found: true,
path: Some("/Applications/Docker.app".to_string()),
version: None,
running: Some(false),
skipped: false,
skip_reason: None,
description: Some(dep.description.clone()),
website: dep.website.clone(),
install_instructions: Some("Docker Desktop is installed but not running. Please start Docker Desktop from your Applications folder.".to_string()),
});
} else if let Some(uv_path) = uv_windows_path {
if !json {
println!("⚠️ Found (not in PATH)");
println!(" UV is installed at: {}", uv_path);
println!(" But it's not available in your PATH.");
println!(" To make it available:");
println!(" Option 1: Open a new Command Prompt or PowerShell window");
println!(" Option 2: Manually refresh your PATH:");
println!(" PowerShell: $env:Path = [System.Environment]::GetEnvironmentVariable(\"Path\",\"User\")");
if !is_ci {
println!(" Option 3: Run 'bv setup' again after restarting your terminal");
}
println!();
}
results.push(DependencyResult {
name: dep.name.clone(),
found: true,
path: Some(uv_path.clone()),
version: get_uv_version(&uv_path),
running: None,
skipped: false,
skip_reason: None,
description: Some(dep.description.clone()),
website: dep.website.clone(),
install_instructions: Some(dep.install_instructions.clone()),
});
} else if let Some(sbenv_path) = syftbox_sbenv_path {
let version = Command::new(&sbenv_path)
.arg("--version")
.output()
.ok()
.filter(|output| output.status.success())
.and_then(|output| {
let version_str = String::from_utf8_lossy(&output.stdout);
version_str.split_whitespace().nth(2).map(|v| v.to_string())
});
if !json {
if let Some(ref ver) = version {
println!("✓ Found (version {})", ver);
} else {
println!("✓ Found");
}
println!(" Path: {}", sbenv_path);
println!(" Note: Found in ~/.sbenv/binaries");
}
results.push(DependencyResult {
name: dep.name.clone(),
found: true,
path: Some(sbenv_path),
version,
running: None,
skipped: false,
skip_reason: None,
description: Some(dep.description.clone()),
website: dep.website.clone(),
install_instructions: Some(dep.install_instructions.clone()),
});
} else if let Some(ref path) = binary_path {
let version_source = Some(path.as_str());
let (version_ok, version_str) = if let Some(min_version) = dep.min_version {
if dep.name == "java" {
check_java_version_with_info_at(version_source, min_version)
} else {
check_version_with_info(&dep.name, min_version, version_source)
}
} else if dep.name == "java" {
(true, get_java_version_string(version_source))
} else {
(true, get_version_string(&dep.name, version_source))
};
if !version_ok {
all_found = false;
if !json {
let min_ver = dep.min_version.unwrap_or(0);
println!("❌ Version too old (requires {} or higher)", min_ver);
println!(" Description: {}", dep.description);
println!(" Installation instructions:");
for line in dep.install_instructions.lines() {
if !line.trim().is_empty() {
println!(" {}", line);
}
}
println!();
}
results.push(DependencyResult {
name: dep.name.clone(),
found: true,
path: Some(path.clone()),
version: version_str,
running: None,
skipped: false,
skip_reason: None,
description: Some(dep.description.clone()),
website: dep.website.clone(),
install_instructions: Some(dep.install_instructions.clone()),
});
} else {
let is_running = if dep.check_running {
Some(check_if_running(&dep.name))
} else {
None
};
if let Some(running) = is_running {
if !running {
all_running = false;
}
}
if !json {
if let Some(ref ver) = version_str {
print!("✓ Found (version {})", ver);
} else {
print!("✓ Found");
}
if let Some(running) = is_running {
if running {
println!(" (running)");
} else {
println!(" (NOT RUNNING)");
println!(
" To start {}, run: {}",
dep.name,
get_start_command(&dep.name)
);
}
} else {
println!();
}
println!(" Path: {}", path);
}
results.push(DependencyResult {
name: dep.name.clone(),
found: true,
path: Some(path.clone()),
version: version_str,
running: is_running,
skipped: false,
skip_reason: None,
description: Some(dep.description.clone()),
website: dep.website.clone(),
install_instructions: Some(dep.install_instructions.clone()),
});
}
}
}
if json {
let result = DependencyCheckResult {
dependencies: results,
all_satisfied: all_found && all_running,
};
println!("{}", serde_json::to_string_pretty(&result)?);
if all_found && all_running {
Ok(())
} else if !all_found {
Err(anyhow!("Dependencies missing").into())
} else {
Err(anyhow!("Services not running").into())
}
} else {
println!("\n=========================");
if all_found && all_running {
println!("✓ All dependencies satisfied!");
Ok(())
} else if !all_found {
println!(
"⚠️ Some dependencies are missing. Please install them using the instructions above."
);
Err(anyhow!("Dependencies missing").into())
} else {
println!(
"⚠️ Some services are not running. Please start them using the commands above."
);
Err(anyhow!("Services not running").into())
}
}
}
fn check_if_running(service: &str) -> bool {
match service {
"docker" => {
Command::new("docker")
.arg("info")
.output()
.map(|output| output.status.success())
.unwrap_or(false)
}
_ => false,
}
}
#[cfg(target_os = "macos")]
fn is_docker_desktop_installed() -> bool {
let docker_app = Path::new("/Applications/Docker.app");
if docker_app.exists() {
return true;
}
if let Some(home) = dirs::home_dir() {
let user_docker_app = home.join("Applications/Docker.app");
if user_docker_app.exists() {
return true;
}
}
false
}
#[cfg(not(target_os = "macos"))]
#[allow(dead_code)]
fn is_docker_desktop_installed() -> bool {
false
}
fn check_syftbox_in_sbenv() -> Option<String> {
let home = dirs::home_dir()?;
let sbenv_binaries = home.join(".sbenv").join("binaries");
if !sbenv_binaries.exists() {
return None;
}
let mut syftbox_binaries = Vec::new();
if let Ok(entries) = std::fs::read_dir(&sbenv_binaries) {
for entry in entries.flatten() {
let path = entry.path();
if path.is_dir() {
let syftbox_path = path.join("syftbox");
if syftbox_path.is_file() {
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
if let Ok(metadata) = syftbox_path.metadata() {
let permissions = metadata.permissions();
if permissions.mode() & 0o111 != 0 {
syftbox_binaries.push(syftbox_path);
}
}
}
#[cfg(not(unix))]
{
syftbox_binaries.push(syftbox_path);
}
}
}
}
}
if syftbox_binaries.is_empty() {
return None;
}
syftbox_binaries.sort_by(|a, b| {
let a_parent = a
.parent()
.and_then(|p| p.file_name())
.map(|n| n.to_string_lossy());
let b_parent = b
.parent()
.and_then(|p| p.file_name())
.map(|n| n.to_string_lossy());
b_parent.cmp(&a_parent)
});
syftbox_binaries.first().map(|p| p.display().to_string())
}
fn get_start_command(service: &str) -> String {
match service {
"docker" => {
if std::env::consts::OS == "macos" {
"Open Docker Desktop from your Applications folder (/Applications/Docker.app)"
.to_string()
} else if std::env::consts::OS == "windows" {
"Open Docker Desktop from your Start menu".to_string()
} else {
"Start Docker daemon with 'sudo systemctl start docker' or open Docker Desktop"
.to_string()
}
}
_ => format!("Start {}", service),
}
}
#[allow(dead_code)]
fn check_version(tool: &str, min_version: u32) -> bool {
match tool {
"java" => check_java_version(min_version),
_ => true, }
}
#[allow(dead_code)]
fn check_java_version(min_version: u32) -> bool {
let output = Command::new("java").arg("-version").output();
match output {
Ok(output) => {
let version_str = String::from_utf8_lossy(&output.stderr);
if let Some(version) = parse_java_version(&version_str) {
if version >= min_version {
print!(" (version {})", version);
true
} else {
false
}
} else {
false
}
}
Err(_) => false,
}
}
fn parse_java_version(output: &str) -> Option<u32> {
for line in output.lines() {
if line.contains("version") {
if let Some(start) = line.find('"') {
if let Some(end) = line[start + 1..].find('"') {
let version_str = &line[start + 1..start + 1 + end];
if let Some(stripped) = version_str.strip_prefix("1.") {
if let Some(dot_pos) = stripped.find('.') {
if let Ok(version) = stripped[..dot_pos].parse::<u32>() {
return Some(version);
}
}
} else {
let major_part = version_str.split('.').next().unwrap_or(version_str);
if let Ok(version) = major_part.parse::<u32>() {
return Some(version);
}
}
}
}
}
}
None
}
fn check_version_with_info(
tool: &str,
min_version: u32,
version_source: Option<&str>,
) -> (bool, Option<String>) {
match tool {
"java" => check_java_version_with_info(min_version),
_ => (true, get_version_string(tool, version_source)),
}
}
fn get_version_string(tool: &str, version_source: Option<&str>) -> Option<String> {
match tool {
"docker" => get_docker_version(version_source),
"syftbox" => get_syftbox_version(version_source),
"nextflow" => get_nextflow_version(version_source),
"uv" => get_uv_version(version_source.unwrap_or(tool)),
_ => None,
}
}
fn command_for_version(path: Option<&str>, fallback: &str) -> Option<Command> {
Some(Command::new(path.unwrap_or(fallback)))
}
fn get_docker_version(version_source: Option<&str>) -> Option<String> {
let mut command = command_for_version(version_source, "docker")?;
let output = command.arg("--version").output().ok()?;
if !output.status.success() {
return None;
}
let version_str = String::from_utf8_lossy(&output.stdout);
version_str
.split_whitespace()
.nth(2)
.map(|v| v.trim_end_matches(',').to_string())
}
fn get_syftbox_version(version_source: Option<&str>) -> Option<String> {
let mut command = command_for_version(version_source, "syftbox")?;
let output = command.arg("--version").output().ok()?;
if !output.status.success() {
return None;
}
let version_str = String::from_utf8_lossy(&output.stdout);
version_str.split_whitespace().nth(2).map(|v| v.to_string())
}
fn get_nextflow_version(version_source: Option<&str>) -> Option<String> {
let mut command = command_for_version(version_source, "nextflow")?;
let output = command.arg("-version").output().ok()?;
if !output.status.success() {
return None;
}
let version_str = String::from_utf8_lossy(&output.stdout);
for line in version_str.lines() {
if line.trim().starts_with("version ") {
return line.split_whitespace().nth(1).map(|v| v.to_string());
}
}
None
}
fn check_java_version_with_info(min_version: u32) -> (bool, Option<String>) {
let output = Command::new("java").arg("-version").output();
match output {
Ok(output) => {
let version_str = String::from_utf8_lossy(&output.stderr);
if let Some(version) = parse_java_version(&version_str) {
(version >= min_version, Some(version.to_string()))
} else {
(false, None)
}
}
Err(_) => (false, None),
}
}
fn is_google_colab() -> bool {
if env::var("COLAB_RELEASE_TAG").is_ok() {
return true;
}
for (key, _) in env::vars() {
if key.starts_with("COLAB_") {
return true;
}
}
false
}
#[cfg(target_os = "macos")]
fn check_java_in_brew_not_in_path() -> Option<String> {
for base in OPENJDK_OPT_BASES.iter() {
let base_path = Path::new(base);
if !base_path.exists() {
continue;
}
if let Ok(entries) = std::fs::read_dir(base_path) {
for entry in entries.flatten() {
let path = entry.path();
if let Some(name) = path.file_name().and_then(|n| n.to_str()) {
if name.starts_with("openjdk") {
if let Some(bin_dir) = java_bin_from_prefix(path.to_string_lossy().as_ref())
{
let java_path = format!("{}/java", bin_dir);
if is_valid_java_binary(&java_path) {
return Some(bin_dir);
}
}
}
}
}
}
}
if let Some(brew_cmd) = resolve_brew_command() {
let formulas = [
"openjdk",
"openjdk@25",
"openjdk@24",
"openjdk@23",
"openjdk@21",
"openjdk@20",
"openjdk@19",
"openjdk@18",
"openjdk@17",
"openjdk@11",
"openjdk@8",
];
for formula in formulas.iter() {
let output = Command::new(&brew_cmd)
.args(["--prefix", formula])
.output()
.ok();
let output = match output {
Some(out) if out.status.success() => out,
_ => continue,
};
let prefix = String::from_utf8_lossy(&output.stdout).trim().to_string();
if prefix.is_empty() {
continue;
}
if let Some(bin_dir) = java_bin_from_prefix(&prefix) {
let java_path = format!("{}/java", bin_dir);
if is_valid_java_binary(&java_path) {
return Some(bin_dir);
}
}
}
}
None
}
fn check_uv_in_windows_not_in_path() -> Option<String> {
let possible_locations = [
env::var("LOCALAPPDATA")
.ok()
.map(|p| format!("{}\\Programs\\uv\\uv.exe", p)),
env::var("LOCALAPPDATA")
.ok()
.map(|p| format!("{}\\uv\\bin\\uv.exe", p)),
env::var("PROGRAMFILES")
.ok()
.map(|p| format!("{}\\uv\\uv.exe", p)),
env::var("USERPROFILE")
.ok()
.map(|p| format!("{}\\.cargo\\bin\\uv.exe", p)),
env::var("USERPROFILE")
.ok()
.map(|p| format!("{}\\.local\\bin\\uv.exe", p)),
env::var("LOCALAPPDATA")
.ok()
.map(|p| format!("{}\\Microsoft\\WinGet\\Packages\\astral-sh.uv_Microsoft.Winget.Source_8wekyb3d8bbwe\\uv.exe", p)),
];
for location in possible_locations.iter().flatten() {
if std::path::Path::new(location).exists() {
return Some(location.clone());
}
}
None
}
fn get_uv_version(uv_path: &str) -> Option<String> {
let output = Command::new(uv_path).arg("--version").output().ok()?;
if !output.status.success() {
return None;
}
let version_str = String::from_utf8_lossy(&output.stdout);
version_str.split_whitespace().nth(1).map(|v| v.to_string())
}
pub fn check_brew_installed() -> Result<bool> {
#[cfg(not(target_os = "macos"))]
{
Ok(true) }
#[cfg(target_os = "macos")]
{
Ok(resolve_brew_command().is_some())
}
}
pub fn install_brew() -> Result<String> {
#[cfg(not(target_os = "macos"))]
{
Err(anyhow!("Homebrew installation is only supported on macOS").into())
}
#[cfg(target_os = "macos")]
{
if let Ok(existing) = which::which("brew") {
return Ok(existing.display().to_string());
}
log_homebrew_install("Homebrew installer: brew not detected, preparing installation");
fn command_output(program: &str, args: &[&str]) -> Result<String> {
let output = Command::new(program)
.args(args)
.output()
.map_err(|e| CliError::Anyhow(anyhow!("Failed to execute '{}': {}", program, e)))?;
if !output.status.success() {
return Err(CliError::Anyhow(anyhow!(
"'{}' exited with status {:?}",
program,
output.status.code()
)));
}
let value = String::from_utf8(output.stdout).map_err(|e| {
CliError::Anyhow(anyhow!(
"Output from '{}' was not valid UTF-8: {}",
program,
e
))
})?;
Ok(value.trim().to_string())
}
fn escape_for_shell_double_quotes(input: &str) -> String {
input.replace("\\", "\\\\").replace('"', "\\\"")
}
fn escape_for_applescript(input: &str) -> String {
input.replace('"', "\\\"")
}
let prompt_message =
"BioVault needs Homebrew to manage required dependencies. macOS will ask for your administrator password.";
let target_user = match std::env::var("SUDO_USER") {
Ok(value) if !value.is_empty() => value,
_ => match std::env::var("USER") {
Ok(value) if !value.is_empty() => value,
_ => {
log_homebrew_install(
"Homebrew installer: SUDO_USER/USER missing, falling back to 'id -un'",
);
command_output("id", &["-un"])?
}
},
};
let user_uid = command_output("id", &["-u", &target_user])?;
let user_group = command_output("id", &["-gn", &target_user])?;
log_homebrew_install(&format!(
"Homebrew installer: target user '{}', uid {}, group {}",
target_user, user_uid, user_group
));
let user_groups_output = command_output("id", &["-Gn", &target_user])?;
log_homebrew_install(&format!(
"Homebrew installer: '{}' groups => {}",
target_user, user_groups_output
));
let is_admin = user_groups_output
.split_whitespace()
.any(|group| group == "admin");
if !is_admin {
log_homebrew_install(&format!(
"Homebrew installer: user '{}' is not a member of the 'admin' group — cannot proceed",
target_user
));
return Err(CliError::Anyhow(anyhow!(
"User '{target_user}' does not have administrator privileges required to install Homebrew automatically."
)));
}
let clt_ready = Command::new("xcode-select")
.arg("-p")
.status()
.map(|status| status.success())
.unwrap_or(false);
if !clt_ready {
log_homebrew_install(
"Homebrew installer: Command Line Tools not detected. Triggering installer",
);
let status = Command::new("xcode-select")
.arg("--install")
.status()
.map_err(|e| {
CliError::Anyhow(anyhow!(
"Failed to launch Command Line Tools installer: {}",
e
))
})?;
log_homebrew_install(&format!(
"Homebrew installer: xcode-select --install exited with status {:?}",
status.code()
));
return Err(CliError::Anyhow(anyhow!(
"macOS Command Line Tools must be installed. The installer has been launched—complete it, then click 'Check Again'."
)));
}
let arch = command_output("uname", &["-m"])?;
log_homebrew_install(&format!(
"Homebrew installer: detected architecture {}",
arch
));
let brew_prefix = if arch.trim() == "arm64" {
"/opt/homebrew"
} else {
"/usr/local"
};
log_homebrew_install(&format!("Homebrew installer: using prefix {}", brew_prefix));
let home_dir = dirs::home_dir().ok_or_else(|| {
CliError::Anyhow(anyhow!(
"Could not determine home directory for '{}'. Please ensure BIOVAULT_HOME is set.",
target_user
))
})?;
log_homebrew_install(&format!(
"Homebrew installer: resolved home directory {}",
home_dir.to_string_lossy()
));
let home_dir = home_dir.to_string_lossy();
let escaped_user = escape_for_shell_double_quotes(&target_user);
let escaped_group = escape_for_shell_double_quotes(&user_group);
let escaped_uid = escape_for_shell_double_quotes(&user_uid);
let escaped_home = escape_for_shell_double_quotes(&home_dir);
let escaped_prefix = escape_for_shell_double_quotes(brew_prefix);
let user_script_contents = r#"#!/bin/bash
set -euo pipefail
/usr/bin/env git --version >/dev/null 2>&1 || {
echo "git is required to install Homebrew. Please install Xcode Command Line Tools." >&2
exit 2
}
if [ -n "${HOMEBREW_PREFIX:-}" ]; then
BREW_PREFIX="${HOMEBREW_PREFIX}"
else
ARCH="$(uname -m)"
if [ "$ARCH" = "arm64" ]; then
BREW_PREFIX="/opt/homebrew"
else
BREW_PREFIX="/usr/local"
fi
fi
BREW_REPOSITORY="${BREW_PREFIX}/Homebrew"
BREW_BIN_DIR="${BREW_PREFIX}/bin"
BREW_BIN="${BREW_BIN_DIR}/brew"
if [ ! -d "${BREW_REPOSITORY}/.git" ]; then
rm -rf "${BREW_REPOSITORY}"
/usr/bin/env git clone https://github.com/Homebrew/brew "${BREW_REPOSITORY}"
else
/usr/bin/env git -C "${BREW_REPOSITORY}" fetch origin --force
/usr/bin/env git -C "${BREW_REPOSITORY}" reset --hard origin/HEAD
fi
/bin/mkdir -p "${BREW_BIN_DIR}"
/bin/ln -sf "${BREW_REPOSITORY}/bin/brew" "${BREW_BIN}"
PROFILE_LINE="eval \"$(${BREW_BIN} shellenv)\""
append_if_missing() {
local file="$1"
local line="$2"
if [ -f "$file" ]; then
if /usr/bin/grep -F "$line" "$file" >/dev/null 2>&1; then
return
fi
printf '\n%s\n' "$line" >>"$file"
else
printf '%s\n' "$line" >"$file"
fi
}
/bin/mkdir -p "$HOME/.config"
append_if_missing "$HOME/.zprofile" "$PROFILE_LINE"
append_if_missing "$HOME/.bash_profile" "$PROFILE_LINE"
"${BREW_BIN}" update --force --quiet
"${BREW_BIN}" analytics off >/dev/null 2>&1 || true
"#;
let mut user_script = NamedTempFile::new().map_err(|e| {
CliError::Anyhow(anyhow!(
"Failed to create Homebrew user installer script: {}",
e
))
})?;
user_script
.write_all(user_script_contents.as_bytes())
.map_err(|e| {
CliError::Anyhow(anyhow!(
"Failed to write Homebrew user installer script: {}",
e
))
})?;
user_script.flush().map_err(|e| {
CliError::Anyhow(anyhow!(
"Failed to flush Homebrew user installer script: {}",
e
))
})?;
std::fs::set_permissions(user_script.path(), PermissionsExt::from_mode(0o755)).map_err(
|e| {
CliError::Anyhow(anyhow!(
"Failed to set permissions on Homebrew user installer script: {}",
e
))
},
)?;
let user_script_path = user_script.path().to_string_lossy().to_string();
log_homebrew_install(&format!(
"Homebrew installer: created user script at {}",
user_script_path
));
let escaped_user_script_path = escape_for_shell_double_quotes(&user_script_path);
log_homebrew_install("Homebrew installer: preparing privileged bootstrap steps");
log_homebrew_install("Homebrew installer: bootstrap will invoke user script via sudo -u");
let script_contents = format!(
r#"#!/bin/bash
set -euo pipefail
USER_NAME="{user}"
USER_GROUP="{group}"
USER_UID="{uid}"
USER_HOME="{home}"
USER_INSTALL_SCRIPT="{user_script}"
trap 'rm -f "${{USER_INSTALL_SCRIPT}}"' EXIT
BREW_PREFIX="{prefix}"
paths=(
"${{BREW_PREFIX}}"
"${{BREW_PREFIX}}/bin"
"${{BREW_PREFIX}}/etc"
"${{BREW_PREFIX}}/include"
"${{BREW_PREFIX}}/lib"
"${{BREW_PREFIX}}/sbin"
"${{BREW_PREFIX}}/share"
"${{BREW_PREFIX}}/share/man"
"${{BREW_PREFIX}}/share/man/man1"
"${{BREW_PREFIX}}/share/zsh"
"${{BREW_PREFIX}}/share/zsh/site-functions"
"${{BREW_PREFIX}}/var"
"${{BREW_PREFIX}}/Homebrew"
"${{BREW_PREFIX}}/Cellar"
"${{BREW_PREFIX}}/Caskroom"
)
for path in "${{paths[@]}}"; do
if [ ! -d "$path" ]; then
mkdir -p "$path"
fi
chown "${{USER_NAME}}:${{USER_GROUP}}" "$path" 2>/dev/null || true
done
/bin/chmod 0755 "${{USER_INSTALL_SCRIPT}}"
sudo -u "${{USER_NAME}}" -H /usr/bin/env HOME="${{USER_HOME}}" USER="${{USER_NAME}}" LOGNAME="${{USER_NAME}}" HOMEBREW_PREFIX="${{BREW_PREFIX}}" NONINTERACTIVE=1 /bin/bash "${{USER_INSTALL_SCRIPT}}"
"#,
user = escaped_user,
group = escaped_group,
uid = escaped_uid,
home = escaped_home,
user_script = escaped_user_script_path,
prefix = escaped_prefix
);
let mut temp_script = NamedTempFile::new().map_err(|e| {
CliError::Anyhow(anyhow!(
"Failed to create temporary Homebrew installer script: {}",
e
))
})?;
temp_script
.write_all(script_contents.as_bytes())
.map_err(|e| {
CliError::Anyhow(anyhow!("Failed to write Homebrew installer script: {}", e))
})?;
temp_script.flush().map_err(|e| {
CliError::Anyhow(anyhow!("Failed to flush Homebrew installer script: {}", e))
})?;
let script_path = temp_script.path().to_string_lossy().to_string();
log_homebrew_install(&format!(
"Homebrew installer: created privileged bootstrap script at {}",
script_path
));
let applescript = format!(
r#"set shellCommand to "/bin/bash " & quoted form of "{}"
do shell script shellCommand with prompt "{}" with administrator privileges"#,
escape_for_applescript(&script_path),
escape_for_applescript(prompt_message)
);
log_homebrew_install("Homebrew installer: invoking osascript to run bootstrap script");
let output = Command::new("osascript")
.arg("-e")
.arg(&applescript)
.output()
.map_err(|e| {
CliError::Anyhow(anyhow!(
"Failed to request administrator privileges for Homebrew: {}",
e
))
})?;
let stdout = String::from_utf8_lossy(&output.stdout).to_string();
let stderr = String::from_utf8_lossy(&output.stderr).to_string();
if !stdout.trim().is_empty() {
eprintln!("Homebrew installer stdout: {}", stdout.trim());
log_homebrew_install(&format!("Homebrew installer stdout: {}", stdout.trim()));
}
if !stderr.trim().is_empty() {
eprintln!("Homebrew installer stderr: {}", stderr.trim());
log_homebrew_install(&format!("Homebrew installer stderr: {}", stderr.trim()));
}
if output.status.success() {
let brew_path = which::which("brew")
.ok()
.map(|p| p.display().to_string())
.unwrap_or_else(|| "/opt/homebrew/bin/brew".to_string());
log_homebrew_install(&format!(
"Homebrew installer: brew detected at {}",
brew_path
));
Ok(brew_path)
} else {
let combined = format!("{}\n{}", stdout, stderr).to_lowercase();
let was_cancelled = output.status.code() == Some(1)
|| combined.contains("user canceled")
|| combined.contains("user cancelled")
|| combined.contains("canceled")
|| combined.contains("cancelled");
let reason = if was_cancelled {
"Homebrew installation was cancelled."
} else {
"Homebrew installation failed."
};
let mut detail = String::new();
if !stderr.trim().is_empty() {
detail = format!(" Details: {}", stderr.trim());
} else if !stdout.trim().is_empty() {
detail = format!(" Details: {}", stdout.trim());
}
log_homebrew_install(&format!(
"Homebrew installer: osascript exit code {:?}, cancelled: {}",
output.status.code(),
was_cancelled
));
Err(anyhow!(
"{} BioVault needs Homebrew to manage dependencies on macOS. Please install Homebrew manually from https://brew.sh and try again.{}",
reason,
detail
)
.into())
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::fs;
use tempfile::TempDir;
#[test]
fn java_version_parsing_various_formats() {
let samples = [
("openjdk version \"17.0.2\" 2022-01-18", Some(17)),
("java version \"1.8.0_321\"", Some(8)),
("openjdk version \"11.0.14\" 2022-01-18", Some(11)),
("java version \"21\" 2023-09-19", Some(21)),
("no version here", None),
];
for (out, expected) in samples {
assert_eq!(parse_java_version(out), expected);
}
}
#[test]
fn start_command_and_running_checks() {
let docker_cmd = get_start_command("docker");
if std::env::consts::OS == "macos" {
assert_eq!(
docker_cmd,
"Open Docker Desktop from your Applications folder (/Applications/Docker.app)"
);
} else if std::env::consts::OS == "windows" {
assert_eq!(docker_cmd, "Open Docker Desktop from your Start menu");
} else {
assert_eq!(
docker_cmd,
"Start Docker daemon with 'sudo systemctl start docker' or open Docker Desktop"
);
}
assert_eq!(get_start_command("xyz"), "Start xyz");
assert!(!check_if_running("xyz"));
}
#[test]
fn check_version_non_java_defaults_true() {
assert!(check_version("not-java", 9999));
}
#[test]
fn test_parse_java_version_edge_cases() {
assert_eq!(parse_java_version(""), None);
assert_eq!(parse_java_version("version"), None);
assert_eq!(parse_java_version("version \"\""), None);
assert_eq!(parse_java_version("version \"not a number\""), None);
assert_eq!(parse_java_version("java 17"), None);
assert_eq!(parse_java_version("version \"1.\""), None);
assert_eq!(parse_java_version("version \"1.x.0\""), None);
}
#[test]
fn test_parse_java_version_modern_formats() {
assert_eq!(
parse_java_version("openjdk version \"17\" 2021-09-14"),
Some(17)
);
assert_eq!(
parse_java_version("openjdk version \"21.0.1\" 2023-10-17 LTS"),
Some(21)
);
assert_eq!(
parse_java_version("java version \"19.0.2\" 2023-01-17"),
Some(19)
);
}
#[test]
fn test_parse_java_version_legacy_formats() {
assert_eq!(parse_java_version("java version \"1.7.0_80\""), Some(7));
assert_eq!(parse_java_version("java version \"1.6.0_45\""), Some(6));
assert_eq!(parse_java_version("java version \"1.8.0_351\""), Some(8));
}
#[test]
#[serial_test::serial]
fn test_google_colab_detection() {
let was_set = env::var("COLAB_RELEASE_TAG").is_ok();
let old_value = env::var("COLAB_RELEASE_TAG").ok();
env::set_var("COLAB_RELEASE_TAG", "release-123");
assert!(is_google_colab());
if was_set {
if let Some(val) = old_value {
env::set_var("COLAB_RELEASE_TAG", val);
}
} else {
env::remove_var("COLAB_RELEASE_TAG");
}
}
#[test]
#[serial_test::serial]
fn test_google_colab_detection_prefix() {
let was_set = env::var("COLAB_TEST_VAR").is_ok();
env::set_var("COLAB_TEST_VAR", "test");
assert!(is_google_colab());
if !was_set {
env::remove_var("COLAB_TEST_VAR");
}
}
#[test]
#[serial_test::serial]
fn execute_reports_missing_in_clean_env() {
let old_path = std::env::var("PATH").unwrap_or_default();
std::env::set_var("PATH", "");
let rt = tokio::runtime::Runtime::new().unwrap();
let res = rt.block_on(execute(false));
assert!(res.is_err());
std::env::set_var("PATH", old_path);
}
#[test]
#[serial_test::serial]
#[cfg_attr(
not(feature = "slow-tests"),
ignore = "env-dependent; covered in slow/integration"
)]
#[cfg_attr(windows, ignore = "Windows shell semantics; covered in integration CI")]
fn execute_with_all_tools_available_returns_ok() {
let dir = TempDir::new().unwrap();
let make_exec = |name: &str, body: &str| {
let p = dir.path().join(name);
fs::write(&p, format!("#!/bin/sh\n{}\n", body)).unwrap();
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
let mut perm = fs::metadata(&p).unwrap().permissions();
perm.set_mode(0o755);
fs::set_permissions(&p, perm).unwrap();
}
p
};
make_exec("java", "echo 'openjdk version \"21\" 2024-01-01' 1>&2");
make_exec("docker", "[ \"$1\" = \"info\" ] && exit 0; exit 0");
make_exec("nextflow", "exit 0");
make_exec("syftbox", "exit 0");
make_exec("uv", "exit 0");
let old_path = std::env::var("PATH").unwrap_or_default();
let new_path = format!("{}:{}", dir.path().display(), old_path);
std::env::set_var("PATH", &new_path);
let rt = tokio::runtime::Runtime::new().unwrap();
let res = rt.block_on(execute(false));
assert!(res.is_ok());
std::env::set_var("PATH", old_path);
}
#[test]
#[serial_test::serial]
fn execute_with_docker_not_running_reports_warning() {
let dir = TempDir::new().unwrap();
let make_exec = |name: &str, body: &str| {
let p = dir.path().join(name);
fs::write(&p, format!("#!/bin/sh\n{}\n", body)).unwrap();
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
let mut perm = fs::metadata(&p).unwrap().permissions();
perm.set_mode(0o755);
fs::set_permissions(&p, perm).unwrap();
}
p
};
make_exec("java", "echo 'openjdk version \"21\"' 1>&2");
make_exec("nextflow", "exit 0");
make_exec("syftbox", "exit 0");
make_exec("uv", "exit 0");
make_exec("docker", "[ \"$1\" = \"info\" ] && exit 1; exit 0");
let old_path = std::env::var("PATH").unwrap_or_default();
let new_path = format!("{}:{}", dir.path().display(), old_path);
std::env::set_var("PATH", &new_path);
let rt = tokio::runtime::Runtime::new().unwrap();
let res = rt.block_on(execute(false));
assert!(res.is_err());
std::env::set_var("PATH", old_path);
}
}