gdelt 0.1.0

CLI for GDELT Project - optimized for agentic usage with local data caching
//! Exit codes for the GDELT CLI.
//!
//! These codes are designed to be machine-readable for AI agents.
//! Each code has a specific meaning that agents can use to determine
//! appropriate recovery actions.

use serde::{Deserialize, Serialize};
use std::process::ExitCode;

/// Exit codes with semantic meaning for agents
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(into = "i32", try_from = "i32")]
#[repr(i32)]
pub enum ExitStatus {
    /// Operation completed successfully
    Success = 0,
    /// Unspecified error occurred
    GeneralError = 1,
    /// Invalid arguments or command syntax
    UsageError = 2,
    /// Requested resource not found
    NotFound = 3,
    /// Input validation failed
    ValidationError = 4,
    /// Authentication or authorization failed
    PermissionDenied = 5,
    /// Network connectivity issue
    NetworkError = 10,
    /// Request timed out
    Timeout = 11,
    /// API rate limit exceeded
    RateLimited = 12,
    /// GDELT API returned an error
    ApiError = 13,
    /// Cache read/write failed
    CacheError = 20,
    /// Cache miss when --cache-only specified
    CacheMiss = 21,
    /// Configuration file error
    ConfigError = 30,
    /// Configuration file not found
    ConfigNotFound = 31,
    /// Database error
    DatabaseError = 40,
    /// Analytics computation failed
    AnalyticsError = 50,
    /// Data download failed
    DownloadError = 60,
    /// Data import failed
    ImportError = 61,
    /// Some operations succeeded, some failed
    PartialSuccess = 100,
    /// Daemon already running
    DaemonAlreadyRunning = 110,
    /// Daemon not running
    DaemonNotRunning = 111,
    /// User interrupted (Ctrl+C)
    Interrupted = 130,
}

impl ExitStatus {
    /// Get the numeric exit code
    pub fn code(&self) -> i32 {
        *self as i32
    }

    /// Get the name of this exit code
    pub fn name(&self) -> &'static str {
        match self {
            Self::Success => "SUCCESS",
            Self::GeneralError => "GENERAL_ERROR",
            Self::UsageError => "USAGE_ERROR",
            Self::NotFound => "NOT_FOUND",
            Self::ValidationError => "VALIDATION_ERROR",
            Self::PermissionDenied => "PERMISSION_DENIED",
            Self::NetworkError => "NETWORK_ERROR",
            Self::Timeout => "TIMEOUT",
            Self::RateLimited => "RATE_LIMITED",
            Self::ApiError => "API_ERROR",
            Self::CacheError => "CACHE_ERROR",
            Self::CacheMiss => "CACHE_MISS",
            Self::ConfigError => "CONFIG_ERROR",
            Self::ConfigNotFound => "CONFIG_NOT_FOUND",
            Self::DatabaseError => "DATABASE_ERROR",
            Self::AnalyticsError => "ANALYTICS_ERROR",
            Self::DownloadError => "DOWNLOAD_ERROR",
            Self::ImportError => "IMPORT_ERROR",
            Self::PartialSuccess => "PARTIAL_SUCCESS",
            Self::DaemonAlreadyRunning => "DAEMON_ALREADY_RUNNING",
            Self::DaemonNotRunning => "DAEMON_NOT_RUNNING",
            Self::Interrupted => "INTERRUPTED",
        }
    }

    /// Get description of this exit code
    pub fn description(&self) -> &'static str {
        match self {
            Self::Success => "Operation completed successfully",
            Self::GeneralError => "An unspecified error occurred",
            Self::UsageError => "Invalid arguments or command syntax",
            Self::NotFound => "Requested resource was not found",
            Self::ValidationError => "Input validation failed",
            Self::PermissionDenied => "Authentication or authorization failed",
            Self::NetworkError => "Network connectivity issue",
            Self::Timeout => "Request timed out",
            Self::RateLimited => "API rate limit exceeded - wait and retry",
            Self::ApiError => "GDELT API returned an error",
            Self::CacheError => "Cache read/write operation failed",
            Self::CacheMiss => "Cache miss when --cache-only was specified",
            Self::ConfigError => "Configuration file is invalid",
            Self::ConfigNotFound => "Configuration file was not found",
            Self::DatabaseError => "Database operation failed",
            Self::AnalyticsError => "Analytics computation failed",
            Self::DownloadError => "Data download failed",
            Self::ImportError => "Data import failed",
            Self::PartialSuccess => "Some operations succeeded, others failed",
            Self::DaemonAlreadyRunning => "Daemon is already running",
            Self::DaemonNotRunning => "Daemon is not running",
            Self::Interrupted => "Operation was interrupted by user",
        }
    }

    /// Get suggested action for agents
    pub fn agent_action(&self) -> &'static str {
        match self {
            Self::Success => "Continue with next task",
            Self::GeneralError => "Retry operation or report error",
            Self::UsageError => "Fix command syntax and retry",
            Self::NotFound => "Handle gracefully - resource doesn't exist",
            Self::ValidationError => "Fix input parameters and retry",
            Self::PermissionDenied => "Check credentials or permissions",
            Self::NetworkError => "Retry with exponential backoff",
            Self::Timeout => "Retry with increased timeout",
            Self::RateLimited => "Wait for retry-after period, then retry",
            Self::ApiError => "Check GDELT API status, retry later",
            Self::CacheError => "Try with --no-cache flag",
            Self::CacheMiss => "Fetch fresh data (remove --cache-only)",
            Self::ConfigError => "Fix configuration file",
            Self::ConfigNotFound => "Create configuration file",
            Self::DatabaseError => "Check database file permissions",
            Self::AnalyticsError => "Check input data validity",
            Self::DownloadError => "Check network, retry download",
            Self::ImportError => "Check file format, retry import",
            Self::PartialSuccess => "Check output for details on failures",
            Self::DaemonAlreadyRunning => "Use existing daemon or stop it first",
            Self::DaemonNotRunning => "Start daemon first",
            Self::Interrupted => "User cancelled - no action needed",
        }
    }

    /// Check if this exit code indicates success
    pub fn is_success(&self) -> bool {
        matches!(self, Self::Success)
    }

    /// Check if this is a retryable error
    pub fn is_retryable(&self) -> bool {
        matches!(
            self,
            Self::NetworkError | Self::Timeout | Self::RateLimited | Self::ApiError
        )
    }

    /// Get all exit codes for documentation
    pub fn all() -> &'static [ExitStatus] {
        &[
            Self::Success,
            Self::GeneralError,
            Self::UsageError,
            Self::NotFound,
            Self::ValidationError,
            Self::PermissionDenied,
            Self::NetworkError,
            Self::Timeout,
            Self::RateLimited,
            Self::ApiError,
            Self::CacheError,
            Self::CacheMiss,
            Self::ConfigError,
            Self::ConfigNotFound,
            Self::DatabaseError,
            Self::AnalyticsError,
            Self::DownloadError,
            Self::ImportError,
            Self::PartialSuccess,
            Self::DaemonAlreadyRunning,
            Self::DaemonNotRunning,
            Self::Interrupted,
        ]
    }
}

impl From<ExitStatus> for i32 {
    fn from(status: ExitStatus) -> Self {
        status.code()
    }
}

impl TryFrom<i32> for ExitStatus {
    type Error = &'static str;

    fn try_from(code: i32) -> Result<Self, Self::Error> {
        match code {
            0 => Ok(Self::Success),
            1 => Ok(Self::GeneralError),
            2 => Ok(Self::UsageError),
            3 => Ok(Self::NotFound),
            4 => Ok(Self::ValidationError),
            5 => Ok(Self::PermissionDenied),
            10 => Ok(Self::NetworkError),
            11 => Ok(Self::Timeout),
            12 => Ok(Self::RateLimited),
            13 => Ok(Self::ApiError),
            20 => Ok(Self::CacheError),
            21 => Ok(Self::CacheMiss),
            30 => Ok(Self::ConfigError),
            31 => Ok(Self::ConfigNotFound),
            40 => Ok(Self::DatabaseError),
            50 => Ok(Self::AnalyticsError),
            60 => Ok(Self::DownloadError),
            61 => Ok(Self::ImportError),
            100 => Ok(Self::PartialSuccess),
            110 => Ok(Self::DaemonAlreadyRunning),
            111 => Ok(Self::DaemonNotRunning),
            130 => Ok(Self::Interrupted),
            _ => Err("Unknown exit code"),
        }
    }
}

impl From<ExitStatus> for ExitCode {
    fn from(status: ExitStatus) -> Self {
        ExitCode::from(status.code() as u8)
    }
}

/// JSON representation of an exit code for --help-json
#[derive(Debug, Serialize)]
pub struct ExitCodeInfo {
    pub code: i32,
    pub name: &'static str,
    pub description: &'static str,
    pub agent_action: &'static str,
    pub retryable: bool,
}

impl From<ExitStatus> for ExitCodeInfo {
    fn from(status: ExitStatus) -> Self {
        Self {
            code: status.code(),
            name: status.name(),
            description: status.description(),
            agent_action: status.agent_action(),
            retryable: status.is_retryable(),
        }
    }
}

/// Get all exit codes as JSON-serializable info
pub fn all_exit_codes() -> Vec<ExitCodeInfo> {
    ExitStatus::all().iter().map(|&s| s.into()).collect()
}

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

    #[test]
    fn test_exit_code_roundtrip() {
        for status in ExitStatus::all() {
            let code = status.code();
            let recovered = ExitStatus::try_from(code).unwrap();
            assert_eq!(*status, recovered);
        }
    }

    #[test]
    fn test_success_is_zero() {
        assert_eq!(ExitStatus::Success.code(), 0);
    }
}