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