use anyhow::{Result, anyhow};
use std::fmt;
use std::io::Write;
use std::path::{Path, PathBuf};
use std::process::Command;
use crate::fsutil;
use super::process::ensure_self_on_path;
use crate::constants::defaults::OPENCODE_PROMPT_FILE_MESSAGE;
use crate::constants::timeouts::TEMP_RETENTION;
use crate::contracts::{ClaudePermissionMode, Model, ReasoningEffort};
pub(super) struct RunnerCommandBuilder {
cmd: Command,
bin: String,
#[allow(dead_code)]
work_dir: PathBuf,
stdin_payload: Option<Vec<u8>>,
temp_resources: Vec<Box<dyn std::any::Any + Send + Sync>>,
}
impl RunnerCommandBuilder {
pub fn new(bin: &str, work_dir: &Path) -> Self {
let mut cmd = Command::new(bin);
cmd.current_dir(work_dir);
cmd.env("PWD", work_dir);
ensure_self_on_path(&mut cmd);
Self {
cmd,
bin: bin.to_string(),
work_dir: work_dir.to_path_buf(),
stdin_payload: None,
temp_resources: Vec::new(),
}
}
pub fn arg(mut self, arg: &str) -> Self {
self.cmd.arg(arg);
self
}
pub fn arg_opt(mut self, arg: &str, value: Option<&str>) -> Self {
if let Some(value) = value {
self.cmd.arg(arg).arg(value);
}
self
}
pub fn args<I, S>(mut self, args: I) -> Self
where
I: IntoIterator<Item = S>,
S: AsRef<std::ffi::OsStr>,
{
self.cmd.args(args);
self
}
pub fn env(mut self, key: &str, val: &str) -> Self {
self.cmd.env(key, val);
self
}
pub fn model(mut self, model: &Model) -> Self {
self.cmd.arg("--model").arg(model.as_str());
self
}
pub fn output_format(mut self, format: &str) -> Self {
self.cmd.arg("--output-format").arg(format);
self
}
pub fn legacy_json_format(mut self) -> Self {
self.cmd.arg("--json");
self
}
pub fn opencode_format(mut self) -> Self {
self.cmd.arg("--format").arg("json");
self
}
pub fn reasoning_effort(mut self, effort: Option<ReasoningEffort>) -> Self {
if let Some(effort) = effort {
self.cmd.arg("-c").arg(format!(
"model_reasoning_effort=\"{}\"",
effort_as_str(effort)
));
}
self
}
pub fn permission_mode(mut self, mode: Option<ClaudePermissionMode>) -> Self {
let mode = mode.unwrap_or(ClaudePermissionMode::BypassPermissions);
self.cmd
.arg("--permission-mode")
.arg(permission_mode_to_arg(mode));
self
}
pub fn with_temp_prompt_file(mut self, content: &str) -> Result<Self> {
if let Err(err) = fsutil::cleanup_default_temp_dirs(TEMP_RETENTION) {
log::warn!("temp cleanup failed: {:#}", err);
}
let temp_dir = fsutil::create_ralph_temp_dir("prompt")
.map_err(|e| temp_prompt_file_error(&self.bin, "create_temp_dir", e))?;
let mut tmp = tempfile::Builder::new()
.prefix("prompt_")
.suffix(".md")
.tempfile_in(temp_dir.path())
.map_err(|e| temp_prompt_file_error(&self.bin, "create_temp_prompt_file", e))?;
tmp.write_all(content.as_bytes())
.map_err(|e| temp_prompt_file_error(&self.bin, "write_prompt_file", e))?;
tmp.flush()
.map_err(|e| temp_prompt_file_error(&self.bin, "flush_prompt_file", e))?;
self.cmd.arg("--file").arg(tmp.path());
self.cmd.arg("--").arg(OPENCODE_PROMPT_FILE_MESSAGE);
self.temp_resources.push(Box::new(tmp));
self.temp_resources.push(Box::new(temp_dir));
Ok(self)
}
pub fn stdin_payload(mut self, payload: Option<Vec<u8>>) -> Self {
self.stdin_payload = payload;
self
}
pub fn build(
self,
) -> (
Command,
Option<Vec<u8>>,
Vec<Box<dyn std::any::Any + Send + Sync>>,
) {
(self.cmd, self.stdin_payload, self.temp_resources)
}
}
fn temp_prompt_file_error(bin: &str, step: &str, source: impl fmt::Display) -> anyhow::Error {
anyhow!(
"Runner prompt file failed (bin={}, step={}): {}. Ensure the temp directory is writable and has available space.",
bin,
step,
source
)
}
pub(super) fn effort_as_str(effort: ReasoningEffort) -> &'static str {
match effort {
ReasoningEffort::Low => "low",
ReasoningEffort::Medium => "medium",
ReasoningEffort::High => "high",
ReasoningEffort::XHigh => "xhigh",
}
}
pub(super) fn permission_mode_to_arg(mode: ClaudePermissionMode) -> &'static str {
match mode {
ClaudePermissionMode::AcceptEdits => "acceptEdits",
ClaudePermissionMode::BypassPermissions => "bypassPermissions",
}
}
#[cfg(test)]
mod tests {
use super::{RunnerCommandBuilder, temp_prompt_file_error};
use std::path::Path;
#[test]
fn temp_prompt_file_error_includes_bin_and_step() {
let err = temp_prompt_file_error("opencode", "create_temp_dir", "boom");
let msg = format!("{err}");
assert!(msg.contains("bin=opencode"));
assert!(msg.contains("step=create_temp_dir"));
assert!(msg.contains("boom"));
}
#[test]
fn arg_opt_adds_flag_and_value_when_present() {
let (cmd, _payload, _guards) = RunnerCommandBuilder::new("echo", Path::new("."))
.arg_opt("--flag", Some("value"))
.build();
let args = cmd
.get_args()
.map(|arg| arg.to_string_lossy().to_string())
.collect::<Vec<_>>();
assert_eq!(args, vec!["--flag".to_string(), "value".to_string()]);
}
#[test]
fn arg_opt_skips_when_none() {
let (cmd, _payload, _guards) = RunnerCommandBuilder::new("echo", Path::new("."))
.arg_opt("--flag", None)
.build();
let args = cmd.get_args().collect::<Vec<_>>();
assert!(args.is_empty());
}
#[test]
fn builder_sets_pwd_env() {
let work_dir = Path::new("/tmp/ralph-workspace");
let (cmd, _payload, _guards) = RunnerCommandBuilder::new("echo", work_dir).build();
let pwd = cmd
.get_envs()
.find_map(|(key, value)| {
if key == "PWD" {
value.map(|value| value.to_string_lossy().to_string())
} else {
None
}
})
.expect("PWD env missing");
assert_eq!(pwd, work_dir.to_string_lossy());
}
}