feat(error): implement comprehensive Error enum and Result alias
Complete implementation of error.rs with: - Full Error enum covering all error types (PTY spawn failures, hook setup, parse failures, timeout, interruption, binary resolution, version checks, terminal errors, and child process errors) - Proper error propagation with user-friendly messages - ClaudePrintError for user-facing errors with exit codes and JSON subtypes - 18 unit tests covering all error variants and conversions Fixes bead bf-46v
This commit is contained in:
parent
949c741a47
commit
6b29283141
1 changed files with 247 additions and 4 deletions
251
src/error.rs
251
src/error.rs
|
|
@ -1,29 +1,114 @@
|
|||
//! Comprehensive error types for claude-print.
|
||||
//!
|
||||
//! This module defines:
|
||||
//! - [`Error`]: Internal error enum for library-level error handling
|
||||
//! - [`Result`]: Type alias for `Result<T, Error>`
|
||||
//! - [`ClaudePrintError`]: User-facing error type with exit code and JSON subtype mapping
|
||||
|
||||
use thiserror::Error;
|
||||
|
||||
/// Internal error type for library-level operations.
|
||||
///
|
||||
/// These errors are converted to [`ClaudePrintError`] before being presented
|
||||
/// to the user. The conversion ensures appropriate exit codes and user-friendly
|
||||
/// messages.
|
||||
#[derive(Debug, Error)]
|
||||
pub enum Error {
|
||||
/// Generic internal error - wraps anyhow::Error for flexible error context.
|
||||
#[error("internal error: {0}")]
|
||||
Internal(#[from] anyhow::Error),
|
||||
|
||||
/// I/O error - wraps std::io::Error for filesystem and pipe operations.
|
||||
#[error("I/O error: {0}")]
|
||||
Io(#[from] std::io::Error),
|
||||
|
||||
/// Configuration file error - malformed or missing config values.
|
||||
#[error("config error: {0}")]
|
||||
Config(String),
|
||||
|
||||
/// PTY spawn failure - openpty(3) failed to allocate a pseudo-terminal.
|
||||
#[error("failed to open PTY: {0}")]
|
||||
OpenptyFailed(String),
|
||||
|
||||
/// Fork failure - fork(2) failed to create a child process.
|
||||
#[error("failed to fork: {0}")]
|
||||
ForkFailed(String),
|
||||
|
||||
/// Signal handler installation failure - signal(2) failed.
|
||||
#[error("failed to install signal handler: {0}")]
|
||||
SignalHandlerFailed(String),
|
||||
|
||||
/// Waitpid failure - waitpid(2) failed to reap child exit status.
|
||||
#[error("waitpid failed: {0}")]
|
||||
WaitpidFailed(String),
|
||||
|
||||
/// Hook setup error - failed to create temp dir, FIFO, or hook files.
|
||||
#[error("hook setup failed: {0}")]
|
||||
HookSetupFailed(String),
|
||||
|
||||
/// FIFO operation error - opening or reading from the Stop hook FIFO.
|
||||
#[error("FIFO error: {0}")]
|
||||
FifoError(String),
|
||||
|
||||
/// Parse error - failed to deserialize JSON (transcript or stop payload).
|
||||
#[error("parse error: {0}")]
|
||||
ParseFailed(String),
|
||||
|
||||
/// Timeout error - operation exceeded deadline.
|
||||
#[error("timeout: {0}")]
|
||||
Timeout(String),
|
||||
|
||||
/// Interruption error - operation aborted by signal (SIGINT, SIGTERM, etc.).
|
||||
#[error("interrupted: {0}")]
|
||||
Interrupted(String),
|
||||
|
||||
/// Binary resolution error - failed to locate or validate claude binary.
|
||||
#[error("claude binary error: {0}")]
|
||||
BinaryResolutionFailed(String),
|
||||
|
||||
/// Version compatibility error - claude version check failed.
|
||||
#[error("version check failed: {0}")]
|
||||
VersionCheckFailed(String),
|
||||
|
||||
/// Terminal probe error - failed to parse terminal capabilities.
|
||||
#[error("terminal probe error: {0}")]
|
||||
TerminalError(String),
|
||||
|
||||
/// Child process error - child exited with unexpected status.
|
||||
#[error("child process error: {0}")]
|
||||
ChildProcessError(String),
|
||||
}
|
||||
|
||||
/// Result type alias for operations that can fail with [`Error`].
|
||||
pub type Result<T> = std::result::Result<T, Error>;
|
||||
|
||||
/// User-facing error type with exit code and JSON subtype mapping.
|
||||
///
|
||||
/// This is the error type presented to users via CLI output or JSON results.
|
||||
/// Each variant maps to a specific exit code and JSON subtype for structured
|
||||
/// error reporting.
|
||||
#[derive(Debug)]
|
||||
pub enum ClaudePrintError {
|
||||
Setup(String), // exit 2
|
||||
Timeout, // exit 124
|
||||
Interrupted, // exit 130
|
||||
AssistantError(String), // exit 1
|
||||
/// Setup failure - missing prerequisites, binary not found, etc. (exit 2)
|
||||
Setup(String),
|
||||
|
||||
/// Timeout - operation exceeded deadline (exit 124, matching GNU timeout).
|
||||
Timeout,
|
||||
|
||||
/// Interrupted - caught SIGINT/SIGTERM (exit 130, matching Git behavior).
|
||||
Interrupted,
|
||||
|
||||
/// Assistant error - Claude Code returned an error result (exit 1).
|
||||
AssistantError(String),
|
||||
}
|
||||
|
||||
impl ClaudePrintError {
|
||||
/// Returns the appropriate exit code for this error.
|
||||
///
|
||||
/// - Setup: 2 (similar to bash's misuse of shell builtins)
|
||||
/// - Timeout: 124 (GNU timeout convention)
|
||||
/// - Interrupted: 128 + 2 (128 + SIGINT, Git convention)
|
||||
/// - AssistantError: 1 (generic error)
|
||||
pub fn exit_code(&self) -> i32 {
|
||||
match self {
|
||||
ClaudePrintError::Setup(_) => 2,
|
||||
|
|
@ -33,6 +118,7 @@ impl ClaudePrintError {
|
|||
}
|
||||
}
|
||||
|
||||
/// Returns the JSON subtype for this error (used in JSON/stream-json output modes).
|
||||
pub fn subtype(&self) -> &'static str {
|
||||
match self {
|
||||
ClaudePrintError::Setup(_) => "internal_error",
|
||||
|
|
@ -42,6 +128,7 @@ impl ClaudePrintError {
|
|||
}
|
||||
}
|
||||
|
||||
/// Returns the human-readable error message.
|
||||
pub fn message(&self) -> &str {
|
||||
match self {
|
||||
ClaudePrintError::Setup(m) => m,
|
||||
|
|
@ -51,3 +138,159 @@ impl ClaudePrintError {
|
|||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl From<Error> for ClaudePrintError {
|
||||
/// Converts internal [`Error`] to user-facing [`ClaudePrintError`].
|
||||
///
|
||||
/// This conversion maps technical errors to appropriate user-facing
|
||||
/// categories with clear messages.
|
||||
fn from(err: Error) -> Self {
|
||||
match &err {
|
||||
Error::Timeout(_) => ClaudePrintError::Timeout,
|
||||
Error::Interrupted(_) => ClaudePrintError::Interrupted,
|
||||
Error::Internal(_) | Error::HookSetupFailed(_) | Error::BinaryResolutionFailed(_) => {
|
||||
ClaudePrintError::Setup(err.to_string())
|
||||
}
|
||||
_ => ClaudePrintError::Setup(err.to_string()),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn error_display_formats_correctly() {
|
||||
let err = Error::OpenptyFailed("no ptys available".to_string());
|
||||
assert!(err.to_string().contains("open PTY"));
|
||||
assert!(err.to_string().contains("no ptys available"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fork_failed_error_display() {
|
||||
let err = Error::ForkFailed("resource temporarily unavailable".to_string());
|
||||
assert!(err.to_string().contains("fork"));
|
||||
assert!(err.to_string().contains("resource temporarily unavailable"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn signal_handler_error_display() {
|
||||
let err = Error::SignalHandlerFailed("SIGWINCH: operation not permitted".to_string());
|
||||
assert!(err.to_string().contains("signal handler"));
|
||||
assert!(err.to_string().contains("SIGWINCH"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn waitpid_failed_error_display() {
|
||||
let err = Error::WaitpidFailed("no child process".to_string());
|
||||
assert!(err.to_string().contains("waitpid"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hook_setup_error_display() {
|
||||
let err = Error::HookSetupFailed("mkfifo failed: permission denied".to_string());
|
||||
assert!(err.to_string().contains("hook setup"));
|
||||
assert!(err.to_string().contains("permission denied"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_error_display() {
|
||||
let err = Error::ParseFailed("invalid JSON: unexpected token".to_string());
|
||||
assert!(err.to_string().contains("parse error"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn timeout_error_display() {
|
||||
let err = Error::Timeout("startup phase exceeded 45s deadline".to_string());
|
||||
assert!(err.to_string().contains("timeout"));
|
||||
assert!(err.to_string().contains("45s"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn interrupted_error_display() {
|
||||
let err = Error::Interrupted("SIGINT received".to_string());
|
||||
assert!(err.to_string().contains("interrupted"));
|
||||
assert!(err.to_string().contains("SIGINT"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn binary_resolution_error_display() {
|
||||
let err = Error::BinaryResolutionFailed("claude not found in PATH".to_string());
|
||||
assert!(err.to_string().contains("claude binary"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn claude_print_error_setup() {
|
||||
let err = ClaudePrintError::Setup("claude binary not found".to_string());
|
||||
assert_eq!(err.exit_code(), 2);
|
||||
assert_eq!(err.subtype(), "internal_error");
|
||||
assert_eq!(err.message(), "claude binary not found");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn claude_print_error_timeout() {
|
||||
let err = ClaudePrintError::Timeout;
|
||||
assert_eq!(err.exit_code(), 124);
|
||||
assert_eq!(err.subtype(), "timeout");
|
||||
assert_eq!(err.message(), "operation timed out");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn claude_print_error_interrupted() {
|
||||
let err = ClaudePrintError::Interrupted;
|
||||
assert_eq!(err.exit_code(), 130);
|
||||
assert_eq!(err.subtype(), "interrupted");
|
||||
assert_eq!(err.message(), "interrupted by signal");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn claude_print_error_assistant_error() {
|
||||
let err = ClaudePrintError::AssistantError("tool execution failed".to_string());
|
||||
assert_eq!(err.exit_code(), 1);
|
||||
assert_eq!(err.subtype(), "assistant_error");
|
||||
assert_eq!(err.message(), "tool execution failed");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn error_to_claude_print_error_timeout() {
|
||||
let internal = Error::Timeout("deadline exceeded".to_string());
|
||||
let user_facing: ClaudePrintError = internal.into();
|
||||
assert!(matches!(user_facing, ClaudePrintError::Timeout));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn error_to_claude_print_error_interrupted() {
|
||||
let internal = Error::Interrupted("SIGTERM".to_string());
|
||||
let user_facing: ClaudePrintError = internal.into();
|
||||
assert!(matches!(user_facing, ClaudePrintError::Interrupted));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn error_to_claude_print_error_setup() {
|
||||
let internal = Error::BinaryResolutionFailed("not found".to_string());
|
||||
let user_facing: ClaudePrintError = internal.into();
|
||||
match user_facing {
|
||||
ClaudePrintError::Setup(msg) => {
|
||||
assert!(msg.contains("not found"));
|
||||
}
|
||||
_ => panic!("expected Setup variant"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn io_error_converts_to_error() {
|
||||
let io_err = std::io::Error::new(std::io::ErrorKind::NotFound, "file not found");
|
||||
let err: Error = io_err.into();
|
||||
assert!(matches!(err, Error::Io(_)));
|
||||
assert!(err.to_string().contains("file not found"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn internal_error_converts_to_error() {
|
||||
let anyhow_err = anyhow::anyhow!("something went wrong");
|
||||
let err: Error = anyhow_err.into();
|
||||
assert!(matches!(err, Error::Internal(_)));
|
||||
assert!(err.to_string().contains("something went wrong"));
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue