From a1549fd2cd9dbc3f3f5a5381f215d598411b5801 Mon Sep 17 00:00:00 2001 From: RA <70325462+RAprogramm@users.noreply.github.com> Date: Fri, 19 Sep 2025 08:18:18 +0700 Subject: [PATCH] Refactor large modules into focused submodules --- src/code.rs | 262 +-------------------- src/code/app_code.rs | 261 +++++++++++++++++++++ src/frontend.rs | 273 +--------------------- src/frontend/browser_console_error.rs | 79 +++++++ src/frontend/browser_console_ext.rs | 107 +++++++++ src/frontend/tests.rs | 79 +++++++ src/turnkey.rs | 322 +------------------------- src/turnkey/classifier.rs | 99 ++++++++ src/turnkey/conversions.rs | 24 ++ src/turnkey/domain.rs | 83 +++++++ src/turnkey/tests.rs | 115 +++++++++ 11 files changed, 860 insertions(+), 844 deletions(-) create mode 100644 src/code/app_code.rs create mode 100644 src/frontend/browser_console_error.rs create mode 100644 src/frontend/browser_console_ext.rs create mode 100644 src/frontend/tests.rs create mode 100644 src/turnkey/classifier.rs create mode 100644 src/turnkey/conversions.rs create mode 100644 src/turnkey/domain.rs create mode 100644 src/turnkey/tests.rs diff --git a/src/code.rs b/src/code.rs index c975069..8ce7783 100644 --- a/src/code.rs +++ b/src/code.rs @@ -67,264 +67,6 @@ //! } //! ``` -use std::fmt::{self, Display}; +mod app_code; -use serde::{Deserialize, Serialize}; -#[cfg(feature = "openapi")] -use utoipa::ToSchema; - -use crate::kind::AppErrorKind; - -/// Stable machine-readable error code exposed to clients. -/// -/// Values are serialized as **SCREAMING_SNAKE_CASE** strings (e.g., -/// `"NOT_FOUND"`). This type is part of the public wire contract. -/// -/// Design rules: -/// - Keep the set small and meaningful. -/// - Prefer adding new variants over overloading existing ones. -/// - Do not encode private/internal details in codes. -#[cfg_attr(feature = "openapi", derive(ToSchema))] -#[non_exhaustive] -#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)] -#[serde(rename_all = "SCREAMING_SNAKE_CASE")] -pub enum AppCode { - // ───────────── 4xx family (client-visible categories) ───────────── - /// Resource does not exist or is not visible to the caller. - /// - /// Typically mapped to HTTP **404 Not Found**. - NotFound, - - /// Input failed validation (shape, constraints, business rules). - /// - /// Typically mapped to HTTP **422 Unprocessable Entity**. - Validation, - - /// State conflict with an existing resource or concurrent update. - /// - /// Typically mapped to HTTP **409 Conflict**. - Conflict, - - /// Authentication required or failed (missing/invalid credentials). - /// - /// Typically mapped to HTTP **401 Unauthorized**. - Unauthorized, - - /// Authenticated but not allowed to perform the operation. - /// - /// Typically mapped to HTTP **403 Forbidden**. - Forbidden, - - /// Operation is not implemented or not supported by this deployment. - /// - /// Typically mapped to HTTP **501 Not Implemented**. - NotImplemented, - - /// Malformed request or missing required parameters. - /// - /// Typically mapped to HTTP **400 Bad Request**. - BadRequest, - - /// Client exceeded rate limits or quota. - /// - /// Typically mapped to HTTP **429 Too Many Requests**. - RateLimited, - - /// Telegram authentication flow failed (signature, timestamp, or payload). - /// - /// Typically mapped to HTTP **401 Unauthorized**. - TelegramAuth, - - /// Provided JWT is invalid (expired, malformed, wrong signature/claims). - /// - /// Typically mapped to HTTP **401 Unauthorized**. - InvalidJwt, - - // ───────────── 5xx family (server/infra categories) ───────────── - /// Unexpected server-side failure not captured by more specific kinds. - /// - /// Typically mapped to HTTP **500 Internal Server Error**. - Internal, - - /// Database-related failure (query, connection, migration, etc.). - /// - /// Typically mapped to HTTP **500 Internal Server Error**. - Database, - - /// Generic service-layer failure (business logic or orchestration). - /// - /// Typically mapped to HTTP **500 Internal Server Error**. - Service, - - /// Configuration error (missing/invalid environment or runtime config). - /// - /// Typically mapped to HTTP **500 Internal Server Error**. - Config, - - /// Failure in the Turnkey subsystem/integration. - /// - /// Typically mapped to HTTP **500 Internal Server Error**. - Turnkey, - - /// Operation did not complete within the allotted time. - /// - /// Typically mapped to HTTP **504 Gateway Timeout**. - Timeout, - - /// Network-level error (DNS, connect, TLS, request build). - /// - /// Typically mapped to HTTP **503 Service Unavailable**. - Network, - - /// External dependency is unavailable or degraded (cache, broker, - /// third-party). - /// - /// Typically mapped to HTTP **503 Service Unavailable**. - DependencyUnavailable, - - /// Failed to serialize data (encode). - /// - /// Typically mapped to HTTP **500 Internal Server Error**. - Serialization, - - /// Failed to deserialize data (decode). - /// - /// Typically mapped to HTTP **500 Internal Server Error**. - Deserialization, - - /// Upstream API returned an error or protocol-level failure. - /// - /// Typically mapped to HTTP **500 Internal Server Error**. - ExternalApi, - - /// Queue processing failure (publish/consume/ack). - /// - /// Typically mapped to HTTP **500 Internal Server Error**. - Queue, - - /// Cache subsystem failure (read/write/encoding). - /// - /// Typically mapped to HTTP **500 Internal Server Error**. - Cache -} - -impl AppCode { - /// Get the canonical string form of this code (SCREAMING_SNAKE_CASE). - /// - /// This is equivalent to how the code is serialized to JSON. - pub const fn as_str(&self) -> &'static str { - match self { - // 4xx - AppCode::NotFound => "NOT_FOUND", - AppCode::Validation => "VALIDATION", - AppCode::Conflict => "CONFLICT", - AppCode::Unauthorized => "UNAUTHORIZED", - AppCode::Forbidden => "FORBIDDEN", - AppCode::NotImplemented => "NOT_IMPLEMENTED", - AppCode::BadRequest => "BAD_REQUEST", - AppCode::RateLimited => "RATE_LIMITED", - AppCode::TelegramAuth => "TELEGRAM_AUTH", - AppCode::InvalidJwt => "INVALID_JWT", - - // 5xx - AppCode::Internal => "INTERNAL", - AppCode::Database => "DATABASE", - AppCode::Service => "SERVICE", - AppCode::Config => "CONFIG", - AppCode::Turnkey => "TURNKEY", - AppCode::Timeout => "TIMEOUT", - AppCode::Network => "NETWORK", - AppCode::DependencyUnavailable => "DEPENDENCY_UNAVAILABLE", - AppCode::Serialization => "SERIALIZATION", - AppCode::Deserialization => "DESERIALIZATION", - AppCode::ExternalApi => "EXTERNAL_API", - AppCode::Queue => "QUEUE", - AppCode::Cache => "CACHE" - } - } -} - -impl Display for AppCode { - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - // Stable human/machine readable form matching JSON representation. - f.write_str(self.as_str()) - } -} - -impl From for AppCode { - /// Map internal taxonomy (`AppErrorKind`) to public machine code - /// (`AppCode`). - /// - /// The mapping is 1:1 today and intentionally conservative. - fn from(kind: AppErrorKind) -> Self { - match kind { - // 4xx - AppErrorKind::NotFound => Self::NotFound, - AppErrorKind::Validation => Self::Validation, - AppErrorKind::Conflict => Self::Conflict, - AppErrorKind::Unauthorized => Self::Unauthorized, - AppErrorKind::Forbidden => Self::Forbidden, - AppErrorKind::NotImplemented => Self::NotImplemented, - AppErrorKind::BadRequest => Self::BadRequest, - AppErrorKind::RateLimited => Self::RateLimited, - AppErrorKind::TelegramAuth => Self::TelegramAuth, - AppErrorKind::InvalidJwt => Self::InvalidJwt, - - // 5xx - AppErrorKind::Internal => Self::Internal, - AppErrorKind::Database => Self::Database, - AppErrorKind::Service => Self::Service, - AppErrorKind::Config => Self::Config, - AppErrorKind::Turnkey => Self::Turnkey, - AppErrorKind::Timeout => Self::Timeout, - AppErrorKind::Network => Self::Network, - AppErrorKind::DependencyUnavailable => Self::DependencyUnavailable, - AppErrorKind::Serialization => Self::Serialization, - AppErrorKind::Deserialization => Self::Deserialization, - AppErrorKind::ExternalApi => Self::ExternalApi, - AppErrorKind::Queue => Self::Queue, - AppErrorKind::Cache => Self::Cache - } - } -} - -#[cfg(test)] -mod tests { - use super::{AppCode, AppErrorKind}; - - #[test] - fn as_str_matches_json_serde_names() { - assert_eq!(AppCode::NotFound.as_str(), "NOT_FOUND"); - assert_eq!(AppCode::RateLimited.as_str(), "RATE_LIMITED"); - assert_eq!( - AppCode::DependencyUnavailable.as_str(), - "DEPENDENCY_UNAVAILABLE" - ); - } - - #[test] - fn mapping_from_kind_is_stable() { - // Spot checks to guard against accidental remaps. - assert!(matches!( - AppCode::from(AppErrorKind::NotFound), - AppCode::NotFound - )); - assert!(matches!( - AppCode::from(AppErrorKind::Validation), - AppCode::Validation - )); - assert!(matches!( - AppCode::from(AppErrorKind::Internal), - AppCode::Internal - )); - assert!(matches!( - AppCode::from(AppErrorKind::Timeout), - AppCode::Timeout - )); - } - - #[test] - fn display_uses_screaming_snake_case() { - assert_eq!(AppCode::BadRequest.to_string(), "BAD_REQUEST"); - } -} +pub use app_code::AppCode; diff --git a/src/code/app_code.rs b/src/code/app_code.rs new file mode 100644 index 0000000..11bf384 --- /dev/null +++ b/src/code/app_code.rs @@ -0,0 +1,261 @@ +use std::fmt::{self, Display}; + +use serde::{Deserialize, Serialize}; +#[cfg(feature = "openapi")] +use utoipa::ToSchema; + +use crate::kind::AppErrorKind; + +/// Stable machine-readable error code exposed to clients. +/// +/// Values are serialized as **SCREAMING_SNAKE_CASE** strings (e.g., +/// `"NOT_FOUND"`). This type is part of the public wire contract. +/// +/// Design rules: +/// - Keep the set small and meaningful. +/// - Prefer adding new variants over overloading existing ones. +/// - Do not encode private/internal details in codes. +#[cfg_attr(feature = "openapi", derive(ToSchema))] +#[non_exhaustive] +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)] +#[serde(rename_all = "SCREAMING_SNAKE_CASE")] +pub enum AppCode { + // ───────────── 4xx family (client-visible categories) ───────────── + /// Resource does not exist or is not visible to the caller. + /// + /// Typically mapped to HTTP **404 Not Found**. + NotFound, + + /// Input failed validation (shape, constraints, business rules). + /// + /// Typically mapped to HTTP **422 Unprocessable Entity**. + Validation, + + /// State conflict with an existing resource or concurrent update. + /// + /// Typically mapped to HTTP **409 Conflict**. + Conflict, + + /// Authentication required or failed (missing/invalid credentials). + /// + /// Typically mapped to HTTP **401 Unauthorized**. + Unauthorized, + + /// Authenticated but not allowed to perform the operation. + /// + /// Typically mapped to HTTP **403 Forbidden**. + Forbidden, + + /// Operation is not implemented or not supported by this deployment. + /// + /// Typically mapped to HTTP **501 Not Implemented**. + NotImplemented, + + /// Malformed request or missing required parameters. + /// + /// Typically mapped to HTTP **400 Bad Request**. + BadRequest, + + /// Client exceeded rate limits or quota. + /// + /// Typically mapped to HTTP **429 Too Many Requests**. + RateLimited, + + /// Telegram authentication flow failed (signature, timestamp, or payload). + /// + /// Typically mapped to HTTP **401 Unauthorized**. + TelegramAuth, + + /// Provided JWT is invalid (expired, malformed, wrong signature/claims). + /// + /// Typically mapped to HTTP **401 Unauthorized**. + InvalidJwt, + + // ───────────── 5xx family (server/infra categories) ───────────── + /// Unexpected server-side failure not captured by more specific kinds. + /// + /// Typically mapped to HTTP **500 Internal Server Error**. + Internal, + + /// Database-related failure (query, connection, migration, etc.). + /// + /// Typically mapped to HTTP **500 Internal Server Error**. + Database, + + /// Generic service-layer failure (business logic or orchestration). + /// + /// Typically mapped to HTTP **500 Internal Server Error**. + Service, + + /// Configuration error (missing/invalid environment or runtime config). + /// + /// Typically mapped to HTTP **500 Internal Server Error**. + Config, + + /// Failure in the Turnkey subsystem/integration. + /// + /// Typically mapped to HTTP **500 Internal Server Error**. + Turnkey, + + /// Operation did not complete within the allotted time. + /// + /// Typically mapped to HTTP **504 Gateway Timeout**. + Timeout, + + /// Network-level error (DNS, connect, TLS, request build). + /// + /// Typically mapped to HTTP **503 Service Unavailable**. + Network, + + /// External dependency is unavailable or degraded (cache, broker, + /// third-party). + /// + /// Typically mapped to HTTP **503 Service Unavailable**. + DependencyUnavailable, + + /// Failed to serialize data (encode). + /// + /// Typically mapped to HTTP **500 Internal Server Error**. + Serialization, + + /// Failed to deserialize data (decode). + /// + /// Typically mapped to HTTP **500 Internal Server Error**. + Deserialization, + + /// Upstream API returned an error or protocol-level failure. + /// + /// Typically mapped to HTTP **500 Internal Server Error**. + ExternalApi, + + /// Queue processing failure (publish/consume/ack). + /// + /// Typically mapped to HTTP **500 Internal Server Error**. + Queue, + + /// Cache subsystem failure (read/write/encoding). + /// + /// Typically mapped to HTTP **500 Internal Server Error**. + Cache +} + +impl AppCode { + /// Get the canonical string form of this code (SCREAMING_SNAKE_CASE). + /// + /// This is equivalent to how the code is serialized to JSON. + pub const fn as_str(&self) -> &'static str { + match self { + // 4xx + AppCode::NotFound => "NOT_FOUND", + AppCode::Validation => "VALIDATION", + AppCode::Conflict => "CONFLICT", + AppCode::Unauthorized => "UNAUTHORIZED", + AppCode::Forbidden => "FORBIDDEN", + AppCode::NotImplemented => "NOT_IMPLEMENTED", + AppCode::BadRequest => "BAD_REQUEST", + AppCode::RateLimited => "RATE_LIMITED", + AppCode::TelegramAuth => "TELEGRAM_AUTH", + AppCode::InvalidJwt => "INVALID_JWT", + + // 5xx + AppCode::Internal => "INTERNAL", + AppCode::Database => "DATABASE", + AppCode::Service => "SERVICE", + AppCode::Config => "CONFIG", + AppCode::Turnkey => "TURNKEY", + AppCode::Timeout => "TIMEOUT", + AppCode::Network => "NETWORK", + AppCode::DependencyUnavailable => "DEPENDENCY_UNAVAILABLE", + AppCode::Serialization => "SERIALIZATION", + AppCode::Deserialization => "DESERIALIZATION", + AppCode::ExternalApi => "EXTERNAL_API", + AppCode::Queue => "QUEUE", + AppCode::Cache => "CACHE" + } + } +} + +impl Display for AppCode { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + // Stable human/machine readable form matching JSON representation. + f.write_str(self.as_str()) + } +} + +impl From for AppCode { + /// Map internal taxonomy (`AppErrorKind`) to public machine code + /// (`AppCode`). + /// + /// The mapping is 1:1 today and intentionally conservative. + fn from(kind: AppErrorKind) -> Self { + match kind { + // 4xx + AppErrorKind::NotFound => Self::NotFound, + AppErrorKind::Validation => Self::Validation, + AppErrorKind::Conflict => Self::Conflict, + AppErrorKind::Unauthorized => Self::Unauthorized, + AppErrorKind::Forbidden => Self::Forbidden, + AppErrorKind::NotImplemented => Self::NotImplemented, + AppErrorKind::BadRequest => Self::BadRequest, + AppErrorKind::RateLimited => Self::RateLimited, + AppErrorKind::TelegramAuth => Self::TelegramAuth, + AppErrorKind::InvalidJwt => Self::InvalidJwt, + + // 5xx + AppErrorKind::Internal => Self::Internal, + AppErrorKind::Database => Self::Database, + AppErrorKind::Service => Self::Service, + AppErrorKind::Config => Self::Config, + AppErrorKind::Turnkey => Self::Turnkey, + AppErrorKind::Timeout => Self::Timeout, + AppErrorKind::Network => Self::Network, + AppErrorKind::DependencyUnavailable => Self::DependencyUnavailable, + AppErrorKind::Serialization => Self::Serialization, + AppErrorKind::Deserialization => Self::Deserialization, + AppErrorKind::ExternalApi => Self::ExternalApi, + AppErrorKind::Queue => Self::Queue, + AppErrorKind::Cache => Self::Cache + } + } +} + +#[cfg(test)] +mod tests { + use super::{AppCode, AppErrorKind}; + + #[test] + fn as_str_matches_json_serde_names() { + assert_eq!(AppCode::NotFound.as_str(), "NOT_FOUND"); + assert_eq!(AppCode::RateLimited.as_str(), "RATE_LIMITED"); + assert_eq!( + AppCode::DependencyUnavailable.as_str(), + "DEPENDENCY_UNAVAILABLE" + ); + } + + #[test] + fn mapping_from_kind_is_stable() { + // Spot checks to guard against accidental remaps. + assert!(matches!( + AppCode::from(AppErrorKind::NotFound), + AppCode::NotFound + )); + assert!(matches!( + AppCode::from(AppErrorKind::Validation), + AppCode::Validation + )); + assert!(matches!( + AppCode::from(AppErrorKind::Internal), + AppCode::Internal + )); + assert!(matches!( + AppCode::from(AppErrorKind::Timeout), + AppCode::Timeout + )); + } + + #[test] + fn display_uses_screaming_snake_case() { + assert_eq!(AppCode::BadRequest.to_string(), "BAD_REQUEST"); + } +} diff --git a/src/frontend.rs b/src/frontend.rs index b1b334f..b79e0c0 100644 --- a/src/frontend.rs +++ b/src/frontend.rs @@ -1,5 +1,3 @@ -#![allow(unused_variables)] - //! Browser/WASM helpers for converting application errors into JavaScript //! values. //! @@ -35,272 +33,11 @@ //! # } //! ``` -#[cfg(target_arch = "wasm32")] -use js_sys::{Function, Reflect}; -#[cfg(target_arch = "wasm32")] -use serde_wasm_bindgen::to_value; -#[cfg(target_arch = "wasm32")] -use wasm_bindgen::JsCast; -use wasm_bindgen::JsValue; - -use crate::{AppError, AppResult, Error, ErrorResponse}; - -/// Error returned when emitting to the browser console fails or is unsupported. -#[derive(Debug, Error, PartialEq, Eq)] -#[cfg_attr(docsrs, doc(cfg(feature = "frontend")))] -pub enum BrowserConsoleError { - /// Failed to serialize the payload into [`JsValue`]. - #[error("failed to serialize payload for browser console: {message}")] - Serialization { - /// Human-readable description of the serialization failure. - message: String - }, - /// The global `console` object is unavailable or could not be accessed. - #[error("browser console object is not available: {message}")] - ConsoleUnavailable { - /// Additional context explaining the failure. - message: String - }, - /// The `console.error` function is missing or not accessible. - #[error("failed to access browser console `error`: {message}")] - ConsoleErrorUnavailable { - /// Additional context explaining the failure. - message: String - }, - /// The retrieved `console.error` value is not callable. - #[error("browser console `error` method is not callable")] - ConsoleMethodNotCallable, - /// Invoking `console.error` returned an error. - #[error("failed to invoke browser console `error`: {message}")] - ConsoleInvocation { - /// Textual representation of the JavaScript exception. - message: String - }, - /// Logging is not supported on the current compilation target. - #[error("browser console logging is not supported on this target")] - UnsupportedTarget -} - -impl BrowserConsoleError { - /// Returns the contextual message associated with the error, when - /// available. - /// - /// This is primarily useful for surfacing browser-provided diagnostics in - /// higher-level logs or telemetry. - /// - /// # Examples - /// - /// ``` - /// # #[cfg(feature = "frontend")] - /// # { - /// use masterror::frontend::BrowserConsoleError; - /// - /// let err = BrowserConsoleError::ConsoleUnavailable { - /// message: "console missing".to_owned() - /// }; - /// assert_eq!(err.context(), Some("console missing")); - /// - /// let err = BrowserConsoleError::ConsoleMethodNotCallable; - /// assert_eq!(err.context(), None); - /// # } - /// ``` - pub fn context(&self) -> Option<&str> { - match self { - Self::Serialization { - message - } - | Self::ConsoleUnavailable { - message - } - | Self::ConsoleErrorUnavailable { - message - } - | Self::ConsoleInvocation { - message - } => Some(message.as_str()), - Self::ConsoleMethodNotCallable | Self::UnsupportedTarget => None - } - } -} - -/// Extensions for serializing errors to JavaScript and logging to the browser -/// console. -#[cfg_attr(docsrs, doc(cfg(feature = "frontend")))] -pub trait BrowserConsoleExt { - /// Convert the error into a [`JsValue`] suitable for passing to JavaScript. - fn to_js_value(&self) -> AppResult; - - /// Emit the error as a structured payload via `console.error`. - /// - /// On non-WASM targets this returns - /// [`BrowserConsoleError::UnsupportedTarget`]. - fn log_to_browser_console(&self) -> AppResult<(), BrowserConsoleError> { - let payload = self.to_js_value()?; - log_js_value(&payload) - } -} - -impl BrowserConsoleExt for ErrorResponse { - fn to_js_value(&self) -> AppResult { - #[cfg(target_arch = "wasm32")] - { - to_value(self).map_err(|err| BrowserConsoleError::Serialization { - message: err.to_string() - }) - } - - #[cfg(not(target_arch = "wasm32"))] - { - Err(BrowserConsoleError::UnsupportedTarget) - } - } -} - -impl BrowserConsoleExt for AppError { - fn to_js_value(&self) -> AppResult { - #[cfg(target_arch = "wasm32")] - { - let response: ErrorResponse = self.into(); - response.to_js_value() - } - - #[cfg(not(target_arch = "wasm32"))] - { - Err(BrowserConsoleError::UnsupportedTarget) - } - } -} - -#[cfg(target_arch = "wasm32")] -fn log_js_value(value: &JsValue) -> AppResult<(), BrowserConsoleError> { - let global = js_sys::global(); - let console = Reflect::get(&global, &JsValue::from_str("console")).map_err(|err| { - BrowserConsoleError::ConsoleUnavailable { - message: format_js_value(&err) - } - })?; - - if console.is_undefined() || console.is_null() { - return Err(BrowserConsoleError::ConsoleUnavailable { - message: "console is undefined".into() - }); - } - - let error_fn = Reflect::get(&console, &JsValue::from_str("error")).map_err(|err| { - BrowserConsoleError::ConsoleErrorUnavailable { - message: format_js_value(&err) - } - })?; - - if error_fn.is_undefined() || error_fn.is_null() { - return Err(BrowserConsoleError::ConsoleErrorUnavailable { - message: "console.error is undefined".into() - }); - } - - let func = error_fn - .dyn_into::() - .map_err(|_| BrowserConsoleError::ConsoleMethodNotCallable)?; +mod browser_console_error; +mod browser_console_ext; - func.call1(&console, value) - .map_err(|err| BrowserConsoleError::ConsoleInvocation { - message: format_js_value(&err) - })?; - - Ok(()) -} - -#[cfg(not(target_arch = "wasm32"))] -fn log_js_value(_value: &JsValue) -> AppResult<(), BrowserConsoleError> { - Err(BrowserConsoleError::UnsupportedTarget) -} - -#[cfg(target_arch = "wasm32")] -fn format_js_value(value: &JsValue) -> String { - value.as_string().unwrap_or_else(|| format!("{value:?}")) -} +pub use browser_console_error::BrowserConsoleError; +pub use browser_console_ext::BrowserConsoleExt; #[cfg(test)] -mod tests { - use super::*; - use crate::AppCode; - - #[test] - fn context_returns_optional_message() { - let serialization = BrowserConsoleError::Serialization { - message: "encode failed".to_owned() - }; - assert_eq!(serialization.context(), Some("encode failed")); - - let invocation = BrowserConsoleError::ConsoleInvocation { - message: "js error".to_owned() - }; - assert_eq!(invocation.context(), Some("js error")); - - assert_eq!( - BrowserConsoleError::ConsoleMethodNotCallable.context(), - None - ); - assert_eq!(BrowserConsoleError::UnsupportedTarget.context(), None); - } - - #[cfg(not(target_arch = "wasm32"))] - mod native { - use super::*; - - #[test] - fn to_js_value_is_unsupported_on_native_targets() { - let response = - ErrorResponse::new(404, AppCode::NotFound, "missing user").expect("status"); - assert!(matches!( - response.to_js_value(), - Err(BrowserConsoleError::UnsupportedTarget) - )); - - let err = AppError::conflict("already exists"); - assert!(matches!( - err.to_js_value(), - Err(BrowserConsoleError::UnsupportedTarget) - )); - } - - #[test] - fn console_logging_returns_unsupported_on_native_targets() { - let err = AppError::internal("boom"); - let result = err.log_to_browser_console(); - assert!(matches!( - result, - Err(BrowserConsoleError::UnsupportedTarget) - )); - } - } - - #[cfg(target_arch = "wasm32")] - mod wasm { - use serde_wasm_bindgen::from_value; - - use super::*; - use crate::AppErrorKind; - - #[test] - fn error_response_to_js_value_roundtrip() { - let response = - ErrorResponse::new(404, AppCode::NotFound, "missing user").expect("status"); - let js = response.to_js_value().expect("serialize"); - let decoded: ErrorResponse = from_value(js).expect("decode"); - assert_eq!(decoded.status, 404); - assert_eq!(decoded.code, AppCode::NotFound); - assert_eq!(decoded.message, "missing user"); - } - - #[test] - fn app_error_to_js_value_roundtrip() { - let err = AppError::conflict("already exists"); - let js = err.to_js_value().expect("serialize"); - let decoded: ErrorResponse = from_value(js).expect("decode"); - assert_eq!(decoded.code, AppCode::Conflict); - assert_eq!(decoded.message, "already exists"); - assert_eq!(decoded.status, AppErrorKind::Conflict.http_status()); - } - } -} +mod tests; diff --git a/src/frontend/browser_console_error.rs b/src/frontend/browser_console_error.rs new file mode 100644 index 0000000..2d648c5 --- /dev/null +++ b/src/frontend/browser_console_error.rs @@ -0,0 +1,79 @@ +use crate::Error; + +/// Error returned when emitting to the browser console fails or is unsupported. +#[derive(Debug, Error, PartialEq, Eq)] +#[cfg_attr(docsrs, doc(cfg(feature = "frontend")))] +pub enum BrowserConsoleError { + /// Failed to serialize the payload into [`wasm_bindgen::JsValue`]. + #[error("failed to serialize payload for browser console: {message}")] + Serialization { + /// Human-readable description of the serialization failure. + message: String + }, + /// The global `console` object is unavailable or could not be accessed. + #[error("browser console object is not available: {message}")] + ConsoleUnavailable { + /// Additional context explaining the failure. + message: String + }, + /// The `console.error` function is missing or not accessible. + #[error("failed to access browser console `error`: {message}")] + ConsoleErrorUnavailable { + /// Additional context explaining the failure. + message: String + }, + /// The retrieved `console.error` value is not callable. + #[error("browser console `error` method is not callable")] + ConsoleMethodNotCallable, + /// Invoking `console.error` returned an error. + #[error("failed to invoke browser console `error`: {message}")] + ConsoleInvocation { + /// Textual representation of the JavaScript exception. + message: String + }, + /// Logging is not supported on the current compilation target. + #[error("browser console logging is not supported on this target")] + UnsupportedTarget +} + +impl BrowserConsoleError { + /// Returns the contextual message associated with the error, when + /// available. + /// + /// This is primarily useful for surfacing browser-provided diagnostics in + /// higher-level logs or telemetry. + /// + /// # Examples + /// + /// ``` + /// # #[cfg(feature = "frontend")] + /// # { + /// use masterror::frontend::BrowserConsoleError; + /// + /// let err = BrowserConsoleError::ConsoleUnavailable { + /// message: "console missing".to_owned() + /// }; + /// assert_eq!(err.context(), Some("console missing")); + /// + /// let err = BrowserConsoleError::ConsoleMethodNotCallable; + /// assert_eq!(err.context(), None); + /// # } + /// ``` + pub fn context(&self) -> Option<&str> { + match self { + Self::Serialization { + message + } + | Self::ConsoleUnavailable { + message + } + | Self::ConsoleErrorUnavailable { + message + } + | Self::ConsoleInvocation { + message + } => Some(message.as_str()), + Self::ConsoleMethodNotCallable | Self::UnsupportedTarget => None + } + } +} diff --git a/src/frontend/browser_console_ext.rs b/src/frontend/browser_console_ext.rs new file mode 100644 index 0000000..f6d4a16 --- /dev/null +++ b/src/frontend/browser_console_ext.rs @@ -0,0 +1,107 @@ +#[cfg(target_arch = "wasm32")] +use js_sys::{Function, Reflect}; +#[cfg(target_arch = "wasm32")] +use serde_wasm_bindgen::to_value; +#[cfg(target_arch = "wasm32")] +use wasm_bindgen::JsCast; +use wasm_bindgen::JsValue; + +use super::BrowserConsoleError; +use crate::{AppError, AppResult, ErrorResponse}; + +/// Extensions for serializing errors to JavaScript and logging to the browser +/// console. +#[cfg_attr(docsrs, doc(cfg(feature = "frontend")))] +pub trait BrowserConsoleExt { + /// Convert the error into a [`JsValue`] suitable for passing to JavaScript. + fn to_js_value(&self) -> AppResult; + + /// Emit the error as a structured payload via `console.error`. + /// + /// On non-WASM targets this returns + /// [`BrowserConsoleError::UnsupportedTarget`]. + fn log_to_browser_console(&self) -> AppResult<(), BrowserConsoleError> { + let payload = self.to_js_value()?; + log_js_value(&payload) + } +} + +impl BrowserConsoleExt for ErrorResponse { + fn to_js_value(&self) -> AppResult { + #[cfg(target_arch = "wasm32")] + { + to_value(self).map_err(|err| BrowserConsoleError::Serialization { + message: err.to_string() + }) + } + + #[cfg(not(target_arch = "wasm32"))] + { + Err(BrowserConsoleError::UnsupportedTarget) + } + } +} + +impl BrowserConsoleExt for AppError { + fn to_js_value(&self) -> AppResult { + #[cfg(target_arch = "wasm32")] + { + let response: ErrorResponse = self.into(); + response.to_js_value() + } + + #[cfg(not(target_arch = "wasm32"))] + { + Err(BrowserConsoleError::UnsupportedTarget) + } + } +} + +#[cfg(target_arch = "wasm32")] +fn log_js_value(value: &JsValue) -> AppResult<(), BrowserConsoleError> { + let global = js_sys::global(); + let console = Reflect::get(&global, &JsValue::from_str("console")).map_err(|err| { + BrowserConsoleError::ConsoleUnavailable { + message: format_js_value(&err) + } + })?; + + if console.is_undefined() || console.is_null() { + return Err(BrowserConsoleError::ConsoleUnavailable { + message: "console is undefined".into() + }); + } + + let error_fn = Reflect::get(&console, &JsValue::from_str("error")).map_err(|err| { + BrowserConsoleError::ConsoleErrorUnavailable { + message: format_js_value(&err) + } + })?; + + if error_fn.is_undefined() || error_fn.is_null() { + return Err(BrowserConsoleError::ConsoleErrorUnavailable { + message: "console.error is undefined".into() + }); + } + + let func = error_fn + .dyn_into::() + .map_err(|_| BrowserConsoleError::ConsoleMethodNotCallable)?; + + func.call1(&console, value) + .map_err(|err| BrowserConsoleError::ConsoleInvocation { + message: format_js_value(&err) + })?; + + Ok(()) +} + +#[cfg(not(target_arch = "wasm32"))] +fn log_js_value(_value: &JsValue) -> AppResult<(), BrowserConsoleError> { + Err(BrowserConsoleError::UnsupportedTarget) +} + +#[cfg(target_arch = "wasm32")] +fn format_js_value(value: &JsValue) -> String { + value.as_string().unwrap_or_else(|| format!("{value:?}")) +} diff --git a/src/frontend/tests.rs b/src/frontend/tests.rs new file mode 100644 index 0000000..893765c --- /dev/null +++ b/src/frontend/tests.rs @@ -0,0 +1,79 @@ +use super::{BrowserConsoleError, BrowserConsoleExt}; +use crate::{AppCode, AppError, ErrorResponse}; + +#[test] +fn context_returns_optional_message() { + let serialization = BrowserConsoleError::Serialization { + message: "encode failed".to_owned() + }; + assert_eq!(serialization.context(), Some("encode failed")); + + let invocation = BrowserConsoleError::ConsoleInvocation { + message: "js error".to_owned() + }; + assert_eq!(invocation.context(), Some("js error")); + + assert_eq!( + BrowserConsoleError::ConsoleMethodNotCallable.context(), + None + ); + assert_eq!(BrowserConsoleError::UnsupportedTarget.context(), None); +} + +#[cfg(not(target_arch = "wasm32"))] +mod native { + use super::*; + + #[test] + fn to_js_value_is_unsupported_on_native_targets() { + let response = ErrorResponse::new(404, AppCode::NotFound, "missing user").expect("status"); + assert!(matches!( + response.to_js_value(), + Err(BrowserConsoleError::UnsupportedTarget) + )); + + let err = AppError::conflict("already exists"); + assert!(matches!( + err.to_js_value(), + Err(BrowserConsoleError::UnsupportedTarget) + )); + } + + #[test] + fn console_logging_returns_unsupported_on_native_targets() { + let err = AppError::internal("boom"); + let result = err.log_to_browser_console(); + assert!(matches!( + result, + Err(BrowserConsoleError::UnsupportedTarget) + )); + } +} + +#[cfg(target_arch = "wasm32")] +mod wasm { + use serde_wasm_bindgen::from_value; + + use super::*; + use crate::AppErrorKind; + + #[test] + fn error_response_to_js_value_roundtrip() { + let response = ErrorResponse::new(404, AppCode::NotFound, "missing user").expect("status"); + let js = response.to_js_value().expect("serialize"); + let decoded: ErrorResponse = from_value(js).expect("decode"); + assert_eq!(decoded.status, 404); + assert_eq!(decoded.code, AppCode::NotFound); + assert_eq!(decoded.message, "missing user"); + } + + #[test] + fn app_error_to_js_value_roundtrip() { + let err = AppError::conflict("already exists"); + let js = err.to_js_value().expect("serialize"); + let decoded: ErrorResponse = from_value(js).expect("decode"); + assert_eq!(decoded.code, AppCode::Conflict); + assert_eq!(decoded.message, "already exists"); + assert_eq!(decoded.status, AppErrorKind::Conflict.http_status()); + } +} diff --git a/src/turnkey.rs b/src/turnkey.rs index 1173151..7d4c080 100644 --- a/src/turnkey.rs +++ b/src/turnkey.rs @@ -28,322 +28,12 @@ //! assert!(matches!(k, TurnkeyErrorKind::UniqueLabel)); //! ``` -use crate::{AppError, AppErrorKind, Error}; +mod classifier; +mod conversions; +mod domain; -/// High-level, stable Turnkey error categories. -/// -/// Marked `#[non_exhaustive]` to allow adding variants without a breaking -/// change. Consumers must use a wildcard arm when matching. -/// -/// Mapping to [`AppErrorKind`] is intentionally conservative: -/// - `UniqueLabel` → `Conflict` -/// - `RateLimited` → `RateLimited` -/// - `Timeout` → `Timeout` -/// - `Auth` → `Unauthorized` -/// - `Network` → `Network` -/// - `Service` → `Turnkey` -#[derive(Debug, Error, Clone, Copy, PartialEq, Eq)] -#[non_exhaustive] -pub enum TurnkeyErrorKind { - /// Unique label violation or duplicate resource. - #[error("label already exists")] - UniqueLabel, - /// Throttling or quota exceeded. - #[error("rate limited or throttled")] - RateLimited, - /// Operation exceeded allowed time. - #[error("request timed out")] - Timeout, - /// Authentication/authorization failure. - #[error("authentication/authorization failed")] - Auth, - /// Network-level error (DNS/connect/TLS/build). - #[error("network error")] - Network, - /// Generic service error in the Turnkey subsystem. - #[error("service error")] - Service -} - -/// Turnkey domain error with stable kind and safe, human-readable message. -#[derive(Debug, Error, Clone, PartialEq, Eq)] -#[error("{kind}: {msg}")] -pub struct TurnkeyError { - /// Stable semantic category. - pub kind: TurnkeyErrorKind, - /// Public, non-sensitive message. - pub msg: String -} - -impl TurnkeyError { - /// Construct a new domain error. - /// - /// # Examples - /// ```rust - /// use masterror::turnkey::{TurnkeyError, TurnkeyErrorKind}; - /// let e = TurnkeyError::new(TurnkeyErrorKind::Timeout, "rpc deadline exceeded"); - /// assert!(matches!(e.kind, TurnkeyErrorKind::Timeout)); - /// ``` - #[inline] - pub fn new(kind: TurnkeyErrorKind, msg: impl Into) -> Self { - Self { - kind, - msg: msg.into() - } - } -} - -/// Map [`TurnkeyErrorKind`] into the canonical [`AppErrorKind`]. -/// -/// Keep mappings conservative and stable. See enum docs for rationale. -#[must_use] -#[inline] -pub fn map_turnkey_kind(kind: TurnkeyErrorKind) -> AppErrorKind { - match kind { - TurnkeyErrorKind::UniqueLabel => AppErrorKind::Conflict, - TurnkeyErrorKind::RateLimited => AppErrorKind::RateLimited, - TurnkeyErrorKind::Timeout => AppErrorKind::Timeout, - TurnkeyErrorKind::Auth => AppErrorKind::Unauthorized, - TurnkeyErrorKind::Network => AppErrorKind::Network, - TurnkeyErrorKind::Service => AppErrorKind::Turnkey, - // Future-proofing: unknown variants map to Turnkey (500) by default. - #[allow(unreachable_patterns)] - _ => AppErrorKind::Turnkey - } -} - -/// Heuristic classifier for raw SDK/provider messages (ASCII case-insensitive). -/// -/// This helper **does not allocate**; it performs case-insensitive `contains` -/// checks over the input string to map common upstream texts to stable kinds. -/// -/// The classifier is intentionally minimal; providers can and will change -/// messages. Prefer returning structured errors from adapters whenever -/// possible. -/// -/// # Examples -/// ```rust -/// use masterror::turnkey::{TurnkeyErrorKind, classify_turnkey_error}; -/// assert!(matches!( -/// classify_turnkey_error("429 Too Many Requests"), -/// TurnkeyErrorKind::RateLimited -/// )); -/// assert!(matches!( -/// classify_turnkey_error("label must be unique"), -/// TurnkeyErrorKind::UniqueLabel -/// )); -/// assert!(matches!( -/// classify_turnkey_error("request timed out"), -/// TurnkeyErrorKind::Timeout -/// )); -/// ``` -#[must_use] -pub fn classify_turnkey_error(msg: &str) -> TurnkeyErrorKind { - // Patterns grouped by kind. Keep short, ASCII, and conservative. - const UNIQUE_PATTERNS: &[&str] = &[ - "label must be unique", - "already exists", - "duplicate", - "unique" - ]; - const RL_PATTERNS: &[&str] = &["429", "rate", "throttle"]; - const TO_PATTERNS: &[&str] = &["timeout", "timed out", "deadline exceeded"]; - const AUTH_PATTERNS: &[&str] = &["401", "403", "unauthor", "forbidden"]; - const NET_PATTERNS: &[&str] = &["network", "connection", "connect", "dns", "tls", "socket"]; - - if contains_any_nocase(msg, UNIQUE_PATTERNS) { - TurnkeyErrorKind::UniqueLabel - } else if contains_any_nocase(msg, RL_PATTERNS) { - TurnkeyErrorKind::RateLimited - } else if contains_any_nocase(msg, TO_PATTERNS) { - TurnkeyErrorKind::Timeout - } else if contains_any_nocase(msg, AUTH_PATTERNS) { - TurnkeyErrorKind::Auth - } else if contains_any_nocase(msg, NET_PATTERNS) { - TurnkeyErrorKind::Network - } else { - TurnkeyErrorKind::Service - } -} - -/// Returns true if `haystack` contains `needle` ignoring ASCII case. -/// Performs the search without allocating. -#[inline] -fn contains_nocase(haystack: &str, needle: &str) -> bool { - // Fast path: empty needle always matches. - if needle.is_empty() { - return true; - } - // Walk haystack windows and compare ASCII case-insensitively. - haystack.as_bytes().windows(needle.len()).any(|w| { - w.iter() - .copied() - .map(ascii_lower) - .eq(needle.as_bytes().iter().copied().map(ascii_lower)) - }) -} - -/// Check whether `haystack` contains any of the `needles` (ASCII -/// case-insensitive). -#[inline] -fn contains_any_nocase(haystack: &str, needles: &[&str]) -> bool { - needles.iter().any(|n| contains_nocase(haystack, n)) -} - -/// Converts ASCII letters to lowercase and leaves other bytes unchanged. -#[inline] -const fn ascii_lower(b: u8) -> u8 { - // ASCII-only fold without RangeInclusive to keep const-friendly on MSRV 1.89 - if b >= b'A' && b <= b'Z' { b + 32 } else { b } -} - -// ── Conversions into AppError ──────────────────────────────────────────────── - -impl From for AppErrorKind { - #[inline] - fn from(k: TurnkeyErrorKind) -> Self { - map_turnkey_kind(k) - } -} - -impl From for AppError { - #[inline] - fn from(e: TurnkeyError) -> Self { - // Prefer explicit constructors to keep transport mapping consistent. - match e.kind { - TurnkeyErrorKind::UniqueLabel => AppError::conflict(e.msg), - TurnkeyErrorKind::RateLimited => AppError::rate_limited(e.msg), - TurnkeyErrorKind::Timeout => AppError::timeout(e.msg), - TurnkeyErrorKind::Auth => AppError::unauthorized(e.msg), - TurnkeyErrorKind::Network => AppError::network(e.msg), - TurnkeyErrorKind::Service => AppError::turnkey(e.msg) - } - } -} +pub use classifier::classify_turnkey_error; +pub use domain::{TurnkeyError, TurnkeyErrorKind, map_turnkey_kind}; #[cfg(test)] -mod tests { - use super::*; - use crate::AppErrorKind; - - #[test] - fn map_is_stable() { - assert_eq!( - map_turnkey_kind(TurnkeyErrorKind::UniqueLabel), - AppErrorKind::Conflict - ); - assert_eq!( - map_turnkey_kind(TurnkeyErrorKind::RateLimited), - AppErrorKind::RateLimited - ); - assert_eq!( - map_turnkey_kind(TurnkeyErrorKind::Timeout), - AppErrorKind::Timeout - ); - assert_eq!( - map_turnkey_kind(TurnkeyErrorKind::Auth), - AppErrorKind::Unauthorized - ); - assert_eq!( - map_turnkey_kind(TurnkeyErrorKind::Network), - AppErrorKind::Network - ); - assert_eq!( - map_turnkey_kind(TurnkeyErrorKind::Service), - AppErrorKind::Turnkey - ); - } - - #[test] - fn classifier_unique() { - for s in [ - "Label must be UNIQUE", - "already exists: trading-key-foo", - "duplicate label", - "unique constraint violation" - ] { - assert!( - matches!(classify_turnkey_error(s), TurnkeyErrorKind::UniqueLabel), - "failed on: {s}" - ); - } - } - - #[test] - fn classifier_rate_limited() { - for s in [ - "429 Too Many Requests", - "rate limit exceeded", - "throttled by upstream" - ] { - assert!( - matches!(classify_turnkey_error(s), TurnkeyErrorKind::RateLimited), - "failed on: {s}" - ); - } - } - - #[test] - fn classifier_timeout() { - for s in [ - "request timed out", - "Timeout while waiting", - "deadline exceeded" - ] { - assert!( - matches!(classify_turnkey_error(s), TurnkeyErrorKind::Timeout), - "failed on: {s}" - ); - } - } - - #[test] - fn classifier_auth() { - for s in ["401 Unauthorized", "403 Forbidden", "unauthor ized"] { - assert!( - matches!(classify_turnkey_error(s), TurnkeyErrorKind::Auth), - "failed on: {s}" - ); - } - } - - #[test] - fn classifier_network() { - for s in [ - "network error", - "connection reset", - "DNS failure", - "TLS handshake", - "socket hang up" - ] { - assert!( - matches!(classify_turnkey_error(s), TurnkeyErrorKind::Network), - "failed on: {s}" - ); - } - } - - #[test] - fn classifier_service_fallback() { - assert!(matches!( - classify_turnkey_error("unrecognized issue"), - TurnkeyErrorKind::Service - )); - } - - #[test] - fn contains_nocase_works_without_alloc() { - assert!(contains_nocase("ABCdef", "cDe")); - assert!(contains_any_nocase("hello world", &["nope", "WORLD"])); - assert!(!contains_nocase("rustacean", "python")); - assert!(contains_nocase("", "")); // by definition - } - - #[test] - fn from_turnkey_error_into_app_error() { - let e = TurnkeyError::new(TurnkeyErrorKind::RateLimited, "try later"); - let a: AppError = e.into(); - assert_eq!(a.kind, AppErrorKind::RateLimited); - // message plumbing is AppError-specific; sanity-check only kind here. - } -} +mod tests; diff --git a/src/turnkey/classifier.rs b/src/turnkey/classifier.rs new file mode 100644 index 0000000..f9a2d93 --- /dev/null +++ b/src/turnkey/classifier.rs @@ -0,0 +1,99 @@ +use super::domain::TurnkeyErrorKind; + +/// Heuristic classifier for raw SDK/provider messages (ASCII case-insensitive). +/// +/// This helper **does not allocate**; it performs case-insensitive `contains` +/// checks over the input string to map common upstream texts to stable kinds. +/// +/// The classifier is intentionally minimal; providers can and will change +/// messages. Prefer returning structured errors from adapters whenever +/// possible. +/// +/// # Examples +/// ```rust +/// use masterror::turnkey::{TurnkeyErrorKind, classify_turnkey_error}; +/// assert!(matches!( +/// classify_turnkey_error("429 Too Many Requests"), +/// TurnkeyErrorKind::RateLimited +/// )); +/// assert!(matches!( +/// classify_turnkey_error("label must be unique"), +/// TurnkeyErrorKind::UniqueLabel +/// )); +/// assert!(matches!( +/// classify_turnkey_error("request timed out"), +/// TurnkeyErrorKind::Timeout +/// )); +/// ``` +#[must_use] +pub fn classify_turnkey_error(msg: &str) -> TurnkeyErrorKind { + // Patterns grouped by kind. Keep short, ASCII, and conservative. + const UNIQUE_PATTERNS: &[&str] = &[ + "label must be unique", + "already exists", + "duplicate", + "unique" + ]; + const RL_PATTERNS: &[&str] = &["429", "rate", "throttle"]; + const TO_PATTERNS: &[&str] = &["timeout", "timed out", "deadline exceeded"]; + const AUTH_PATTERNS: &[&str] = &["401", "403", "unauthor", "forbidden"]; + const NET_PATTERNS: &[&str] = &["network", "connection", "connect", "dns", "tls", "socket"]; + + if contains_any_nocase(msg, UNIQUE_PATTERNS) { + TurnkeyErrorKind::UniqueLabel + } else if contains_any_nocase(msg, RL_PATTERNS) { + TurnkeyErrorKind::RateLimited + } else if contains_any_nocase(msg, TO_PATTERNS) { + TurnkeyErrorKind::Timeout + } else if contains_any_nocase(msg, AUTH_PATTERNS) { + TurnkeyErrorKind::Auth + } else if contains_any_nocase(msg, NET_PATTERNS) { + TurnkeyErrorKind::Network + } else { + TurnkeyErrorKind::Service + } +} + +/// Returns true if `haystack` contains `needle` ignoring ASCII case. +/// Performs the search without allocating. +#[inline] +fn contains_nocase(haystack: &str, needle: &str) -> bool { + // Fast path: empty needle always matches. + if needle.is_empty() { + return true; + } + // Walk haystack windows and compare ASCII case-insensitively. + haystack.as_bytes().windows(needle.len()).any(|w| { + w.iter() + .copied() + .map(ascii_lower) + .eq(needle.as_bytes().iter().copied().map(ascii_lower)) + }) +} + +/// Check whether `haystack` contains any of the `needles` (ASCII +/// case-insensitive). +#[inline] +fn contains_any_nocase(haystack: &str, needles: &[&str]) -> bool { + needles.iter().any(|n| contains_nocase(haystack, n)) +} + +/// Converts ASCII letters to lowercase and leaves other bytes unchanged. +#[inline] +const fn ascii_lower(b: u8) -> u8 { + // ASCII-only fold without RangeInclusive to keep const-friendly on MSRV 1.89 + if b >= b'A' && b <= b'Z' { b + 32 } else { b } +} + +#[cfg(test)] +pub(super) mod internal_tests { + use super::*; + + #[test] + fn contains_nocase_works_without_alloc() { + assert!(contains_nocase("ABCdef", "cDe")); + assert!(contains_any_nocase("hello world", &["nope", "WORLD"])); + assert!(!contains_nocase("rustacean", "python")); + assert!(contains_nocase("", "")); + } +} diff --git a/src/turnkey/conversions.rs b/src/turnkey/conversions.rs new file mode 100644 index 0000000..22c0d0e --- /dev/null +++ b/src/turnkey/conversions.rs @@ -0,0 +1,24 @@ +use super::domain::{TurnkeyError, TurnkeyErrorKind, map_turnkey_kind}; +use crate::{AppError, AppErrorKind}; + +impl From for AppErrorKind { + #[inline] + fn from(k: TurnkeyErrorKind) -> Self { + map_turnkey_kind(k) + } +} + +impl From for AppError { + #[inline] + fn from(e: TurnkeyError) -> Self { + // Prefer explicit constructors to keep transport mapping consistent. + match e.kind { + TurnkeyErrorKind::UniqueLabel => AppError::conflict(e.msg), + TurnkeyErrorKind::RateLimited => AppError::rate_limited(e.msg), + TurnkeyErrorKind::Timeout => AppError::timeout(e.msg), + TurnkeyErrorKind::Auth => AppError::unauthorized(e.msg), + TurnkeyErrorKind::Network => AppError::network(e.msg), + TurnkeyErrorKind::Service => AppError::turnkey(e.msg) + } + } +} diff --git a/src/turnkey/domain.rs b/src/turnkey/domain.rs new file mode 100644 index 0000000..b30a8fa --- /dev/null +++ b/src/turnkey/domain.rs @@ -0,0 +1,83 @@ +use crate::{AppErrorKind, Error}; + +/// High-level, stable Turnkey error categories. +/// +/// Marked `#[non_exhaustive]` to allow adding variants without a breaking +/// change. Consumers must use a wildcard arm when matching. +/// +/// Mapping to [`AppErrorKind`] is intentionally conservative: +/// - `UniqueLabel` → `Conflict` +/// - `RateLimited` → `RateLimited` +/// - `Timeout` → `Timeout` +/// - `Auth` → `Unauthorized` +/// - `Network` → `Network` +/// - `Service` → `Turnkey` +#[derive(Debug, Error, Clone, Copy, PartialEq, Eq)] +#[non_exhaustive] +pub enum TurnkeyErrorKind { + /// Unique label violation or duplicate resource. + #[error("label already exists")] + UniqueLabel, + /// Throttling or quota exceeded. + #[error("rate limited or throttled")] + RateLimited, + /// Operation exceeded allowed time. + #[error("request timed out")] + Timeout, + /// Authentication/authorization failure. + #[error("authentication/authorization failed")] + Auth, + /// Network-level error (DNS/connect/TLS/build). + #[error("network error")] + Network, + /// Generic service error in the Turnkey subsystem. + #[error("service error")] + Service +} + +/// Turnkey domain error with stable kind and safe, human-readable message. +#[derive(Debug, Error, Clone, PartialEq, Eq)] +#[error("{kind}: {msg}")] +pub struct TurnkeyError { + /// Stable semantic category. + pub kind: TurnkeyErrorKind, + /// Public, non-sensitive message. + pub msg: String +} + +impl TurnkeyError { + /// Construct a new domain error. + /// + /// # Examples + /// ```rust + /// use masterror::turnkey::{TurnkeyError, TurnkeyErrorKind}; + /// let e = TurnkeyError::new(TurnkeyErrorKind::Timeout, "rpc deadline exceeded"); + /// assert!(matches!(e.kind, TurnkeyErrorKind::Timeout)); + /// ``` + #[inline] + pub fn new(kind: TurnkeyErrorKind, msg: impl Into) -> Self { + Self { + kind, + msg: msg.into() + } + } +} + +/// Map [`TurnkeyErrorKind`] into the canonical [`AppErrorKind`]. +/// +/// Keep mappings conservative and stable. See enum docs for rationale. +#[must_use] +#[inline] +pub fn map_turnkey_kind(kind: TurnkeyErrorKind) -> AppErrorKind { + match kind { + TurnkeyErrorKind::UniqueLabel => AppErrorKind::Conflict, + TurnkeyErrorKind::RateLimited => AppErrorKind::RateLimited, + TurnkeyErrorKind::Timeout => AppErrorKind::Timeout, + TurnkeyErrorKind::Auth => AppErrorKind::Unauthorized, + TurnkeyErrorKind::Network => AppErrorKind::Network, + TurnkeyErrorKind::Service => AppErrorKind::Turnkey, + // Future-proofing: unknown variants map to Turnkey (500) by default. + #[allow(unreachable_patterns)] + _ => AppErrorKind::Turnkey + } +} diff --git a/src/turnkey/tests.rs b/src/turnkey/tests.rs new file mode 100644 index 0000000..64bfa70 --- /dev/null +++ b/src/turnkey/tests.rs @@ -0,0 +1,115 @@ +use super::{TurnkeyError, TurnkeyErrorKind, classify_turnkey_error, map_turnkey_kind}; +use crate::{AppError, AppErrorKind}; + +#[test] +fn map_is_stable() { + assert_eq!( + map_turnkey_kind(TurnkeyErrorKind::UniqueLabel), + AppErrorKind::Conflict + ); + assert_eq!( + map_turnkey_kind(TurnkeyErrorKind::RateLimited), + AppErrorKind::RateLimited + ); + assert_eq!( + map_turnkey_kind(TurnkeyErrorKind::Timeout), + AppErrorKind::Timeout + ); + assert_eq!( + map_turnkey_kind(TurnkeyErrorKind::Auth), + AppErrorKind::Unauthorized + ); + assert_eq!( + map_turnkey_kind(TurnkeyErrorKind::Network), + AppErrorKind::Network + ); + assert_eq!( + map_turnkey_kind(TurnkeyErrorKind::Service), + AppErrorKind::Turnkey + ); +} + +#[test] +fn classifier_unique() { + for s in [ + "Label must be UNIQUE", + "already exists: trading-key-foo", + "duplicate label", + "unique constraint violation" + ] { + assert!( + matches!(classify_turnkey_error(s), TurnkeyErrorKind::UniqueLabel), + "failed on: {s}" + ); + } +} + +#[test] +fn classifier_rate_limited() { + for s in [ + "429 Too Many Requests", + "rate limit exceeded", + "throttled by upstream" + ] { + assert!( + matches!(classify_turnkey_error(s), TurnkeyErrorKind::RateLimited), + "failed on: {s}" + ); + } +} + +#[test] +fn classifier_timeout() { + for s in [ + "request timed out", + "Timeout while waiting", + "deadline exceeded" + ] { + assert!( + matches!(classify_turnkey_error(s), TurnkeyErrorKind::Timeout), + "failed on: {s}" + ); + } +} + +#[test] +fn classifier_auth() { + for s in ["401 Unauthorized", "403 Forbidden", "unauthor ized"] { + assert!( + matches!(classify_turnkey_error(s), TurnkeyErrorKind::Auth), + "failed on: {s}" + ); + } +} + +#[test] +fn classifier_network() { + for s in [ + "network error", + "connection reset", + "DNS failure", + "TLS handshake", + "socket hang up" + ] { + assert!( + matches!(classify_turnkey_error(s), TurnkeyErrorKind::Network), + "failed on: {s}" + ); + } +} + +#[test] +fn classifier_service_fallback() { + assert!(matches!( + classify_turnkey_error("unrecognized issue"), + TurnkeyErrorKind::Service + )); +} + +#[test] +fn from_turnkey_error_into_app_error() { + let e = TurnkeyError::new(TurnkeyErrorKind::RateLimited, "try later"); + let a: AppError = e.into(); + assert_eq!(a.kind, AppErrorKind::RateLimited); + // message plumbing is AppError-specific; sanity-check only kind here. +}