use std::env;
use std::path::{Path, PathBuf};
use std::process::{Command, Stdio};
use std::thread;
use std::time::{Duration, Instant};
use super::b4_vendor;
use crate::infra::error::{CriewError, ErrorCode, Result};
const EXECUTABLE_BUSY_RETRY_ATTEMPTS: u8 = 5;
const EXECUTABLE_BUSY_RETRY_DELAY: Duration = Duration::from_millis(10);
#[derive(Debug, Clone)]
pub struct B4Check {
pub status: B4Status,
}
#[derive(Debug, Clone)]
pub enum B4Status {
Available { path: PathBuf, version: String },
Broken { path: PathBuf, reason: String },
Missing,
}
#[derive(Debug, Clone)]
pub struct B4CommandResult {
pub command_line: String,
pub stdout: String,
pub stderr: String,
pub exit_code: Option<i32>,
pub timed_out: bool,
}
enum Candidate {
Path(PathBuf),
EmbeddedVendor(PathBuf),
Program(String),
}
#[derive(Debug, Clone)]
struct ResolvedCommand {
command: String,
display_path: PathBuf,
}
pub fn check(configured_path: Option<&Path>, runtime_data_dir: Option<&Path>) -> B4Check {
check_from_candidates(candidates(configured_path, runtime_data_dir))
}
fn check_from_candidates(candidates: Vec<Candidate>) -> B4Check {
let mut last_failure: Option<(PathBuf, String)> = None;
for candidate in candidates {
match probe(&candidate) {
Probe::Available { path, version, .. } => {
return B4Check {
status: B4Status::Available { path, version },
};
}
Probe::Missing => continue,
Probe::Broken { path, reason } => {
last_failure = Some((path, reason));
}
}
}
if let Some((path, reason)) = last_failure {
B4Check {
status: B4Status::Broken { path, reason },
}
} else {
B4Check {
status: B4Status::Missing,
}
}
}
pub fn run(
configured_path: Option<&Path>,
runtime_data_dir: Option<&Path>,
subcommand: &str,
args: &[String],
timeout: Duration,
working_dir: Option<&Path>,
) -> Result<B4CommandResult> {
let resolved = resolve_command(configured_path, runtime_data_dir)?;
run_with_resolved_command(&resolved, subcommand, args, timeout, working_dir)
}
fn run_with_resolved_command(
resolved: &ResolvedCommand,
subcommand: &str,
args: &[String],
timeout: Duration,
working_dir: Option<&Path>,
) -> Result<B4CommandResult> {
let mut command = Command::new(&resolved.command);
if let Some(working_dir) = working_dir {
command.current_dir(working_dir);
}
command.arg(subcommand);
for arg in args {
command.arg(arg);
}
command.stdout(Stdio::piped()).stderr(Stdio::piped());
let command_line = render_command_line(&resolved.command, subcommand, args);
let mut child = spawn_command_with_retry(&mut command).map_err(|error| {
CriewError::with_source(
ErrorCode::B4,
format!(
"failed to spawn b4 command '{}' ({})",
command_line,
resolved.display_path.display()
),
error,
)
})?;
let started_at = Instant::now();
let mut timed_out = false;
loop {
match child.try_wait() {
Ok(Some(_)) => break,
Ok(None) => {
if started_at.elapsed() >= timeout {
timed_out = true;
let _ = child.kill();
break;
}
thread::sleep(Duration::from_millis(30));
}
Err(error) => {
return Err(CriewError::with_source(
ErrorCode::B4,
format!("failed while waiting for b4 command '{}'", command_line),
error,
));
}
}
}
let output = child.wait_with_output().map_err(|error| {
CriewError::with_source(
ErrorCode::B4,
format!("failed to collect output for b4 command '{}'", command_line),
error,
)
})?;
Ok(B4CommandResult {
command_line,
stdout: String::from_utf8_lossy(&output.stdout).to_string(),
stderr: String::from_utf8_lossy(&output.stderr).to_string(),
exit_code: output.status.code(),
timed_out,
})
}
fn candidates(configured_path: Option<&Path>, runtime_data_dir: Option<&Path>) -> Vec<Candidate> {
let env_b4_path = env::var_os("CRIEW_B4_PATH").map(PathBuf::from);
let cwd = env::current_dir().ok();
candidates_with(
configured_path,
runtime_data_dir,
env_b4_path.as_deref(),
cwd.as_deref(),
)
}
fn candidates_with(
configured_path: Option<&Path>,
runtime_data_dir: Option<&Path>,
env_b4_path: Option<&Path>,
cwd: Option<&Path>,
) -> Vec<Candidate> {
let mut values = Vec::new();
if let Some(path) = configured_path {
values.push(Candidate::Path(path.to_path_buf()));
}
if let Some(path) = env_b4_path {
if !path.as_os_str().is_empty() {
values.push(Candidate::Path(path.to_path_buf()));
}
}
if let Some(cwd) = cwd {
values.push(Candidate::Path(cwd.join("vendor/b4/b4.sh")));
}
if let Some(data_dir) = runtime_data_dir {
values.push(Candidate::EmbeddedVendor(data_dir.to_path_buf()));
}
values.push(Candidate::Program("b4".to_string()));
values
}
enum Probe {
Available {
command: String,
path: PathBuf,
version: String,
},
Broken {
path: PathBuf,
reason: String,
},
Missing,
}
fn probe(candidate: &Candidate) -> Probe {
match candidate {
Candidate::Path(path) => {
if !path.exists() {
return Probe::Missing;
}
run_probe(path, path, path.display().to_string())
}
Candidate::EmbeddedVendor(data_dir) => {
let script_path = b4_vendor::script_path(data_dir);
match b4_vendor::ensure_installed(data_dir) {
Ok(Some(path)) => run_probe(&path, &path, path.display().to_string()),
Ok(None) => Probe::Missing,
Err(error) => Probe::Broken {
path: script_path,
reason: error.to_string(),
},
}
}
Candidate::Program(program) => {
let label = PathBuf::from(format!("{program} (PATH)"));
run_probe(program, &label, program.clone())
}
}
}
fn spawn_command_with_retry(command: &mut Command) -> std::io::Result<std::process::Child> {
let mut attempts_remaining = EXECUTABLE_BUSY_RETRY_ATTEMPTS;
loop {
match command.spawn() {
Ok(child) => return Ok(child),
Err(error) if is_retryable_executable_busy(&error) && attempts_remaining > 0 => {
attempts_remaining -= 1;
thread::sleep(EXECUTABLE_BUSY_RETRY_DELAY);
}
Err(error) => return Err(error),
}
}
}
fn run_probe<T>(command: T, label: &Path, command_value: String) -> Probe
where
T: AsRef<std::ffi::OsStr>,
{
match output_with_retry(Command::new(command).arg("--version")) {
Ok(output) if output.status.success() => {
let version = normalize_output(&output.stdout)
.or_else(|| normalize_output(&output.stderr))
.unwrap_or_else(|| "unknown".to_string());
Probe::Available {
command: command_value,
path: label.to_path_buf(),
version,
}
}
Ok(output) => {
let reason = normalize_output(&output.stderr)
.or_else(|| normalize_output(&output.stdout))
.unwrap_or_else(|| format!("exit status {}", output.status));
Probe::Broken {
path: label.to_path_buf(),
reason,
}
}
Err(error) if error.kind() == std::io::ErrorKind::NotFound => Probe::Missing,
Err(error) => Probe::Broken {
path: label.to_path_buf(),
reason: error.to_string(),
},
}
}
fn output_with_retry(command: &mut Command) -> std::io::Result<std::process::Output> {
let mut attempts_remaining = EXECUTABLE_BUSY_RETRY_ATTEMPTS;
loop {
match command.output() {
Ok(output) => return Ok(output),
Err(error) if is_retryable_executable_busy(&error) && attempts_remaining > 0 => {
attempts_remaining -= 1;
thread::sleep(EXECUTABLE_BUSY_RETRY_DELAY);
}
Err(error) => return Err(error),
}
}
}
fn is_retryable_executable_busy(error: &std::io::Error) -> bool {
error.kind() == std::io::ErrorKind::ExecutableFileBusy
}
fn resolve_command(
configured_path: Option<&Path>,
runtime_data_dir: Option<&Path>,
) -> Result<ResolvedCommand> {
resolve_from_candidates(candidates(configured_path, runtime_data_dir))
}
fn resolve_from_candidates(candidates: Vec<Candidate>) -> Result<ResolvedCommand> {
let mut last_failure: Option<(PathBuf, String)> = None;
for candidate in candidates {
match probe(&candidate) {
Probe::Available { command, path, .. } => {
return Ok(ResolvedCommand {
command,
display_path: path,
});
}
Probe::Broken { path, reason } => {
last_failure = Some((path, reason));
}
Probe::Missing => {}
}
}
if let Some((path, reason)) = last_failure {
return Err(CriewError::new(
ErrorCode::B4,
format!("b4 executable '{}' is broken: {}", path.display(), reason),
));
}
Err(CriewError::new(
ErrorCode::B4,
"b4 executable not found (checked config path, CRIEW_B4_PATH, ./vendor/b4/b4.sh, embedded runtime vendor, and PATH)",
))
}
fn render_command_line(command: &str, subcommand: &str, args: &[String]) -> String {
let mut pieces = Vec::with_capacity(2 + args.len());
pieces.push(render_shell_token(command));
pieces.push(render_shell_token(subcommand));
for arg in args {
pieces.push(render_shell_token(arg));
}
pieces.join(" ")
}
fn render_shell_token(token: &str) -> String {
if token.is_empty() {
return "''".to_string();
}
if token
.chars()
.all(|character| character.is_ascii_alphanumeric() || "_-./:@".contains(character))
{
return token.to_string();
}
format!("'{}'", token.replace('\'', "'\\''"))
}
fn normalize_output(bytes: &[u8]) -> Option<String> {
let text = String::from_utf8_lossy(bytes);
text.lines()
.map(str::trim)
.find(|line| !line.is_empty())
.map(ToOwned::to_owned)
}
#[cfg(test)]
mod tests {
use std::fs;
use std::io::Write;
#[cfg(unix)]
use std::os::unix::fs::PermissionsExt;
use std::path::{Path, PathBuf};
use std::time::{Duration, SystemTime, UNIX_EPOCH};
use crate::infra::error::ErrorCode;
use super::{
B4Status, Candidate, Probe, candidates_with, check_from_candidates, normalize_output,
probe, render_command_line, resolve_from_candidates, run_with_resolved_command,
};
fn temp_dir(label: &str) -> PathBuf {
let nonce = SystemTime::now()
.duration_since(UNIX_EPOCH)
.expect("system time")
.as_nanos();
let path = std::env::temp_dir().join(format!("criew-b4-{label}-{nonce}"));
fs::create_dir_all(&path).expect("create temp dir");
path
}
fn write_script(root: &Path, name: &str, body: &str) -> PathBuf {
let path = root.join(name);
let staging_path = root.join(format!(".{name}.tmp"));
let mut staging_file = fs::File::create(&staging_path).expect("create staging script");
staging_file
.write_all(body.as_bytes())
.expect("write staging script");
staging_file.sync_all().expect("sync staging script");
drop(staging_file);
#[cfg(unix)]
{
let mut permissions = fs::metadata(&staging_path)
.expect("staging metadata")
.permissions();
permissions.set_mode(0o755);
fs::set_permissions(&staging_path, permissions).expect("mark executable");
}
fs::rename(&staging_path, &path).expect("install script");
path
}
fn canonicalize_existing_path(path: &Path) -> PathBuf {
fs::canonicalize(path).unwrap_or_else(|_| path.to_path_buf())
}
fn canonicalized_path_from_output(output: &str, prefix: &str) -> PathBuf {
let raw_path = output
.lines()
.find_map(|line| line.strip_prefix(prefix))
.unwrap_or_else(|| panic!("missing {prefix} line in output: {output}"));
canonicalize_existing_path(Path::new(raw_path))
}
#[test]
fn check_prefers_available_configured_script() {
let root = temp_dir("configured-ok");
let configured_script = write_script(
&root,
"b4-ok.sh",
"#!/bin/sh\nif [ \"$1\" = \"--version\" ]; then\n echo 'b4 1.2.3'\n exit 0\nfi\nexit 0\n",
);
let fallback_script = write_script(
&root,
"b4-fallback.sh",
"#!/bin/sh\nif [ \"$1\" = \"--version\" ]; then\n echo 'b4 9.9.9'\n exit 0\nfi\nexit 0\n",
);
let result = check_from_candidates(vec![
Candidate::Path(configured_script.clone()),
Candidate::Path(fallback_script),
]);
match result.status {
B4Status::Available { path, version } => {
assert_eq!(path, configured_script);
assert_eq!(version, "b4 1.2.3");
}
status => panic!("expected available status, got {status:?}"),
}
let _ = fs::remove_dir_all(root);
}
#[test]
fn check_reports_broken_configured_script_when_no_fallback_exists() {
let root = temp_dir("configured-broken");
let script = write_script(
&root,
"b4-broken.sh",
"#!/bin/sh\nif [ \"$1\" = \"--version\" ]; then\n echo 'broken runtime' >&2\n exit 1\nfi\nexit 1\n",
);
let result = check_from_candidates(vec![Candidate::Path(script.clone())]);
match result.status {
B4Status::Broken { path, reason } => {
assert_eq!(path, script);
assert_eq!(reason, "broken runtime");
}
status => panic!("expected broken status, got {status:?}"),
}
let _ = fs::remove_dir_all(root);
}
#[test]
fn run_executes_configured_script_in_requested_workdir() {
let root = temp_dir("run-ok");
let workdir = root.join("workdir");
fs::create_dir_all(&workdir).expect("create workdir");
let script = write_script(
&root,
"b4-run.sh",
"#!/bin/sh\nif [ \"$1\" = \"--version\" ]; then\n echo 'b4 2.0.0'\n exit 0\nfi\nprintf 'cwd=%s\\n' \"$PWD\"\nprintf 'subcommand=%s\\n' \"$1\"\nprintf 'arg1=%s\\n' \"$2\"\nprintf 'arg2=%s\\n' \"$3\"\nprintf 'stderr-line\\n' >&2\n",
);
let resolved = resolve_from_candidates(vec![Candidate::Path(script.clone())])
.expect("resolve configured b4");
let result = run_with_resolved_command(
&resolved,
"am",
&["--foo".to_string(), "bar baz".to_string()],
Duration::from_secs(1),
Some(&workdir),
)
.expect("run b4");
assert_eq!(result.exit_code, Some(0));
assert!(!result.timed_out);
assert_eq!(
result.command_line,
format!("{} am --foo 'bar baz'", script.display())
);
assert_eq!(
canonicalized_path_from_output(&result.stdout, "cwd="),
canonicalize_existing_path(&workdir)
);
assert!(result.stdout.contains("subcommand=am"));
assert!(result.stdout.contains("arg1=--foo"));
assert!(result.stdout.contains("arg2=bar baz"));
assert!(result.stderr.contains("stderr-line"));
let _ = fs::remove_dir_all(root);
}
#[test]
fn run_marks_timed_out_processes() {
let root = temp_dir("run-timeout");
let script = write_script(
&root,
"b4-timeout.sh",
"#!/bin/sh\nif [ \"$1\" = \"--version\" ]; then\n echo 'b4 2.1.0'\n exit 0\nfi\nwhile :; do :; done\n",
);
let resolved =
resolve_from_candidates(vec![Candidate::Path(script)]).expect("resolve timeout b4");
let result =
run_with_resolved_command(&resolved, "am", &[], Duration::from_millis(10), None)
.expect("run b4");
assert!(result.timed_out);
assert_ne!(result.exit_code, Some(0));
let _ = fs::remove_dir_all(root);
}
#[test]
fn candidates_include_config_env_cwd_runtime_and_program_in_order() {
let root = temp_dir("candidates");
let cwd = root.join("cwd");
fs::create_dir_all(&cwd).expect("create cwd");
let configured = root.join("configured-b4");
let env_path = root.join("env-b4");
let runtime = root.join("runtime");
let values = candidates_with(
Some(&configured),
Some(&runtime),
Some(&env_path),
Some(&cwd),
);
assert!(matches!(&values[0], Candidate::Path(path) if path == &configured));
assert!(matches!(&values[1], Candidate::Path(path) if path == &env_path));
assert!(
matches!(&values[2], Candidate::Path(path) if path == &cwd.join("vendor/b4/b4.sh"))
);
assert!(matches!(&values[3], Candidate::EmbeddedVendor(path) if path == &runtime));
assert!(matches!(&values[4], Candidate::Program(program) if program == "b4"));
let _ = fs::remove_dir_all(root);
}
#[test]
fn probe_reports_missing_and_embedded_vendor_failures() {
let missing = probe(&Candidate::Path(PathBuf::from("/definitely/missing/b4")));
assert!(matches!(missing, Probe::Missing));
let root = temp_dir("embedded-broken");
let vendor_root = root.join("vendor");
fs::write(&vendor_root, "not a directory").expect("block vendor root");
let broken = probe(&Candidate::EmbeddedVendor(root.clone()));
match broken {
Probe::Broken { path, reason } => {
assert_eq!(path, root.join("vendor/b4/b4.sh"));
assert!(reason.contains("failed to create embedded b4 directory"));
}
_ => panic!("expected broken embedded vendor probe"),
}
let _ = fs::remove_dir_all(root);
}
#[test]
fn resolve_command_reports_not_found_without_candidates() {
let error = resolve_from_candidates(vec![
Candidate::Path(PathBuf::from("/definitely/missing/b4")),
Candidate::Program("criew-b4-definitely-missing".to_string()),
])
.expect_err("missing b4 should fail");
assert_eq!(error.code(), ErrorCode::B4);
assert!(error.to_string().contains("b4 executable not found"));
}
#[test]
fn render_command_line_quotes_special_tokens() {
let rendered = render_command_line(
"/tmp/demo path/b4.sh",
"am",
&["bar baz".to_string(), "quote'char".to_string()],
);
assert_eq!(
rendered,
"'/tmp/demo path/b4.sh' am 'bar baz' 'quote'\\''char'"
);
}
#[test]
fn normalize_output_returns_first_non_empty_trimmed_line() {
assert_eq!(
normalize_output(b"\n \n b4 3.0.0 \nnext line\n"),
Some("b4 3.0.0".to_string())
);
assert_eq!(normalize_output(b"\n\t \n"), None);
}
}