Skip to main content

humanize_cli_core/
codex.rs

1//! Codex command helpers for Humanize.
2
3use 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/// Configuration for Codex invocations.
16#[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    /// Construct options using the current environment for sandbox bypass.
39    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/// Result of a Codex command.
54#[derive(Debug, Clone)]
55pub struct CodexRunResult {
56    pub stdout: String,
57    pub stderr: String,
58    pub exit_code: i32,
59}
60
61/// Errors from Codex command execution.
62#[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
94/// Build arguments for `codex exec`.
95pub 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
108/// Build arguments for `codex review`.
109pub 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
128/// Determine the automation flag for Codex.
129pub 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
137/// Run `codex exec` with the provided prompt on stdin.
138pub 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        // stdin is dropped here, closing the pipe and sending EOF to the child
150    }
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
183/// Run `codex review`.
184pub 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
224/// Detect `[P0]`..`[P9]` severity markers in review output.
225pub 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}