Skip to main content

humanize_cli_core/
codex.rs

1//! Codex command helpers for Humanize.
2
3use std::io::Write;
4use std::path::{Path, PathBuf};
5use std::process::{Command, Stdio};
6use std::time::Duration;
7use wait_timeout::ChildExt;
8
9use crate::constants::{
10    DEFAULT_CODEX_EFFORT, DEFAULT_CODEX_MODEL, DEFAULT_CODEX_TIMEOUT_SECS, ENV_CODEX_BYPASS_SANDBOX,
11};
12
13/// Configuration for Codex invocations.
14#[derive(Debug, Clone)]
15pub struct CodexOptions {
16    pub model: String,
17    pub effort: String,
18    pub timeout_secs: u64,
19    pub project_root: PathBuf,
20    pub bypass_sandbox: bool,
21}
22
23impl Default for CodexOptions {
24    fn default() -> Self {
25        Self {
26            model: DEFAULT_CODEX_MODEL.to_string(),
27            effort: DEFAULT_CODEX_EFFORT.to_string(),
28            timeout_secs: DEFAULT_CODEX_TIMEOUT_SECS,
29            project_root: PathBuf::from("."),
30            bypass_sandbox: false,
31        }
32    }
33}
34
35impl CodexOptions {
36    /// Construct options using the current environment for sandbox bypass.
37    pub fn from_env(project_root: impl AsRef<Path>) -> Self {
38        let bypass = matches!(
39            std::env::var(ENV_CODEX_BYPASS_SANDBOX).ok().as_deref(),
40            Some("1") | Some("true")
41        );
42
43        Self {
44            project_root: project_root.as_ref().to_path_buf(),
45            bypass_sandbox: bypass,
46            ..Self::default()
47        }
48    }
49}
50
51/// Result of a Codex command.
52#[derive(Debug, Clone)]
53pub struct CodexRunResult {
54    pub stdout: String,
55    pub stderr: String,
56    pub exit_code: i32,
57}
58
59/// Errors from Codex command execution.
60#[derive(Debug, thiserror::Error)]
61pub enum CodexError {
62    #[error("Codex process IO error: {0}")]
63    Io(#[from] std::io::Error),
64
65    #[error("Codex exited with code {exit_code}")]
66    Exit {
67        exit_code: i32,
68        stdout: String,
69        stderr: String,
70    },
71
72    #[error("Codex timed out after {0} seconds")]
73    Timeout(u64),
74
75    #[error("Codex returned empty output")]
76    EmptyOutput,
77}
78
79/// Build arguments for `codex exec`.
80pub fn build_exec_args(options: &CodexOptions) -> Vec<String> {
81    let mut args = vec!["exec".to_string(), "-m".to_string(), options.model.clone()];
82    if !options.effort.is_empty() {
83        args.push("-c".to_string());
84        args.push(format!("model_reasoning_effort={}", options.effort));
85    }
86    args.push(codex_auto_flag(options).to_string());
87    args.push("-C".to_string());
88    args.push(options.project_root.display().to_string());
89    args.push("-".to_string());
90    args
91}
92
93/// Build arguments for `codex review`.
94pub fn build_review_args(base: &str, options: &CodexOptions) -> Vec<String> {
95    let mut args = vec![
96        "review".to_string(),
97        "--base".to_string(),
98        base.to_string(),
99        "-c".to_string(),
100        format!("model={}", options.model),
101        "-c".to_string(),
102        format!("review_model={}", options.model),
103    ];
104
105    if !options.effort.is_empty() {
106        args.push("-c".to_string());
107        args.push(format!("model_reasoning_effort={}", options.effort));
108    }
109
110    args
111}
112
113/// Determine the automation flag for Codex.
114pub fn codex_auto_flag(options: &CodexOptions) -> &'static str {
115    if options.bypass_sandbox {
116        "--dangerously-bypass-approvals-and-sandbox"
117    } else {
118        "--full-auto"
119    }
120}
121
122/// Run `codex exec` with the provided prompt on stdin.
123pub fn run_exec(prompt: &str, options: &CodexOptions) -> Result<CodexRunResult, CodexError> {
124    let mut child = Command::new("codex")
125        .args(build_exec_args(options))
126        .stdin(Stdio::piped())
127        .stdout(Stdio::piped())
128        .stderr(Stdio::piped())
129        .spawn()?;
130
131    if let Some(stdin) = child.stdin.as_mut() {
132        stdin.write_all(prompt.as_bytes())?;
133    }
134
135    let status = match child.wait_timeout(Duration::from_secs(options.timeout_secs))? {
136        Some(status) => status,
137        None => {
138            let _ = child.kill();
139            let _ = child.wait();
140            return Err(CodexError::Timeout(options.timeout_secs));
141        }
142    };
143    let output = child.wait_with_output()?;
144    let exit_code = status.code().unwrap_or(1);
145    let stdout = String::from_utf8_lossy(&output.stdout).to_string();
146    let stderr = String::from_utf8_lossy(&output.stderr).to_string();
147
148    if exit_code != 0 {
149        return Err(CodexError::Exit {
150            exit_code,
151            stdout,
152            stderr,
153        });
154    }
155    if stdout.trim().is_empty() {
156        return Err(CodexError::EmptyOutput);
157    }
158
159    Ok(CodexRunResult {
160        stdout,
161        stderr,
162        exit_code,
163    })
164}
165
166/// Run `codex review`.
167pub fn run_review(base: &str, options: &CodexOptions) -> Result<CodexRunResult, CodexError> {
168    let mut child = Command::new("codex")
169        .args(build_review_args(base, options))
170        .current_dir(&options.project_root)
171        .stdout(Stdio::piped())
172        .stderr(Stdio::piped())
173        .spawn()?;
174
175    let status = match child.wait_timeout(Duration::from_secs(options.timeout_secs))? {
176        Some(status) => status,
177        None => {
178            let _ = child.kill();
179            let _ = child.wait();
180            return Err(CodexError::Timeout(options.timeout_secs));
181        }
182    };
183    let output = child.wait_with_output()?;
184    let exit_code = status.code().unwrap_or(1);
185    let stdout = String::from_utf8_lossy(&output.stdout).to_string();
186    let stderr = String::from_utf8_lossy(&output.stderr).to_string();
187
188    if exit_code != 0 {
189        return Err(CodexError::Exit {
190            exit_code,
191            stdout,
192            stderr,
193        });
194    }
195    if stdout.trim().is_empty() && stderr.trim().is_empty() {
196        return Err(CodexError::EmptyOutput);
197    }
198
199    Ok(CodexRunResult {
200        stdout,
201        stderr,
202        exit_code,
203    })
204}
205
206/// Detect `[P0]`..`[P9]` severity markers in review output.
207pub fn contains_severity_markers(text: &str) -> bool {
208    let bytes = text.as_bytes();
209    bytes.windows(4).any(|window| {
210        window[0] == b'[' && window[1] == b'P' && window[2].is_ascii_digit() && window[3] == b']'
211    })
212}
213
214#[cfg(test)]
215mod tests {
216    use super::*;
217
218    #[test]
219    fn exec_args_match_legacy_shape() {
220        let options = CodexOptions {
221            model: "gpt-5.4".to_string(),
222            effort: "high".to_string(),
223            project_root: PathBuf::from("/tmp/project"),
224            ..CodexOptions::default()
225        };
226
227        let args = build_exec_args(&options);
228        assert_eq!(
229            args,
230            vec![
231                "exec",
232                "-m",
233                "gpt-5.4",
234                "-c",
235                "model_reasoning_effort=high",
236                "--full-auto",
237                "-C",
238                "/tmp/project",
239                "-",
240            ]
241        );
242    }
243
244    #[test]
245    fn review_args_match_legacy_shape() {
246        let options = CodexOptions {
247            model: "gpt-5.4".to_string(),
248            effort: "high".to_string(),
249            ..CodexOptions::default()
250        };
251
252        let args = build_review_args("main", &options);
253        assert_eq!(
254            args,
255            vec![
256                "review",
257                "--base",
258                "main",
259                "-c",
260                "model=gpt-5.4",
261                "-c",
262                "review_model=gpt-5.4",
263                "-c",
264                "model_reasoning_effort=high",
265            ]
266        );
267    }
268
269    #[test]
270    fn severity_marker_detection_matches_review_contract() {
271        assert!(contains_severity_markers("Issue: [P1] this is bad"));
272        assert!(contains_severity_markers("[P0] blocker"));
273        assert!(!contains_severity_markers("No priority markers here"));
274        assert!(!contains_severity_markers("[PX] invalid"));
275    }
276}