use log::debug;
use std::path::PathBuf;
use std::process::Stdio;
#[derive(Debug, Clone)]
pub struct AppServerBuilder {
command: PathBuf,
working_directory: Option<PathBuf>,
config_overrides: Vec<(String, String)>,
extra_args: Vec<String>,
}
impl Default for AppServerBuilder {
fn default() -> Self {
Self::new()
}
}
impl AppServerBuilder {
pub fn new() -> Self {
Self {
command: PathBuf::from("codex"),
working_directory: None,
config_overrides: Vec::new(),
extra_args: Vec::new(),
}
}
pub fn command<P: Into<PathBuf>>(mut self, path: P) -> Self {
self.command = path.into();
self
}
pub fn working_directory<P: Into<PathBuf>>(mut self, dir: P) -> Self {
self.working_directory = Some(dir.into());
self
}
pub fn config_override<K, V>(mut self, key: K, value: V) -> Self
where
K: Into<String>,
V: Into<String>,
{
self.config_overrides.push((key.into(), value.into()));
self
}
pub fn extra_args<I, S>(mut self, args: I) -> Self
where
I: IntoIterator<Item = S>,
S: Into<String>,
{
self.extra_args.extend(args.into_iter().map(Into::into));
self
}
fn resolve_command(&self) -> crate::error::Result<PathBuf> {
if self.command.is_absolute() {
return Ok(self.command.clone());
}
which::which(&self.command).map_err(|_| crate::error::Error::BinaryNotFound {
name: self.command.display().to_string(),
})
}
fn build_args(&self) -> Vec<String> {
let mut args =
Vec::with_capacity(self.config_overrides.len() * 2 + 3 + self.extra_args.len());
for (k, v) in &self.config_overrides {
args.push("-c".to_string());
args.push(format!("{k}={v}"));
}
args.push("app-server".to_string());
args.push("--listen".to_string());
args.push("stdio://".to_string());
args.extend(self.extra_args.iter().cloned());
args
}
#[cfg(feature = "async-client")]
pub async fn spawn(self) -> crate::error::Result<tokio::process::Child> {
let resolved = self.resolve_command()?;
let args = self.build_args();
debug!(
"[CLI] Spawning async app-server: {} {}",
resolved.display(),
args.join(" ")
);
let mut cmd = tokio::process::Command::new(&resolved);
cmd.args(&args)
.stdin(Stdio::piped())
.stdout(Stdio::piped())
.stderr(Stdio::piped());
if let Some(ref dir) = self.working_directory {
cmd.current_dir(dir);
}
cmd.spawn().map_err(crate::error::Error::Io)
}
pub fn spawn_sync(self) -> crate::error::Result<std::process::Child> {
let resolved = self.resolve_command()?;
let args = self.build_args();
debug!(
"[CLI] Spawning sync app-server: {} {}",
resolved.display(),
args.join(" ")
);
let mut cmd = std::process::Command::new(&resolved);
cmd.args(&args)
.stdin(Stdio::piped())
.stdout(Stdio::piped())
.stderr(Stdio::piped());
if let Some(ref dir) = self.working_directory {
cmd.current_dir(dir);
}
cmd.spawn().map_err(crate::error::Error::Io)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_default_args() {
let builder = AppServerBuilder::new();
let args = builder.build_args();
assert_eq!(args, vec!["app-server", "--listen", "stdio://"]);
}
#[test]
fn test_custom_command() {
let builder = AppServerBuilder::new().command("/usr/local/bin/codex");
assert_eq!(builder.command, PathBuf::from("/usr/local/bin/codex"));
}
#[test]
fn test_working_directory() {
let builder = AppServerBuilder::new().working_directory("/tmp/work");
assert_eq!(builder.working_directory, Some(PathBuf::from("/tmp/work")));
}
#[test]
fn test_config_override_single() {
let args = AppServerBuilder::new()
.config_override("sandbox_mode", "workspace-write")
.build_args();
assert_eq!(
args,
vec![
"-c",
"sandbox_mode=workspace-write",
"app-server",
"--listen",
"stdio://"
]
);
}
#[test]
fn test_config_override_multiple_preserves_order() {
let args = AppServerBuilder::new()
.config_override("sandbox_mode", "workspace-write")
.config_override("approval_policy", "on-request")
.build_args();
assert_eq!(
args,
vec![
"-c",
"sandbox_mode=workspace-write",
"-c",
"approval_policy=on-request",
"app-server",
"--listen",
"stdio://"
]
);
}
#[test]
fn test_extra_args_appended_after_listen() {
let args = AppServerBuilder::new()
.extra_args(["--strict-config"])
.build_args();
assert_eq!(
args,
vec!["app-server", "--listen", "stdio://", "--strict-config"]
);
}
#[test]
fn test_config_override_and_extra_args_combined() {
let args = AppServerBuilder::new()
.config_override("sandbox_mode", "workspace-write")
.extra_args(["--strict-config", "--something-else"])
.build_args();
assert_eq!(
args,
vec![
"-c",
"sandbox_mode=workspace-write",
"app-server",
"--listen",
"stdio://",
"--strict-config",
"--something-else",
]
);
}
#[test]
fn test_config_override_value_with_special_chars_unchanged() {
let args = AppServerBuilder::new()
.config_override("sandbox_permissions", r#"["disk-full-read-access"]"#)
.build_args();
assert_eq!(args[1], r#"sandbox_permissions=["disk-full-read-access"]"#);
}
}