#[derive(Debug, thiserror::Error)]
#[non_exhaustive]
pub enum Error {
#[error("Claude Code CLI not found. Install: npm install -g @anthropic-ai/claude-code")]
CliNotFound,
#[error("CLI version {found} below minimum {required}")]
VersionMismatch {
found: String,
required: String,
},
#[error("Failed to spawn Claude process: {0}")]
SpawnFailed(#[source] std::io::Error),
#[error("Claude process exited with code {code:?}: {stderr}")]
ProcessExited {
code: Option<i32>,
stderr: String,
},
#[error("Failed to parse JSON: {message} (line: {line})")]
ParseError {
message: String,
line: String,
},
#[error("I/O error: {0}")]
Io(#[from] std::io::Error),
#[error("JSON error: {0}")]
Json(#[from] serde_json::Error),
#[error("Client not connected. Call connect() first.")]
NotConnected,
#[error("Transport error: {0}")]
Transport(String),
#[error("Invalid configuration: {0}")]
Config(String),
#[error("Control protocol error: {0}")]
ControlProtocol(String),
#[error("Image validation error: {0}")]
ImageValidation(String),
#[error("Operation timed out: {0}")]
Timeout(String),
#[error("Operation cancelled")]
Cancelled,
}
pub type Result<T> = std::result::Result<T, Error>;
impl Error {
#[inline]
#[must_use]
pub fn is_process_exit(&self) -> bool {
matches!(self, Self::ProcessExited { .. })
}
#[inline]
#[must_use]
pub fn is_retriable(&self) -> bool {
matches!(self, Self::Io(_) | Self::Timeout(_) | Self::Transport(_))
}
#[inline]
#[must_use]
pub fn is_cancelled(&self) -> bool {
matches!(self, Self::Cancelled)
}
}
#[cfg(test)]
mod tests {
use super::*;
fn display(e: &Error) -> String {
e.to_string()
}
#[test]
fn cli_not_found_display() {
let e = Error::CliNotFound;
assert!(
display(&e).contains("npm install -g @anthropic-ai/claude-code"),
"message should include install hint, got: {e}"
);
}
#[test]
fn version_mismatch_display() {
let e = Error::VersionMismatch {
found: "1.0.0".to_owned(),
required: "2.0.0".to_owned(),
};
let s = display(&e);
assert!(s.contains("1.0.0"), "should contain found version");
assert!(s.contains("2.0.0"), "should contain required version");
}
#[test]
fn spawn_failed_display() {
let inner = std::io::Error::new(std::io::ErrorKind::NotFound, "no such file");
let e = Error::SpawnFailed(inner);
assert!(display(&e).contains("Failed to spawn Claude process"));
}
#[test]
fn process_exited_display_with_code() {
let e = Error::ProcessExited {
code: Some(1),
stderr: "fatal error".to_owned(),
};
let s = display(&e);
assert!(s.contains("1"), "should contain exit code");
assert!(s.contains("fatal error"), "should contain stderr");
}
#[test]
fn process_exited_display_signal_kill() {
let e = Error::ProcessExited {
code: None,
stderr: String::new(),
};
assert!(display(&e).contains("None"));
}
#[test]
fn parse_error_display() {
let e = Error::ParseError {
message: "unexpected token".to_owned(),
line: "{bad json}".to_owned(),
};
let s = display(&e);
assert!(s.contains("unexpected token"));
assert!(s.contains("{bad json}"));
}
#[test]
fn io_error_display() {
let inner = std::io::Error::new(std::io::ErrorKind::BrokenPipe, "pipe broken");
let e = Error::from(inner);
assert!(display(&e).contains("I/O error"));
}
#[test]
fn json_error_display() {
let inner: serde_json::Error =
serde_json::from_str::<serde_json::Value>("{bad}").unwrap_err();
let e = Error::from(inner);
assert!(display(&e).contains("JSON error"));
}
#[test]
fn not_connected_display() {
let e = Error::NotConnected;
assert!(display(&e).contains("connect()"));
}
#[test]
fn transport_display() {
let e = Error::Transport("connection refused".to_owned());
assert!(display(&e).contains("connection refused"));
}
#[test]
fn config_display() {
let e = Error::Config("max_turns must be > 0".to_owned());
assert!(display(&e).contains("max_turns must be > 0"));
}
#[test]
fn control_protocol_display() {
let e = Error::ControlProtocol("unexpected init sequence".to_owned());
assert!(display(&e).contains("unexpected init sequence"));
}
#[test]
fn image_validation_display() {
let e = Error::ImageValidation("unsupported MIME type: image/bmp".to_owned());
assert!(display(&e).contains("image/bmp"));
}
#[test]
fn timeout_display() {
let e = Error::Timeout("connect timed out after 30s".to_owned());
assert!(display(&e).contains("30s"));
}
#[test]
fn is_process_exit_true_for_process_exited() {
let e = Error::ProcessExited {
code: Some(1),
stderr: String::new(),
};
assert!(e.is_process_exit());
}
#[test]
fn is_process_exit_false_for_other_variants() {
assert!(!Error::NotConnected.is_process_exit());
assert!(!Error::CliNotFound.is_process_exit());
}
#[test]
fn is_retriable_for_io_timeout_transport() {
let io_err = Error::Io(std::io::Error::new(
std::io::ErrorKind::ConnectionReset,
"reset",
));
assert!(io_err.is_retriable());
assert!(Error::Timeout("deadline".to_owned()).is_retriable());
assert!(Error::Transport("broken".to_owned()).is_retriable());
}
#[test]
fn is_retriable_false_for_not_connected() {
assert!(!Error::NotConnected.is_retriable());
}
#[test]
fn cancelled_display() {
let e = Error::Cancelled;
assert!(display(&e).contains("cancelled"));
}
#[test]
fn is_cancelled_true_for_cancelled() {
assert!(Error::Cancelled.is_cancelled());
}
#[test]
fn is_cancelled_false_for_other_variants() {
assert!(!Error::NotConnected.is_cancelled());
assert!(!Error::CliNotFound.is_cancelled());
assert!(!Error::Timeout("x".into()).is_cancelled());
}
#[test]
fn cancelled_is_not_retriable() {
assert!(!Error::Cancelled.is_retriable());
}
#[test]
fn result_alias_compiles() {
fn ok_fn() -> Result<u32> {
Ok(42)
}
assert_eq!(ok_fn().unwrap(), 42);
}
}