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(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
182/// Run `codex review`.
183pub 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
223/// Detect `[P0]`..`[P9]` severity markers in review output.
224pub 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}