1use std::ffi::{OsStr, OsString};
4use std::io::Write;
5use std::path::{Path, PathBuf};
6use std::process::{Command, Stdio};
7use std::time::Duration;
8use wait_timeout::ChildExt;
9
10use crate::constants::{
11 DEFAULT_CODEX_EFFORT, DEFAULT_CODEX_MODEL, DEFAULT_CODEX_TIMEOUT_SECS, ENV_CODEX_BIN,
12 ENV_CODEX_BYPASS_SANDBOX,
13};
14
15#[derive(Debug, Clone)]
17pub struct CodexOptions {
18 pub model: String,
19 pub effort: String,
20 pub timeout_secs: u64,
21 pub project_root: PathBuf,
22 pub bypass_sandbox: bool,
23}
24
25impl Default for CodexOptions {
26 fn default() -> Self {
27 Self {
28 model: DEFAULT_CODEX_MODEL.to_string(),
29 effort: DEFAULT_CODEX_EFFORT.to_string(),
30 timeout_secs: DEFAULT_CODEX_TIMEOUT_SECS,
31 project_root: PathBuf::from("."),
32 bypass_sandbox: false,
33 }
34 }
35}
36
37impl CodexOptions {
38 pub fn from_env(project_root: impl AsRef<Path>) -> Self {
40 let bypass = matches!(
41 std::env::var(ENV_CODEX_BYPASS_SANDBOX).ok().as_deref(),
42 Some("1") | Some("true")
43 );
44
45 Self {
46 project_root: project_root.as_ref().to_path_buf(),
47 bypass_sandbox: bypass,
48 ..Self::default()
49 }
50 }
51}
52
53#[derive(Debug, Clone)]
55pub struct CodexRunResult {
56 pub stdout: String,
57 pub stderr: String,
58 pub exit_code: i32,
59}
60
61#[derive(Debug, thiserror::Error)]
63pub enum CodexError {
64 #[error("Codex process IO error: {0}")]
65 Io(#[from] std::io::Error),
66
67 #[error("Codex exited with code {exit_code}")]
68 Exit {
69 exit_code: i32,
70 stdout: String,
71 stderr: String,
72 },
73
74 #[error("Codex timed out after {0} seconds")]
75 Timeout(u64),
76
77 #[error("Codex returned empty output")]
78 EmptyOutput,
79}
80
81#[derive(Debug, Clone, PartialEq, Eq)]
82enum CodexLauncher {
83 Direct(OsString),
84 CmdShim(PathBuf),
85 PowerShellShim(PathBuf),
86}
87
88#[derive(Debug, Clone, PartialEq, Eq)]
89pub struct CodexBinaryResolution {
90 pub launcher: &'static str,
91 pub path: PathBuf,
92}
93
94pub fn build_exec_args(options: &CodexOptions) -> Vec<String> {
96 let mut args = vec!["exec".to_string(), "-m".to_string(), options.model.clone()];
97 if !options.effort.is_empty() {
98 args.push("-c".to_string());
99 args.push(format!("model_reasoning_effort={}", options.effort));
100 }
101 args.push(codex_auto_flag(options).to_string());
102 args.push("-C".to_string());
103 args.push(options.project_root.display().to_string());
104 args.push("-".to_string());
105 args
106}
107
108pub fn build_review_args(base: &str, options: &CodexOptions) -> Vec<String> {
110 let mut args = vec![
111 "review".to_string(),
112 "--base".to_string(),
113 base.to_string(),
114 "-c".to_string(),
115 format!("model={}", options.model),
116 "-c".to_string(),
117 format!("review_model={}", options.model),
118 ];
119
120 if !options.effort.is_empty() {
121 args.push("-c".to_string());
122 args.push(format!("model_reasoning_effort={}", options.effort));
123 }
124
125 args
126}
127
128pub fn codex_auto_flag(options: &CodexOptions) -> &'static str {
130 if options.bypass_sandbox {
131 "--dangerously-bypass-approvals-and-sandbox"
132 } else {
133 "--full-auto"
134 }
135}
136
137pub fn run_exec(prompt: &str, options: &CodexOptions) -> Result<CodexRunResult, CodexError> {
139 let mut command = codex_command()?;
140 let mut child = command
141 .args(build_exec_args(options))
142 .stdin(Stdio::piped())
143 .stdout(Stdio::piped())
144 .stderr(Stdio::piped())
145 .spawn()?;
146
147 if let Some(mut stdin) = child.stdin.take() {
148 stdin.write_all(prompt.as_bytes())?;
149 }
151
152 let status = match child.wait_timeout(Duration::from_secs(options.timeout_secs))? {
153 Some(status) => status,
154 None => {
155 let _ = child.kill();
156 let _ = child.wait();
157 return Err(CodexError::Timeout(options.timeout_secs));
158 }
159 };
160 let output = child.wait_with_output()?;
161 let exit_code = status.code().unwrap_or(1);
162 let stdout = String::from_utf8_lossy(&output.stdout).to_string();
163 let stderr = String::from_utf8_lossy(&output.stderr).to_string();
164
165 if exit_code != 0 {
166 return Err(CodexError::Exit {
167 exit_code,
168 stdout,
169 stderr,
170 });
171 }
172 if stdout.trim().is_empty() {
173 return Err(CodexError::EmptyOutput);
174 }
175
176 Ok(CodexRunResult {
177 stdout,
178 stderr,
179 exit_code,
180 })
181}
182
183pub fn run_review(base: &str, options: &CodexOptions) -> Result<CodexRunResult, CodexError> {
185 let mut command = codex_command()?;
186 let mut child = command
187 .args(build_review_args(base, options))
188 .current_dir(&options.project_root)
189 .stdout(Stdio::piped())
190 .stderr(Stdio::piped())
191 .spawn()?;
192
193 let status = match child.wait_timeout(Duration::from_secs(options.timeout_secs))? {
194 Some(status) => status,
195 None => {
196 let _ = child.kill();
197 let _ = child.wait();
198 return Err(CodexError::Timeout(options.timeout_secs));
199 }
200 };
201 let output = child.wait_with_output()?;
202 let exit_code = status.code().unwrap_or(1);
203 let stdout = String::from_utf8_lossy(&output.stdout).to_string();
204 let stderr = String::from_utf8_lossy(&output.stderr).to_string();
205
206 if exit_code != 0 {
207 return Err(CodexError::Exit {
208 exit_code,
209 stdout,
210 stderr,
211 });
212 }
213 if stdout.trim().is_empty() && stderr.trim().is_empty() {
214 return Err(CodexError::EmptyOutput);
215 }
216
217 Ok(CodexRunResult {
218 stdout,
219 stderr,
220 exit_code,
221 })
222}
223
224pub fn contains_severity_markers(text: &str) -> bool {
226 let bytes = text.as_bytes();
227 bytes.windows(4).any(|window| {
228 window[0] == b'[' && window[1] == b'P' && window[2].is_ascii_digit() && window[3] == b']'
229 })
230}
231
232pub fn detect_codex_binary() -> Result<CodexBinaryResolution, std::io::Error> {
233 let launcher = resolve_codex_launcher()?;
234 Ok(match launcher {
235 CodexLauncher::Direct(program) => CodexBinaryResolution {
236 launcher: "direct",
237 path: PathBuf::from(program),
238 },
239 CodexLauncher::CmdShim(path) => CodexBinaryResolution {
240 launcher: "cmd-shim",
241 path,
242 },
243 CodexLauncher::PowerShellShim(path) => CodexBinaryResolution {
244 launcher: "powershell-shim",
245 path,
246 },
247 })
248}
249
250fn codex_command() -> Result<Command, std::io::Error> {
251 let launcher = resolve_codex_launcher()?;
252 Ok(command_for_launcher(&launcher))
253}
254
255fn resolve_codex_launcher() -> Result<CodexLauncher, std::io::Error> {
256 if cfg!(windows) {
257 resolve_windows_codex_launcher(std::env::var_os(ENV_CODEX_BIN), std::env::var_os("PATH"))
258 } else {
259 Ok(match std::env::var_os(ENV_CODEX_BIN) {
260 Some(bin) if !bin.is_empty() => CodexLauncher::Direct(bin),
261 _ => CodexLauncher::Direct(OsString::from("codex")),
262 })
263 }
264}
265
266fn resolve_windows_codex_launcher(
267 override_bin: Option<OsString>,
268 path_var: Option<OsString>,
269) -> Result<CodexLauncher, std::io::Error> {
270 let program = match override_bin {
271 Some(bin) if !bin.is_empty() => bin,
272 _ => OsString::from("codex"),
273 };
274
275 let program_path = PathBuf::from(&program);
276 if program_path.is_absolute() || program_path.components().count() > 1 {
277 return Ok(classify_windows_launcher(program_path));
278 }
279
280 if let Some(found) = search_windows_path(path_var.as_deref(), &program) {
281 return Ok(classify_windows_launcher(found));
282 }
283
284 Err(std::io::Error::new(
285 std::io::ErrorKind::NotFound,
286 format!(
287 "program not found (codex not found on PATH; set {} to override)",
288 ENV_CODEX_BIN
289 ),
290 ))
291}
292
293fn search_windows_path(path_var: Option<&OsStr>, program: &OsStr) -> Option<PathBuf> {
294 let path_var = path_var?;
295 for dir in std::env::split_paths(path_var) {
296 for candidate in windows_program_candidates(program) {
297 let path = dir.join(candidate);
298 if path.is_file() {
299 return Some(path);
300 }
301 }
302 }
303 None
304}
305
306fn windows_program_candidates(program: &OsStr) -> Vec<OsString> {
307 if Path::new(program).extension().is_some() {
308 return vec![program.to_os_string()];
309 }
310
311 let mut candidates = Vec::with_capacity(5);
312 for suffix in [".exe", ".cmd", ".bat", ".ps1", ""] {
313 let mut candidate = program.to_os_string();
314 candidate.push(suffix);
315 candidates.push(candidate);
316 }
317 candidates
318}
319
320fn classify_windows_launcher(path: PathBuf) -> CodexLauncher {
321 match path
322 .extension()
323 .and_then(|ext| ext.to_str())
324 .map(|ext| ext.to_ascii_lowercase())
325 .as_deref()
326 {
327 Some("cmd") | Some("bat") => CodexLauncher::CmdShim(path),
328 Some("ps1") => CodexLauncher::PowerShellShim(path),
329 _ => CodexLauncher::Direct(path.into_os_string()),
330 }
331}
332
333fn command_for_launcher(launcher: &CodexLauncher) -> Command {
334 match launcher {
335 CodexLauncher::Direct(program) => Command::new(program),
336 CodexLauncher::CmdShim(path) => {
337 let mut command = Command::new("cmd");
338 command.arg("/C").arg(path);
339 command
340 }
341 CodexLauncher::PowerShellShim(path) => {
342 let mut command = Command::new("powershell");
343 command
344 .arg("-NoProfile")
345 .arg("-ExecutionPolicy")
346 .arg("Bypass")
347 .arg("-File")
348 .arg(path);
349 command
350 }
351 }
352}
353
354#[cfg(test)]
355mod tests {
356 use super::*;
357 use std::ffi::OsString;
358 use std::sync::{Mutex, OnceLock};
359 use tempfile::TempDir;
360
361 fn env_lock() -> &'static Mutex<()> {
362 static LOCK: OnceLock<Mutex<()>> = OnceLock::new();
363 LOCK.get_or_init(|| Mutex::new(()))
364 }
365
366 struct ScopedEnvVar {
367 key: &'static str,
368 previous: Option<OsString>,
369 }
370
371 impl ScopedEnvVar {
372 fn set(key: &'static str, value: &OsStr) -> Self {
373 let previous = std::env::var_os(key);
374 unsafe { std::env::set_var(key, value) };
375 Self { key, previous }
376 }
377 }
378
379 impl Drop for ScopedEnvVar {
380 fn drop(&mut self) {
381 match &self.previous {
382 Some(value) => unsafe { std::env::set_var(self.key, value) },
383 None => unsafe { std::env::remove_var(self.key) },
384 }
385 }
386 }
387
388 #[cfg(unix)]
389 fn create_fake_codex(tempdir: &TempDir, capture_path: &Path) -> PathBuf {
390 use std::os::unix::fs::PermissionsExt;
391
392 let script_path = tempdir.path().join("fake-codex.sh");
393 std::fs::write(
394 &script_path,
395 format!(
396 "#!/bin/sh\ncat > '{}'\nprintf 'COMPLETE\\n'\n",
397 capture_path.display()
398 ),
399 )
400 .unwrap();
401
402 let mut perms = std::fs::metadata(&script_path).unwrap().permissions();
403 perms.set_mode(0o755);
404 std::fs::set_permissions(&script_path, perms).unwrap();
405 script_path
406 }
407
408 #[cfg(windows)]
409 fn create_fake_codex(tempdir: &TempDir, capture_path: &Path) -> PathBuf {
410 let script_path = tempdir.path().join("fake-codex.cmd");
411 std::fs::write(
412 &script_path,
413 format!(
414 "@echo off\r\nmore > \"{}\"\r\necho COMPLETE\r\n",
415 capture_path.display()
416 ),
417 )
418 .unwrap();
419 script_path
420 }
421
422 #[test]
423 fn exec_args_match_legacy_shape() {
424 let options = CodexOptions {
425 model: "gpt-5.4".to_string(),
426 effort: "high".to_string(),
427 project_root: PathBuf::from("/tmp/project"),
428 ..CodexOptions::default()
429 };
430
431 let args = build_exec_args(&options);
432 assert_eq!(
433 args,
434 vec![
435 "exec",
436 "-m",
437 "gpt-5.4",
438 "-c",
439 "model_reasoning_effort=high",
440 "--full-auto",
441 "-C",
442 "/tmp/project",
443 "-",
444 ]
445 );
446 }
447
448 #[test]
449 fn review_args_match_legacy_shape() {
450 let options = CodexOptions {
451 model: "gpt-5.4".to_string(),
452 effort: "high".to_string(),
453 ..CodexOptions::default()
454 };
455
456 let args = build_review_args("main", &options);
457 assert_eq!(
458 args,
459 vec![
460 "review",
461 "--base",
462 "main",
463 "-c",
464 "model=gpt-5.4",
465 "-c",
466 "review_model=gpt-5.4",
467 "-c",
468 "model_reasoning_effort=high",
469 ]
470 );
471 }
472
473 #[test]
474 fn severity_marker_detection_matches_review_contract() {
475 assert!(contains_severity_markers("Issue: [P1] this is bad"));
476 assert!(contains_severity_markers("[P0] blocker"));
477 assert!(!contains_severity_markers("No priority markers here"));
478 assert!(!contains_severity_markers("[PX] invalid"));
479 }
480
481 #[test]
482 fn run_exec_closes_stdin_after_writing_prompt() {
483 let _env_guard = env_lock().lock().unwrap();
484 let tempdir = TempDir::new().unwrap();
485 let capture_path = tempdir.path().join("captured-stdin.txt");
486 let fake_codex = create_fake_codex(&tempdir, &capture_path);
487 let _codex_bin = ScopedEnvVar::set(ENV_CODEX_BIN, fake_codex.as_os_str());
488
489 let prompt = "line one\nline two\n";
490 let options = CodexOptions {
491 timeout_secs: 2,
492 project_root: tempdir.path().to_path_buf(),
493 ..CodexOptions::default()
494 };
495
496 let result = run_exec(prompt, &options).expect("fake codex should receive EOF");
497
498 assert_eq!(result.stdout.trim(), "COMPLETE");
499 assert_eq!(std::fs::read_to_string(capture_path).unwrap(), prompt);
500 }
501
502 #[test]
503 fn windows_launcher_prefers_exe_then_cmd() {
504 let tempdir = TempDir::new().unwrap();
505 let bin_dir = tempdir.path().join("bin");
506 std::fs::create_dir_all(&bin_dir).unwrap();
507 std::fs::write(bin_dir.join("codex.cmd"), "").unwrap();
508 std::fs::write(bin_dir.join("codex.exe"), "").unwrap();
509
510 let path_var = std::env::join_paths([bin_dir]).unwrap();
511 let launcher =
512 resolve_windows_codex_launcher(None, Some(path_var)).expect("launcher should resolve");
513
514 assert_eq!(
515 launcher,
516 CodexLauncher::Direct(tempdir.path().join("bin").join("codex.exe").into())
517 );
518 }
519
520 #[test]
521 fn windows_launcher_uses_cmd_shim_when_only_cmd_exists() {
522 let tempdir = TempDir::new().unwrap();
523 let bin_dir = tempdir.path().join("bin");
524 std::fs::create_dir_all(&bin_dir).unwrap();
525 let cmd_path = bin_dir.join("codex.cmd");
526 std::fs::write(&cmd_path, "").unwrap();
527
528 let path_var = std::env::join_paths([bin_dir]).unwrap();
529 let launcher =
530 resolve_windows_codex_launcher(None, Some(path_var)).expect("launcher should resolve");
531
532 assert_eq!(launcher, CodexLauncher::CmdShim(cmd_path));
533 }
534
535 #[test]
536 fn windows_launcher_honors_override() {
537 let tempdir = TempDir::new().unwrap();
538 let cmd_path = tempdir.path().join("custom-codex.cmd");
539 std::fs::write(&cmd_path, "").unwrap();
540
541 let launcher = resolve_windows_codex_launcher(
542 Some(cmd_path.as_os_str().to_os_string()),
543 None::<OsString>,
544 )
545 .expect("launcher should resolve");
546
547 assert_eq!(launcher, CodexLauncher::CmdShim(cmd_path));
548 }
549}