lxmf-sdk 0.7.0

High-level Rust SDK for LXMF clients and RPC-backed LXMF workflows.
Documentation
use super::operations::RegistryError;
use crate::error::{code, ErrorCategory as SdkErrorCategory, SdkError};
use serde::{Deserialize, Serialize};
use serde_json::Value as JsonValue;
use std::collections::BTreeMap;

#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "PascalCase")]
#[non_exhaustive]
pub enum ErrorCategory {
    Validation,
    Capability,
    Config,
    Policy,
    Delivery,
    Connectivity,
    Persistence,
    Security,
    Timeout,
    Runtime,
    Internal,
}

#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
#[non_exhaustive]
pub enum ErrorCode {
    ValidationInvalidArgument,
    ValidationUnknownField,
    CapabilityUnsupportedProfile,
    CapabilityRequiredFeatureMissing,
    ConfigInvalid,
    RuntimeInvalidState,
    RuntimeAlreadyRunningDifferentConfig,
    RuntimeStreamDegraded,
    RuntimeNotStarted,
    DeliveryQueuePressure,
    DeliveryPartialAcceptance,
    DeliveryRetryExhausted,
    DeliveryCancelled,
    ConnectivityDisconnected,
    ConnectivityReconnectFailed,
    PersistenceUnavailable,
    PersistenceRecoveryRequired,
    TimeoutOperationExpired,
    SecurityAuthRequired,
    SecurityAuthzDenied,
    SecurityRedactionRequired,
    InternalUnexpectedFailure,
    Unknown(String),
}

impl ErrorCode {
    pub fn as_str(&self) -> &str {
        match self {
            Self::ValidationInvalidArgument => "SDK_APP_VALIDATION_INVALID_ARGUMENT",
            Self::ValidationUnknownField => "SDK_APP_VALIDATION_UNKNOWN_FIELD",
            Self::CapabilityUnsupportedProfile => "SDK_APP_CAPABILITY_UNSUPPORTED_PROFILE",
            Self::CapabilityRequiredFeatureMissing => "SDK_APP_CAPABILITY_REQUIRED_FEATURE_MISSING",
            Self::ConfigInvalid => "SDK_APP_CONFIG_INVALID",
            Self::RuntimeInvalidState => "SDK_APP_RUNTIME_INVALID_STATE",
            Self::RuntimeAlreadyRunningDifferentConfig => {
                "SDK_APP_RUNTIME_ALREADY_RUNNING_DIFFERENT_CONFIG"
            }
            Self::RuntimeStreamDegraded => "SDK_APP_RUNTIME_STREAM_DEGRADED",
            Self::RuntimeNotStarted => "SDK_APP_RUNTIME_NOT_STARTED",
            Self::DeliveryQueuePressure => "SDK_APP_DELIVERY_QUEUE_PRESSURE",
            Self::DeliveryPartialAcceptance => "SDK_APP_DELIVERY_PARTIAL_ACCEPTANCE",
            Self::DeliveryRetryExhausted => "SDK_APP_DELIVERY_RETRY_EXHAUSTED",
            Self::DeliveryCancelled => "SDK_APP_DELIVERY_CANCELLED",
            Self::ConnectivityDisconnected => "SDK_APP_CONNECTIVITY_DISCONNECTED",
            Self::ConnectivityReconnectFailed => "SDK_APP_CONNECTIVITY_RECONNECT_FAILED",
            Self::PersistenceUnavailable => "SDK_APP_PERSISTENCE_UNAVAILABLE",
            Self::PersistenceRecoveryRequired => "SDK_APP_PERSISTENCE_RECOVERY_REQUIRED",
            Self::TimeoutOperationExpired => "SDK_APP_TIMEOUT_OPERATION_EXPIRED",
            Self::SecurityAuthRequired => "SDK_APP_SECURITY_AUTH_REQUIRED",
            Self::SecurityAuthzDenied => "SDK_APP_SECURITY_AUTHZ_DENIED",
            Self::SecurityRedactionRequired => "SDK_APP_SECURITY_REDACTION_REQUIRED",
            Self::InternalUnexpectedFailure => "SDK_APP_INTERNAL_UNEXPECTED_FAILURE",
            Self::Unknown(code) => code.as_str(),
        }
    }
}

#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
#[non_exhaustive]
pub struct Error {
    pub code: ErrorCode,
    pub category: ErrorCategory,
    pub retryable: bool,
    pub terminal: bool,
    pub user_action_required: bool,
    pub message: String,
    #[serde(default)]
    pub details: BTreeMap<String, JsonValue>,
    pub cause_code: Option<String>,
}

impl Error {
    pub fn not_started() -> Self {
        Self {
            code: ErrorCode::RuntimeNotStarted,
            category: ErrorCategory::Runtime,
            retryable: false,
            terminal: true,
            user_action_required: false,
            message: "runtime has not been started".to_owned(),
            details: BTreeMap::new(),
            cause_code: None,
        }
    }

    pub fn already_running_different_config() -> Self {
        Self {
            code: ErrorCode::RuntimeAlreadyRunningDifferentConfig,
            category: ErrorCategory::Runtime,
            retryable: false,
            terminal: true,
            user_action_required: true,
            message: "runtime is already running with a different config".to_owned(),
            details: BTreeMap::new(),
            cause_code: None,
        }
    }

    pub fn unsupported_profile(profile_id: &str) -> Self {
        let mut details = BTreeMap::new();
        details.insert("profile_id".to_owned(), JsonValue::String(profile_id.to_owned()));
        Self {
            code: ErrorCode::CapabilityUnsupportedProfile,
            category: ErrorCategory::Capability,
            retryable: false,
            terminal: true,
            user_action_required: true,
            message: format!("profile '{profile_id}' is not supported by the current runtime"),
            details,
            cause_code: None,
        }
    }
}

impl From<RegistryError> for Error {
    fn from(err: RegistryError) -> Self {
        let (message, mut details) = match err {
            RegistryError::DuplicateOperationId { id } => (
                format!("duplicate operation id '{}'", id.as_str()),
                BTreeMap::from([(
                    "operation_id".to_owned(),
                    JsonValue::String(id.as_str().to_owned()),
                )]),
            ),
            RegistryError::DuplicateAlias { alias, existing_id, conflicting_id } => (
                format!("duplicate operation alias '{alias}'"),
                BTreeMap::from([
                    ("alias".to_owned(), JsonValue::String(alias)),
                    ("existing_id".to_owned(), JsonValue::String(existing_id.as_str().to_owned())),
                    (
                        "conflicting_id".to_owned(),
                        JsonValue::String(conflicting_id.as_str().to_owned()),
                    ),
                ]),
            ),
            RegistryError::AliasConflictsWithOperationId { alias, operation_id } => (
                format!("operation alias '{alias}' conflicts with canonical operation id"),
                BTreeMap::from([
                    ("alias".to_owned(), JsonValue::String(alias)),
                    (
                        "operation_id".to_owned(),
                        JsonValue::String(operation_id.as_str().to_owned()),
                    ),
                ]),
            ),
        };
        details.insert("registry_scope".to_owned(), JsonValue::String("app".to_owned()));
        Self {
            code: ErrorCode::ConfigInvalid,
            category: ErrorCategory::Config,
            retryable: false,
            terminal: true,
            user_action_required: true,
            message,
            details,
            cause_code: None,
        }
    }
}

impl From<SdkError> for Error {
    fn from(err: SdkError) -> Self {
        let SdkError {
            machine_code,
            category,
            retryable,
            is_user_actionable,
            message,
            details,
            cause_code,
            extensions: _,
        } = err;

        let code = match machine_code.as_str() {
            code::VALIDATION_INVALID_ARGUMENT | code::VALIDATION_MAX_POLL_EVENTS_EXCEEDED => {
                ErrorCode::ValidationInvalidArgument
            }
            code::VALIDATION_UNKNOWN_FIELD => ErrorCode::ValidationUnknownField,
            code::CAPABILITY_DISABLED | code::CAPABILITY_CONTRACT_INCOMPATIBLE => {
                ErrorCode::CapabilityRequiredFeatureMissing
            }
            code::CONFIG_CONFLICT | code::CONFIG_UNKNOWN_KEY => ErrorCode::ConfigInvalid,
            code::RUNTIME_ALREADY_RUNNING_WITH_DIFFERENT_CONFIG => {
                ErrorCode::RuntimeAlreadyRunningDifferentConfig
            }
            code::RUNTIME_STREAM_DEGRADED | code::RUNTIME_CURSOR_EXPIRED => {
                ErrorCode::RuntimeStreamDegraded
            }
            "SDK_RUNTIME_STORE_FORWARD_CAPACITY_REACHED" => ErrorCode::DeliveryQueuePressure,
            code::RUNTIME_INVALID_STATE => ErrorCode::RuntimeInvalidState,
            code::SECURITY_AUTH_REQUIRED
            | code::SECURITY_TOKEN_INVALID
            | code::SECURITY_TOKEN_REPLAYED
            | code::SECURITY_RATE_LIMITED
            | code::SECURITY_REMOTE_BIND_DISALLOWED => ErrorCode::SecurityAuthRequired,
            code::SECURITY_AUTHZ_DENIED => ErrorCode::SecurityAuthzDenied,
            code::SECURITY_REDACTION_REQUIRED => ErrorCode::SecurityRedactionRequired,
            code::INTERNAL => ErrorCode::InternalUnexpectedFailure,
            other => ErrorCode::Unknown(other.to_owned()),
        };

        let category = match category {
            SdkErrorCategory::Validation => ErrorCategory::Validation,
            SdkErrorCategory::Capability => ErrorCategory::Capability,
            SdkErrorCategory::Config => ErrorCategory::Config,
            SdkErrorCategory::Policy => ErrorCategory::Policy,
            SdkErrorCategory::Transport => ErrorCategory::Connectivity,
            SdkErrorCategory::Storage => ErrorCategory::Persistence,
            SdkErrorCategory::Crypto => ErrorCategory::Security,
            SdkErrorCategory::Timeout => ErrorCategory::Timeout,
            SdkErrorCategory::Runtime if matches!(code, ErrorCode::DeliveryQueuePressure) => {
                ErrorCategory::Delivery
            }
            SdkErrorCategory::Runtime => ErrorCategory::Runtime,
            SdkErrorCategory::Security => ErrorCategory::Security,
            SdkErrorCategory::Internal => ErrorCategory::Internal,
        };

        let terminal = matches!(
            code,
            ErrorCode::RuntimeInvalidState
                | ErrorCode::RuntimeAlreadyRunningDifferentConfig
                | ErrorCode::RuntimeNotStarted
                | ErrorCode::RuntimeStreamDegraded
                | ErrorCode::DeliveryRetryExhausted
                | ErrorCode::DeliveryCancelled
                | ErrorCode::TimeoutOperationExpired
                | ErrorCode::SecurityAuthRequired
                | ErrorCode::SecurityAuthzDenied
                | ErrorCode::SecurityRedactionRequired
                | ErrorCode::InternalUnexpectedFailure
        );

        let cause_code = if matches!(code, ErrorCode::Unknown(_)) {
            cause_code.or_else(|| Some(machine_code.clone()))
        } else if code.as_str() == machine_code {
            cause_code
        } else {
            Some(machine_code)
        };

        let retryable = retryable || matches!(code, ErrorCode::DeliveryQueuePressure);

        Self {
            code,
            category,
            retryable,
            terminal,
            user_action_required: is_user_actionable,
            message,
            details,
            cause_code,
        }
    }
}

#[cfg(test)]
mod tests {
    use super::{Error, ErrorCategory, ErrorCode};
    use crate::error::{code, ErrorCategory as SdkErrorCategory, SdkError};

    #[test]
    fn maps_runtime_invalid_state_into_app_error() {
        let err = Error::from(SdkError::new(
            code::RUNTIME_INVALID_STATE,
            SdkErrorCategory::Runtime,
            "bad state",
        ));
        assert_eq!(err.code, ErrorCode::RuntimeInvalidState);
        assert_eq!(err.category, ErrorCategory::Runtime);
        assert!(err.terminal);
    }

    #[test]
    fn preserves_original_code_as_cause_when_remapped() {
        let err = Error::from(SdkError::new(
            code::CAPABILITY_DISABLED,
            SdkErrorCategory::Capability,
            "missing capability",
        ));
        assert_eq!(err.code, ErrorCode::CapabilityRequiredFeatureMissing);
        assert_eq!(err.cause_code.as_deref(), Some(code::CAPABILITY_DISABLED));
    }

    #[test]
    fn maps_queue_pressure_into_typed_delivery_error() {
        let err = Error::from(SdkError::new(
            "SDK_RUNTIME_STORE_FORWARD_CAPACITY_REACHED",
            SdkErrorCategory::Runtime,
            "queue pressure",
        ));
        assert_eq!(err.code, ErrorCode::DeliveryQueuePressure);
        assert_eq!(err.category, ErrorCategory::Delivery);
        assert!(err.retryable);
        assert!(!err.terminal);
    }

    #[test]
    fn not_started_is_not_user_action_required() {
        let err = Error::not_started();
        assert!(!err.user_action_required);
        assert!(err.terminal);
    }

    #[test]
    fn degraded_stream_is_terminal_for_the_subscription() {
        let err = Error::from(SdkError::new(
            code::RUNTIME_STREAM_DEGRADED,
            SdkErrorCategory::Runtime,
            "degraded",
        ));
        assert_eq!(err.code, ErrorCode::RuntimeStreamDegraded);
        assert!(err.terminal);
    }
}