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:
jedarden 2026-06-11 09:11:26 -04:00
parent 949c741a47
commit 6b29283141

View file

@ -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"));
}
}