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,
11 ENV_CODEX_BYPASS_SANDBOX,
12};
13
14#[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 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#[derive(Debug, Clone)]
54pub struct CodexRunResult {
55 pub stdout: String,
56 pub stderr: String,
57 pub exit_code: i32,
58}
59
60#[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
80pub 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
94pub 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
114pub 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
123pub 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
167pub 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
207pub 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}