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,]+",
76 "[AUTH_REDACTED]",
77 ),
78 ]
79 .into_iter()
80 .filter_map(|(pat, repl)| Regex::new(pat).ok().map(|re| (re, repl)))
81 .collect()
82});
83
84static CONTAINS_SENSITIVE_PATTERNS: LazyLock<Vec<Regex>> = LazyLock::new(|| {
85 [
86 r"(?i)api[_-]?key\s*[=:]",
87 r"(?i)password\s*[=:]",
88 r"(?i)token\s*[=:]",
89 r"(?i)secret\s*[=:]",
90 r"(?i)authorization\s*:\s*Bearer",
91 ]
92 .into_iter()
93 .filter_map(|pat| Regex::new(pat).ok())
94 .collect()
95});
96
97pub fn mask_sensitive_info(text: &str) -> String {
132 let mut result = match API_KEY_PATTERN.as_ref() {
133 Some(re) => re.replace_all(text, "[FILTERED]").into_owned(),
134 None => text.to_string(),
135 };
136
137 for (re, replacement) in SENSITIVE_PATTERNS.iter() {
138 result = re.replace_all(&result, *replacement).into_owned();
139 }
140
141 result
142}
143
144pub fn mask_api_key(text: &str) -> String {
149 match API_KEY_PATTERN.as_ref() {
150 Some(re) => re.replace_all(text, "[FILTERED]").into_owned(),
151 None => text.to_string(),
152 }
153}
154
155pub fn contains_sensitive_info(text: &str) -> bool {
157 if API_KEY_PATTERN.as_ref().is_some_and(|re| re.is_match(text)) {
158 return true;
159 }
160
161 CONTAINS_SENSITIVE_PATTERNS
162 .iter()
163 .any(|re| re.is_match(text))
164}
165
166pub fn validate_api_key(api_key: &str) -> ZaiResult<()> {
191 if api_key.is_empty() {
192 return Err(ZaiError::ApiError {
193 code: codes::SDK_VALIDATION,
194 message: "API key cannot be empty".to_string(),
195 });
196 }
197
198 let parts: Vec<&str> = api_key.split('.').collect();
199 if parts.len() != 2 {
200 return Err(ZaiError::ApiError {
201 code: codes::SDK_VALIDATION,
202 message: "API key must be in format '<id>.<secret>'".to_string(),
203 });
204 }
205
206 let (id, secret) = (parts[0], parts[1]);
207
208 if id.is_empty() || secret.is_empty() {
209 return Err(ZaiError::ApiError {
210 code: codes::SDK_VALIDATION,
211 message: "API key id and secret must not be empty".to_string(),
212 });
213 }
214
215 let valid_chars = |s: &str| -> bool {
218 s.chars()
219 .all(|c| c.is_alphanumeric() || c == '_' || c == '-')
220 };
221
222 if !valid_chars(id) || !valid_chars(secret) {
223 return Err(ZaiError::ApiError {
224 code: codes::SDK_VALIDATION,
225 message: "API key contains invalid characters".to_string(),
226 });
227 }
228
229 if id.len() < 3 {
232 return Err(ZaiError::ApiError {
233 code: codes::SDK_VALIDATION,
234 message: "API key id is too short".to_string(),
235 });
236 }
237
238 if secret.len() < 10 {
239 return Err(ZaiError::ApiError {
240 code: codes::SDK_VALIDATION,
241 message: "API key secret is too short".to_string(),
242 });
243 }
244
245 Ok(())
246}
247
248pub mod codes {
257 pub const SDK_VALIDATION: u16 = 9001;
259
260 pub const SDK_CONFIG: u16 = 9600;
262
263 pub const SDK_FILE_NOT_FOUND: u16 = 9100;
265
266 pub const SDK_FILE_TOO_LARGE: u16 = 9101;
268
269 pub const SDK_FILE_TYPE_UNSUPPORTED: u16 = 9102;
271
272 pub const SDK_IO: u16 = 9400;
274
275 pub const SDK_TIMEOUT: u16 = 9300;
277
278 pub const SDK_EXTERNAL_TOOL: u16 = 9500;
280}
281
282#[derive(Error, Debug, Clone)]
284#[non_exhaustive]
285pub enum ZaiError {
286 #[error("HTTP error [{status}]: {message}")]
288 HttpError {
289 status: u16,
291 message: String,
293 },
294
295 #[error("Authentication error [{code}]: {message}")]
297 AuthError {
298 code: u16,
300 message: String,
302 },
303
304 #[error("Account error [{code}]: {message}")]
306 AccountError {
307 code: u16,
309 message: String,
311 },
312
313 #[error("API error [{code}]: {message}")]
315 ApiError {
316 code: u16,
319 message: String,
321 },
322
323 #[error("Rate limit error [{code}]: {message}")]
325 RateLimitError {
326 code: u16,
328 message: String,
330 },
331
332 #[error("Content policy error [{code}]: {message}")]
334 ContentPolicyError {
335 code: u16,
338 message: String,
340 },
341
342 #[error("File error [{code}]: {message}")]
344 FileError {
345 code: u16,
348 message: String,
350 },
351
352 #[error("Network error: {0}")]
355 NetworkError(#[source] Arc<reqwest::Error>),
356
357 #[error("JSON error: {0}")]
360 JsonError(#[source] Arc<serde_json::Error>),
361
362 #[error("Realtime error: {0}")]
366 RealtimeError(#[source] Arc<RealtimeErrorKind>),
367
368 #[error("Realtime auth error: {0}")]
371 RealtimeAuthError(String),
372
373 #[error("Unknown error [{code}]: {message}")]
375 Unknown {
376 code: u16,
379 message: String,
381 },
382}
383
384#[derive(Debug, Clone, Copy, PartialEq, Eq)]
391#[non_exhaustive]
392pub enum ErrorCategory {
393 Client,
395 Server,
397 RateLimit,
399 Network,
401 Auth,
403 Serialization,
405 Other,
407}
408
409fn classify_status(status: u16) -> ErrorCategory {
411 match status {
412 429 => ErrorCategory::RateLimit,
413 s if (400..500).contains(&s) => ErrorCategory::Client,
414 s if (500..600).contains(&s) => ErrorCategory::Server,
415 _ => ErrorCategory::Other,
416 }
417}
418
419#[derive(Debug, thiserror::Error)]
425#[non_exhaustive]
426pub enum RealtimeErrorKind {
427 #[cfg(feature = "realtime")]
431 #[error("websocket: {source}")]
432 WebSocket {
433 #[source]
435 source: tokio_tungstenite::tungstenite::Error,
436 },
437
438 #[error("serialize: {source}")]
440 Serialize {
441 #[source]
443 source: serde_json::Error,
444 },
445
446 #[error("protocol: {0}")]
448 Protocol(String),
449
450 #[error("server error event [code={code:?}]: {message}")]
452 ServerEvent {
453 code: String,
455 message: String,
457 },
458
459 #[error("session closed")]
461 Closed,
462}
463
464impl ZaiError {
465 pub fn from_api_response(status: u16, api_code: u16, api_message: String) -> Self {
467 if api_code != 0 {
468 return match api_code {
469 1000..=1004 | 1100 => ZaiError::AuthError {
471 code: api_code,
472 message: api_message,
473 },
474 1110..=1121 => ZaiError::AccountError {
476 code: api_code,
477 message: api_message,
478 },
479 1200..=1234 => ZaiError::ApiError {
481 code: api_code,
482 message: api_message,
483 },
484 1300..=1301 => ZaiError::ContentPolicyError {
486 code: api_code,
487 message: api_message,
488 },
489 1302..=1305 | 1308..=1313 => ZaiError::RateLimitError {
491 code: api_code,
492 message: api_message,
493 },
494 1400..=1499 => ZaiError::FileError {
496 code: api_code,
497 message: api_message,
498 },
499 _ => ZaiError::Unknown {
500 code: api_code,
501 message: if api_message.is_empty() {
502 "Unknown error".to_string()
503 } else {
504 api_message
505 },
506 },
507 };
508 }
509
510 match status {
514 400 => ZaiError::HttpError {
515 status,
516 message: if api_message.is_empty() {
517 "Bad request - check your parameters".to_string()
518 } else {
519 api_message
520 },
521 },
522 401 | 403 => ZaiError::AuthError {
523 code: status,
524 message: if api_message.is_empty() {
525 "Unauthorized - check your API key".to_string()
526 } else {
527 api_message
528 },
529 },
530 404 => ZaiError::HttpError {
531 status,
532 message: "Not found - requested resource doesn't exist".to_string(),
533 },
534 429 => ZaiError::RateLimitError {
535 code: status,
536 message: if api_message.is_empty() {
537 "Too many requests - rate limit exceeded".to_string()
538 } else {
539 api_message
540 },
541 },
542 434 => ZaiError::HttpError {
543 status,
544 message: "No API permission - feature not available".to_string(),
545 },
546 435 => ZaiError::HttpError {
547 status,
548 message: "File size exceeds 100MB limit".to_string(),
549 },
550 s if (500..600).contains(&s) => ZaiError::HttpError {
552 status,
553 message: if api_message.is_empty() {
554 format!("Server error (HTTP {status}) - try again later")
555 } else {
556 api_message
557 },
558 },
559 _ => ZaiError::Unknown {
560 code: status,
561 message: if api_message.is_empty() {
562 "Unknown error".to_string()
563 } else {
564 api_message
565 },
566 },
567 }
568 }
569
570 pub fn is_rate_limit(&self) -> bool {
572 matches!(self, ZaiError::RateLimitError { .. })
573 }
574
575 pub fn is_auth_error(&self) -> bool {
577 matches!(self, ZaiError::AuthError { .. })
578 }
579
580 pub fn category(&self) -> ErrorCategory {
588 match self {
589 ZaiError::RateLimitError { .. } => ErrorCategory::RateLimit,
590 ZaiError::NetworkError(_) => ErrorCategory::Network,
591 ZaiError::AuthError { .. } | ZaiError::RealtimeAuthError(_) => ErrorCategory::Auth,
592 ZaiError::AccountError { .. }
593 | ZaiError::ApiError { .. }
594 | ZaiError::ContentPolicyError { .. }
595 | ZaiError::FileError { .. } => ErrorCategory::Client,
596 ZaiError::JsonError(_) => ErrorCategory::Serialization,
597 ZaiError::RealtimeError(kind) => match kind.as_ref() {
598 RealtimeErrorKind::Protocol(_)
601 | RealtimeErrorKind::Serialize { .. }
602 | RealtimeErrorKind::ServerEvent { .. } => ErrorCategory::Client,
603 #[cfg(feature = "realtime")]
604 RealtimeErrorKind::WebSocket { .. } => ErrorCategory::Network,
605 RealtimeErrorKind::Closed => ErrorCategory::Other,
606 },
607 ZaiError::HttpError { status, .. } => classify_status(*status),
608 ZaiError::Unknown { code, .. } => {
614 if (500..600).contains(code) {
615 ErrorCategory::Server
616 } else {
617 ErrorCategory::Other
618 }
619 },
620 }
621 }
622
623 pub fn is_client_error(&self) -> bool {
626 matches!(
627 self.category(),
628 ErrorCategory::Client | ErrorCategory::Auth | ErrorCategory::RateLimit
629 )
630 }
631
632 pub fn is_server_error(&self) -> bool {
634 matches!(self.category(), ErrorCategory::Server)
635 }
636
637 pub fn is_retryable(&self) -> bool {
644 match self {
645 ZaiError::HttpError { status, .. } => *status == 429 || (500..600).contains(status),
646 ZaiError::RateLimitError { .. } => true,
647 ZaiError::NetworkError(_) => true,
648 _ => false,
649 }
650 }
651
652 pub fn is_sdk_error(&self) -> bool {
659 self.code().is_some_and(|c| (9000..=9999).contains(&c))
660 }
661
662 pub fn compact(&self) -> String {
664 match self {
665 ZaiError::HttpError { status, message } => {
666 format!("HTTP[{status}]: {message}")
667 },
668 ZaiError::AuthError { code, message } => {
669 format!("AUTH[{code}]: {message}")
670 },
671 ZaiError::AccountError { code, message } => {
672 format!("ACCOUNT[{code}]: {message}")
673 },
674 ZaiError::ApiError { code, message } => {
675 format!("API[{code}]: {message}")
676 },
677 ZaiError::RateLimitError { code, message } => {
678 format!("RATE_LIMIT[{code}]: {message}")
679 },
680 ZaiError::ContentPolicyError { code, message } => {
681 format!("POLICY[{code}]: {message}")
682 },
683 ZaiError::FileError { code, message } => {
684 format!("FILE[{code}]: {message}")
685 },
686 ZaiError::NetworkError(err) => {
687 format!("NETWORK: {err}")
688 },
689 ZaiError::JsonError(err) => {
690 format!("JSON: {err}")
691 },
692 ZaiError::RealtimeError(kind) => {
693 format!("REALTIME: {kind}")
694 },
695 ZaiError::RealtimeAuthError(msg) => {
696 format!("REALTIME_AUTH: {msg}")
697 },
698 ZaiError::Unknown { code, message } => {
699 format!("UNKNOWN[{code}]: {message}")
700 },
701 }
702 }
703
704 pub fn code(&self) -> Option<u16> {
706 match self {
707 ZaiError::HttpError { status, .. } => Some(*status),
708 ZaiError::AuthError { code, .. } => Some(*code),
709 ZaiError::AccountError { code, .. } => Some(*code),
710 ZaiError::ApiError { code, .. } => Some(*code),
711 ZaiError::RateLimitError { code, .. } => Some(*code),
712 ZaiError::ContentPolicyError { code, .. } => Some(*code),
713 ZaiError::FileError { code, .. } => Some(*code),
714 ZaiError::NetworkError(_) => None,
715 ZaiError::JsonError(_) => None,
716 ZaiError::RealtimeError(_) | ZaiError::RealtimeAuthError(_) => None,
717 ZaiError::Unknown { code, .. } => Some(*code),
718 }
719 }
720
721 pub fn message(&self) -> String {
723 match self {
724 ZaiError::HttpError { message, .. } => message.clone(),
725 ZaiError::AuthError { message, .. } => message.clone(),
726 ZaiError::AccountError { message, .. } => message.clone(),
727 ZaiError::ApiError { message, .. } => message.clone(),
728 ZaiError::RateLimitError { message, .. } => message.clone(),
729 ZaiError::ContentPolicyError { message, .. } => message.clone(),
730 ZaiError::FileError { message, .. } => message.clone(),
731 ZaiError::NetworkError(err) => err.to_string(),
732 ZaiError::JsonError(err) => err.to_string(),
733 ZaiError::RealtimeError(kind) => kind.to_string(),
734 ZaiError::RealtimeAuthError(msg) => msg.clone(),
735 ZaiError::Unknown { message, .. } => message.clone(),
736 }
737 }
738
739 pub fn context(self, context: &str) -> Self {
763 let with_context = |message: String| format!("{context}: {message}");
764 match self {
765 Self::HttpError { status, message } => Self::HttpError {
766 status,
767 message: with_context(message),
768 },
769 Self::AuthError { code, message } => Self::AuthError {
770 code,
771 message: with_context(message),
772 },
773 Self::AccountError { code, message } => Self::AccountError {
774 code,
775 message: with_context(message),
776 },
777 Self::ApiError { code, message } => Self::ApiError {
778 code,
779 message: with_context(message),
780 },
781 Self::RateLimitError { code, message } => Self::RateLimitError {
782 code,
783 message: with_context(message),
784 },
785 Self::ContentPolicyError { code, message } => Self::ContentPolicyError {
786 code,
787 message: with_context(message),
788 },
789 Self::FileError { code, message } => Self::FileError {
790 code,
791 message: with_context(message),
792 },
793 Self::NetworkError(err) => Self::NetworkError(err),
796 Self::JsonError(err) => Self::JsonError(err),
797 Self::RealtimeError(kind) => Self::RealtimeError(kind),
798 Self::RealtimeAuthError(message) => Self::RealtimeAuthError(with_context(message)),
799 Self::Unknown { code, message } => Self::Unknown {
800 code,
801 message: with_context(message),
802 },
803 }
804 }
805}
806
807pub type ZaiResult<T> = Result<T, ZaiError>;
809
810impl From<reqwest::Error> for ZaiError {
812 fn from(err: reqwest::Error) -> Self {
813 if let Some(status) = err.status() {
814 ZaiError::from_api_response(status.as_u16(), 0, err.to_string())
815 } else {
816 ZaiError::NetworkError(Arc::new(err))
817 }
818 }
819}
820
821impl From<serde_json::Error> for ZaiError {
823 fn from(err: serde_json::Error) -> Self {
824 ZaiError::JsonError(Arc::new(err))
825 }
826}
827
828impl From<validator::ValidationErrors> for ZaiError {
830 fn from(err: validator::ValidationErrors) -> Self {
831 ZaiError::ApiError {
832 code: codes::SDK_VALIDATION,
833 message: format!("Validation error: {err:?}"),
834 }
835 }
836}
837
838impl From<std::io::Error> for ZaiError {
846 fn from(err: std::io::Error) -> Self {
847 use std::io::ErrorKind;
848 match err.kind() {
849 ErrorKind::NotFound => ZaiError::FileError {
850 code: codes::SDK_FILE_NOT_FOUND,
851 message: err.to_string(),
852 },
853 ErrorKind::PermissionDenied => ZaiError::FileError {
854 code: codes::SDK_IO,
855 message: err.to_string(),
856 },
857 ErrorKind::TimedOut => ZaiError::ApiError {
858 code: codes::SDK_TIMEOUT,
859 message: err.to_string(),
860 },
861 _ => ZaiError::Unknown {
862 code: codes::SDK_IO,
863 message: err.to_string(),
864 },
865 }
866 }
867}
868
869impl From<RealtimeErrorKind> for ZaiError {
871 fn from(kind: RealtimeErrorKind) -> Self {
872 ZaiError::RealtimeError(Arc::new(kind))
873 }
874}
875
876#[cfg(feature = "realtime")]
881impl From<tokio_tungstenite::tungstenite::Error> for ZaiError {
882 fn from(err: tokio_tungstenite::tungstenite::Error) -> Self {
883 ZaiError::RealtimeError(Arc::new(RealtimeErrorKind::WebSocket { source: err }))
884 }
885}
886
887#[cfg(test)]
888mod tests {
889 use super::*;
890 use std::io::{Error, ErrorKind};
894
895 #[test]
896 fn test_from_api_response_bad_request() {
897 let err = ZaiError::from_api_response(400, 0, "Invalid input".to_string());
898 assert!(err.is_client_error());
899 assert!(!err.is_server_error());
900 assert_eq!(err.code(), Some(400));
901 }
902
903 #[test]
904 fn test_from_api_response_unauthorized() {
905 let err = ZaiError::from_api_response(401, 0, "".to_string());
906 assert!(err.is_client_error());
907 assert_eq!(err.message(), "Unauthorized - check your API key");
908 }
909
910 #[test]
911 fn test_from_api_response_rate_limit() {
912 let err = ZaiError::from_api_response(429, 1302, "Too many requests".to_string());
914 assert!(err.is_client_error());
915 assert!(err.is_rate_limit());
916 assert_eq!(err.code(), Some(1302));
917
918 let err = ZaiError::from_api_response(200, 1302, "Too many requests".to_string());
921 assert!(err.is_client_error());
922 assert!(err.is_rate_limit());
923 assert_eq!(err.code(), Some(1302));
924 }
925
926 #[test]
927 fn test_from_api_response_package_limit_codes() {
928 for code in [1302, 1303, 1304, 1305, 1308, 1309, 1310, 1311, 1312, 1313] {
929 let err = ZaiError::from_api_response(429, code, "Limited".to_string());
930 assert!(err.is_rate_limit());
931 assert_eq!(err.code(), Some(code));
932 }
933 }
934
935 #[test]
936 fn test_from_api_response_content_policy_codes() {
937 for code in [1300, 1301] {
938 let err = ZaiError::from_api_response(400, code, "Blocked".to_string());
939 assert!(matches!(err, ZaiError::ContentPolicyError { .. }));
940 assert!(err.is_client_error());
941 assert!(!err.is_rate_limit());
942 assert_eq!(err.code(), Some(code));
943 }
944 }
945
946 #[test]
947 fn test_from_api_response_server_error() {
948 let err = ZaiError::from_api_response(500, 0, "".to_string());
949 assert!(!err.is_client_error());
950 assert!(err.is_server_error());
951 }
952
953 #[test]
954 fn test_from_api_response_auth_error_code() {
955 let err = ZaiError::from_api_response(200, 1001, "Invalid API key".to_string());
956 assert!(err.is_auth_error());
957 assert_eq!(err.code(), Some(1001));
958 assert_eq!(err.message(), "Invalid API key");
959 }
960
961 #[test]
962 fn test_from_api_response_account_error() {
963 let err = ZaiError::from_api_response(200, 1110, "Account expired".to_string());
964 assert!(err.is_client_error());
965 assert_eq!(err.code(), Some(1110));
966 }
967
968 #[test]
969 fn test_from_api_response_api_error() {
970 let err = ZaiError::from_api_response(200, 1200, "Invalid parameters".to_string());
971 assert!(err.is_client_error());
972 assert_eq!(err.code(), Some(1200));
973 }
974
975 #[test]
976 fn test_from_api_response_unknown_code() {
977 let err = ZaiError::from_api_response(200, 9999, "Unknown error".to_string());
978 assert!(!err.is_client_error()); assert_eq!(err.code(), Some(9999));
980 }
981
982 #[test]
983 fn test_compact() {
984 let err = ZaiError::HttpError {
985 status: 404,
986 message: "Not found".to_string(),
987 };
988 assert_eq!(err.compact(), "HTTP[404]: Not found");
989
990 let err = ZaiError::AuthError {
991 code: 1001,
992 message: "Invalid key".to_string(),
993 };
994 assert_eq!(err.compact(), "AUTH[1001]: Invalid key");
995 }
996
997 #[test]
998 fn test_code() {
999 let io_err = Error::new(ErrorKind::ConnectionRefused, "connection refused");
1003 let err = ZaiError::from(io_err);
1004 assert_eq!(err.code(), Some(codes::SDK_IO));
1005
1006 let err = ZaiError::JsonError(Arc::new(serde_json::Error::io(Error::new(
1008 ErrorKind::InvalidData,
1009 "invalid JSON",
1010 ))));
1011 assert!(err.code().is_none());
1012
1013 let err = ZaiError::HttpError {
1015 status: 500,
1016 message: "Server error".to_string(),
1017 };
1018 assert_eq!(err.code(), Some(500));
1019 }
1020
1021 #[test]
1022 fn test_message() {
1023 let err = ZaiError::RateLimitError {
1024 code: 1302,
1025 message: "Too many requests".to_string(),
1026 };
1027 assert_eq!(err.message(), "Too many requests");
1028 }
1029
1030 #[test]
1031 fn test_from_reqwest_error_with_status() {
1032 let io_err = Error::other("test error");
1033 let zai_err = ZaiError::from(io_err);
1034 match zai_err {
1035 ZaiError::Unknown { .. } => {},
1036 _ => panic!("Expected Unknown error for io::Error"),
1037 }
1038 }
1039
1040 #[test]
1041 fn test_sdk_code_constants_in_reserved_range() {
1042 for code in [
1043 codes::SDK_VALIDATION,
1044 codes::SDK_CONFIG,
1045 codes::SDK_FILE_NOT_FOUND,
1046 codes::SDK_FILE_TOO_LARGE,
1047 codes::SDK_FILE_TYPE_UNSUPPORTED,
1048 codes::SDK_IO,
1049 codes::SDK_TIMEOUT,
1050 codes::SDK_EXTERNAL_TOOL,
1051 ] {
1052 assert!(
1053 (9000..=9999).contains(&code),
1054 "code {code} outside 9000-9999"
1055 );
1056 }
1057 }
1058
1059 #[test]
1060 fn test_is_sdk_error_classification() {
1061 assert!(
1063 ZaiError::FileError {
1064 code: codes::SDK_FILE_NOT_FOUND,
1065 message: "x".into(),
1066 }
1067 .is_sdk_error()
1068 );
1069 assert!(
1070 ZaiError::ApiError {
1071 code: codes::SDK_TIMEOUT,
1072 message: "x".into(),
1073 }
1074 .is_sdk_error()
1075 );
1076
1077 assert!(
1079 !ZaiError::AuthError {
1080 code: 1001,
1081 message: "x".into(),
1082 }
1083 .is_sdk_error()
1084 );
1085 assert!(
1086 !ZaiError::RateLimitError {
1087 code: 1302,
1088 message: "x".into(),
1089 }
1090 .is_sdk_error()
1091 );
1092 assert!(
1093 !ZaiError::HttpError {
1094 status: 500,
1095 message: "x".into(),
1096 }
1097 .is_sdk_error()
1098 );
1099
1100 assert!(!ZaiError::RealtimeAuthError("x".into()).is_sdk_error());
1102 }
1103
1104 #[test]
1105 fn test_from_io_maps_by_kind() {
1106 use std::io::{Error, ErrorKind};
1107
1108 let err = ZaiError::from(Error::from(ErrorKind::NotFound));
1109 assert!(matches!(
1110 err,
1111 ZaiError::FileError { code, .. } if code == codes::SDK_FILE_NOT_FOUND
1112 ));
1113
1114 let err = ZaiError::from(Error::from(ErrorKind::TimedOut));
1115 assert!(matches!(
1116 err,
1117 ZaiError::ApiError { code, .. } if code == codes::SDK_TIMEOUT
1118 ));
1119
1120 let err = ZaiError::from(Error::from(ErrorKind::PermissionDenied));
1121 assert!(matches!(
1122 err,
1123 ZaiError::FileError { code, .. } if code == codes::SDK_IO
1124 ));
1125
1126 let err = ZaiError::from(Error::other("boom"));
1128 assert!(matches!(
1129 err,
1130 ZaiError::Unknown { code, .. } if code == codes::SDK_IO
1131 ));
1132 }
1133
1134 #[test]
1135 fn test_context_preserves_code_and_variant() {
1136 let err = ZaiError::ApiError {
1137 code: 1200,
1138 message: "bad model".into(),
1139 }
1140 .context("file parser create");
1141 assert!(matches!(
1142 err,
1143 ZaiError::ApiError { code, .. } if code == 1200
1144 ));
1145 assert_eq!(err.message(), "file parser create: bad model");
1146
1147 let err = ZaiError::Unknown {
1148 code: codes::SDK_IO,
1149 message: "boom".into(),
1150 }
1151 .context("read");
1152 assert_eq!(err.code(), Some(codes::SDK_IO));
1153 assert_eq!(err.message(), "read: boom");
1154 }
1155
1156 #[test]
1157 fn test_sdk_timeout_is_not_rate_limit() {
1158 let err = ZaiError::ApiError {
1162 code: codes::SDK_TIMEOUT,
1163 message: "Timeout waiting for parsing result".into(),
1164 };
1165 assert!(!err.is_rate_limit());
1166 assert!(err.is_sdk_error());
1167 }
1168
1169 #[test]
1170 fn test_validate_api_key_valid() {
1171 assert!(validate_api_key("abc123.abcdefghijklmnopqrstuvwxyz").is_ok());
1172 }
1173
1174 #[test]
1175 fn test_validate_api_key_empty() {
1176 let result = validate_api_key("");
1177 assert!(result.is_err());
1178 match result {
1179 Err(ZaiError::ApiError { code, .. }) => {
1180 assert_eq!(code, codes::SDK_VALIDATION);
1181 },
1182 _ => panic!("Expected ApiError"),
1183 }
1184 }
1185
1186 #[test]
1187 fn test_validate_api_key_no_dot() {
1188 let result = validate_api_key("invalid");
1189 assert!(result.is_err());
1190 match result {
1191 Err(ZaiError::ApiError { code, message }) => {
1192 assert_eq!(code, codes::SDK_VALIDATION);
1193 assert!(message.contains("format"));
1194 },
1195 _ => panic!("Expected ApiError"),
1196 }
1197 }
1198
1199 #[test]
1200 fn test_validate_api_key_multiple_dots() {
1201 let result = validate_api_key("id.secret.extra");
1202 assert!(result.is_err());
1203 assert_eq!(result.unwrap_err().code(), Some(codes::SDK_VALIDATION));
1204 }
1205
1206 #[test]
1207 fn test_validate_api_key_empty_id() {
1208 let result = validate_api_key(".secret123456789");
1209 assert!(result.is_err());
1210 assert_eq!(result.unwrap_err().code(), Some(codes::SDK_VALIDATION));
1211 }
1212
1213 #[test]
1214 fn test_validate_api_key_empty_secret() {
1215 let result = validate_api_key("id123.");
1216 assert!(result.is_err());
1217 assert_eq!(result.unwrap_err().code(), Some(codes::SDK_VALIDATION));
1218 }
1219
1220 #[test]
1221 fn test_validate_api_key_invalid_chars() {
1222 let result = validate_api_key("id$123.secret@456");
1223 assert!(result.is_err());
1224 assert_eq!(result.unwrap_err().code(), Some(codes::SDK_VALIDATION));
1225 }
1226
1227 #[test]
1228 fn test_validate_api_key_id_too_short() {
1229 let result = validate_api_key("ab.abcdefghijklmn");
1230 assert!(result.is_err());
1231 assert!(result.unwrap_err().message().contains("id is too short"));
1232 }
1233
1234 #[test]
1235 fn test_validate_api_key_secret_too_short() {
1236 let result = validate_api_key("id123.short");
1237 assert!(result.is_err());
1238 assert!(
1239 result
1240 .unwrap_err()
1241 .message()
1242 .contains("secret is too short")
1243 );
1244 }
1245
1246 #[test]
1247 fn test_mask_sensitive_info_api_key() {
1248 let text = "API key: abc123.abcdefghijklmnopqrstuvwxyz12345";
1249 let filtered = mask_sensitive_info(text);
1250 assert!(filtered.contains("[FILTERED]"));
1251 assert!(!filtered.contains("abc123"));
1252 assert!(!filtered.contains("abcdefghijklmnopqrstuvwxyz"));
1253 }
1254
1255 #[test]
1256 fn test_mask_sensitive_info_password() {
1257 let text = "password: secret123, other text";
1258 let filtered = mask_sensitive_info(text);
1259 assert!(filtered.contains("[FILTERED]"));
1260 assert!(!filtered.contains("secret123"));
1261 }
1262
1263 #[test]
1264 fn test_mask_sensitive_info_token() {
1265 let text = "token=abc123xyz, other content";
1266 let filtered = mask_sensitive_info(text);
1267 assert!(filtered.contains("[FILTERED]"));
1268 assert!(!filtered.contains("abc123xyz"));
1269 }
1270
1271 #[test]
1272 fn test_mask_sensitive_info_bearer() {
1273 let text = "Authorization: Bearer abc123.abc1234567890";
1274 let filtered = mask_sensitive_info(text);
1275 assert!(filtered.contains("[AUTH_REDACTED]"));
1278 assert!(!filtered.contains("abc123"));
1279 assert!(!filtered.contains("Bearer"));
1280 assert!(!filtered.contains("Authorization"));
1281 }
1282
1283 #[test]
1284 fn test_mask_sensitive_info_multiple() {
1285 let text = "api_key=abc123.xyz456, password=secret123";
1286 let filtered = mask_sensitive_info(text);
1287 let filtered_count = filtered.matches("[FILTERED]").count();
1288 assert_eq!(filtered_count, 2);
1289 }
1290
1291 #[test]
1292 fn test_mask_sensitive_info_no_sensitive() {
1293 let text = "Regular text without sensitive information";
1294 let filtered = mask_sensitive_info(text);
1295 assert_eq!(filtered, text);
1296 }
1297
1298 #[test]
1299 fn test_mask_api_key() {
1300 let text = "API key: abc123.abcdefghijklmnopqrstuvwxyz12345";
1301 let filtered = mask_api_key(text);
1302 assert!(filtered.contains("[FILTERED]"));
1303 assert!(!filtered.contains("abc123"));
1304 }
1305
1306 #[test]
1307 fn test_contains_sensitive_info_api_key() {
1308 assert!(contains_sensitive_info("api_key: abc123.abc1234567890"));
1309 assert!(!contains_sensitive_info("regular text"));
1310 }
1311
1312 #[test]
1313 fn test_contains_sensitive_info_password() {
1314 assert!(contains_sensitive_info("password: secret"));
1315 assert!(contains_sensitive_info("password=123"));
1316 assert!(!contains_sensitive_info("password"));
1317 assert!(!contains_sensitive_info("word:password"));
1318 }
1319
1320 #[test]
1321 fn test_contains_sensitive_info_token() {
1322 assert!(contains_sensitive_info("token=abc123"));
1323 assert!(contains_sensitive_info("token: xyz123"));
1324 assert!(!contains_sensitive_info("token"));
1325 assert!(!contains_sensitive_info("tokenize this"));
1326 }
1327
1328 #[test]
1329 fn test_error_category_classification() {
1330 let rl = ZaiError::RateLimitError {
1335 code: 1302,
1336 message: "slow down".into(),
1337 };
1338 assert_eq!(rl.category(), ErrorCategory::RateLimit);
1339 assert!(rl.is_retryable());
1340 assert!(rl.is_client_error());
1341 assert!(!rl.is_server_error());
1342
1343 let h429 = ZaiError::HttpError {
1345 status: 429,
1346 message: "too many".into(),
1347 };
1348 assert_eq!(h429.category(), ErrorCategory::RateLimit);
1349 assert!(h429.is_retryable());
1350 assert!(h429.is_client_error());
1351
1352 let h500 = ZaiError::HttpError {
1354 status: 500,
1355 message: "boom".into(),
1356 };
1357 assert_eq!(h500.category(), ErrorCategory::Server);
1358 assert!(h500.is_retryable());
1359 assert!(h500.is_server_error());
1360 assert!(!h500.is_client_error());
1361
1362 let h400 = ZaiError::HttpError {
1364 status: 400,
1365 message: "bad".into(),
1366 };
1367 assert_eq!(h400.category(), ErrorCategory::Client);
1368 assert!(!h400.is_retryable());
1369 assert!(h400.is_client_error());
1370
1371 let auth = ZaiError::AuthError {
1373 code: 1001,
1374 message: "bad key".into(),
1375 };
1376 assert_eq!(auth.category(), ErrorCategory::Auth);
1377 assert!(!auth.is_retryable());
1378 assert!(auth.is_client_error());
1379
1380 let unk = ZaiError::Unknown {
1384 code: 503,
1385 message: "?".into(),
1386 };
1387 assert_eq!(unk.category(), ErrorCategory::Server);
1388 assert!(unk.is_server_error());
1389 assert!(!unk.is_retryable());
1390 }
1391
1392 #[test]
1393 fn test_business_code_band_boundaries() {
1394 for code in [1306, 1307] {
1398 let e = ZaiError::from_api_response(400, code, "gap".to_string());
1399 assert!(
1400 matches!(e, ZaiError::Unknown { .. }),
1401 "code {code} -> Unknown"
1402 );
1403 assert!(!e.is_rate_limit());
1404 }
1405 let e = ZaiError::from_api_response(400, 1499, "file".to_string());
1407 assert!(matches!(e, ZaiError::FileError { code, .. } if code == 1499));
1408 let e = ZaiError::from_api_response(400, 1400, "file".to_string());
1410 assert!(matches!(e, ZaiError::FileError { code, .. } if code == 1400));
1411 for code in [1302, 1305, 1308, 1313] {
1413 let e = ZaiError::from_api_response(429, code, "rl".to_string());
1414 assert!(e.is_rate_limit(), "code {code} -> RateLimitError");
1415 }
1416 }
1417
1418 #[test]
1421 fn status_502_503_504_classify_as_server_and_carry_status() {
1422 for status in [502, 503, 504] {
1425 let e = ZaiError::from_api_response(status, 0, String::new());
1426 match &e {
1427 ZaiError::HttpError {
1428 status: s,
1429 message: _,
1430 } => {
1431 assert_eq!(*s, status, "HTTP {status} lost its status code");
1432 assert!(e.is_server_error(), "HTTP {status} not classified Server");
1433 assert!(
1434 e.is_retryable(),
1435 "HTTP {status} should be retryable as a 5xx"
1436 );
1437 },
1438 other => panic!("HTTP {status} classified as {other:?}, expected HttpError"),
1439 }
1440 }
1441 let e = ZaiError::from_api_response(500, 0, String::new());
1443 assert!(matches!(e, ZaiError::HttpError { status: 500, .. }));
1444 assert!(e.is_server_error());
1445 }
1446
1447 #[test]
1448 fn status_401_403_classify_as_auth() {
1449 for status in [401, 403] {
1450 let e = ZaiError::from_api_response(status, 0, String::new());
1451 assert!(
1452 e.is_auth_error(),
1453 "HTTP {status} should classify as auth, got {e:?}"
1454 );
1455 assert!(
1456 e.is_client_error(),
1457 "HTTP {status} should be a client error"
1458 );
1459 assert!(
1460 !e.is_retryable(),
1461 "HTTP {status} (auth) should not be retryable"
1462 );
1463 }
1464 }
1465
1466 #[test]
1467 fn status_429_classifies_as_rate_limit() {
1468 let e = ZaiError::from_api_response(429, 0, String::new());
1469 assert!(
1470 e.is_rate_limit(),
1471 "HTTP 429 should classify as rate limit, got {e:?}"
1472 );
1473 assert!(e.is_retryable(), "HTTP 429 should be retryable");
1474 }
1475}