use std::path::PathBuf;
use std::process::Stdio;
use std::time::Duration;
use tokio::process::Command;
use crate::config::Config;
use crate::error::{Error, Result};
use crate::isolation::IsolatedConfigDir;
use crate::parse::{self, Outcome};
const DEFAULT_TIMEOUT: Duration = Duration::from_secs(300);
fn effective_timeout(config: &Config) -> Duration {
config.timeout.unwrap_or(DEFAULT_TIMEOUT)
}
fn resolve_binary() -> Result<PathBuf> {
if let Ok(path) = std::env::var("CLAUDE_BINARY") {
return Ok(PathBuf::from(path));
}
which::which("claude").map_err(|_| Error::BinaryNotFound)
}
pub async fn execute(config: &Config, prompt: &str) -> Result<Outcome> {
let binary = resolve_binary()?;
let args = config.build_args(prompt);
let isolation_guard = if config.isolated {
Some(IsolatedConfigDir::new()?)
} else {
None
};
let call = async {
let mut command = Command::new(&binary);
command
.args(&args)
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.kill_on_drop(true);
if let Some(cwd) = &config.cwd {
command.current_dir(cwd);
}
if !config.env.is_empty() {
command.envs(config.env.iter().map(|(k, v)| (k, v)));
}
if let Some(guard) = &isolation_guard {
command.env("CLAUDE_CONFIG_DIR", guard.path());
}
let output = command.output().await.map_err(Error::Spawn)?;
let stdout = String::from_utf8_lossy(&output.stdout);
if stdout.trim().is_empty() {
return Err(Error::Cli {
status: output.status.code(),
stderr: String::from_utf8_lossy(&output.stderr).trim().to_string(),
});
}
let outcome = parse::parse_result(&stdout)?;
if outcome.is_error {
return Err(Error::Api {
status: outcome.api_error_status,
message: outcome.text,
});
}
Ok(outcome)
};
let result = match tokio::time::timeout(effective_timeout(config), call).await {
Ok(result) => result,
Err(_elapsed) => Err(Error::Timeout),
};
drop(isolation_guard);
result
}
#[cfg(test)]
mod tests {
use super::*;
use std::io::Write;
use std::sync::Mutex;
static CLAUDE_BINARY_ENV_LOCK: Mutex<()> = Mutex::new(());
#[test]
fn resolve_binary_prefers_claude_binary_env() {
let _guard = CLAUDE_BINARY_ENV_LOCK
.lock()
.unwrap_or_else(|e| e.into_inner());
unsafe {
std::env::set_var("CLAUDE_BINARY", "/usr/bin/env");
}
let resolved = resolve_binary();
unsafe {
std::env::remove_var("CLAUDE_BINARY");
}
assert_eq!(
resolved.expect("should resolve"),
PathBuf::from("/usr/bin/env")
);
}
#[cfg(unix)]
fn fake_binary_reporting(env_var: &str) -> (tempfile::TempDir, PathBuf) {
write_fake_binary(&format!(
"val=\"${{{env_var}:-<unset>}}\"\nprintf '{{\"total_cost_usd\":0.0,\"usage\":{{}},\"is_error\":false,\"result\":\"%s|%s\"}}' \"$val\" \"$PWD\"\n"
))
}
#[cfg(unix)]
fn write_fake_binary(body: &str) -> (tempfile::TempDir, PathBuf) {
let dir = tempfile::tempdir().expect("temp dir");
let script_path = dir.path().join("fake-claude.sh");
let mut file = std::fs::File::create(&script_path).expect("create script");
writeln!(file, "#!/bin/sh\n{body}").expect("write script");
drop(file);
use std::os::unix::fs::PermissionsExt;
std::fs::set_permissions(&script_path, std::fs::Permissions::from_mode(0o755))
.expect("chmod +x");
(dir, script_path)
}
#[cfg(unix)]
fn try_run_with_fake_binary(script_path: &std::path::Path, config: &Config) -> Result<Outcome> {
let _guard = CLAUDE_BINARY_ENV_LOCK
.lock()
.unwrap_or_else(|e| e.into_inner());
unsafe {
std::env::set_var("CLAUDE_BINARY", script_path);
}
let result = tokio::runtime::Builder::new_current_thread()
.enable_all()
.build()
.expect("runtime")
.block_on(execute(config, "hi"));
unsafe {
std::env::remove_var("CLAUDE_BINARY");
}
result
}
#[cfg(unix)]
fn run_with_fake_binary(script_path: &std::path::Path, config: &Config) -> Outcome {
try_run_with_fake_binary(script_path, config).expect("fake execute should succeed")
}
#[cfg(unix)]
#[test]
fn execute_applies_cwd_override() {
let (_script_dir, script_path) = fake_binary_reporting("CLAUDE_CODE_RS_TEST_VAR");
let target_dir = tempfile::tempdir().expect("target dir");
let canonical_target = std::fs::canonicalize(target_dir.path()).expect("canonicalize");
let config = Config {
cwd: Some(target_dir.path().to_path_buf()),
..Config::default()
};
let outcome = run_with_fake_binary(&script_path, &config);
let reported_cwd = outcome
.text
.split('|')
.nth(1)
.expect("result carries var|cwd");
assert_eq!(
std::fs::canonicalize(reported_cwd).expect("canonicalize reported cwd"),
canonical_target
);
}
#[cfg(unix)]
#[test]
fn execute_applies_env_overrides() {
let (_script_dir, script_path) = fake_binary_reporting("CLAUDE_CODE_RS_TEST_VAR");
let config = Config {
env: vec![(
"CLAUDE_CODE_RS_TEST_VAR".to_string(),
"isolation-seam-value".to_string(),
)],
..Config::default()
};
let outcome = run_with_fake_binary(&script_path, &config);
let reported_var = outcome
.text
.split('|')
.next()
.expect("result carries var|cwd");
assert_eq!(reported_var, "isolation-seam-value");
}
#[cfg(unix)]
#[test]
fn execute_default_path_sets_no_config_dir_override() {
let (_script_dir, script_path) = fake_binary_reporting("CLAUDE_CONFIG_DIR");
let config = Config::default();
assert!(!config.isolated);
let outcome = run_with_fake_binary(&script_path, &config);
let reported_var = outcome
.text
.split('|')
.next()
.expect("result carries var|cwd");
assert_eq!(reported_var, "<unset>");
}
#[cfg(unix)]
#[test]
fn execute_isolated_sets_config_dir_and_guard_outlives_child() {
let (_script_dir, script_path) = fake_binary_reporting("CLAUDE_CONFIG_DIR");
let config = Config {
isolated: true,
..Config::default()
};
let outcome = run_with_fake_binary(&script_path, &config);
let reported_var = outcome
.text
.split('|')
.next()
.expect("result carries var|cwd");
assert_ne!(reported_var, "<unset>");
assert!(!std::path::Path::new(reported_var).exists());
}
#[cfg(unix)]
#[test]
fn envelope_reporting_is_error_becomes_api_error_with_message_from_result() {
let (_dir, script_path) = write_fake_binary(
"printf '{\"total_cost_usd\":0,\"usage\":{},\"modelUsage\":{},\"is_error\":true,\"subtype\":\"success\",\"api_error_status\":404,\"result\":\"model not found\"}'\nexit 1\n",
);
let err = try_run_with_fake_binary(&script_path, &Config::default())
.expect_err("an is_error envelope must not surface as Ok");
match err {
Error::Api { status, message } => {
assert_eq!(status, Some(404));
assert_eq!(message, "model not found");
}
other => panic!("expected Error::Api, got {other:?}"),
}
}
#[cfg(unix)]
#[test]
fn is_error_wins_over_a_subtype_claiming_success() {
let (_dir, script_path) = write_fake_binary(
"printf '{\"total_cost_usd\":0,\"usage\":{},\"is_error\":true,\"subtype\":\"success\",\"result\":\"boom\"}'\n",
);
assert!(
matches!(
try_run_with_fake_binary(&script_path, &Config::default()),
Err(Error::Api { .. })
),
"`subtype: success` must not mask `is_error: true`"
);
}
#[cfg(unix)]
#[test]
fn empty_stdout_becomes_cli_error_carrying_stderr() {
let (_dir, script_path) =
write_fake_binary("echo \"error: unknown option '--bogus'\" >&2\nexit 1\n");
let err = try_run_with_fake_binary(&script_path, &Config::default())
.expect_err("an empty stdout must not surface as Ok");
match err {
Error::Cli { status, stderr } => {
assert_eq!(status, Some(1));
assert_eq!(stderr, "error: unknown option '--bogus'");
}
other => panic!("expected Error::Cli, got {other:?}"),
}
}
#[test]
fn effective_timeout_defaults_to_the_constant_and_honors_an_override() {
assert_eq!(effective_timeout(&Config::default()), DEFAULT_TIMEOUT);
assert_eq!(DEFAULT_TIMEOUT, Duration::from_secs(300));
let overridden = Config {
timeout: Some(Duration::from_millis(50)),
..Config::default()
};
assert_eq!(effective_timeout(&overridden), Duration::from_millis(50));
}
#[cfg(unix)]
#[test]
fn configured_timeout_fires_before_a_slow_binary_finishes() {
let (_dir, script_path) = write_fake_binary("sleep 2\nprintf '{}'\n");
let config = Config {
timeout: Some(Duration::from_millis(200)),
..Config::default()
};
let started = std::time::Instant::now();
let result = try_run_with_fake_binary(&script_path, &config);
let elapsed = started.elapsed();
assert!(
matches!(result, Err(Error::Timeout)),
"a 200ms configured timeout against a 2s binary must yield Error::Timeout, got {result:?}"
);
assert!(
elapsed < Duration::from_secs(2),
"the configured 200ms timeout was not the one that fired — call took {elapsed:?}"
);
}
#[tokio::test]
#[ignore]
async fn live_execute_returns_populated_outcome() {
let config = Config::default();
let outcome = execute(&config, "Say hello in one word.")
.await
.expect("live execute should succeed");
assert!(
!outcome.text.is_empty(),
"live call returned empty text — the response-text field has drifted again"
);
assert!(
outcome.primary_model().is_some(),
"live success envelope must report at least one modelUsage entry"
);
assert!(outcome.cost_usd >= 0.0);
assert!(!outcome.is_error);
}
}