1use std::sync::{Arc, LazyLock};
50
51use regex::Regex;
52use thiserror::Error;
53
54static API_KEY_PATTERN: LazyLock<Option<Regex>> =
60 LazyLock::new(|| Regex::new(r"\b[a-zA-Z0-9_-]{3,}\.[a-zA-Z0-9_-]{10,}\b").ok());
61
62static SENSITIVE_PATTERNS: LazyLock<Vec<(Regex, &'static str)>> = LazyLock::new(|| {
63 [
64 (r"(?i)(api[_-]?key\s*[=:]\s*)[^\s,]+", "$1[FILTERED]"),
65 (r"(?i)(password\s*[=:]\s*)[^\s,]+", "$1[FILTERED]"),
66 (r"(?i)(token\s*[=:]\s*)[^\s,]+", "$1[FILTERED]"),
67 (r"(?i)(secret\s*[=:]\s*)[^\s,]+", "$1[FILTERED]"),
68 (
69 r"(?i)(bearer\s+)[a-zA-Z0-9_-]+\.([a-zA-Z0-9_-]{10,})",
70 "$1[FILTERED]",
71 ),
72 (
73 r"(?i)authorization\s*:\s*Bearer\s+[^\s,]+",
79 "[AUTH_REDACTED]",
80 ),
81 ]
82 .into_iter()
83 .filter_map(|(pat, repl)| Regex::new(pat).ok().map(|re| (re, repl)))
84 .collect()
85});
86
87static CONTAINS_SENSITIVE_PATTERNS: LazyLock<Vec<Regex>> = LazyLock::new(|| {
88 [
89 r"(?i)api[_-]?key\s*[=:]",
90 r"(?i)password\s*[=:]",
91 r"(?i)token\s*[=:]",
92 r"(?i)secret\s*[=:]",
93 r"(?i)authorization\s*:\s*Bearer",
94 ]
95 .into_iter()
96 .filter_map(|pat| Regex::new(pat).ok())
97 .collect()
98});
99
100pub fn mask_sensitive_info(text: &str) -> String {
134 let mut result = match API_KEY_PATTERN.as_ref() {
135 Some(re) => re.replace_all(text, "[FILTERED]").into_owned(),
136 None => text.to_string(),
137 };
138
139 for (re, replacement) in SENSITIVE_PATTERNS.iter() {
140 result = re.replace_all(&result, *replacement).into_owned();
141 }
142
143 result
144}
145
146pub fn mask_api_key(text: &str) -> String {
151 match API_KEY_PATTERN.as_ref() {
152 Some(re) => re.replace_all(text, "[FILTERED]").into_owned(),
153 None => text.to_string(),
154 }
155}
156
157pub fn contains_sensitive_info(text: &str) -> bool {
159 if API_KEY_PATTERN.as_ref().is_some_and(|re| re.is_match(text)) {
160 return true;
161 }
162
163 CONTAINS_SENSITIVE_PATTERNS
164 .iter()
165 .any(|re| re.is_match(text))
166}
167
168pub fn validate_api_key(api_key: &str) -> ZaiResult<()> {
193 if api_key.is_empty() {
194 return Err(ZaiError::ApiError {
195 code: codes::SDK_VALIDATION,
196 message: "API key cannot be empty".to_string(),
197 });
198 }
199
200 let parts: Vec<&str> = api_key.split('.').collect();
201 if parts.len() != 2 {
202 return Err(ZaiError::ApiError {
203 code: codes::SDK_VALIDATION,
204 message: "API key must be in format '<id>.<secret>'".to_string(),
205 });
206 }
207
208 let (id, secret) = (parts[0], parts[1]);
209
210 if id.is_empty() || secret.is_empty() {
211 return Err(ZaiError::ApiError {
212 code: codes::SDK_VALIDATION,
213 message: "API key id and secret must not be empty".to_string(),
214 });
215 }
216
217 let valid_chars = |s: &str| -> bool {
220 s.chars()
221 .all(|c| c.is_alphanumeric() || c == '_' || c == '-')
222 };
223
224 if !valid_chars(id) || !valid_chars(secret) {
225 return Err(ZaiError::ApiError {
226 code: codes::SDK_VALIDATION,
227 message: "API key contains invalid characters".to_string(),
228 });
229 }
230
231 if id.len() < 3 {
234 return Err(ZaiError::ApiError {
235 code: codes::SDK_VALIDATION,
236 message: "API key id is too short".to_string(),
237 });
238 }
239
240 if secret.len() < 10 {
241 return Err(ZaiError::ApiError {
242 code: codes::SDK_VALIDATION,
243 message: "API key secret is too short".to_string(),
244 });
245 }
246
247 Ok(())
248}
249
250pub mod codes {
259 pub const SDK_VALIDATION: u16 = 9001;
261
262 pub const SDK_CONFIG: u16 = 9600;
264
265 pub const SDK_FILE_NOT_FOUND: u16 = 9100;
267
268 pub const SDK_FILE_TOO_LARGE: u16 = 9101;
270
271 pub const SDK_FILE_TYPE_UNSUPPORTED: u16 = 9102;
273
274 pub const SDK_IO: u16 = 9400;
276
277 pub const SDK_TIMEOUT: u16 = 9300;
279
280 pub const SDK_EXTERNAL_TOOL: u16 = 9500;
282}
283
284#[derive(Error, Debug, Clone)]
286#[non_exhaustive]
287pub enum ZaiError {
288 #[error("HTTP error [{status}]: {message}")]
290 HttpError {
291 status: u16,
293 message: String,
295 },
296
297 #[error("Authentication error [{code}]: {message}")]
299 AuthError {
300 code: u16,
302 message: String,
304 },
305
306 #[error("Account error [{code}]: {message}")]
308 AccountError {
309 code: u16,
311 message: String,
313 },
314
315 #[error("API error [{code}]: {message}")]
317 ApiError {
318 code: u16,
321 message: String,
323 },
324
325 #[error("Rate limit error [{code}]: {message}")]
327 RateLimitError {
328 code: u16,
330 message: String,
332 },
333
334 #[error("Content policy error [{code}]: {message}")]
336 ContentPolicyError {
337 code: u16,
340 message: String,
342 },
343
344 #[error("File error [{code}]: {message}")]
346 FileError {
347 code: u16,
350 message: String,
352 },
353
354 #[error("Network error: {0}")]
357 NetworkError(#[source] Arc<reqwest::Error>),
358
359 #[error("JSON error: {0}")]
362 JsonError(#[source] Arc<serde_json::Error>),
363
364 #[error("Realtime error: {0}")]
368 RealtimeError(#[source] Arc<RealtimeErrorKind>),
369
370 #[error("Realtime auth error: {0}")]
373 RealtimeAuthError(String),
374
375 #[error("Unknown error [{code}]: {message}")]
377 Unknown {
378 code: u16,
381 message: String,
383 },
384}
385
386#[derive(Debug, Clone, Copy, PartialEq, Eq)]
393#[non_exhaustive]
394pub enum ErrorCategory {
395 Client,
397 Server,
399 RateLimit,
401 Network,
403 Auth,
405 Serialization,
407 Other,
409}
410
411fn classify_status(status: u16) -> ErrorCategory {
413 match status {
414 429 => ErrorCategory::RateLimit,
415 s if (400..500).contains(&s) => ErrorCategory::Client,
416 s if (500..600).contains(&s) => ErrorCategory::Server,
417 _ => ErrorCategory::Other,
418 }
419}
420
421#[derive(Debug, thiserror::Error)]
427#[non_exhaustive]
428pub enum RealtimeErrorKind {
429 #[cfg(feature = "realtime")]
433 #[error("websocket: {source}")]
434 WebSocket {
435 #[source]
437 source: tokio_tungstenite::tungstenite::Error,
438 },
439
440 #[error("serialize: {source}")]
442 Serialize {
443 #[source]
445 source: serde_json::Error,
446 },
447
448 #[error("protocol: {0}")]
450 Protocol(String),
451
452 #[error("server error event [code={code:?}]: {message}")]
454 ServerEvent {
455 code: String,
457 message: String,
459 },
460
461 #[error("session closed")]
463 Closed,
464}
465
466impl ZaiError {
467 pub fn from_api_response(status: u16, api_code: u16, api_message: String) -> Self {
469 if api_code != 0 {
470 return match api_code {
471 1000..=1004 | 1100 => ZaiError::AuthError {
473 code: api_code,
474 message: api_message,
475 },
476 1110..=1121 => ZaiError::AccountError {
478 code: api_code,
479 message: api_message,
480 },
481 1200..=1234 => ZaiError::ApiError {
483 code: api_code,
484 message: api_message,
485 },
486 1300..=1301 => ZaiError::ContentPolicyError {
488 code: api_code,
489 message: api_message,
490 },
491 1302..=1305 | 1308..=1313 => ZaiError::RateLimitError {
493 code: api_code,
494 message: api_message,
495 },
496 1400..=1499 => ZaiError::FileError {
498 code: api_code,
499 message: api_message,
500 },
501 _ => ZaiError::Unknown {
502 code: api_code,
503 message: if api_message.is_empty() {
504 "Unknown error".to_string()
505 } else {
506 api_message
507 },
508 },
509 };
510 }
511
512 match status {
519 400 => ZaiError::HttpError {
520 status,
521 message: if api_message.is_empty() {
522 "Bad request - check your parameters".to_string()
523 } else {
524 api_message
525 },
526 },
527 401 | 403 => ZaiError::AuthError {
528 code: status,
529 message: if api_message.is_empty() {
530 "Unauthorized - check your API key".to_string()
531 } else {
532 api_message
533 },
534 },
535 404 => ZaiError::HttpError {
536 status,
537 message: "Not found - requested resource doesn't exist".to_string(),
538 },
539 429 => ZaiError::RateLimitError {
540 code: status,
541 message: if api_message.is_empty() {
542 "Too many requests - rate limit exceeded".to_string()
543 } else {
544 api_message
545 },
546 },
547 434 => ZaiError::HttpError {
548 status,
549 message: "No API permission - feature not available".to_string(),
550 },
551 435 => ZaiError::HttpError {
552 status,
553 message: "File size exceeds 100MB limit".to_string(),
554 },
555 s if (500..600).contains(&s) => ZaiError::HttpError {
559 status,
560 message: if api_message.is_empty() {
561 format!("Server error (HTTP {status}) - try again later")
562 } else {
563 api_message
564 },
565 },
566 _ => ZaiError::Unknown {
567 code: status,
568 message: if api_message.is_empty() {
569 "Unknown error".to_string()
570 } else {
571 api_message
572 },
573 },
574 }
575 }
576
577 pub fn is_rate_limit(&self) -> bool {
579 matches!(self, ZaiError::RateLimitError { .. })
580 }
581
582 pub fn is_auth_error(&self) -> bool {
584 matches!(self, ZaiError::AuthError { .. })
585 }
586
587 pub fn category(&self) -> ErrorCategory {
595 match self {
596 ZaiError::RateLimitError { .. } => ErrorCategory::RateLimit,
597 ZaiError::NetworkError(_) => ErrorCategory::Network,
598 ZaiError::AuthError { .. } | ZaiError::RealtimeAuthError(_) => ErrorCategory::Auth,
599 ZaiError::AccountError { .. }
600 | ZaiError::ApiError { .. }
601 | ZaiError::ContentPolicyError { .. }
602 | ZaiError::FileError { .. } => ErrorCategory::Client,
603 ZaiError::JsonError(_) => ErrorCategory::Serialization,
604 ZaiError::RealtimeError(kind) => match kind.as_ref() {
605 RealtimeErrorKind::Protocol(_)
608 | RealtimeErrorKind::Serialize { .. }
609 | RealtimeErrorKind::ServerEvent { .. } => ErrorCategory::Client,
610 #[cfg(feature = "realtime")]
611 RealtimeErrorKind::WebSocket { .. } => ErrorCategory::Network,
612 RealtimeErrorKind::Closed => ErrorCategory::Other,
613 },
614 ZaiError::HttpError { status, .. } => classify_status(*status),
615 ZaiError::Unknown { code, .. } => {
621 if (500..600).contains(code) {
622 ErrorCategory::Server
623 } else {
624 ErrorCategory::Other
625 }
626 },
627 }
628 }
629
630 pub fn is_client_error(&self) -> bool {
633 matches!(
634 self.category(),
635 ErrorCategory::Client | ErrorCategory::Auth | ErrorCategory::RateLimit
636 )
637 }
638
639 pub fn is_server_error(&self) -> bool {
641 matches!(self.category(), ErrorCategory::Server)
642 }
643
644 pub fn is_retryable(&self) -> bool {
653 match self {
654 ZaiError::HttpError { status, .. } => *status == 429 || (500..600).contains(status),
655 ZaiError::RateLimitError { .. } => true,
656 ZaiError::NetworkError(_) => true,
657 _ => false,
658 }
659 }
660
661 pub fn is_sdk_error(&self) -> bool {
668 self.code().is_some_and(|c| (9000..=9999).contains(&c))
669 }
670
671 pub fn compact(&self) -> String {
673 match self {
674 ZaiError::HttpError { status, message } => {
675 format!("HTTP[{status}]: {message}")
676 },
677 ZaiError::AuthError { code, message } => {
678 format!("AUTH[{code}]: {message}")
679 },
680 ZaiError::AccountError { code, message } => {
681 format!("ACCOUNT[{code}]: {message}")
682 },
683 ZaiError::ApiError { code, message } => {
684 format!("API[{code}]: {message}")
685 },
686 ZaiError::RateLimitError { code, message } => {
687 format!("RATE_LIMIT[{code}]: {message}")
688 },
689 ZaiError::ContentPolicyError { code, message } => {
690 format!("POLICY[{code}]: {message}")
691 },
692 ZaiError::FileError { code, message } => {
693 format!("FILE[{code}]: {message}")
694 },
695 ZaiError::NetworkError(err) => {
696 format!("NETWORK: {err}")
697 },
698 ZaiError::JsonError(err) => {
699 format!("JSON: {err}")
700 },
701 ZaiError::RealtimeError(kind) => {
702 format!("REALTIME: {kind}")
703 },
704 ZaiError::RealtimeAuthError(msg) => {
705 format!("REALTIME_AUTH: {msg}")
706 },
707 ZaiError::Unknown { code, message } => {
708 format!("UNKNOWN[{code}]: {message}")
709 },
710 }
711 }
712
713 pub fn code(&self) -> Option<u16> {
715 match self {
716 ZaiError::HttpError { status, .. } => Some(*status),
717 ZaiError::AuthError { code, .. } => Some(*code),
718 ZaiError::AccountError { code, .. } => Some(*code),
719 ZaiError::ApiError { code, .. } => Some(*code),
720 ZaiError::RateLimitError { code, .. } => Some(*code),
721 ZaiError::ContentPolicyError { code, .. } => Some(*code),
722 ZaiError::FileError { code, .. } => Some(*code),
723 ZaiError::NetworkError(_) => None,
724 ZaiError::JsonError(_) => None,
725 ZaiError::RealtimeError(_) | ZaiError::RealtimeAuthError(_) => None,
726 ZaiError::Unknown { code, .. } => Some(*code),
727 }
728 }
729
730 pub fn message(&self) -> String {
732 match self {
733 ZaiError::HttpError { message, .. } => message.clone(),
734 ZaiError::AuthError { message, .. } => message.clone(),
735 ZaiError::AccountError { message, .. } => message.clone(),
736 ZaiError::ApiError { message, .. } => message.clone(),
737 ZaiError::RateLimitError { message, .. } => message.clone(),
738 ZaiError::ContentPolicyError { message, .. } => message.clone(),
739 ZaiError::FileError { message, .. } => message.clone(),
740 ZaiError::NetworkError(err) => err.to_string(),
741 ZaiError::JsonError(err) => err.to_string(),
742 ZaiError::RealtimeError(kind) => kind.to_string(),
743 ZaiError::RealtimeAuthError(msg) => msg.clone(),
744 ZaiError::Unknown { message, .. } => message.clone(),
745 }
746 }
747
748 pub fn context(self, context: &str) -> Self {
772 let with_context = |message: String| format!("{context}: {message}");
773 match self {
774 Self::HttpError { status, message } => Self::HttpError {
775 status,
776 message: with_context(message),
777 },
778 Self::AuthError { code, message } => Self::AuthError {
779 code,
780 message: with_context(message),
781 },
782 Self::AccountError { code, message } => Self::AccountError {
783 code,
784 message: with_context(message),
785 },
786 Self::ApiError { code, message } => Self::ApiError {
787 code,
788 message: with_context(message),
789 },
790 Self::RateLimitError { code, message } => Self::RateLimitError {
791 code,
792 message: with_context(message),
793 },
794 Self::ContentPolicyError { code, message } => Self::ContentPolicyError {
795 code,
796 message: with_context(message),
797 },
798 Self::FileError { code, message } => Self::FileError {
799 code,
800 message: with_context(message),
801 },
802 Self::NetworkError(err) => Self::NetworkError(err),
805 Self::JsonError(err) => Self::JsonError(err),
806 Self::RealtimeError(kind) => Self::RealtimeError(kind),
807 Self::RealtimeAuthError(message) => Self::RealtimeAuthError(with_context(message)),
808 Self::Unknown { code, message } => Self::Unknown {
809 code,
810 message: with_context(message),
811 },
812 }
813 }
814}
815
816pub type ZaiResult<T> = Result<T, ZaiError>;
818
819impl From<reqwest::Error> for ZaiError {
821 fn from(err: reqwest::Error) -> Self {
822 if let Some(status) = err.status() {
823 ZaiError::from_api_response(status.as_u16(), 0, err.to_string())
824 } else {
825 ZaiError::NetworkError(Arc::new(err))
826 }
827 }
828}
829
830impl From<serde_json::Error> for ZaiError {
832 fn from(err: serde_json::Error) -> Self {
833 ZaiError::JsonError(Arc::new(err))
834 }
835}
836
837impl From<validator::ValidationErrors> for ZaiError {
839 fn from(err: validator::ValidationErrors) -> Self {
840 ZaiError::ApiError {
841 code: codes::SDK_VALIDATION,
842 message: format!("Validation error: {err:?}"),
843 }
844 }
845}
846
847impl From<std::io::Error> for ZaiError {
855 fn from(err: std::io::Error) -> Self {
856 use std::io::ErrorKind;
857 match err.kind() {
858 ErrorKind::NotFound => ZaiError::FileError {
859 code: codes::SDK_FILE_NOT_FOUND,
860 message: err.to_string(),
861 },
862 ErrorKind::PermissionDenied => ZaiError::FileError {
863 code: codes::SDK_IO,
864 message: err.to_string(),
865 },
866 ErrorKind::TimedOut => ZaiError::ApiError {
867 code: codes::SDK_TIMEOUT,
868 message: err.to_string(),
869 },
870 _ => ZaiError::Unknown {
871 code: codes::SDK_IO,
872 message: err.to_string(),
873 },
874 }
875 }
876}
877
878impl From<RealtimeErrorKind> for ZaiError {
880 fn from(kind: RealtimeErrorKind) -> Self {
881 ZaiError::RealtimeError(Arc::new(kind))
882 }
883}
884
885#[cfg(feature = "realtime")]
890impl From<tokio_tungstenite::tungstenite::Error> for ZaiError {
891 fn from(err: tokio_tungstenite::tungstenite::Error) -> Self {
892 ZaiError::RealtimeError(Arc::new(RealtimeErrorKind::WebSocket { source: err }))
893 }
894}
895
896#[cfg(test)]
897mod tests {
898 use super::*;
899 use std::io::{Error, ErrorKind};
903
904 #[test]
905 fn test_from_api_response_bad_request() {
906 let err = ZaiError::from_api_response(400, 0, "Invalid input".to_string());
907 assert!(err.is_client_error());
908 assert!(!err.is_server_error());
909 assert_eq!(err.code(), Some(400));
910 }
911
912 #[test]
913 fn test_from_api_response_unauthorized() {
914 let err = ZaiError::from_api_response(401, 0, "".to_string());
915 assert!(err.is_client_error());
916 assert_eq!(err.message(), "Unauthorized - check your API key");
917 }
918
919 #[test]
920 fn test_from_api_response_rate_limit() {
921 let err = ZaiError::from_api_response(429, 1302, "Too many requests".to_string());
923 assert!(err.is_client_error());
924 assert!(err.is_rate_limit());
925 assert_eq!(err.code(), Some(1302));
926
927 let err = ZaiError::from_api_response(200, 1302, "Too many requests".to_string());
930 assert!(err.is_client_error());
931 assert!(err.is_rate_limit());
932 assert_eq!(err.code(), Some(1302));
933 }
934
935 #[test]
936 fn test_from_api_response_package_limit_codes() {
937 for code in [1302, 1303, 1304, 1305, 1308, 1309, 1310, 1311, 1312, 1313] {
938 let err = ZaiError::from_api_response(429, code, "Limited".to_string());
939 assert!(err.is_rate_limit());
940 assert_eq!(err.code(), Some(code));
941 }
942 }
943
944 #[test]
945 fn test_from_api_response_content_policy_codes() {
946 for code in [1300, 1301] {
947 let err = ZaiError::from_api_response(400, code, "Blocked".to_string());
948 assert!(matches!(err, ZaiError::ContentPolicyError { .. }));
949 assert!(err.is_client_error());
950 assert!(!err.is_rate_limit());
951 assert_eq!(err.code(), Some(code));
952 }
953 }
954
955 #[test]
956 fn test_from_api_response_server_error() {
957 let err = ZaiError::from_api_response(500, 0, "".to_string());
958 assert!(!err.is_client_error());
959 assert!(err.is_server_error());
960 }
961
962 #[test]
963 fn test_from_api_response_auth_error_code() {
964 let err = ZaiError::from_api_response(200, 1001, "Invalid API key".to_string());
965 assert!(err.is_auth_error());
966 assert_eq!(err.code(), Some(1001));
967 assert_eq!(err.message(), "Invalid API key");
968 }
969
970 #[test]
971 fn test_from_api_response_account_error() {
972 let err = ZaiError::from_api_response(200, 1110, "Account expired".to_string());
973 assert!(err.is_client_error());
974 assert_eq!(err.code(), Some(1110));
975 }
976
977 #[test]
978 fn test_from_api_response_api_error() {
979 let err = ZaiError::from_api_response(200, 1200, "Invalid parameters".to_string());
980 assert!(err.is_client_error());
981 assert_eq!(err.code(), Some(1200));
982 }
983
984 #[test]
985 fn test_from_api_response_unknown_code() {
986 let err = ZaiError::from_api_response(200, 9999, "Unknown error".to_string());
987 assert!(!err.is_client_error()); assert_eq!(err.code(), Some(9999));
989 }
990
991 #[test]
992 fn test_compact() {
993 let err = ZaiError::HttpError {
994 status: 404,
995 message: "Not found".to_string(),
996 };
997 assert_eq!(err.compact(), "HTTP[404]: Not found");
998
999 let err = ZaiError::AuthError {
1000 code: 1001,
1001 message: "Invalid key".to_string(),
1002 };
1003 assert_eq!(err.compact(), "AUTH[1001]: Invalid key");
1004 }
1005
1006 #[test]
1007 fn test_code() {
1008 let io_err = Error::new(ErrorKind::ConnectionRefused, "connection refused");
1012 let err = ZaiError::from(io_err);
1013 assert_eq!(err.code(), Some(codes::SDK_IO));
1014
1015 let err = ZaiError::JsonError(Arc::new(serde_json::Error::io(Error::new(
1017 ErrorKind::InvalidData,
1018 "invalid JSON",
1019 ))));
1020 assert!(err.code().is_none());
1021
1022 let err = ZaiError::HttpError {
1024 status: 500,
1025 message: "Server error".to_string(),
1026 };
1027 assert_eq!(err.code(), Some(500));
1028 }
1029
1030 #[test]
1031 fn test_message() {
1032 let err = ZaiError::RateLimitError {
1033 code: 1302,
1034 message: "Too many requests".to_string(),
1035 };
1036 assert_eq!(err.message(), "Too many requests");
1037 }
1038
1039 #[test]
1040 fn test_from_reqwest_error_with_status() {
1041 let io_err = Error::other("test error");
1042 let zai_err = ZaiError::from(io_err);
1043 match zai_err {
1044 ZaiError::Unknown { .. } => {},
1045 _ => panic!("Expected Unknown error for io::Error"),
1046 }
1047 }
1048
1049 #[test]
1050 fn test_sdk_code_constants_in_reserved_range() {
1051 for code in [
1052 codes::SDK_VALIDATION,
1053 codes::SDK_CONFIG,
1054 codes::SDK_FILE_NOT_FOUND,
1055 codes::SDK_FILE_TOO_LARGE,
1056 codes::SDK_FILE_TYPE_UNSUPPORTED,
1057 codes::SDK_IO,
1058 codes::SDK_TIMEOUT,
1059 codes::SDK_EXTERNAL_TOOL,
1060 ] {
1061 assert!(
1062 (9000..=9999).contains(&code),
1063 "code {code} outside 9000-9999"
1064 );
1065 }
1066 }
1067
1068 #[test]
1069 fn test_is_sdk_error_classification() {
1070 assert!(
1072 ZaiError::FileError {
1073 code: codes::SDK_FILE_NOT_FOUND,
1074 message: "x".into(),
1075 }
1076 .is_sdk_error()
1077 );
1078 assert!(
1079 ZaiError::ApiError {
1080 code: codes::SDK_TIMEOUT,
1081 message: "x".into(),
1082 }
1083 .is_sdk_error()
1084 );
1085
1086 assert!(
1088 !ZaiError::AuthError {
1089 code: 1001,
1090 message: "x".into(),
1091 }
1092 .is_sdk_error()
1093 );
1094 assert!(
1095 !ZaiError::RateLimitError {
1096 code: 1302,
1097 message: "x".into(),
1098 }
1099 .is_sdk_error()
1100 );
1101 assert!(
1102 !ZaiError::HttpError {
1103 status: 500,
1104 message: "x".into(),
1105 }
1106 .is_sdk_error()
1107 );
1108
1109 assert!(!ZaiError::RealtimeAuthError("x".into()).is_sdk_error());
1111 }
1112
1113 #[test]
1114 fn test_from_io_maps_by_kind() {
1115 use std::io::{Error, ErrorKind};
1116
1117 let err = ZaiError::from(Error::from(ErrorKind::NotFound));
1118 assert!(matches!(
1119 err,
1120 ZaiError::FileError { code, .. } if code == codes::SDK_FILE_NOT_FOUND
1121 ));
1122
1123 let err = ZaiError::from(Error::from(ErrorKind::TimedOut));
1124 assert!(matches!(
1125 err,
1126 ZaiError::ApiError { code, .. } if code == codes::SDK_TIMEOUT
1127 ));
1128
1129 let err = ZaiError::from(Error::from(ErrorKind::PermissionDenied));
1130 assert!(matches!(
1131 err,
1132 ZaiError::FileError { code, .. } if code == codes::SDK_IO
1133 ));
1134
1135 let err = ZaiError::from(Error::other("boom"));
1137 assert!(matches!(
1138 err,
1139 ZaiError::Unknown { code, .. } if code == codes::SDK_IO
1140 ));
1141 }
1142
1143 #[test]
1144 fn test_context_preserves_code_and_variant() {
1145 let err = ZaiError::ApiError {
1146 code: 1200,
1147 message: "bad model".into(),
1148 }
1149 .context("file parser create");
1150 assert!(matches!(
1151 err,
1152 ZaiError::ApiError { code, .. } if code == 1200
1153 ));
1154 assert_eq!(err.message(), "file parser create: bad model");
1155
1156 let err = ZaiError::Unknown {
1157 code: codes::SDK_IO,
1158 message: "boom".into(),
1159 }
1160 .context("read");
1161 assert_eq!(err.code(), Some(codes::SDK_IO));
1162 assert_eq!(err.message(), "read: boom");
1163 }
1164
1165 #[test]
1166 fn test_sdk_timeout_is_not_rate_limit() {
1167 let err = ZaiError::ApiError {
1171 code: codes::SDK_TIMEOUT,
1172 message: "Timeout waiting for parsing result".into(),
1173 };
1174 assert!(!err.is_rate_limit());
1175 assert!(err.is_sdk_error());
1176 }
1177
1178 #[test]
1179 fn test_validate_api_key_valid() {
1180 assert!(validate_api_key("abc123.abcdefghijklmnopqrstuvwxyz").is_ok());
1181 }
1186
1187 #[test]
1188 fn test_validate_api_key_empty() {
1189 let result = validate_api_key("");
1190 assert!(result.is_err());
1191 match result {
1192 Err(ZaiError::ApiError { code, .. }) => {
1193 assert_eq!(code, codes::SDK_VALIDATION);
1194 },
1195 _ => panic!("Expected ApiError"),
1196 }
1197 }
1198
1199 #[test]
1200 fn test_validate_api_key_no_dot() {
1201 let result = validate_api_key("invalid");
1202 assert!(result.is_err());
1203 match result {
1204 Err(ZaiError::ApiError { code, message }) => {
1205 assert_eq!(code, codes::SDK_VALIDATION);
1206 assert!(message.contains("format"));
1207 },
1208 _ => panic!("Expected ApiError"),
1209 }
1210 }
1211
1212 #[test]
1213 fn test_validate_api_key_multiple_dots() {
1214 let result = validate_api_key("id.secret.extra");
1215 assert!(result.is_err());
1216 assert_eq!(result.unwrap_err().code(), Some(codes::SDK_VALIDATION));
1217 }
1218
1219 #[test]
1220 fn test_validate_api_key_empty_id() {
1221 let result = validate_api_key(".secret123456789");
1222 assert!(result.is_err());
1223 assert_eq!(result.unwrap_err().code(), Some(codes::SDK_VALIDATION));
1224 }
1225
1226 #[test]
1227 fn test_validate_api_key_empty_secret() {
1228 let result = validate_api_key("id123.");
1229 assert!(result.is_err());
1230 assert_eq!(result.unwrap_err().code(), Some(codes::SDK_VALIDATION));
1231 }
1232
1233 #[test]
1234 fn test_validate_api_key_invalid_chars() {
1235 let result = validate_api_key("id$123.secret@456");
1236 assert!(result.is_err());
1237 assert_eq!(result.unwrap_err().code(), Some(codes::SDK_VALIDATION));
1238 }
1239
1240 #[test]
1241 fn test_validate_api_key_id_too_short() {
1242 let result = validate_api_key("ab.abcdefghijklmn");
1243 assert!(result.is_err());
1244 assert!(result.unwrap_err().message().contains("id is too short"));
1245 }
1246
1247 #[test]
1248 fn test_validate_api_key_secret_too_short() {
1249 let result = validate_api_key("id123.short");
1250 assert!(result.is_err());
1251 assert!(
1252 result
1253 .unwrap_err()
1254 .message()
1255 .contains("secret is too short")
1256 );
1257 }
1258
1259 #[test]
1260 fn test_mask_sensitive_info_api_key() {
1261 let text = "API key: abc123.abcdefghijklmnopqrstuvwxyz12345";
1262 let filtered = mask_sensitive_info(text);
1263 assert!(filtered.contains("[FILTERED]"));
1264 assert!(!filtered.contains("abc123"));
1265 assert!(!filtered.contains("abcdefghijklmnopqrstuvwxyz"));
1266 }
1267
1268 #[test]
1269 fn test_mask_sensitive_info_password() {
1270 let text = "password: secret123, other text";
1271 let filtered = mask_sensitive_info(text);
1272 assert!(filtered.contains("[FILTERED]"));
1273 assert!(!filtered.contains("secret123"));
1274 }
1275
1276 #[test]
1277 fn test_mask_sensitive_info_token() {
1278 let text = "token=abc123xyz, other content";
1279 let filtered = mask_sensitive_info(text);
1280 assert!(filtered.contains("[FILTERED]"));
1281 assert!(!filtered.contains("abc123xyz"));
1282 }
1283
1284 #[test]
1285 fn test_mask_sensitive_info_bearer() {
1286 let text = "Authorization: Bearer abc123.abc1234567890";
1287 let filtered = mask_sensitive_info(text);
1288 assert!(filtered.contains("[AUTH_REDACTED]"));
1291 assert!(!filtered.contains("abc123"));
1292 assert!(!filtered.contains("Bearer"));
1293 assert!(!filtered.contains("Authorization"));
1294 }
1295
1296 #[test]
1297 fn test_mask_sensitive_info_multiple() {
1298 let text = "api_key=abc123.xyz456, password=secret123";
1299 let filtered = mask_sensitive_info(text);
1300 let filtered_count = filtered.matches("[FILTERED]").count();
1301 assert_eq!(filtered_count, 2);
1302 }
1303
1304 #[test]
1305 fn test_mask_sensitive_info_no_sensitive() {
1306 let text = "Regular text without sensitive information";
1307 let filtered = mask_sensitive_info(text);
1308 assert_eq!(filtered, text);
1309 }
1310
1311 #[test]
1312 fn test_mask_api_key() {
1313 let text = "API key: abc123.abcdefghijklmnopqrstuvwxyz12345";
1314 let filtered = mask_api_key(text);
1315 assert!(filtered.contains("[FILTERED]"));
1316 assert!(!filtered.contains("abc123"));
1317 }
1318
1319 #[test]
1320 fn test_contains_sensitive_info_api_key() {
1321 assert!(contains_sensitive_info("api_key: abc123.abc1234567890"));
1322 assert!(!contains_sensitive_info("regular text"));
1323 }
1324
1325 #[test]
1326 fn test_contains_sensitive_info_password() {
1327 assert!(contains_sensitive_info("password: secret"));
1328 assert!(contains_sensitive_info("password=123"));
1329 assert!(!contains_sensitive_info("password"));
1330 assert!(!contains_sensitive_info("word:password"));
1331 }
1332
1333 #[test]
1334 fn test_contains_sensitive_info_token() {
1335 assert!(contains_sensitive_info("token=abc123"));
1336 assert!(contains_sensitive_info("token: xyz123"));
1337 assert!(!contains_sensitive_info("token"));
1338 assert!(!contains_sensitive_info("tokenize this"));
1339 }
1340
1341 #[test]
1342 fn test_error_category_classification() {
1343 let rl = ZaiError::RateLimitError {
1348 code: 1302,
1349 message: "slow down".into(),
1350 };
1351 assert_eq!(rl.category(), ErrorCategory::RateLimit);
1352 assert!(rl.is_retryable());
1353 assert!(rl.is_client_error());
1354 assert!(!rl.is_server_error());
1355
1356 let h429 = ZaiError::HttpError {
1358 status: 429,
1359 message: "too many".into(),
1360 };
1361 assert_eq!(h429.category(), ErrorCategory::RateLimit);
1362 assert!(h429.is_retryable());
1363 assert!(h429.is_client_error());
1364
1365 let h500 = ZaiError::HttpError {
1367 status: 500,
1368 message: "boom".into(),
1369 };
1370 assert_eq!(h500.category(), ErrorCategory::Server);
1371 assert!(h500.is_retryable());
1372 assert!(h500.is_server_error());
1373 assert!(!h500.is_client_error());
1374
1375 let h400 = ZaiError::HttpError {
1377 status: 400,
1378 message: "bad".into(),
1379 };
1380 assert_eq!(h400.category(), ErrorCategory::Client);
1381 assert!(!h400.is_retryable());
1382 assert!(h400.is_client_error());
1383
1384 let auth = ZaiError::AuthError {
1386 code: 1001,
1387 message: "bad key".into(),
1388 };
1389 assert_eq!(auth.category(), ErrorCategory::Auth);
1390 assert!(!auth.is_retryable());
1391 assert!(auth.is_client_error());
1392
1393 let unk = ZaiError::Unknown {
1397 code: 503,
1398 message: "?".into(),
1399 };
1400 assert_eq!(unk.category(), ErrorCategory::Server);
1401 assert!(unk.is_server_error());
1402 assert!(!unk.is_retryable());
1403 }
1404
1405 #[test]
1406 fn test_business_code_band_boundaries() {
1407 for code in [1306, 1307] {
1411 let e = ZaiError::from_api_response(400, code, "gap".to_string());
1412 assert!(
1413 matches!(e, ZaiError::Unknown { .. }),
1414 "code {code} -> Unknown"
1415 );
1416 assert!(!e.is_rate_limit());
1417 }
1418 let e = ZaiError::from_api_response(400, 1499, "file".to_string());
1420 assert!(matches!(e, ZaiError::FileError { code, .. } if code == 1499));
1421 let e = ZaiError::from_api_response(400, 1400, "file".to_string());
1423 assert!(matches!(e, ZaiError::FileError { code, .. } if code == 1400));
1424 for code in [1302, 1305, 1308, 1313] {
1426 let e = ZaiError::from_api_response(429, code, "rl".to_string());
1427 assert!(e.is_rate_limit(), "code {code} -> RateLimitError");
1428 }
1429 }
1430
1431 #[test]
1434 fn status_502_503_504_classify_as_server_and_carry_status() {
1435 for status in [502, 503, 504] {
1438 let e = ZaiError::from_api_response(status, 0, String::new());
1439 match &e {
1440 ZaiError::HttpError {
1441 status: s,
1442 message: _,
1443 } => {
1444 assert_eq!(*s, status, "HTTP {status} lost its status code");
1445 assert!(e.is_server_error(), "HTTP {status} not classified Server");
1446 assert!(
1447 e.is_retryable(),
1448 "HTTP {status} should be retryable as a 5xx"
1449 );
1450 },
1451 other => panic!("HTTP {status} classified as {other:?}, expected HttpError"),
1452 }
1453 }
1454 let e = ZaiError::from_api_response(500, 0, String::new());
1456 assert!(matches!(e, ZaiError::HttpError { status: 500, .. }));
1457 assert!(e.is_server_error());
1458 }
1459
1460 #[test]
1461 fn status_401_403_classify_as_auth() {
1462 for status in [401, 403] {
1463 let e = ZaiError::from_api_response(status, 0, String::new());
1464 assert!(
1465 e.is_auth_error(),
1466 "HTTP {status} should classify as auth, got {e:?}"
1467 );
1468 assert!(
1469 e.is_client_error(),
1470 "HTTP {status} should be a client error"
1471 );
1472 assert!(
1473 !e.is_retryable(),
1474 "HTTP {status} (auth) should not be retryable"
1475 );
1476 }
1477 }
1478
1479 #[test]
1480 fn status_429_classifies_as_rate_limit() {
1481 let e = ZaiError::from_api_response(429, 0, String::new());
1482 assert!(
1483 e.is_rate_limit(),
1484 "HTTP 429 should classify as rate limit, got {e:?}"
1485 );
1486 assert!(e.is_retryable(), "HTTP 429 should be retryable");
1487 }
1488}