1use 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#[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 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#[derive(Debug, Clone)]
53pub struct CodexRunResult {
54 pub stdout: String,
55 pub stderr: String,
56 pub exit_code: i32,
57}
58
59#[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
79pub 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
93pub 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
113pub 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
122pub 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
166pub 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
206pub 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}