use std::process::Command;
use std::sync::OnceLock;
pub const PYTHON_CANDIDATES: &[&str] = &["python3", "python", "py -3"];
#[must_use]
pub fn probe_executable(spec: &str) -> bool {
let mut parts = spec.split_whitespace();
let Some(program) = parts.next() else {
return false;
};
let mut cmd = Command::new(program);
for arg in parts {
cmd.arg(arg);
}
cmd.arg("--version");
cmd.stdout(std::process::Stdio::null());
cmd.stderr(std::process::Stdio::null());
matches!(cmd.status(), Ok(status) if status.success())
}
pub fn resolve_python_interpreter() -> Option<String> {
static CACHE: OnceLock<Option<String>> = OnceLock::new();
CACHE
.get_or_init(|| {
for candidate in PYTHON_CANDIDATES {
if probe_executable(candidate) {
tracing::info!(
target: "tool_dependencies",
candidate = candidate,
"Resolved Python interpreter",
);
return Some((*candidate).to_string());
}
}
tracing::warn!(
target: "tool_dependencies",
tried = ?PYTHON_CANDIDATES,
"No Python interpreter found",
);
None
})
.clone()
}
pub fn resolve_pdftotext() -> Option<String> {
static CACHE: OnceLock<Option<String>> = OnceLock::new();
CACHE
.get_or_init(|| {
if probe_executable("pdftotext") {
Some("pdftotext".to_string())
} else {
None
}
})
.clone()
}
pub fn resolve_tesseract() -> Option<String> {
static CACHE: OnceLock<Option<String>> = OnceLock::new();
CACHE
.get_or_init(|| {
if probe_executable("tesseract") {
tracing::info!(
target: "tool_dependencies",
"Resolved tesseract binary for image_ocr",
);
Some("tesseract".to_string())
} else {
tracing::warn!(
target: "tool_dependencies",
"tesseract binary not found; image_ocr will rely on native OCR if available",
);
None
}
})
.clone()
}
pub fn resolve_pandoc() -> Option<String> {
static CACHE: OnceLock<Option<String>> = OnceLock::new();
CACHE
.get_or_init(|| {
if probe_executable("pandoc") {
tracing::info!(
target: "tool_dependencies",
"Resolved pandoc binary for pandoc_convert",
);
Some("pandoc".to_string())
} else {
tracing::warn!(
target: "tool_dependencies",
"pandoc binary not found; pandoc_convert tool will not be registered",
);
None
}
})
.clone()
}
pub fn resolve_node() -> Option<String> {
static CACHE: OnceLock<Option<String>> = OnceLock::new();
CACHE
.get_or_init(|| {
if probe_executable("node") {
tracing::info!(
target: "tool_dependencies",
"Resolved Node.js runtime for js_execution",
);
Some("node".to_string())
} else {
tracing::warn!(
target: "tool_dependencies",
"Node.js runtime not found; js_execution tool will not be advertised",
);
None
}
})
.clone()
}
pub trait ExternalTool {
fn candidates() -> &'static [&'static str];
fn resolve() -> Option<String>;
#[allow(dead_code)]
fn available() -> bool {
Self::resolve().is_some()
}
fn command() -> Option<Command> {
let spec = Self::resolve()?;
let (program, fixed_args) = split_interpreter_spec(&spec);
let mut cmd = Command::new(&program);
for arg in &fixed_args {
cmd.arg(arg);
}
Some(cmd)
}
fn output(args: &[&str], cwd: &std::path::Path) -> std::io::Result<std::process::Output> {
let mut cmd = Self::command().ok_or_else(|| {
std::io::Error::new(
std::io::ErrorKind::NotFound,
format!("{} not found on PATH", std::any::type_name::<Self>()),
)
})?;
cmd.args(args).current_dir(cwd).output()
}
#[allow(dead_code)]
fn status(args: &[&str], cwd: &std::path::Path) -> std::io::Result<std::process::ExitStatus> {
let mut cmd = Self::command().ok_or_else(|| {
std::io::Error::new(
std::io::ErrorKind::NotFound,
format!("{} not found on PATH", std::any::type_name::<Self>()),
)
})?;
cmd.args(args).current_dir(cwd).status()
}
fn tokio_command() -> Option<tokio::process::Command> {
let spec = Self::resolve()?;
let (program, fixed_args) = split_interpreter_spec(&spec);
let mut cmd = tokio::process::Command::new(&program);
for arg in &fixed_args {
cmd.arg(arg);
}
Some(cmd)
}
}
pub struct Git;
impl ExternalTool for Git {
fn candidates() -> &'static [&'static str] {
&["git"]
}
fn resolve() -> Option<String> {
static CACHE: OnceLock<Option<String>> = OnceLock::new();
CACHE
.get_or_init(|| {
for candidate in Self::candidates() {
if probe_executable(candidate) {
tracing::info!(target: "tool_dependencies", "Resolved git binary");
return Some((*candidate).to_string());
}
}
None
})
.clone()
}
}
pub struct Gh;
impl ExternalTool for Gh {
fn candidates() -> &'static [&'static str] {
&["gh"]
}
fn resolve() -> Option<String> {
static CACHE: OnceLock<Option<String>> = OnceLock::new();
CACHE
.get_or_init(|| {
for candidate in Self::candidates() {
if probe_executable(candidate) {
tracing::info!(target: "tool_dependencies", "Resolved gh binary");
return Some((*candidate).to_string());
}
}
None
})
.clone()
}
}
pub struct RustC;
impl ExternalTool for RustC {
fn candidates() -> &'static [&'static str] {
&["rustc"]
}
fn resolve() -> Option<String> {
static CACHE: OnceLock<Option<String>> = OnceLock::new();
CACHE
.get_or_init(|| {
for candidate in Self::candidates() {
if probe_executable(candidate) {
tracing::info!(target: "tool_dependencies", "Resolved rustc binary");
return Some((*candidate).to_string());
}
}
None
})
.clone()
}
}
pub struct Cargo;
impl ExternalTool for Cargo {
fn candidates() -> &'static [&'static str] {
&["cargo"]
}
fn resolve() -> Option<String> {
static CACHE: OnceLock<Option<String>> = OnceLock::new();
CACHE
.get_or_init(|| {
for candidate in Self::candidates() {
if probe_executable(candidate) {
tracing::info!(target: "tool_dependencies", "Resolved cargo binary");
return Some((*candidate).to_string());
}
}
None
})
.clone()
}
}
pub struct Python;
impl ExternalTool for Python {
fn candidates() -> &'static [&'static str] {
PYTHON_CANDIDATES
}
fn resolve() -> Option<String> {
resolve_python_interpreter()
}
}
pub struct Node;
impl ExternalTool for Node {
fn candidates() -> &'static [&'static str] {
&["node"]
}
fn resolve() -> Option<String> {
resolve_node()
}
}
#[must_use]
pub fn split_interpreter_spec(spec: &str) -> (String, Vec<String>) {
let mut parts = spec.split_whitespace();
let program = parts.next().unwrap_or("").to_string();
let args = parts.map(str::to_string).collect();
(program, args)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn probe_executable_returns_false_for_unknown_binary() {
assert!(!probe_executable("codewhale-tui-imaginary-binary-xyz123"));
}
#[test]
fn probe_executable_handles_multi_word_specs() {
let _ = probe_executable("py -3");
}
#[test]
fn split_interpreter_spec_strips_args() {
assert_eq!(
split_interpreter_spec("python3"),
("python3".to_string(), Vec::<String>::new())
);
assert_eq!(
split_interpreter_spec("py -3"),
("py".to_string(), vec!["-3".to_string()])
);
assert_eq!(
split_interpreter_spec(" python3 "),
("python3".to_string(), Vec::<String>::new()),
"leading/trailing whitespace must be tolerated"
);
}
#[test]
fn split_interpreter_spec_handles_empty_string() {
assert_eq!(
split_interpreter_spec(""),
(String::new(), Vec::<String>::new())
);
}
#[test]
fn python_resolver_is_cached_across_calls() {
let first = resolve_python_interpreter();
let second = resolve_python_interpreter();
assert_eq!(first, second);
}
#[test]
fn python_resolver_returns_some_on_developer_machines() {
let resolved = resolve_python_interpreter();
if let Some(name) = resolved {
assert!(
!name.is_empty(),
"resolved interpreter name must be non-empty"
);
assert!(
PYTHON_CANDIDATES.contains(&name.as_str()),
"resolved {name:?} is not in PYTHON_CANDIDATES {PYTHON_CANDIDATES:?}"
);
}
}
#[test]
fn python_candidates_matches_const() {
assert_eq!(Python::candidates(), PYTHON_CANDIDATES);
}
#[test]
fn node_candidates_is_node_only() {
assert_eq!(Node::candidates(), &["node"]);
}
#[test]
fn git_candidates_is_git_only() {
assert_eq!(Git::candidates(), &["git"]);
}
#[test]
fn gh_candidates_is_gh_only() {
assert_eq!(Gh::candidates(), &["gh"]);
}
#[test]
fn rustc_candidates_is_rustc_only() {
assert_eq!(RustC::candidates(), &["rustc"]);
}
#[test]
fn cargo_candidates_is_cargo_only() {
assert_eq!(Cargo::candidates(), &["cargo"]);
}
#[test]
fn concrete_resolvers_do_not_cross_contaminate_when_available() {
let values = [
Git::resolve().map(|v| ("git", v)),
Gh::resolve().map(|v| ("gh", v)),
RustC::resolve().map(|v| ("rustc", v)),
Cargo::resolve().map(|v| ("cargo", v)),
Node::resolve().map(|v| ("node", v)),
];
let resolved: Vec<(&str, String)> = values.into_iter().flatten().collect();
for i in 0..resolved.len() {
for j in (i + 1)..resolved.len() {
assert_ne!(
resolved[i].1, resolved[j].1,
"{} and {} unexpectedly resolved to the same binary",
resolved[i].0, resolved[j].0
);
}
}
}
#[test]
fn git_resolve_is_cached() {
let first = Git::resolve();
let second = Git::resolve();
assert_eq!(first, second);
}
#[test]
fn gh_resolve_is_cached() {
let first = Gh::resolve();
let second = Gh::resolve();
assert_eq!(first, second);
}
#[test]
fn python_trait_resolve_is_cached() {
let first = Python::resolve();
let second = Python::resolve();
assert_eq!(first, second);
}
#[test]
fn node_resolve_is_cached() {
let first = Node::resolve();
let second = Node::resolve();
assert_eq!(first, second);
}
#[test]
fn rustc_resolve_is_cached() {
let first = RustC::resolve();
let second = RustC::resolve();
assert_eq!(first, second);
}
#[test]
fn cargo_resolve_is_cached() {
let first = Cargo::resolve();
let second = Cargo::resolve();
assert_eq!(first, second);
}
#[test]
fn git_available_matches_resolve() {
assert_eq!(Git::available(), Git::resolve().is_some());
}
#[test]
fn python_available_matches_resolve() {
assert_eq!(Python::available(), Python::resolve().is_some());
}
#[test]
fn node_available_matches_resolve() {
assert_eq!(Node::available(), Node::resolve().is_some());
}
#[test]
fn rustc_available_matches_resolve() {
assert_eq!(RustC::available(), RustC::resolve().is_some());
}
#[test]
fn cargo_available_matches_resolve() {
assert_eq!(Cargo::available(), Cargo::resolve().is_some());
}
#[test]
fn git_command_returns_some_when_available() {
if Git::available() {
assert!(Git::command().is_some());
}
}
#[test]
fn python_command_returns_some_when_available() {
if Python::available() {
assert!(Python::command().is_some());
}
}
#[test]
fn python_tokio_command_returns_some_when_available() {
if Python::available() {
assert!(Python::tokio_command().is_some());
}
}
#[test]
fn node_tokio_command_returns_some_when_available() {
if Node::available() {
assert!(Node::tokio_command().is_some());
}
}
#[test]
fn git_output_version_succeeds() {
if !Git::available() {
return;
}
let tmp = std::env::temp_dir();
let out = Git::output(&["--version"], &tmp);
assert!(
out.is_ok(),
"git --version must succeed when git is available"
);
let out = out.unwrap();
assert!(out.status.success(), "git --version must exit 0");
let stdout = String::from_utf8_lossy(&out.stdout);
assert!(
stdout.contains("git version"),
"git --version stdout must contain 'git version', got: {}",
stdout.trim()
);
}
#[test]
fn python_output_version_succeeds() {
if !Python::available() {
return;
}
let tmp = std::env::temp_dir();
let out = Python::output(&["--version"], &tmp);
assert!(out.is_ok(), "python --version must spawn");
let out = out.unwrap();
assert!(out.status.success(), "python --version must exit 0");
}
#[test]
fn node_output_version_succeeds() {
if !Node::available() {
return;
}
let tmp = std::env::temp_dir();
let out = Node::output(&["--version"], &tmp);
assert!(out.is_ok(), "node --version must spawn");
let out = out.unwrap();
assert!(out.status.success(), "node --version must exit 0");
}
#[test]
fn cargo_output_version_succeeds() {
if !Cargo::available() {
return;
}
let tmp = std::env::temp_dir();
let out = Cargo::output(&["--version"], &tmp);
assert!(out.is_ok(), "cargo --version must spawn");
let out = out.unwrap();
assert!(out.status.success(), "cargo --version must exit 0");
}
#[test]
fn external_tool_output_respects_cwd() {
if !Git::available() {
return;
}
let tmp = std::env::temp_dir();
let out = Git::output(&["rev-parse", "--show-toplevel"], &tmp);
assert!(out.is_ok(), "git rev-parse must spawn");
let out = out.unwrap();
let _ = out; }
}