escher-execution-engine 0.1.2

Production-ready async execution engine for system commands
Documentation
//! Error types for Execution Engine
//!
//! Comprehensive error handling using thiserror.
//! See docs/error-handling.md for usage patterns.

use std::path::PathBuf;
use thiserror::Error;
use uuid::Uuid;

/// Main error type for execution operations
#[derive(Debug, Error)]
pub enum ExecutionError {
    /// Validation error
    #[error("Validation error: {0}")]
    Validation(#[from] ValidationError),

    /// Execution not found
    #[error("Execution not found: {0}")]
    NotFound(Uuid),

    /// Execution timeout
    #[error("Execution timeout after {0}ms")]
    Timeout(u64),

    /// Execution cancelled
    #[error("Execution cancelled")]
    Cancelled,

    /// Command failed with exit code
    #[error("Command failed with exit code {0}")]
    CommandFailed(i32),

    /// Process spawn failed
    #[error("Process spawn failed: {0}")]
    SpawnFailed(String),

    /// IO error
    #[error("IO error: {0}")]
    Io(#[from] std::io::Error),

    /// Serialization error
    #[error("Serialization error: {0}")]
    Serialization(#[from] serde_json::Error),

    /// Output size exceeded
    #[error("Output size exceeded maximum of {0} bytes")]
    OutputSizeExceeded(usize),

    /// Concurrency limit reached
    #[error("Concurrency limit reached: {0} executions running")]
    ConcurrencyLimitReached(usize),

    /// Invalid configuration
    #[error("Invalid configuration: {0}")]
    InvalidConfig(String),

    /// Internal error
    #[error("Internal error: {0}")]
    Internal(String),
}

/// Validation-specific errors
#[derive(Debug, Error)]
pub enum ValidationError {
    /// Invalid command format
    #[error("Invalid command format: {0}")]
    InvalidCommand(String),

    /// Script file not found
    #[error("Script file not found: {0}")]
    ScriptNotFound(PathBuf),

    /// Script file not executable
    #[error("Script file not executable: {0}")]
    ScriptNotExecutable(PathBuf),

    /// Timeout exceeds maximum
    #[error("Timeout exceeds maximum allowed: {0}ms > {1}ms")]
    TimeoutTooLarge(u64, u64),

    /// Invalid working directory
    #[error("Invalid working directory: {0}")]
    InvalidWorkingDir(PathBuf),

    /// Working directory does not exist
    #[error("Working directory does not exist: {0}")]
    WorkingDirNotFound(PathBuf),

    /// Missing required field
    #[error("Missing required field: {0}")]
    MissingField(String),

    /// Invalid execution plan
    #[error("Invalid execution plan: {0}")]
    InvalidPlan(String),

    /// Empty command
    #[error("Command cannot be empty")]
    EmptyCommand,

    /// Invalid dependency graph
    #[error("Invalid dependency graph: {0}")]
    InvalidDependencyGraph(String),

    /// Program not found in PATH
    #[error("Program not found in PATH: {0}")]
    ProgramNotFound(String),
}

/// Result type alias for execution operations
pub type Result<T> = std::result::Result<T, ExecutionError>;

/// Result type alias for validation operations
pub type ValidationResult<T> = std::result::Result<T, ValidationError>;

// ============================================================================
// Helper implementations
// ============================================================================

impl ExecutionError {
    /// Check if error is retryable
    #[must_use]
    pub fn is_retryable(&self) -> bool {
        matches!(
            self,
            ExecutionError::Timeout(_)
                | ExecutionError::Io(_)
                | ExecutionError::SpawnFailed(_)
                | ExecutionError::ConcurrencyLimitReached(_)
        )
    }

    /// Check if error is terminal (not retryable)
    #[must_use]
    pub fn is_terminal(&self) -> bool {
        !self.is_retryable()
    }

    /// Get error code for categorization
    #[must_use]
    pub fn error_code(&self) -> &str {
        match self {
            ExecutionError::Validation(_) => "VALIDATION_ERROR",
            ExecutionError::NotFound(_) => "NOT_FOUND",
            ExecutionError::Timeout(_) => "TIMEOUT",
            ExecutionError::Cancelled => "CANCELLED",
            ExecutionError::CommandFailed(_) => "COMMAND_FAILED",
            ExecutionError::SpawnFailed(_) => "SPAWN_FAILED",
            ExecutionError::Io(_) => "IO_ERROR",
            ExecutionError::Serialization(_) => "SERIALIZATION_ERROR",
            ExecutionError::OutputSizeExceeded(_) => "OUTPUT_SIZE_EXCEEDED",
            ExecutionError::ConcurrencyLimitReached(_) => "CONCURRENCY_LIMIT_REACHED",
            ExecutionError::InvalidConfig(_) => "INVALID_CONFIG",
            ExecutionError::Internal(_) => "INTERNAL_ERROR",
        }
    }
}

impl ValidationError {
    /// Create InvalidCommand error with message
    pub fn invalid_command<S: Into<String>>(msg: S) -> Self {
        ValidationError::InvalidCommand(msg.into())
    }

    /// Create MissingField error
    pub fn missing_field<S: Into<String>>(field: S) -> Self {
        ValidationError::MissingField(field.into())
    }

    /// Create InvalidPlan error with message
    pub fn invalid_plan<S: Into<String>>(msg: S) -> Self {
        ValidationError::InvalidPlan(msg.into())
    }
}

// ============================================================================
// Tests
// ============================================================================

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn test_execution_error_display() {
        let err = ExecutionError::Timeout(5000);
        assert_eq!(err.to_string(), "Execution timeout after 5000ms");

        let err = ExecutionError::CommandFailed(1);
        assert_eq!(err.to_string(), "Command failed with exit code 1");

        let err = ExecutionError::Cancelled;
        assert_eq!(err.to_string(), "Execution cancelled");
    }

    #[test]
    fn test_validation_error_display() {
        let err = ValidationError::EmptyCommand;
        assert_eq!(err.to_string(), "Command cannot be empty");

        let err = ValidationError::TimeoutTooLarge(10000, 5000);
        assert_eq!(
            err.to_string(),
            "Timeout exceeds maximum allowed: 10000ms > 5000ms"
        );

        let err = ValidationError::MissingField("command".to_string());
        assert_eq!(err.to_string(), "Missing required field: command");
    }

    #[test]
    fn test_error_from_validation() {
        let validation_err = ValidationError::EmptyCommand;
        let exec_err: ExecutionError = validation_err.into();

        match exec_err {
            ExecutionError::Validation(e) => {
                assert_eq!(e.to_string(), "Command cannot be empty");
            }
            _ => panic!("Expected Validation error"),
        }
    }

    #[test]
    fn test_error_is_retryable() {
        assert!(ExecutionError::Timeout(5000).is_retryable());
        assert!(ExecutionError::SpawnFailed("error".to_string()).is_retryable());
        assert!(ExecutionError::ConcurrencyLimitReached(100).is_retryable());

        assert!(!ExecutionError::Cancelled.is_retryable());
        assert!(!ExecutionError::CommandFailed(1).is_retryable());
        assert!(!ExecutionError::NotFound(Uuid::new_v4()).is_retryable());
    }

    #[test]
    fn test_error_is_terminal() {
        assert!(!ExecutionError::Timeout(5000).is_terminal());
        assert!(ExecutionError::Cancelled.is_terminal());
        assert!(ExecutionError::CommandFailed(1).is_terminal());
    }

    #[test]
    fn test_error_code() {
        assert_eq!(ExecutionError::Timeout(5000).error_code(), "TIMEOUT");
        assert_eq!(ExecutionError::Cancelled.error_code(), "CANCELLED");
        assert_eq!(
            ExecutionError::CommandFailed(1).error_code(),
            "COMMAND_FAILED"
        );
        assert_eq!(
            ExecutionError::NotFound(Uuid::new_v4()).error_code(),
            "NOT_FOUND"
        );
        assert_eq!(
            ExecutionError::OutputSizeExceeded(1000).error_code(),
            "OUTPUT_SIZE_EXCEEDED"
        );
    }

    #[test]
    fn test_validation_error_helpers() {
        let err = ValidationError::invalid_command("invalid syntax");
        assert_eq!(err.to_string(), "Invalid command format: invalid syntax");

        let err = ValidationError::missing_field("timeout_ms");
        assert_eq!(err.to_string(), "Missing required field: timeout_ms");

        let err = ValidationError::invalid_plan("circular dependency");
        assert_eq!(
            err.to_string(),
            "Invalid execution plan: circular dependency"
        );
    }

    #[test]
    fn test_result_type_alias() {
        fn returns_result() -> Result<i32> {
            Ok(42)
        }

        fn returns_validation_result() -> ValidationResult<String> {
            Ok("valid".to_string())
        }

        assert_eq!(returns_result().unwrap(), 42);
        assert_eq!(returns_validation_result().unwrap(), "valid");
    }

    #[test]
    fn test_error_chain() {
        let io_err = std::io::Error::new(std::io::ErrorKind::NotFound, "file not found");
        let exec_err: ExecutionError = io_err.into();

        match exec_err {
            ExecutionError::Io(e) => {
                assert_eq!(e.kind(), std::io::ErrorKind::NotFound);
            }
            _ => panic!("Expected IO error"),
        }
    }
}