1use std::fmt;
5use std::time::Duration;
6
7use tokio::process::Command;
8use tracing::debug;
9
10use crate::Codex;
11use crate::error::{Error, Result};
12
13#[derive(Clone)]
18pub struct CommandOutput {
19 pub stdout: String,
21 pub stderr: String,
23 pub exit_code: i32,
25 pub success: bool,
27}
28
29const DEBUG_TRUNCATE_LEN: usize = 200;
30
31fn truncate_for_debug(s: &str) -> String {
32 if s.len() > DEBUG_TRUNCATE_LEN {
33 format!("{}... ({} bytes total)", &s[..DEBUG_TRUNCATE_LEN], s.len())
34 } else {
35 s.to_string()
36 }
37}
38
39impl fmt::Debug for CommandOutput {
40 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
41 f.debug_struct("CommandOutput")
42 .field("stdout", &truncate_for_debug(&self.stdout))
43 .field("stderr", &truncate_for_debug(&self.stderr))
44 .field("exit_code", &self.exit_code)
45 .field("success", &self.success)
46 .finish()
47 }
48}
49
50pub async fn run_codex(codex: &Codex, args: Vec<String>) -> Result<CommandOutput> {
56 run_codex_with_retry(codex, args, None).await
57}
58
59pub async fn run_codex_with_retry(
61 codex: &Codex,
62 args: Vec<String>,
63 retry_override: Option<&crate::retry::RetryPolicy>,
64) -> Result<CommandOutput> {
65 let policy = retry_override.or(codex.retry_policy.as_ref());
66
67 match policy {
68 Some(policy) => {
69 crate::retry::with_retry(policy, || run_codex_once(codex, args.clone())).await
70 }
71 None => run_codex_once(codex, args).await,
72 }
73}
74
75async fn run_codex_once(codex: &Codex, args: Vec<String>) -> Result<CommandOutput> {
76 let mut command_args = Vec::new();
77
78 command_args.extend(codex.global_args.clone());
80
81 command_args.extend(args);
83
84 debug!(binary = %codex.binary.display(), args = ?command_args, "executing codex command");
85
86 let output = if let Some(timeout) = codex.timeout {
87 run_with_timeout(
88 &codex.binary,
89 &command_args,
90 &codex.env,
91 codex.working_dir.as_deref(),
92 timeout,
93 )
94 .await?
95 } else {
96 run_internal(
97 &codex.binary,
98 &command_args,
99 &codex.env,
100 codex.working_dir.as_deref(),
101 )
102 .await?
103 };
104
105 Ok(output)
106}
107
108pub async fn run_codex_allow_exit_codes(
110 codex: &Codex,
111 args: Vec<String>,
112 allowed_codes: &[i32],
113) -> Result<CommandOutput> {
114 let output = run_codex(codex, args).await;
115
116 match output {
117 Err(Error::CommandFailed {
118 exit_code,
119 stdout,
120 stderr,
121 ..
122 }) if allowed_codes.contains(&exit_code) => Ok(CommandOutput {
123 stdout,
124 stderr,
125 exit_code,
126 success: false,
127 }),
128 other => other,
129 }
130}
131
132async fn run_internal(
133 binary: &std::path::Path,
134 args: &[String],
135 env: &std::collections::HashMap<String, String>,
136 working_dir: Option<&std::path::Path>,
137) -> Result<CommandOutput> {
138 let mut cmd = Command::new(binary);
139 cmd.args(args);
140
141 cmd.stdin(std::process::Stdio::null());
143
144 if let Some(dir) = working_dir {
145 cmd.current_dir(dir);
146 }
147
148 for (key, value) in env {
149 cmd.env(key, value);
150 }
151
152 let output = cmd.output().await.map_err(|e| Error::Io {
153 message: format!("failed to spawn codex: {e}"),
154 source: e,
155 working_dir: working_dir.map(|p| p.to_path_buf()),
156 })?;
157
158 let stdout = String::from_utf8_lossy(&output.stdout).to_string();
159 let stderr = String::from_utf8_lossy(&output.stderr).to_string();
160 let exit_code = output.status.code().unwrap_or(-1);
161
162 if !output.status.success() {
163 return Err(Error::CommandFailed {
164 command: format!("{} {}", binary.display(), args.join(" ")),
165 exit_code,
166 stdout,
167 stderr,
168 working_dir: working_dir.map(|p| p.to_path_buf()),
169 });
170 }
171
172 Ok(CommandOutput {
173 stdout,
174 stderr,
175 exit_code,
176 success: true,
177 })
178}
179
180async fn run_with_timeout(
181 binary: &std::path::Path,
182 args: &[String],
183 env: &std::collections::HashMap<String, String>,
184 working_dir: Option<&std::path::Path>,
185 timeout: Duration,
186) -> Result<CommandOutput> {
187 tokio::time::timeout(timeout, run_internal(binary, args, env, working_dir))
188 .await
189 .map_err(|_| Error::Timeout {
190 timeout_seconds: timeout.as_secs(),
191 })?
192}
193
194#[cfg(test)]
195mod tests {
196 use super::*;
197
198 fn make_output(stdout: &str, stderr: &str) -> CommandOutput {
199 CommandOutput {
200 stdout: stdout.to_string(),
201 stderr: stderr.to_string(),
202 exit_code: 0,
203 success: true,
204 }
205 }
206
207 #[test]
208 fn debug_short_output_not_truncated() {
209 let output = make_output("hello", "world");
210 let debug = format!("{output:?}");
211 assert!(debug.contains("hello"));
212 assert!(debug.contains("world"));
213 assert!(!debug.contains("bytes total"));
214 }
215
216 #[test]
217 fn debug_long_output_truncated() {
218 let long = "x".repeat(300);
219 let output = make_output(&long, &long);
220 let debug = format!("{output:?}");
221 assert!(debug.contains("... (300 bytes total)"));
222 assert!(!debug.contains(&long));
223 }
224}