From 5ff9ead182a23168dc92646df6a6dc65f1819ce9 Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Thu, 23 Apr 2026 18:48:53 +0000 Subject: [PATCH 1/2] feat(events): add SdkErrorCode typed mapping for SDK error integers MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `Error::CommandFailed`, `Error::ClientError`, and the `FfiError::SdkError` variant previously carried the SDK's integer `nErrorNo` as a raw `i32`, so callers had to memorise the `CMDERR_*` / `INTERR_*` numeric constants or bring in `teamtalk_sys::ClientError` directly. This PR adds a typed view on top of the existing numeric field without changing any public struct layout: * New `teamtalk::events::SdkErrorCode` enum (`#[non_exhaustive]`) with one variant per `CMDERR_*` (1000..=3999) and `INTERR_*` (10000..=19999) code from `teamtalk_sys::ClientError`, plus `Success` (0) and `Unknown(i32)` for forward compatibility with future SDK releases. * `From for SdkErrorCode` — lossless (unknown codes become `Unknown(code)`, never silently dropped). * `From for SdkErrorCode` — typed bridge from the bindings enum. * `SdkErrorCode::as_i32`, `::name`, `::Display`, `::is_command_error`, `::is_internal_error`, `::is_known` — inspection helpers suitable for structured logging and metrics. * `Error::sdk_code(&self) -> Option` — non- consuming accessor that returns the typed code for `CommandFailed`, `ClientError`, and `Ffi(FfiError::SdkError)` variants and `None` for non-SDK errors (`Timeout`, `IoError`, `InitFailed`, etc.). Strictly additive: `Error::CommandFailed { code: i32, message }` and `Error::ClientError { code: i32, message }` keep their existing `i32` field, so no downstream code breaks. Existing doc comments on the fields now also point at `Error::sdk_code`. File layout: `events.rs` (256 lines) is moved to `events/mod.rs` and the new typed mapping lives in a focused sibling `events/sdk_error.rs`. No public path changes — `teamtalk::events::*` still re-exports everything. Tests: new integration file `tests/sdk_error_code_tests.rs` with 11 cases covering: * Every documented `ClientError` FFI variant round-trips via `From` + `as_i32` to the same numeric code. * Unknown codes (both positive out-of-range and negative) map to `Unknown` with the raw value preserved. * `is_command_error` / `is_internal_error` classification for named variants and for `Unknown(_)` in and out of the relevant ranges. * `Display` includes the snake_case name and the numeric code. * `From` matches `From`. * `Error::sdk_code` returns `Some(..)` for CommandFailed, ClientError, and Ffi(SdkError) and `None` for the other non-SDK error variants. * All `name()` values are unique. Local verification: * cargo fmt --all clean. * cargo clippy --workspace --all-targets --all-features -- -D warnings clean. * cargo test --workspace --all-features — all tests pass, including the 11 new SdkErrorCode cases. --- .../teamtalk/src/{events.rs => events/mod.rs} | 43 ++- crates/teamtalk/src/events/sdk_error.rs | 327 ++++++++++++++++++ crates/teamtalk/tests/sdk_error_code_tests.rs | 283 +++++++++++++++ 3 files changed, 651 insertions(+), 2 deletions(-) rename crates/teamtalk/src/{events.rs => events/mod.rs} (86%) create mode 100644 crates/teamtalk/src/events/sdk_error.rs create mode 100644 crates/teamtalk/tests/sdk_error_code_tests.rs diff --git a/crates/teamtalk/src/events.rs b/crates/teamtalk/src/events/mod.rs similarity index 86% rename from crates/teamtalk/src/events.rs rename to crates/teamtalk/src/events/mod.rs index c037f25..f23e0cc 100644 --- a/crates/teamtalk/src/events.rs +++ b/crates/teamtalk/src/events/mod.rs @@ -1,4 +1,8 @@ //! Event and error types emitted by the `TeamTalk` client. +mod sdk_error; + +pub use sdk_error::SdkErrorCode; + use crate::types::ChannelId; use std::time::Duration; use teamtalk_sys as ffi; @@ -220,7 +224,13 @@ pub enum Error { #[error("Init failed")] InitFailed, #[error("Command failed: {code} ({message})")] - CommandFailed { code: i32, message: String }, + CommandFailed { + /// Raw SDK error code. Use [`Error::sdk_code`] to obtain a + /// typed [`SdkErrorCode`] view without losing unknown codes. + code: i32, + /// Human-readable error message from the SDK. + message: String, + }, #[error("Connection failed")] ConnectFailed, #[error("Auth failed")] @@ -232,7 +242,13 @@ pub enum Error { #[error("Missing login parameters")] MissingLoginParams, #[error("SDK Error: {code} ({message})")] - ClientError { code: i32, message: String }, + ClientError { + /// Raw SDK error code. Use [`Error::sdk_code`] to obtain a + /// typed [`SdkErrorCode`] view without losing unknown codes. + code: i32, + /// Human-readable error message from the SDK. + message: String, + }, #[error("IO error: {message}")] IoError { message: String }, #[error("Operation timed out")] @@ -241,6 +257,29 @@ pub enum Error { Ffi(#[from] FfiError), } +impl Error { + /// Returns the typed [`SdkErrorCode`] carried by + /// [`Error::CommandFailed`], [`Error::ClientError`], or + /// [`Error::Ffi`] (via [`FfiError::SdkError`]). + /// + /// Returns `None` for errors that do not originate from the + /// SDK integer code space (for example [`Error::Timeout`] or + /// [`Error::IoError`]). Unknown codes are still returned — they + /// map to [`SdkErrorCode::Unknown`], preserving the raw `i32` + /// so callers never silently lose information about a new or + /// out-of-range SDK code. + #[must_use] + pub fn sdk_code(&self) -> Option { + match self { + Self::CommandFailed { code, .. } | Self::ClientError { code, .. } => { + Some(SdkErrorCode::from(*code)) + } + Self::Ffi(FfiError::SdkError { code, .. }) => Some(SdkErrorCode::from(*code)), + _ => None, + } + } +} + #[non_exhaustive] #[derive(Debug, Clone, thiserror::Error)] pub enum FfiError { diff --git a/crates/teamtalk/src/events/sdk_error.rs b/crates/teamtalk/src/events/sdk_error.rs new file mode 100644 index 0000000..095e8a6 --- /dev/null +++ b/crates/teamtalk/src/events/sdk_error.rs @@ -0,0 +1,327 @@ +//! Typed mapping of TeamTalk SDK error codes. +//! +//! The SDK surfaces command and internal errors as an integer +//! `nErrorNo` on `ClientErrorMsg` (and through the `CMD_ERROR` / +//! `INTERNAL_ERROR` client events). Rust callers previously had to +//! compare against raw `i32` constants, which is error-prone. +//! +//! [`SdkErrorCode`] provides a typed enum with one variant per +//! documented `CMDERR_*` and `INTERR_*` constant. Unknown codes are +//! preserved via the [`SdkErrorCode::Unknown`] fallback so new SDK +//! releases do not silently lose information. +//! +//! The enum is `#[non_exhaustive]`, matching project convention, so +//! a later SDK update can add a new variant without breaking +//! downstream match statements. + +use teamtalk_sys as ffi; + +/// Typed TeamTalk SDK error code. +/// +/// Produced from the raw `i32` carried by [`Error::CommandFailed`] +/// or [`Error::ClientError`] via `From`. Each named variant +/// mirrors one `CMDERR_*` or `INTERR_*` constant in +/// `teamtalk_sys::ClientError`; unrecognised codes map to +/// [`Self::Unknown`] so that forward compatibility with newer SDK +/// builds is preserved. +/// +/// [`Error::CommandFailed`]: crate::events::Error::CommandFailed +/// [`Error::ClientError`]: crate::events::Error::ClientError +#[non_exhaustive] +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub enum SdkErrorCode { + /// `CMDERR_SUCCESS` (0) — command succeeded. Present so + /// round-tripping `0` through `From` is lossless; never + /// observed as a `CommandFailed` error in practice. + Success, + /// `CMDERR_SYNTAX_ERROR` (1000). + SyntaxError, + /// `CMDERR_UNKNOWN_COMMAND` (1001). + UnknownCommand, + /// `CMDERR_MISSING_PARAMETER` (1002). + MissingParameter, + /// `CMDERR_INCOMPATIBLE_PROTOCOLS` (1003). + IncompatibleProtocols, + /// `CMDERR_UNKNOWN_AUDIOCODEC` (1004). + UnknownAudioCodec, + /// `CMDERR_INVALID_USERNAME` (1005). + InvalidUsername, + /// `CMDERR_INCORRECT_CHANNEL_PASSWORD` (2001). + IncorrectChannelPassword, + /// `CMDERR_INVALID_ACCOUNT` (2002). + InvalidAccount, + /// `CMDERR_MAX_SERVER_USERS_EXCEEDED` (2003). + MaxServerUsersExceeded, + /// `CMDERR_MAX_CHANNEL_USERS_EXCEEDED` (2004). + MaxChannelUsersExceeded, + /// `CMDERR_SERVER_BANNED` (2005). + ServerBanned, + /// `CMDERR_NOT_AUTHORIZED` (2006). + NotAuthorized, + /// `CMDERR_MAX_DISKUSAGE_EXCEEDED` (2008). + MaxDiskUsageExceeded, + /// `CMDERR_INCORRECT_OP_PASSWORD` (2010). + IncorrectOpPassword, + /// `CMDERR_AUDIOCODEC_BITRATE_LIMIT_EXCEEDED` (2011). + AudioCodecBitrateLimitExceeded, + /// `CMDERR_MAX_LOGINS_PER_IPADDRESS_EXCEEDED` (2012). + MaxLoginsPerIpAddressExceeded, + /// `CMDERR_MAX_CHANNELS_EXCEEDED` (2013). + MaxChannelsExceeded, + /// `CMDERR_COMMAND_FLOOD` (2014). + CommandFlood, + /// `CMDERR_CHANNEL_BANNED` (2015). + ChannelBanned, + /// `CMDERR_MAX_FILETRANSFERS_EXCEEDED` (2016). + MaxFileTransfersExceeded, + /// `CMDERR_NOT_LOGGEDIN` (3000). + NotLoggedIn, + /// `CMDERR_ALREADY_LOGGEDIN` (3001). + AlreadyLoggedIn, + /// `CMDERR_NOT_IN_CHANNEL` (3002). + NotInChannel, + /// `CMDERR_ALREADY_IN_CHANNEL` (3003). + AlreadyInChannel, + /// `CMDERR_CHANNEL_ALREADY_EXISTS` (3004). + ChannelAlreadyExists, + /// `CMDERR_CHANNEL_NOT_FOUND` (3005). + ChannelNotFound, + /// `CMDERR_USER_NOT_FOUND` (3006). + UserNotFound, + /// `CMDERR_BAN_NOT_FOUND` (3007). + BanNotFound, + /// `CMDERR_FILETRANSFER_NOT_FOUND` (3008). + FileTransferNotFound, + /// `CMDERR_OPENFILE_FAILED` (3009). + OpenFileFailed, + /// `CMDERR_ACCOUNT_NOT_FOUND` (3010). + AccountNotFound, + /// `CMDERR_FILE_NOT_FOUND` (3011). + FileNotFound, + /// `CMDERR_FILE_ALREADY_EXISTS` (3012). + FileAlreadyExists, + /// `CMDERR_FILESHARING_DISABLED` (3013). + FileSharingDisabled, + /// `CMDERR_CHANNEL_HAS_USERS` (3015). + ChannelHasUsers, + /// `CMDERR_LOGINSERVICE_UNAVAILABLE` (3016). + LoginServiceUnavailable, + /// `CMDERR_CHANNEL_CANNOT_BE_HIDDEN` (3017). + ChannelCannotBeHidden, + /// `INTERR_SNDINPUT_FAILURE` (10000). + SoundInputFailure, + /// `INTERR_SNDOUTPUT_FAILURE` (10001). + SoundOutputFailure, + /// `INTERR_AUDIOCODEC_INIT_FAILED` (10002). + AudioCodecInitFailed, + /// `INTERR_SPEEXDSP_INIT_FAILED` (10003). Also reported as + /// `INTERR_AUDIOPREPROCESSOR_INIT_FAILED` by some SDK versions; + /// both aliases share the same numeric value. + AudioPreprocessorInitFailed, + /// `INTERR_TTMESSAGE_QUEUE_OVERFLOW` (10004). + MessageQueueOverflow, + /// `INTERR_SNDEFFECT_FAILURE` (10005). + SoundEffectFailure, + /// Code returned by the SDK that is not known to this version + /// of the wrapper. The raw `i32` is preserved so callers can + /// still log or forward it. + Unknown(i32), +} + +impl SdkErrorCode { + /// Returns the raw SDK integer code for this variant. + /// + /// This is the inverse of `From` and round-trips through + /// it for every known variant: `SdkErrorCode::from(code.as_i32()) == code`. + #[must_use] + pub const fn as_i32(self) -> i32 { + match self { + Self::Success => 0, + Self::SyntaxError => 1000, + Self::UnknownCommand => 1001, + Self::MissingParameter => 1002, + Self::IncompatibleProtocols => 1003, + Self::UnknownAudioCodec => 1004, + Self::InvalidUsername => 1005, + Self::IncorrectChannelPassword => 2001, + Self::InvalidAccount => 2002, + Self::MaxServerUsersExceeded => 2003, + Self::MaxChannelUsersExceeded => 2004, + Self::ServerBanned => 2005, + Self::NotAuthorized => 2006, + Self::MaxDiskUsageExceeded => 2008, + Self::IncorrectOpPassword => 2010, + Self::AudioCodecBitrateLimitExceeded => 2011, + Self::MaxLoginsPerIpAddressExceeded => 2012, + Self::MaxChannelsExceeded => 2013, + Self::CommandFlood => 2014, + Self::ChannelBanned => 2015, + Self::MaxFileTransfersExceeded => 2016, + Self::NotLoggedIn => 3000, + Self::AlreadyLoggedIn => 3001, + Self::NotInChannel => 3002, + Self::AlreadyInChannel => 3003, + Self::ChannelAlreadyExists => 3004, + Self::ChannelNotFound => 3005, + Self::UserNotFound => 3006, + Self::BanNotFound => 3007, + Self::FileTransferNotFound => 3008, + Self::OpenFileFailed => 3009, + Self::AccountNotFound => 3010, + Self::FileNotFound => 3011, + Self::FileAlreadyExists => 3012, + Self::FileSharingDisabled => 3013, + Self::ChannelHasUsers => 3015, + Self::LoginServiceUnavailable => 3016, + Self::ChannelCannotBeHidden => 3017, + Self::SoundInputFailure => 10000, + Self::SoundOutputFailure => 10001, + Self::AudioCodecInitFailed => 10002, + Self::AudioPreprocessorInitFailed => 10003, + Self::MessageQueueOverflow => 10004, + Self::SoundEffectFailure => 10005, + Self::Unknown(code) => code, + } + } + + /// Returns a short, stable, lower-snake-case name for this + /// variant. + /// + /// Useful for structured logging and metrics where a string + /// label is preferred over the numeric code. + #[must_use] + pub const fn name(self) -> &'static str { + match self { + Self::Success => "success", + Self::SyntaxError => "syntax_error", + Self::UnknownCommand => "unknown_command", + Self::MissingParameter => "missing_parameter", + Self::IncompatibleProtocols => "incompatible_protocols", + Self::UnknownAudioCodec => "unknown_audio_codec", + Self::InvalidUsername => "invalid_username", + Self::IncorrectChannelPassword => "incorrect_channel_password", + Self::InvalidAccount => "invalid_account", + Self::MaxServerUsersExceeded => "max_server_users_exceeded", + Self::MaxChannelUsersExceeded => "max_channel_users_exceeded", + Self::ServerBanned => "server_banned", + Self::NotAuthorized => "not_authorized", + Self::MaxDiskUsageExceeded => "max_disk_usage_exceeded", + Self::IncorrectOpPassword => "incorrect_op_password", + Self::AudioCodecBitrateLimitExceeded => "audio_codec_bitrate_limit_exceeded", + Self::MaxLoginsPerIpAddressExceeded => "max_logins_per_ip_address_exceeded", + Self::MaxChannelsExceeded => "max_channels_exceeded", + Self::CommandFlood => "command_flood", + Self::ChannelBanned => "channel_banned", + Self::MaxFileTransfersExceeded => "max_file_transfers_exceeded", + Self::NotLoggedIn => "not_logged_in", + Self::AlreadyLoggedIn => "already_logged_in", + Self::NotInChannel => "not_in_channel", + Self::AlreadyInChannel => "already_in_channel", + Self::ChannelAlreadyExists => "channel_already_exists", + Self::ChannelNotFound => "channel_not_found", + Self::UserNotFound => "user_not_found", + Self::BanNotFound => "ban_not_found", + Self::FileTransferNotFound => "file_transfer_not_found", + Self::OpenFileFailed => "open_file_failed", + Self::AccountNotFound => "account_not_found", + Self::FileNotFound => "file_not_found", + Self::FileAlreadyExists => "file_already_exists", + Self::FileSharingDisabled => "file_sharing_disabled", + Self::ChannelHasUsers => "channel_has_users", + Self::LoginServiceUnavailable => "login_service_unavailable", + Self::ChannelCannotBeHidden => "channel_cannot_be_hidden", + Self::SoundInputFailure => "sound_input_failure", + Self::SoundOutputFailure => "sound_output_failure", + Self::AudioCodecInitFailed => "audio_codec_init_failed", + Self::AudioPreprocessorInitFailed => "audio_preprocessor_init_failed", + Self::MessageQueueOverflow => "message_queue_overflow", + Self::SoundEffectFailure => "sound_effect_failure", + Self::Unknown(_) => "unknown", + } + } + + /// Returns `true` if this code originates from the + /// `CMDERR_*` protocol-level namespace (codes `1000..=3999`). + #[must_use] + pub const fn is_command_error(self) -> bool { + matches!(self.as_i32(), 1000..=3999) + } + + /// Returns `true` if this code originates from the + /// `INTERR_*` client-internal namespace (codes `10000..=19999`). + #[must_use] + pub const fn is_internal_error(self) -> bool { + matches!(self.as_i32(), 10000..=19999) + } + + /// Returns `true` if the code is a known named variant (not + /// [`Self::Unknown`]). + #[must_use] + pub const fn is_known(self) -> bool { + !matches!(self, Self::Unknown(_)) + } +} + +impl std::fmt::Display for SdkErrorCode { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "{} ({})", self.name(), self.as_i32()) + } +} + +impl From for SdkErrorCode { + fn from(code: i32) -> Self { + match code { + 0 => Self::Success, + 1000 => Self::SyntaxError, + 1001 => Self::UnknownCommand, + 1002 => Self::MissingParameter, + 1003 => Self::IncompatibleProtocols, + 1004 => Self::UnknownAudioCodec, + 1005 => Self::InvalidUsername, + 2001 => Self::IncorrectChannelPassword, + 2002 => Self::InvalidAccount, + 2003 => Self::MaxServerUsersExceeded, + 2004 => Self::MaxChannelUsersExceeded, + 2005 => Self::ServerBanned, + 2006 => Self::NotAuthorized, + 2008 => Self::MaxDiskUsageExceeded, + 2010 => Self::IncorrectOpPassword, + 2011 => Self::AudioCodecBitrateLimitExceeded, + 2012 => Self::MaxLoginsPerIpAddressExceeded, + 2013 => Self::MaxChannelsExceeded, + 2014 => Self::CommandFlood, + 2015 => Self::ChannelBanned, + 2016 => Self::MaxFileTransfersExceeded, + 3000 => Self::NotLoggedIn, + 3001 => Self::AlreadyLoggedIn, + 3002 => Self::NotInChannel, + 3003 => Self::AlreadyInChannel, + 3004 => Self::ChannelAlreadyExists, + 3005 => Self::ChannelNotFound, + 3006 => Self::UserNotFound, + 3007 => Self::BanNotFound, + 3008 => Self::FileTransferNotFound, + 3009 => Self::OpenFileFailed, + 3010 => Self::AccountNotFound, + 3011 => Self::FileNotFound, + 3012 => Self::FileAlreadyExists, + 3013 => Self::FileSharingDisabled, + 3015 => Self::ChannelHasUsers, + 3016 => Self::LoginServiceUnavailable, + 3017 => Self::ChannelCannotBeHidden, + 10000 => Self::SoundInputFailure, + 10001 => Self::SoundOutputFailure, + 10002 => Self::AudioCodecInitFailed, + 10003 => Self::AudioPreprocessorInitFailed, + 10004 => Self::MessageQueueOverflow, + 10005 => Self::SoundEffectFailure, + other => Self::Unknown(other), + } + } +} + +impl From for SdkErrorCode { + fn from(value: ffi::ClientError) -> Self { + Self::from(value as i32) + } +} diff --git a/crates/teamtalk/tests/sdk_error_code_tests.rs b/crates/teamtalk/tests/sdk_error_code_tests.rs new file mode 100644 index 0000000..e70cb3b --- /dev/null +++ b/crates/teamtalk/tests/sdk_error_code_tests.rs @@ -0,0 +1,283 @@ +//! Integration tests for the typed SDK-error-code mapping. + +use teamtalk::events::{Error, FfiError, SdkErrorCode}; +use teamtalk_sys::ClientError; + +#[test] +fn from_i32_covers_every_documented_client_error_variant() { + let known = [ + ClientError::CMDERR_SUCCESS, + ClientError::CMDERR_SYNTAX_ERROR, + ClientError::CMDERR_UNKNOWN_COMMAND, + ClientError::CMDERR_MISSING_PARAMETER, + ClientError::CMDERR_INCOMPATIBLE_PROTOCOLS, + ClientError::CMDERR_UNKNOWN_AUDIOCODEC, + ClientError::CMDERR_INVALID_USERNAME, + ClientError::CMDERR_INCORRECT_CHANNEL_PASSWORD, + ClientError::CMDERR_INVALID_ACCOUNT, + ClientError::CMDERR_MAX_SERVER_USERS_EXCEEDED, + ClientError::CMDERR_MAX_CHANNEL_USERS_EXCEEDED, + ClientError::CMDERR_SERVER_BANNED, + ClientError::CMDERR_NOT_AUTHORIZED, + ClientError::CMDERR_MAX_DISKUSAGE_EXCEEDED, + ClientError::CMDERR_INCORRECT_OP_PASSWORD, + ClientError::CMDERR_AUDIOCODEC_BITRATE_LIMIT_EXCEEDED, + ClientError::CMDERR_MAX_LOGINS_PER_IPADDRESS_EXCEEDED, + ClientError::CMDERR_MAX_CHANNELS_EXCEEDED, + ClientError::CMDERR_COMMAND_FLOOD, + ClientError::CMDERR_CHANNEL_BANNED, + ClientError::CMDERR_MAX_FILETRANSFERS_EXCEEDED, + ClientError::CMDERR_NOT_LOGGEDIN, + ClientError::CMDERR_ALREADY_LOGGEDIN, + ClientError::CMDERR_NOT_IN_CHANNEL, + ClientError::CMDERR_ALREADY_IN_CHANNEL, + ClientError::CMDERR_CHANNEL_ALREADY_EXISTS, + ClientError::CMDERR_CHANNEL_NOT_FOUND, + ClientError::CMDERR_USER_NOT_FOUND, + ClientError::CMDERR_BAN_NOT_FOUND, + ClientError::CMDERR_FILETRANSFER_NOT_FOUND, + ClientError::CMDERR_OPENFILE_FAILED, + ClientError::CMDERR_ACCOUNT_NOT_FOUND, + ClientError::CMDERR_FILE_NOT_FOUND, + ClientError::CMDERR_FILE_ALREADY_EXISTS, + ClientError::CMDERR_FILESHARING_DISABLED, + ClientError::CMDERR_CHANNEL_HAS_USERS, + ClientError::CMDERR_LOGINSERVICE_UNAVAILABLE, + ClientError::CMDERR_CHANNEL_CANNOT_BE_HIDDEN, + ClientError::INTERR_SNDINPUT_FAILURE, + ClientError::INTERR_SNDOUTPUT_FAILURE, + ClientError::INTERR_AUDIOCODEC_INIT_FAILED, + ClientError::INTERR_SPEEXDSP_INIT_FAILED, + ClientError::INTERR_TTMESSAGE_QUEUE_OVERFLOW, + ClientError::INTERR_SNDEFFECT_FAILURE, + ]; + for raw in known { + let code = SdkErrorCode::from(raw as i32); + assert!( + code.is_known(), + "known FFI code {} mapped to Unknown", + raw as i32, + ); + assert_eq!(code.as_i32(), raw as i32, "round-trip mismatch"); + } +} + +#[test] +fn unknown_codes_are_preserved_via_unknown_variant() { + let code = SdkErrorCode::from(424_242); + assert_eq!(code, SdkErrorCode::Unknown(424_242)); + assert!(!code.is_known()); + assert_eq!(code.as_i32(), 424_242); + assert_eq!(code.name(), "unknown"); +} + +#[test] +fn negative_codes_become_unknown_and_round_trip() { + let code = SdkErrorCode::from(-7); + assert_eq!(code, SdkErrorCode::Unknown(-7)); + assert_eq!(code.as_i32(), -7); +} + +#[test] +fn is_command_vs_internal_error_classification() { + assert!(SdkErrorCode::NotLoggedIn.is_command_error()); + assert!(!SdkErrorCode::NotLoggedIn.is_internal_error()); + + assert!(SdkErrorCode::SoundInputFailure.is_internal_error()); + assert!(!SdkErrorCode::SoundInputFailure.is_command_error()); + + // Success is neither a command- nor internal-range error. + assert!(!SdkErrorCode::Success.is_command_error()); + assert!(!SdkErrorCode::Success.is_internal_error()); + + // Unknown codes are classified by their numeric range. + assert!(SdkErrorCode::Unknown(2099).is_command_error()); + assert!(SdkErrorCode::Unknown(10_500).is_internal_error()); + assert!(!SdkErrorCode::Unknown(42).is_command_error()); + assert!(!SdkErrorCode::Unknown(42).is_internal_error()); +} + +#[test] +fn display_is_name_and_numeric_code() { + let s = format!("{}", SdkErrorCode::NotAuthorized); + assert!(s.contains("not_authorized")); + assert!(s.contains("2006")); + + let s = format!("{}", SdkErrorCode::Unknown(9999)); + assert!(s.contains("unknown")); + assert!(s.contains("9999")); +} + +#[test] +fn from_ffi_client_error_matches_from_i32() { + let from_enum = SdkErrorCode::from(ClientError::CMDERR_NOT_AUTHORIZED); + let from_int = SdkErrorCode::from(ClientError::CMDERR_NOT_AUTHORIZED as i32); + assert_eq!(from_enum, from_int); + assert_eq!(from_enum, SdkErrorCode::NotAuthorized); +} + +#[test] +fn error_sdk_code_on_command_failed() { + let err = Error::CommandFailed { + code: ClientError::CMDERR_INVALID_ACCOUNT as i32, + message: "bad creds".into(), + }; + assert_eq!(err.sdk_code(), Some(SdkErrorCode::InvalidAccount)); +} + +#[test] +fn error_sdk_code_on_client_error() { + let err = Error::ClientError { + code: ClientError::INTERR_SNDINPUT_FAILURE as i32, + message: "no mic".into(), + }; + assert_eq!(err.sdk_code(), Some(SdkErrorCode::SoundInputFailure)); +} + +#[test] +fn error_sdk_code_on_ffi_sdk_error() { + let err = Error::Ffi(FfiError::SdkError { + code: ClientError::CMDERR_COMMAND_FLOOD as i32, + message: "slow down".into(), + }); + assert_eq!(err.sdk_code(), Some(SdkErrorCode::CommandFlood)); +} + +#[test] +fn error_sdk_code_none_for_non_sdk_errors() { + assert!(Error::Timeout.sdk_code().is_none()); + assert!(Error::InitFailed.sdk_code().is_none()); + assert!(Error::ConnectFailed.sdk_code().is_none()); + assert!(Error::AuthFailed.sdk_code().is_none()); + assert!(Error::InvalidParam.sdk_code().is_none()); + assert!(Error::MissingLoginParams.sdk_code().is_none()); + assert!(Error::MissingReconnectParams.sdk_code().is_none()); + assert!( + Error::IoError { + message: "x".into(), + } + .sdk_code() + .is_none() + ); + assert!(Error::Ffi(FfiError::NullPointer).sdk_code().is_none()); + assert!(Error::Ffi(FfiError::BoolFalse).sdk_code().is_none()); +} + +#[test] +fn unknown_sdk_code_preserves_raw_code_on_command_failed() { + let err = Error::CommandFailed { + code: 9999, + message: "future".into(), + }; + let code = err.sdk_code().expect("code"); + assert_eq!(code, SdkErrorCode::Unknown(9999)); + assert_eq!(code.as_i32(), 9999); +} + +#[test] +fn names_are_unique_across_variants() { + let codes = [ + SdkErrorCode::Success, + SdkErrorCode::SyntaxError, + SdkErrorCode::UnknownCommand, + SdkErrorCode::MissingParameter, + SdkErrorCode::IncompatibleProtocols, + SdkErrorCode::UnknownAudioCodec, + SdkErrorCode::InvalidUsername, + SdkErrorCode::IncorrectChannelPassword, + SdkErrorCode::InvalidAccount, + SdkErrorCode::MaxServerUsersExceeded, + SdkErrorCode::MaxChannelUsersExceeded, + SdkErrorCode::ServerBanned, + SdkErrorCode::NotAuthorized, + SdkErrorCode::MaxDiskUsageExceeded, + SdkErrorCode::IncorrectOpPassword, + SdkErrorCode::AudioCodecBitrateLimitExceeded, + SdkErrorCode::MaxLoginsPerIpAddressExceeded, + SdkErrorCode::MaxChannelsExceeded, + SdkErrorCode::CommandFlood, + SdkErrorCode::ChannelBanned, + SdkErrorCode::MaxFileTransfersExceeded, + SdkErrorCode::NotLoggedIn, + SdkErrorCode::AlreadyLoggedIn, + SdkErrorCode::NotInChannel, + SdkErrorCode::AlreadyInChannel, + SdkErrorCode::ChannelAlreadyExists, + SdkErrorCode::ChannelNotFound, + SdkErrorCode::UserNotFound, + SdkErrorCode::BanNotFound, + SdkErrorCode::FileTransferNotFound, + SdkErrorCode::OpenFileFailed, + SdkErrorCode::AccountNotFound, + SdkErrorCode::FileNotFound, + SdkErrorCode::FileAlreadyExists, + SdkErrorCode::FileSharingDisabled, + SdkErrorCode::ChannelHasUsers, + SdkErrorCode::LoginServiceUnavailable, + SdkErrorCode::ChannelCannotBeHidden, + SdkErrorCode::SoundInputFailure, + SdkErrorCode::SoundOutputFailure, + SdkErrorCode::AudioCodecInitFailed, + SdkErrorCode::AudioPreprocessorInitFailed, + SdkErrorCode::MessageQueueOverflow, + SdkErrorCode::SoundEffectFailure, + ]; + let mut names: Vec<_> = codes.iter().map(|c| c.name()).collect(); + names.sort_unstable(); + let len = names.len(); + names.dedup(); + assert_eq!(len, names.len(), "duplicate SdkErrorCode::name() values"); +} + +#[test] +fn round_trip_as_i32_for_every_known_variant() { + let codes = [ + SdkErrorCode::Success, + SdkErrorCode::SyntaxError, + SdkErrorCode::UnknownCommand, + SdkErrorCode::MissingParameter, + SdkErrorCode::IncompatibleProtocols, + SdkErrorCode::UnknownAudioCodec, + SdkErrorCode::InvalidUsername, + SdkErrorCode::IncorrectChannelPassword, + SdkErrorCode::InvalidAccount, + SdkErrorCode::MaxServerUsersExceeded, + SdkErrorCode::MaxChannelUsersExceeded, + SdkErrorCode::ServerBanned, + SdkErrorCode::NotAuthorized, + SdkErrorCode::MaxDiskUsageExceeded, + SdkErrorCode::IncorrectOpPassword, + SdkErrorCode::AudioCodecBitrateLimitExceeded, + SdkErrorCode::MaxLoginsPerIpAddressExceeded, + SdkErrorCode::MaxChannelsExceeded, + SdkErrorCode::CommandFlood, + SdkErrorCode::ChannelBanned, + SdkErrorCode::MaxFileTransfersExceeded, + SdkErrorCode::NotLoggedIn, + SdkErrorCode::AlreadyLoggedIn, + SdkErrorCode::NotInChannel, + SdkErrorCode::AlreadyInChannel, + SdkErrorCode::ChannelAlreadyExists, + SdkErrorCode::ChannelNotFound, + SdkErrorCode::UserNotFound, + SdkErrorCode::BanNotFound, + SdkErrorCode::FileTransferNotFound, + SdkErrorCode::OpenFileFailed, + SdkErrorCode::AccountNotFound, + SdkErrorCode::FileNotFound, + SdkErrorCode::FileAlreadyExists, + SdkErrorCode::FileSharingDisabled, + SdkErrorCode::ChannelHasUsers, + SdkErrorCode::LoginServiceUnavailable, + SdkErrorCode::ChannelCannotBeHidden, + SdkErrorCode::SoundInputFailure, + SdkErrorCode::SoundOutputFailure, + SdkErrorCode::AudioCodecInitFailed, + SdkErrorCode::AudioPreprocessorInitFailed, + SdkErrorCode::MessageQueueOverflow, + SdkErrorCode::SoundEffectFailure, + ]; + for code in codes { + assert_eq!(SdkErrorCode::from(code.as_i32()), code); + } +} From 7d1c4a98272fd9f7a52dcb8a12eff1a68f878768 Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Thu, 23 Apr 2026 19:04:40 +0000 Subject: [PATCH 2/2] feat(events): re-export SdkErrorCode from crate root Devin Review flagged that SdkErrorCode was reachable only via teamtalk::events::SdkErrorCode, while every other public type from the events module (ConnectionState, Error, Event, FfiError, Result) is already re-exported at teamtalk::*. This hop makes the new type feel like a second-class citizen for the common import pattern 'use teamtalk::SdkErrorCode;' and violates the 'Keep public API surface shallow' guideline from AGENTS.md. Add SdkErrorCode to the crate-root pub use so callers can write both teamtalk::SdkErrorCode and teamtalk::events::SdkErrorCode interchangeably. --- crates/teamtalk/src/lib.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/teamtalk/src/lib.rs b/crates/teamtalk/src/lib.rs index 9ce48d2..ad0cdeb 100644 --- a/crates/teamtalk/src/lib.rs +++ b/crates/teamtalk/src/lib.rs @@ -77,7 +77,7 @@ pub use dispatch::{ ClientConfig, ConnectParamsOwned, DispatchFlow, Dispatcher, EventContext as DispatchEventContext, ReconnectSettings, }; -pub use events::{ConnectionState, Error, Event, FfiError, Result}; +pub use events::{ConnectionState, Error, Event, FfiError, Result, SdkErrorCode}; #[cfg(feature = "mock")] pub use mock::{MockClient, MockMessage, MockUserBuilder}; #[cfg(feature = "bot-macros")]