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 #[cfg(feature = "config")]
108 #[error("failed to parse {}: {message}", path.display())]
109 ConfigParse {
110 path: PathBuf,
112 message: String,
114 },
115
116 #[error("bypassing codex safety controls requires {variable} to be set")]
120 DangerousNotAllowed {
121 variable: &'static str,
123 },
124
125 #[error("codex run cancelled (after a {grace_seconds}s grace period)")]
131 Cancelled {
132 grace_seconds: u64,
134 },
135
136 #[cfg(feature = "json")]
138 #[error("json parse error: {message}")]
139 Json {
140 message: String,
141 #[source]
142 source: serde_json::Error,
143 },
144
145 #[error("CLI version {found} does not meet minimum requirement {minimum}")]
147 VersionMismatch {
148 found: crate::version::CliVersion,
149 minimum: crate::version::CliVersion,
150 },
151
152 #[error("CLI version {found} is outside the tested range {tested_min}..={tested_max}")]
159 UntestedCliVersion {
160 found: crate::version::CliVersion,
161 tested_min: crate::version::CliVersion,
162 tested_max: crate::version::CliVersion,
163 },
164}
165
166impl From<std::io::Error> for Error {
167 fn from(e: std::io::Error) -> Self {
168 Self::Io {
169 message: e.to_string(),
170 source: e,
171 working_dir: None,
172 }
173 }
174}
175
176pub type Result<T> = std::result::Result<T, Error>;
178
179fn first_line(message: &str) -> &str {
181 message
182 .lines()
183 .map(str::trim)
184 .find(|line| !line.is_empty())
185 .unwrap_or(message)
186}
187
188#[derive(Debug, Clone, Copy, PartialEq, Eq)]
192#[non_exhaustive]
193pub enum FailureKind {
194 Auth,
196 Config,
198 NotTrustedDirectory,
200 SessionNotFound,
202 Unclassified,
204}
205
206const SIGNATURES: &[(&str, FailureKind)] = &[
214 ("401 Unauthorized", FailureKind::Auth),
215 ("Missing bearer or basic authentication", FailureKind::Auth),
216 (
217 "Not inside a trusted directory",
218 FailureKind::NotTrustedDirectory,
219 ),
220 ("Error loading config.toml", FailureKind::Config),
221 ("unknown configuration field", FailureKind::Config),
222 (
223 "no rollout found for thread id",
224 FailureKind::SessionNotFound,
225 ),
226];
227
228impl Error {
229 #[must_use]
242 pub fn from_command_failure(
243 command: String,
244 exit_code: i32,
245 stdout: String,
246 stderr: String,
247 working_dir: Option<PathBuf>,
248 ) -> Self {
249 let message = stderr.trim().to_string();
250
251 let kind = SIGNATURES
252 .iter()
253 .find(|(needle, _)| message.contains(needle))
254 .map(|(_, kind)| *kind);
255
256 match kind {
257 Some(FailureKind::Auth) => Error::Auth {
258 message,
259 command,
260 exit_code,
261 working_dir,
262 },
263 Some(FailureKind::Config) => Error::Config {
264 message,
265 command,
266 exit_code,
267 working_dir,
268 },
269 Some(FailureKind::NotTrustedDirectory) => Error::NotTrustedDirectory {
270 message,
271 command,
272 exit_code,
273 working_dir,
274 },
275 Some(FailureKind::SessionNotFound) => Error::SessionNotFound {
276 message,
277 command,
278 exit_code,
279 working_dir,
280 },
281 _ => Error::CommandFailed {
282 command,
283 exit_code,
284 stdout,
285 stderr,
286 working_dir,
287 },
288 }
289 }
290
291 #[must_use]
293 pub fn failure_kind(&self) -> Option<FailureKind> {
294 match self {
295 Error::Auth { .. } => Some(FailureKind::Auth),
296 Error::Config { .. } => Some(FailureKind::Config),
297 Error::NotTrustedDirectory { .. } => Some(FailureKind::NotTrustedDirectory),
298 Error::SessionNotFound { .. } => Some(FailureKind::SessionNotFound),
299 Error::CommandFailed { .. } => Some(FailureKind::Unclassified),
300 _ => None,
301 }
302 }
303
304 #[must_use]
310 pub fn exit_code(&self) -> Option<i32> {
311 match self {
312 Error::CommandFailed { exit_code, .. }
313 | Error::Auth { exit_code, .. }
314 | Error::Config { exit_code, .. }
315 | Error::NotTrustedDirectory { exit_code, .. }
316 | Error::SessionNotFound { exit_code, .. } => Some(*exit_code),
317 _ => None,
318 }
319 }
320
321 #[must_use]
327 pub fn is_deterministic_failure(&self) -> bool {
328 matches!(
329 self,
330 Error::Auth { .. }
331 | Error::Config { .. }
332 | Error::NotTrustedDirectory { .. }
333 | Error::SessionNotFound { .. }
334 )
335 }
336}
337
338#[cfg(test)]
339mod tests {
340 use super::*;
341
342 #[test]
343 fn display_not_found() {
344 let err = Error::NotFound;
345 assert_eq!(err.to_string(), "codex binary not found in PATH");
346 }
347
348 #[test]
349 fn display_command_failed_minimal() {
350 let err = Error::CommandFailed {
351 command: "exec".to_string(),
352 exit_code: 1,
353 stdout: String::new(),
354 stderr: String::new(),
355 working_dir: None,
356 };
357 assert_eq!(err.to_string(), "codex command failed: exec (exit code 1)");
358 }
359
360 #[test]
361 fn display_command_failed_with_all_fields() {
362 let err = Error::CommandFailed {
363 command: "exec".to_string(),
364 exit_code: 2,
365 stdout: "out".to_string(),
366 stderr: "err".to_string(),
367 working_dir: Some(PathBuf::from("/tmp")),
368 };
369 assert_eq!(
370 err.to_string(),
371 "codex command failed: exec (exit code 2) (in /tmp)\nstdout: out\nstderr: err"
372 );
373 }
374
375 #[test]
376 fn display_io_without_working_dir() {
377 let source = std::io::Error::other("disk full");
378 let err = Error::Io {
379 message: source.to_string(),
380 source,
381 working_dir: None,
382 };
383 assert_eq!(err.to_string(), "io error: disk full");
384 }
385
386 #[test]
387 fn display_io_with_working_dir() {
388 let source = std::io::Error::other("disk full");
389 let err = Error::Io {
390 message: source.to_string(),
391 source,
392 working_dir: Some(PathBuf::from("/home/user")),
393 };
394 assert_eq!(err.to_string(), "io error: disk full (in /home/user)");
395 }
396
397 #[test]
398 fn display_timeout() {
399 let err = Error::Timeout {
400 timeout_seconds: 30,
401 };
402 assert_eq!(err.to_string(), "codex command timed out after 30s");
403 }
404
405 #[cfg(feature = "json")]
406 #[test]
407 fn display_json() {
408 let source: serde_json::Error =
409 serde_json::from_str::<serde_json::Value>("invalid").unwrap_err();
410 let err = Error::Json {
411 message: source.to_string(),
412 source,
413 };
414 assert!(err.to_string().starts_with("json parse error:"));
415 }
416
417 #[test]
418 fn display_version_mismatch() {
419 let err = Error::VersionMismatch {
420 found: crate::version::CliVersion::new(0, 100, 0),
421 minimum: crate::version::CliVersion::new(0, 145, 0),
422 };
423 assert_eq!(
424 err.to_string(),
425 "CLI version 0.100.0 does not meet minimum requirement 0.145.0"
426 );
427 }
428
429 fn classify(stderr: &str) -> Error {
434 Error::from_command_failure(
435 "codex exec hi".into(),
436 1,
437 String::new(),
438 stderr.into(),
439 None,
440 )
441 }
442
443 #[test]
446 fn classifies_every_captured_signature() {
447 let cases: &[(&str, FailureKind)] = &[
448 (
449 "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",
450 FailureKind::Auth,
451 ),
452 (
453 "Not inside a trusted directory and --skip-git-repo-check was not specified.",
454 FailureKind::NotTrustedDirectory,
455 ),
456 (
457 "Error loading config.toml: unknown configuration field `bogus` in -c/--config override",
458 FailureKind::Config,
459 ),
460 (
461 "Error: thread/resume: thread/resume failed: no rollout found for thread id 00000000-0000-0000-0000-000000000000 (code -32600)",
462 FailureKind::SessionNotFound,
463 ),
464 ];
465
466 for (stderr, expected) in cases {
467 let err = classify(stderr);
468 assert_eq!(
469 err.failure_kind(),
470 Some(*expected),
471 "misclassified: {stderr}"
472 );
473 assert_eq!(err.exit_code(), Some(1));
474 assert!(err.is_deterministic_failure(), "{stderr}");
475 }
476 }
477
478 #[test]
481 fn an_unknown_failure_stays_command_failed_with_its_output() {
482 let err = Error::from_command_failure(
483 "codex exec hi".into(),
484 2,
485 "partial stdout".into(),
486 "something new the CLI started saying".into(),
487 None,
488 );
489
490 assert_eq!(err.failure_kind(), Some(FailureKind::Unclassified));
491 assert!(!err.is_deterministic_failure());
492 match err {
493 Error::CommandFailed {
494 stdout,
495 stderr,
496 exit_code,
497 ..
498 } => {
499 assert_eq!(stdout, "partial stdout");
500 assert_eq!(stderr, "something new the CLI started saying");
501 assert_eq!(exit_code, 2);
502 }
503 other => panic!("expected CommandFailed, got {other:?}"),
504 }
505 }
506
507 #[test]
510 fn a_classified_failure_keeps_the_full_message() {
511 let err = classify(
512 "ERROR: Reconnecting... 5/5\nERROR: unexpected status 401 Unauthorized: Missing bearer",
513 );
514 match &err {
515 Error::Auth { message, .. } => {
516 assert!(message.contains("Reconnecting... 5/5"), "{message}");
517 assert!(message.contains("401 Unauthorized"), "{message}");
518 }
519 other => panic!("expected Auth, got {other:?}"),
520 }
521 assert_eq!(
523 err.to_string(),
524 "codex authentication failed: ERROR: Reconnecting... 5/5"
525 );
526 }
527
528 #[test]
529 fn non_command_errors_have_no_failure_kind() {
530 assert_eq!(Error::NotFound.failure_kind(), None);
531 assert_eq!(Error::Timeout { timeout_seconds: 5 }.failure_kind(), None);
532 assert_eq!(Error::NotFound.exit_code(), None);
533 }
534}