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(stdin) = child.stdin.as_mut() {
148 stdin.write_all(prompt.as_bytes())?;
149 }
150
151 let status = match child.wait_timeout(Duration::from_secs(options.timeout_secs))? {
152 Some(status) => status,
153 None => {
154 let _ = child.kill();
155 let _ = child.wait();
156 return Err(CodexError::Timeout(options.timeout_secs));
157 }
158 };
159 let output = child.wait_with_output()?;
160 let exit_code = status.code().unwrap_or(1);
161 let stdout = String::from_utf8_lossy(&output.stdout).to_string();
162 let stderr = String::from_utf8_lossy(&output.stderr).to_string();
163
164 if exit_code != 0 {
165 return Err(CodexError::Exit {
166 exit_code,
167 stdout,
168 stderr,
169 });
170 }
171 if stdout.trim().is_empty() {
172 return Err(CodexError::EmptyOutput);
173 }
174
175 Ok(CodexRunResult {
176 stdout,
177 stderr,
178 exit_code,
179 })
180}
181
182pub fn run_review(base: &str, options: &CodexOptions) -> Result<CodexRunResult, CodexError> {
184 let mut command = codex_command()?;
185 let mut child = command
186 .args(build_review_args(base, options))
187 .current_dir(&options.project_root)
188 .stdout(Stdio::piped())
189 .stderr(Stdio::piped())
190 .spawn()?;
191
192 let status = match child.wait_timeout(Duration::from_secs(options.timeout_secs))? {
193 Some(status) => status,
194 None => {
195 let _ = child.kill();
196 let _ = child.wait();
197 return Err(CodexError::Timeout(options.timeout_secs));
198 }
199 };
200 let output = child.wait_with_output()?;
201 let exit_code = status.code().unwrap_or(1);
202 let stdout = String::from_utf8_lossy(&output.stdout).to_string();
203 let stderr = String::from_utf8_lossy(&output.stderr).to_string();
204
205 if exit_code != 0 {
206 return Err(CodexError::Exit {
207 exit_code,
208 stdout,
209 stderr,
210 });
211 }
212 if stdout.trim().is_empty() && stderr.trim().is_empty() {
213 return Err(CodexError::EmptyOutput);
214 }
215
216 Ok(CodexRunResult {
217 stdout,
218 stderr,
219 exit_code,
220 })
221}
222
223pub fn contains_severity_markers(text: &str) -> bool {
225 let bytes = text.as_bytes();
226 bytes.windows(4).any(|window| {
227 window[0] == b'[' && window[1] == b'P' && window[2].is_ascii_digit() && window[3] == b']'
228 })
229}
230
231pub fn detect_codex_binary() -> Result<CodexBinaryResolution, std::io::Error> {
232 let launcher = resolve_codex_launcher()?;
233 Ok(match launcher {
234 CodexLauncher::Direct(program) => CodexBinaryResolution {
235 launcher: "direct",
236 path: PathBuf::from(program),
237 },
238 CodexLauncher::CmdShim(path) => CodexBinaryResolution {
239 launcher: "cmd-shim",
240 path,
241 },
242 CodexLauncher::PowerShellShim(path) => CodexBinaryResolution {
243 launcher: "powershell-shim",
244 path,
245 },
246 })
247}
248
249fn codex_command() -> Result<Command, std::io::Error> {
250 let launcher = resolve_codex_launcher()?;
251 Ok(command_for_launcher(&launcher))
252}
253
254fn resolve_codex_launcher() -> Result<CodexLauncher, std::io::Error> {
255 if cfg!(windows) {
256 resolve_windows_codex_launcher(std::env::var_os(ENV_CODEX_BIN), std::env::var_os("PATH"))
257 } else {
258 Ok(match std::env::var_os(ENV_CODEX_BIN) {
259 Some(bin) if !bin.is_empty() => CodexLauncher::Direct(bin),
260 _ => CodexLauncher::Direct(OsString::from("codex")),
261 })
262 }
263}
264
265fn resolve_windows_codex_launcher(
266 override_bin: Option<OsString>,
267 path_var: Option<OsString>,
268) -> Result<CodexLauncher, std::io::Error> {
269 let program = match override_bin {
270 Some(bin) if !bin.is_empty() => bin,
271 _ => OsString::from("codex"),
272 };
273
274 let program_path = PathBuf::from(&program);
275 if program_path.is_absolute() || program_path.components().count() > 1 {
276 return Ok(classify_windows_launcher(program_path));
277 }
278
279 if let Some(found) = search_windows_path(path_var.as_deref(), &program) {
280 return Ok(classify_windows_launcher(found));
281 }
282
283 Err(std::io::Error::new(
284 std::io::ErrorKind::NotFound,
285 format!(
286 "program not found (codex not found on PATH; set {} to override)",
287 ENV_CODEX_BIN
288 ),
289 ))
290}
291
292fn search_windows_path(path_var: Option<&OsStr>, program: &OsStr) -> Option<PathBuf> {
293 let path_var = path_var?;
294 for dir in std::env::split_paths(path_var) {
295 for candidate in windows_program_candidates(program) {
296 let path = dir.join(candidate);
297 if path.is_file() {
298 return Some(path);
299 }
300 }
301 }
302 None
303}
304
305fn windows_program_candidates(program: &OsStr) -> Vec<OsString> {
306 if Path::new(program).extension().is_some() {
307 return vec![program.to_os_string()];
308 }
309
310 let mut candidates = Vec::with_capacity(5);
311 for suffix in [".exe", ".cmd", ".bat", ".ps1", ""] {
312 let mut candidate = program.to_os_string();
313 candidate.push(suffix);
314 candidates.push(candidate);
315 }
316 candidates
317}
318
319fn classify_windows_launcher(path: PathBuf) -> CodexLauncher {
320 match path
321 .extension()
322 .and_then(|ext| ext.to_str())
323 .map(|ext| ext.to_ascii_lowercase())
324 .as_deref()
325 {
326 Some("cmd") | Some("bat") => CodexLauncher::CmdShim(path),
327 Some("ps1") => CodexLauncher::PowerShellShim(path),
328 _ => CodexLauncher::Direct(path.into_os_string()),
329 }
330}
331
332fn command_for_launcher(launcher: &CodexLauncher) -> Command {
333 match launcher {
334 CodexLauncher::Direct(program) => Command::new(program),
335 CodexLauncher::CmdShim(path) => {
336 let mut command = Command::new("cmd");
337 command.arg("/C").arg(path);
338 command
339 }
340 CodexLauncher::PowerShellShim(path) => {
341 let mut command = Command::new("powershell");
342 command
343 .arg("-NoProfile")
344 .arg("-ExecutionPolicy")
345 .arg("Bypass")
346 .arg("-File")
347 .arg(path);
348 command
349 }
350 }
351}
352
353#[cfg(test)]
354mod tests {
355 use super::*;
356 use tempfile::TempDir;
357
358 #[test]
359 fn exec_args_match_legacy_shape() {
360 let options = CodexOptions {
361 model: "gpt-5.4".to_string(),
362 effort: "high".to_string(),
363 project_root: PathBuf::from("/tmp/project"),
364 ..CodexOptions::default()
365 };
366
367 let args = build_exec_args(&options);
368 assert_eq!(
369 args,
370 vec![
371 "exec",
372 "-m",
373 "gpt-5.4",
374 "-c",
375 "model_reasoning_effort=high",
376 "--full-auto",
377 "-C",
378 "/tmp/project",
379 "-",
380 ]
381 );
382 }
383
384 #[test]
385 fn review_args_match_legacy_shape() {
386 let options = CodexOptions {
387 model: "gpt-5.4".to_string(),
388 effort: "high".to_string(),
389 ..CodexOptions::default()
390 };
391
392 let args = build_review_args("main", &options);
393 assert_eq!(
394 args,
395 vec![
396 "review",
397 "--base",
398 "main",
399 "-c",
400 "model=gpt-5.4",
401 "-c",
402 "review_model=gpt-5.4",
403 "-c",
404 "model_reasoning_effort=high",
405 ]
406 );
407 }
408
409 #[test]
410 fn severity_marker_detection_matches_review_contract() {
411 assert!(contains_severity_markers("Issue: [P1] this is bad"));
412 assert!(contains_severity_markers("[P0] blocker"));
413 assert!(!contains_severity_markers("No priority markers here"));
414 assert!(!contains_severity_markers("[PX] invalid"));
415 }
416
417 #[test]
418 fn windows_launcher_prefers_exe_then_cmd() {
419 let tempdir = TempDir::new().unwrap();
420 let bin_dir = tempdir.path().join("bin");
421 std::fs::create_dir_all(&bin_dir).unwrap();
422 std::fs::write(bin_dir.join("codex.cmd"), "").unwrap();
423 std::fs::write(bin_dir.join("codex.exe"), "").unwrap();
424
425 let path_var = std::env::join_paths([bin_dir]).unwrap();
426 let launcher =
427 resolve_windows_codex_launcher(None, Some(path_var)).expect("launcher should resolve");
428
429 assert_eq!(
430 launcher,
431 CodexLauncher::Direct(tempdir.path().join("bin").join("codex.exe").into())
432 );
433 }
434
435 #[test]
436 fn windows_launcher_uses_cmd_shim_when_only_cmd_exists() {
437 let tempdir = TempDir::new().unwrap();
438 let bin_dir = tempdir.path().join("bin");
439 std::fs::create_dir_all(&bin_dir).unwrap();
440 let cmd_path = bin_dir.join("codex.cmd");
441 std::fs::write(&cmd_path, "").unwrap();
442
443 let path_var = std::env::join_paths([bin_dir]).unwrap();
444 let launcher =
445 resolve_windows_codex_launcher(None, Some(path_var)).expect("launcher should resolve");
446
447 assert_eq!(launcher, CodexLauncher::CmdShim(cmd_path));
448 }
449
450 #[test]
451 fn windows_launcher_honors_override() {
452 let tempdir = TempDir::new().unwrap();
453 let cmd_path = tempdir.path().join("custom-codex.cmd");
454 std::fs::write(&cmd_path, "").unwrap();
455
456 let launcher = resolve_windows_codex_launcher(
457 Some(cmd_path.as_os_str().to_os_string()),
458 None::<OsString>,
459 )
460 .expect("launcher should resolve");
461
462 assert_eq!(launcher, CodexLauncher::CmdShim(cmd_path));
463 }
464}