1use std::path::PathBuf;
2
3#[derive(Debug, thiserror::Error)]
5pub enum Error {
6 #[error("claude binary not found in PATH")]
8 NotFound,
9
10 #[error("claude 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}") })]
12 CommandFailed {
13 command: String,
14 exit_code: i32,
15 stdout: String,
16 stderr: String,
17 working_dir: Option<PathBuf>,
18 },
19
20 #[error("io error: {message}{}", working_dir.as_ref().map(|d| format!(" (in {})", d.display())).unwrap_or_default())]
22 Io {
23 message: String,
24 #[source]
25 source: std::io::Error,
26 working_dir: Option<PathBuf>,
27 },
28
29 #[error("claude command timed out after {timeout_seconds}s")]
31 Timeout { timeout_seconds: u64 },
32
33 #[cfg(feature = "json")]
35 #[error("json parse error: {message}")]
36 Json {
37 message: String,
38 #[source]
39 source: serde_json::Error,
40 },
41
42 #[error("CLI version {found} does not meet minimum requirement {minimum}")]
44 VersionMismatch {
45 found: crate::version::CliVersion,
46 minimum: crate::version::CliVersion,
47 },
48
49 #[error(
53 "dangerous operations are not allowed; set the env var `{env_var}=1` at process start if you really mean it"
54 )]
55 DangerousNotAllowed { env_var: &'static str },
56
57 #[error("budget exceeded: ${total_usd:.4} spent, ${max_usd:.4} max")]
61 BudgetExceeded { total_usd: f64, max_usd: f64 },
62
63 #[cfg(feature = "async")]
68 #[error("duplex session is closed")]
69 DuplexClosed,
70
71 #[cfg(feature = "async")]
75 #[error("duplex session has a turn in flight")]
76 DuplexTurnInFlight,
77
78 #[cfg(feature = "async")]
83 #[error("duplex control request failed: {message}")]
84 DuplexControlFailed {
85 message: String,
87 },
88}
89
90impl From<std::io::Error> for Error {
91 fn from(e: std::io::Error) -> Self {
92 Self::Io {
93 message: e.to_string(),
94 source: e,
95 working_dir: None,
96 }
97 }
98}
99
100pub type Result<T> = std::result::Result<T, Error>;
102
103#[cfg(test)]
104mod tests {
105 use super::*;
106
107 fn command_failed(stdout: &str, stderr: &str, working_dir: Option<PathBuf>) -> Error {
108 Error::CommandFailed {
109 command: "/bin/claude --print".to_string(),
110 exit_code: 7,
111 stdout: stdout.to_string(),
112 stderr: stderr.to_string(),
113 working_dir,
114 }
115 }
116
117 #[test]
118 fn command_failed_display_includes_command_and_exit_code() {
119 let e = command_failed("", "", None);
120 let s = e.to_string();
121 assert!(s.contains("/bin/claude --print"));
122 assert!(s.contains("exit code 7"));
123 }
124
125 #[test]
126 fn command_failed_display_omits_empty_stdout_and_stderr() {
127 let s = command_failed("", "", None).to_string();
128 assert!(!s.contains("stdout:"));
129 assert!(!s.contains("stderr:"));
130 }
131
132 #[test]
133 fn command_failed_display_includes_nonempty_stdout() {
134 let s = command_failed("hello", "", None).to_string();
135 assert!(s.contains("stdout: hello"));
136 }
137
138 #[test]
139 fn command_failed_display_includes_nonempty_stderr() {
140 let s = command_failed("", "boom", None).to_string();
141 assert!(s.contains("stderr: boom"));
142 }
143
144 #[test]
145 fn command_failed_display_includes_both_streams_when_present() {
146 let s = command_failed("out", "err", None).to_string();
147 assert!(s.contains("stdout: out"));
148 assert!(s.contains("stderr: err"));
149 }
150
151 #[test]
152 fn command_failed_display_includes_working_dir_when_present() {
153 let s = command_failed("", "", Some(PathBuf::from("/tmp/proj"))).to_string();
154 assert!(s.contains("/tmp/proj"));
155 }
156
157 #[test]
158 fn command_failed_display_omits_working_dir_when_absent() {
159 let s = command_failed("", "", None).to_string();
160 assert!(!s.contains("(in "));
161 }
162
163 #[test]
164 fn timeout_display_formats_seconds() {
165 let s = Error::Timeout {
166 timeout_seconds: 42,
167 }
168 .to_string();
169 assert!(s.contains("42s"));
170 }
171
172 #[test]
173 fn io_error_display_includes_working_dir_when_present() {
174 let e = Error::Io {
175 message: "spawn failed".to_string(),
176 source: std::io::Error::new(std::io::ErrorKind::NotFound, "no file"),
177 working_dir: Some(PathBuf::from("/work")),
178 };
179 let s = e.to_string();
180 assert!(s.contains("spawn failed"));
181 assert!(s.contains("/work"));
182 }
183}