1use log::debug;
7use std::path::PathBuf;
8use std::process::Stdio;
9
10#[derive(Debug, Clone)]
19pub struct AppServerBuilder {
20 command: PathBuf,
21 working_directory: Option<PathBuf>,
22 config_overrides: Vec<(String, String)>,
25 extra_args: Vec<String>,
28}
29
30impl Default for AppServerBuilder {
31 fn default() -> Self {
32 Self::new()
33 }
34}
35
36impl AppServerBuilder {
37 pub fn new() -> Self {
39 Self {
40 command: PathBuf::from("codex"),
41 working_directory: None,
42 config_overrides: Vec::new(),
43 extra_args: Vec::new(),
44 }
45 }
46
47 pub fn command<P: Into<PathBuf>>(mut self, path: P) -> Self {
49 self.command = path.into();
50 self
51 }
52
53 pub fn working_directory<P: Into<PathBuf>>(mut self, dir: P) -> Self {
55 self.working_directory = Some(dir.into());
56 self
57 }
58
59 pub fn config_override<K, V>(mut self, key: K, value: V) -> Self
80 where
81 K: Into<String>,
82 V: Into<String>,
83 {
84 self.config_overrides.push((key.into(), value.into()));
85 self
86 }
87
88 pub fn extra_args<I, S>(mut self, args: I) -> Self
107 where
108 I: IntoIterator<Item = S>,
109 S: Into<String>,
110 {
111 self.extra_args.extend(args.into_iter().map(Into::into));
112 self
113 }
114
115 fn resolve_command(&self) -> crate::error::Result<PathBuf> {
117 if self.command.is_absolute() {
118 return Ok(self.command.clone());
119 }
120 which::which(&self.command).map_err(|_| crate::error::Error::BinaryNotFound {
121 name: self.command.display().to_string(),
122 })
123 }
124
125 fn build_args(&self) -> Vec<String> {
129 let mut args =
130 Vec::with_capacity(self.config_overrides.len() * 2 + 3 + self.extra_args.len());
131 for (k, v) in &self.config_overrides {
132 args.push("-c".to_string());
133 args.push(format!("{k}={v}"));
134 }
135 args.push("app-server".to_string());
136 args.push("--listen".to_string());
137 args.push("stdio://".to_string());
138 args.extend(self.extra_args.iter().cloned());
139 args
140 }
141
142 #[cfg(feature = "async-client")]
144 pub async fn spawn(self) -> crate::error::Result<tokio::process::Child> {
145 let resolved = self.resolve_command()?;
146 let args = self.build_args();
147
148 debug!(
149 "[CLI] Spawning async app-server: {} {}",
150 resolved.display(),
151 args.join(" ")
152 );
153
154 let mut cmd = tokio::process::Command::new(&resolved);
155 cmd.args(&args)
156 .stdin(Stdio::piped())
157 .stdout(Stdio::piped())
158 .stderr(Stdio::piped());
159
160 if let Some(ref dir) = self.working_directory {
161 cmd.current_dir(dir);
162 }
163
164 cmd.spawn().map_err(crate::error::Error::Io)
165 }
166
167 pub fn spawn_sync(self) -> crate::error::Result<std::process::Child> {
169 let resolved = self.resolve_command()?;
170 let args = self.build_args();
171
172 debug!(
173 "[CLI] Spawning sync app-server: {} {}",
174 resolved.display(),
175 args.join(" ")
176 );
177
178 let mut cmd = std::process::Command::new(&resolved);
179 cmd.args(&args)
180 .stdin(Stdio::piped())
181 .stdout(Stdio::piped())
182 .stderr(Stdio::piped());
183
184 if let Some(ref dir) = self.working_directory {
185 cmd.current_dir(dir);
186 }
187
188 cmd.spawn().map_err(crate::error::Error::Io)
189 }
190}
191
192#[cfg(test)]
193mod tests {
194 use super::*;
195
196 #[test]
197 fn test_default_args() {
198 let builder = AppServerBuilder::new();
199 let args = builder.build_args();
200
201 assert_eq!(args, vec!["app-server", "--listen", "stdio://"]);
202 }
203
204 #[test]
205 fn test_custom_command() {
206 let builder = AppServerBuilder::new().command("/usr/local/bin/codex");
207 assert_eq!(builder.command, PathBuf::from("/usr/local/bin/codex"));
208 }
209
210 #[test]
211 fn test_working_directory() {
212 let builder = AppServerBuilder::new().working_directory("/tmp/work");
213 assert_eq!(builder.working_directory, Some(PathBuf::from("/tmp/work")));
214 }
215
216 #[test]
217 fn test_config_override_single() {
218 let args = AppServerBuilder::new()
219 .config_override("sandbox_mode", "workspace-write")
220 .build_args();
221 assert_eq!(
222 args,
223 vec![
224 "-c",
225 "sandbox_mode=workspace-write",
226 "app-server",
227 "--listen",
228 "stdio://"
229 ]
230 );
231 }
232
233 #[test]
234 fn test_config_override_multiple_preserves_order() {
235 let args = AppServerBuilder::new()
236 .config_override("sandbox_mode", "workspace-write")
237 .config_override("approval_policy", "on-request")
238 .build_args();
239 assert_eq!(
242 args,
243 vec![
244 "-c",
245 "sandbox_mode=workspace-write",
246 "-c",
247 "approval_policy=on-request",
248 "app-server",
249 "--listen",
250 "stdio://"
251 ]
252 );
253 }
254
255 #[test]
256 fn test_extra_args_appended_after_listen() {
257 let args = AppServerBuilder::new()
258 .extra_args(["--strict-config"])
259 .build_args();
260 assert_eq!(
261 args,
262 vec!["app-server", "--listen", "stdio://", "--strict-config"]
263 );
264 }
265
266 #[test]
267 fn test_config_override_and_extra_args_combined() {
268 let args = AppServerBuilder::new()
269 .config_override("sandbox_mode", "workspace-write")
270 .extra_args(["--strict-config", "--something-else"])
271 .build_args();
272 assert_eq!(
273 args,
274 vec![
275 "-c",
276 "sandbox_mode=workspace-write",
277 "app-server",
278 "--listen",
279 "stdio://",
280 "--strict-config",
281 "--something-else",
282 ]
283 );
284 }
285
286 #[test]
287 fn test_config_override_value_with_special_chars_unchanged() {
288 let args = AppServerBuilder::new()
291 .config_override("sandbox_permissions", r#"["disk-full-read-access"]"#)
292 .build_args();
293 assert_eq!(args[1], r#"sandbox_permissions=["disk-full-read-access"]"#);
294 }
295}