1use std::path::PathBuf;
4
5#[derive(Debug, thiserror::Error)]
11#[non_exhaustive]
12pub enum Error {
13 #[error("codex binary not found in PATH")]
15 NotFound,
16
17 #[error("codex authentication failed: {}", first_line(message))]
24 Auth {
25 message: String,
28 command: String,
29 exit_code: i32,
30 working_dir: Option<PathBuf>,
31 },
32
33 #[error("codex rejected the configuration: {}", first_line(message))]
37 Config {
38 message: String,
40 command: String,
41 exit_code: i32,
42 working_dir: Option<PathBuf>,
43 },
44
45 #[error("codex refused an untrusted directory: {}", first_line(message))]
48 NotTrustedDirectory {
49 message: String,
51 command: String,
52 exit_code: i32,
53 working_dir: Option<PathBuf>,
54 },
55
56 #[error("codex session not found: {}", first_line(message))]
58 SessionNotFound {
59 message: String,
61 command: String,
62 exit_code: i32,
63 working_dir: Option<PathBuf>,
64 },
65
66 #[error("codex command failed: {command} (exit code {exit_code}){}{}{}", working_dir.as_ref().map(|d| format!(" (in {})", d.display())).unwrap_or_default(), if stdout.is_empty() { String::new() } else { format!("\nstdout: {stdout}") }, if stderr.is_empty() { String::new() } else { format!("\nstderr: {stderr}") })]
68 CommandFailed {
69 command: String,
70 exit_code: i32,
71 stdout: String,
72 stderr: String,
73 working_dir: Option<PathBuf>,
74 },
75
76 #[error("io error: {message}{}", working_dir.as_ref().map(|d| format!(" (in {})", d.display())).unwrap_or_default())]
78 Io {
79 message: String,
80 #[source]
81 source: std::io::Error,
82 working_dir: Option<PathBuf>,
83 },
84
85 #[error("codex command timed out after {timeout_seconds}s")]
87 Timeout { timeout_seconds: u64 },
88
89 #[error("token budget exceeded: {total_tokens} of {max_tokens} tokens")]
94 TokenBudgetExceeded {
95 total_tokens: u64,
98 max_tokens: u64,
100 },
101
102 #[error("invalid Codex rollout budget: {message}")]
104 InvalidRolloutBudget {
105 message: String,
107 },
108
109 #[cfg(feature = "config")]
115 #[error("failed to parse {}: {message}", path.display())]
116 ConfigParse {
117 path: PathBuf,
119 message: String,
121 },
122
123 #[error("bypassing codex safety controls requires {variable} to be set")]
127 DangerousNotAllowed {
128 variable: &'static str,
130 },
131
132 #[error("codex run cancelled (after a {grace_seconds}s grace period)")]
138 Cancelled {
139 grace_seconds: u64,
141 },
142
143 #[cfg(feature = "json")]
145 #[error("json parse error: {message}")]
146 Json {
147 message: String,
148 #[source]
149 source: serde_json::Error,
150 },
151
152 #[error("CLI version {found} does not meet minimum requirement {minimum}")]
154 VersionMismatch {
155 found: crate::version::CliVersion,
156 minimum: crate::version::CliVersion,
157 },
158
159 #[error("CLI version {found} is outside the tested range {tested_min}..={tested_max}")]
166 UntestedCliVersion {
167 found: crate::version::CliVersion,
168 tested_min: crate::version::CliVersion,
169 tested_max: crate::version::CliVersion,
170 },
171}
172
173impl From<std::io::Error> for Error {
174 fn from(e: std::io::Error) -> Self {
175 Self::Io {
176 message: e.to_string(),
177 source: e,
178 working_dir: None,
179 }
180 }
181}
182
183pub type Result<T> = std::result::Result<T, Error>;
185
186fn first_line(message: &str) -> &str {
188 message
189 .lines()
190 .map(str::trim)
191 .find(|line| !line.is_empty())
192 .unwrap_or(message)
193}
194
195#[derive(Debug, Clone, Copy, PartialEq, Eq)]
199#[non_exhaustive]
200pub enum FailureKind {
201 Auth,
203 Config,
205 NotTrustedDirectory,
207 SessionNotFound,
209 Unclassified,
211}
212
213const SIGNATURES: &[(&str, FailureKind)] = &[
221 ("401 Unauthorized", FailureKind::Auth),
222 ("Missing bearer or basic authentication", FailureKind::Auth),
223 (
224 "Not inside a trusted directory",
225 FailureKind::NotTrustedDirectory,
226 ),
227 ("Error loading config.toml", FailureKind::Config),
228 ("unknown configuration field", FailureKind::Config),
229 (
230 "no rollout found for thread id",
231 FailureKind::SessionNotFound,
232 ),
233];
234
235impl Error {
236 #[must_use]
249 pub fn from_command_failure(
250 command: String,
251 exit_code: i32,
252 stdout: String,
253 stderr: String,
254 working_dir: Option<PathBuf>,
255 ) -> Self {
256 let message = stderr.trim().to_string();
257
258 let kind = SIGNATURES
259 .iter()
260 .find(|(needle, _)| message.contains(needle))
261 .map(|(_, kind)| *kind);
262
263 match kind {
264 Some(FailureKind::Auth) => Error::Auth {
265 message,
266 command,
267 exit_code,
268 working_dir,
269 },
270 Some(FailureKind::Config) => Error::Config {
271 message,
272 command,
273 exit_code,
274 working_dir,
275 },
276 Some(FailureKind::NotTrustedDirectory) => Error::NotTrustedDirectory {
277 message,
278 command,
279 exit_code,
280 working_dir,
281 },
282 Some(FailureKind::SessionNotFound) => Error::SessionNotFound {
283 message,
284 command,
285 exit_code,
286 working_dir,
287 },
288 _ => Error::CommandFailed {
289 command,
290 exit_code,
291 stdout,
292 stderr,
293 working_dir,
294 },
295 }
296 }
297
298 #[must_use]
300 pub fn failure_kind(&self) -> Option<FailureKind> {
301 match self {
302 Error::Auth { .. } => Some(FailureKind::Auth),
303 Error::Config { .. } => Some(FailureKind::Config),
304 Error::NotTrustedDirectory { .. } => Some(FailureKind::NotTrustedDirectory),
305 Error::SessionNotFound { .. } => Some(FailureKind::SessionNotFound),
306 Error::CommandFailed { .. } => Some(FailureKind::Unclassified),
307 _ => None,
308 }
309 }
310
311 #[must_use]
317 pub fn exit_code(&self) -> Option<i32> {
318 match self {
319 Error::CommandFailed { exit_code, .. }
320 | Error::Auth { exit_code, .. }
321 | Error::Config { exit_code, .. }
322 | Error::NotTrustedDirectory { exit_code, .. }
323 | Error::SessionNotFound { exit_code, .. } => Some(*exit_code),
324 _ => None,
325 }
326 }
327
328 #[must_use]
334 pub fn is_deterministic_failure(&self) -> bool {
335 matches!(
336 self,
337 Error::Auth { .. }
338 | Error::Config { .. }
339 | Error::NotTrustedDirectory { .. }
340 | Error::SessionNotFound { .. }
341 )
342 }
343}
344
345#[cfg(test)]
346mod tests {
347 use super::*;
348
349 #[test]
350 fn display_not_found() {
351 let err = Error::NotFound;
352 assert_eq!(err.to_string(), "codex binary not found in PATH");
353 }
354
355 #[test]
356 fn display_command_failed_minimal() {
357 let err = Error::CommandFailed {
358 command: "exec".to_string(),
359 exit_code: 1,
360 stdout: String::new(),
361 stderr: String::new(),
362 working_dir: None,
363 };
364 assert_eq!(err.to_string(), "codex command failed: exec (exit code 1)");
365 }
366
367 #[test]
368 fn display_command_failed_with_all_fields() {
369 let err = Error::CommandFailed {
370 command: "exec".to_string(),
371 exit_code: 2,
372 stdout: "out".to_string(),
373 stderr: "err".to_string(),
374 working_dir: Some(PathBuf::from("/tmp")),
375 };
376 assert_eq!(
377 err.to_string(),
378 "codex command failed: exec (exit code 2) (in /tmp)\nstdout: out\nstderr: err"
379 );
380 }
381
382 #[test]
383 fn display_io_without_working_dir() {
384 let source = std::io::Error::other("disk full");
385 let err = Error::Io {
386 message: source.to_string(),
387 source,
388 working_dir: None,
389 };
390 assert_eq!(err.to_string(), "io error: disk full");
391 }
392
393 #[test]
394 fn display_io_with_working_dir() {
395 let source = std::io::Error::other("disk full");
396 let err = Error::Io {
397 message: source.to_string(),
398 source,
399 working_dir: Some(PathBuf::from("/home/user")),
400 };
401 assert_eq!(err.to_string(), "io error: disk full (in /home/user)");
402 }
403
404 #[test]
405 fn display_timeout() {
406 let err = Error::Timeout {
407 timeout_seconds: 30,
408 };
409 assert_eq!(err.to_string(), "codex command timed out after 30s");
410 }
411
412 #[cfg(feature = "json")]
413 #[test]
414 fn display_json() {
415 let source: serde_json::Error =
416 serde_json::from_str::<serde_json::Value>("invalid").unwrap_err();
417 let err = Error::Json {
418 message: source.to_string(),
419 source,
420 };
421 assert!(err.to_string().starts_with("json parse error:"));
422 }
423
424 #[test]
425 fn display_version_mismatch() {
426 let err = Error::VersionMismatch {
427 found: crate::version::CliVersion::new(0, 100, 0),
428 minimum: crate::version::CliVersion::new(0, 145, 0),
429 };
430 assert_eq!(
431 err.to_string(),
432 "CLI version 0.100.0 does not meet minimum requirement 0.145.0"
433 );
434 }
435
436 fn classify(stderr: &str) -> Error {
441 Error::from_command_failure(
442 "codex exec hi".into(),
443 1,
444 String::new(),
445 stderr.into(),
446 None,
447 )
448 }
449
450 #[test]
453 fn classifies_every_captured_signature() {
454 let cases: &[(&str, FailureKind)] = &[
455 (
456 "ERROR: unexpected status 401 Unauthorized: Missing bearer or basic authentication in header, url: https://api.openai.com/v1/responses, cf-ray: a272310168bcba62-SJC, request id: req_ef11",
457 FailureKind::Auth,
458 ),
459 (
460 "Not inside a trusted directory and --skip-git-repo-check was not specified.",
461 FailureKind::NotTrustedDirectory,
462 ),
463 (
464 "Error loading config.toml: unknown configuration field `bogus` in -c/--config override",
465 FailureKind::Config,
466 ),
467 (
468 "Error: thread/resume: thread/resume failed: no rollout found for thread id 00000000-0000-0000-0000-000000000000 (code -32600)",
469 FailureKind::SessionNotFound,
470 ),
471 ];
472
473 for (stderr, expected) in cases {
474 let err = classify(stderr);
475 assert_eq!(
476 err.failure_kind(),
477 Some(*expected),
478 "misclassified: {stderr}"
479 );
480 assert_eq!(err.exit_code(), Some(1));
481 assert!(err.is_deterministic_failure(), "{stderr}");
482 }
483 }
484
485 #[test]
488 fn an_unknown_failure_stays_command_failed_with_its_output() {
489 let err = Error::from_command_failure(
490 "codex exec hi".into(),
491 2,
492 "partial stdout".into(),
493 "something new the CLI started saying".into(),
494 None,
495 );
496
497 assert_eq!(err.failure_kind(), Some(FailureKind::Unclassified));
498 assert!(!err.is_deterministic_failure());
499 match err {
500 Error::CommandFailed {
501 stdout,
502 stderr,
503 exit_code,
504 ..
505 } => {
506 assert_eq!(stdout, "partial stdout");
507 assert_eq!(stderr, "something new the CLI started saying");
508 assert_eq!(exit_code, 2);
509 }
510 other => panic!("expected CommandFailed, got {other:?}"),
511 }
512 }
513
514 #[test]
517 fn a_classified_failure_keeps_the_full_message() {
518 let err = classify(
519 "ERROR: Reconnecting... 5/5\nERROR: unexpected status 401 Unauthorized: Missing bearer",
520 );
521 match &err {
522 Error::Auth { message, .. } => {
523 assert!(message.contains("Reconnecting... 5/5"), "{message}");
524 assert!(message.contains("401 Unauthorized"), "{message}");
525 }
526 other => panic!("expected Auth, got {other:?}"),
527 }
528 assert_eq!(
530 err.to_string(),
531 "codex authentication failed: ERROR: Reconnecting... 5/5"
532 );
533 }
534
535 #[test]
536 fn non_command_errors_have_no_failure_kind() {
537 assert_eq!(Error::NotFound.failure_kind(), None);
538 assert_eq!(Error::Timeout { timeout_seconds: 5 }.failure_kind(), None);
539 assert_eq!(Error::NotFound.exit_code(), None);
540 }
541}