use std::error::Error as StdError;
use std::path::{Path, PathBuf};
use std::time::Duration;
use whatsapp_rust::client::{ConnectError, ConnectStage};
use whatsapp_rust::handshake::HandshakeError;
use wacore::request::{IqError as CoreIqError, ServerErrorCode};
use wacore::store::error::StoreError;
use whatsapp_rust::features::{
BlockingError, ChatStateError, CommunityError, ContactError, GroupError, MediaReuploadError,
MexError, NewsletterError, PollError, PresenceError, ProfileError, StanzaResponseError,
TcTokenError,
};
use whatsapp_rust::http::HttpStatusError;
use whatsapp_rust::{ClientError, ErrorChainExt, IqError, SendError, ServerRejection};
fn rust_sources() -> Vec<PathBuf> {
fn walk(dir: &Path, out: &mut Vec<PathBuf>) {
let entries = std::fs::read_dir(dir).unwrap_or_else(|e| panic!("read {dir:?}: {e}"));
for entry in entries.flatten() {
let path = entry.path();
if path.is_dir() {
walk(&path, out);
} else if path.extension().is_some_and(|e| e == "rs") {
out.push(path);
}
}
}
let root = Path::new(env!("CARGO_MANIFEST_DIR"));
let mut out = Vec::new();
walk(&root.join("src"), &mut out);
walk(&root.join("wacore/src"), &mut out);
out.sort();
out
}
fn enums_of(text: &str) -> Vec<syn::ItemEnum> {
fn collect(items: &[syn::Item], out: &mut Vec<syn::ItemEnum>) {
for item in items {
match item {
syn::Item::Enum(item) => out.push(item.clone()),
syn::Item::Mod(module) => {
if let Some((_, items)) = &module.content {
collect(items, out);
}
}
_ => {}
}
}
}
let file = syn::parse_file(text).expect("parse Rust source");
let mut out = Vec::new();
collect(&file.items, &mut out);
out
}
fn has_attribute(attrs: &[syn::Attribute], name: &str) -> bool {
attrs.iter().any(|attr| attr.path().is_ident(name))
}
fn is_error_enum(item: &syn::ItemEnum) -> bool {
item.variants
.iter()
.any(|variant| has_attribute(&variant.attrs, "error"))
}
fn error_enums_missing_non_exhaustive(text: &str) -> Vec<String> {
enums_of(text)
.iter()
.filter(|item| matches!(item.vis, syn::Visibility::Public(_)))
.filter(|item| is_error_enum(item))
.filter(|item| !has_attribute(&item.attrs, "non_exhaustive"))
.map(|item| item.ident.to_string())
.collect()
}
fn transparent_variants(text: &str) -> Vec<String> {
let mut out = Vec::new();
for item in enums_of(text) {
for variant in &item.variants {
for attr in &variant.attrs {
if attr.path().is_ident("error")
&& attr
.parse_args::<syn::Ident>()
.is_ok_and(|arg| arg == "transparent")
{
out.push(format!("{}::{}", item.ident, variant.ident));
}
}
}
}
out
}
#[test]
fn surface_has_no_transparent_error_attribute() {
let mut offenders = Vec::new();
for file in rust_sources() {
let text = std::fs::read_to_string(&file).expect("read source");
for variant in transparent_variants(&text) {
offenders.push(format!("{} {}", file.display(), variant));
}
}
assert!(
offenders.is_empty(),
"`#[error(transparent)]` erases the wrapped error from the source chain. \
Use `#[error(\"{{0}}\")]`, which renders the same text and keeps it \
downcastable. Found at:\n {}",
offenders.join("\n ")
);
}
#[test]
fn surface_error_enums_are_non_exhaustive() {
let mut offenders = Vec::new();
for file in rust_sources() {
let text = std::fs::read_to_string(&file).expect("read source");
for name in error_enums_missing_non_exhaustive(&text) {
offenders.push(format!("{} {}", file.display(), name));
}
}
assert!(
offenders.is_empty(),
"public error enums must be `#[non_exhaustive]` so a new variant is not \
a breaking change. Found without it:\n {}",
offenders.join("\n ")
);
}
#[test]
fn scanner_sees_attributes_separated_by_a_blank_line() {
let source = "#[derive(Debug, thiserror::Error)]\n#[non_exhaustive]\n\npub enum E {\n #[error(\"x\")]\n V,\n}";
assert!(error_enums_missing_non_exhaustive(source).is_empty());
}
#[test]
fn scanner_sees_through_unbalanced_braces_in_doc_comments() {
let source = "#[derive(Debug, thiserror::Error)]\npub enum E {\n /// shape: { \"k\": v }}\n #[error(\"x\")]\n V,\n}";
assert_eq!(error_enums_missing_non_exhaustive(source), vec!["E"]);
}
#[test]
fn scanner_sees_transparent_followed_by_other_text() {
let source =
"pub enum E {\n #[error(transparent)] // legacy\n V(#[from] std::fmt::Error),\n}";
assert_eq!(transparent_variants(source), vec!["E::V"]);
}
#[test]
fn scanner_reads_a_declaration_whose_header_wraps() {
let source =
"#[derive(Debug, thiserror::Error)]\npub enum\n E\n{\n #[error(\"x\")]\n V,\n}";
assert_eq!(error_enums_missing_non_exhaustive(source), vec!["E"]);
}
#[test]
fn scanner_ignores_enums_that_are_not_errors() {
let source = "/// Errors are boxed here.\n#[derive(Debug)]\npub enum Outcome {\n Value(u8),\n Error(Box<u8>),\n}";
assert!(error_enums_missing_non_exhaustive(source).is_empty());
}
#[test]
fn scanner_ignores_private_and_empty_enums_for_non_exhaustive() {
let private = "#[derive(Debug, thiserror::Error)]\nenum E {\n #[error(\"x\")]\n V,\n}";
assert!(error_enums_missing_non_exhaustive(private).is_empty());
let empty = "#[derive(Debug)]\npub enum E {}";
assert!(error_enums_missing_non_exhaustive(empty).is_empty());
}
#[test]
fn scanner_finds_enums_nested_in_modules() {
let source = "mod inner {\n #[derive(Debug, thiserror::Error)]\n pub enum E {\n #[error(\"x\")]\n V,\n }\n}";
assert_eq!(error_enums_missing_non_exhaustive(source), vec!["E"]);
}
fn rejected(code: u16) -> IqError {
IqError::ServerError {
code,
text: "forbidden".to_string(),
error_type: Some("cancel".to_string()),
backoff: None,
}
}
#[track_caller]
fn assert_source_is<T: StdError + 'static>(err: &(dyn StdError + 'static), what: &str) {
let source = err
.source()
.unwrap_or_else(|| panic!("{what}: source() is None, the wrapped error was erased"));
assert!(
source.downcast_ref::<T>().is_some(),
"{what}: source() is not a {}",
std::any::type_name::<T>()
);
}
#[test]
fn wrapping_variants_preserve_their_typed_source() {
assert_source_is::<IqError>(&BlockingError::Iq(rejected(403)), "BlockingError::Iq");
assert_source_is::<IqError>(&ContactError::Iq(rejected(403)), "ContactError::Iq");
assert_source_is::<IqError>(&GroupError::Iq(rejected(403)), "GroupError::Iq");
assert_source_is::<IqError>(&NewsletterError::Iq(rejected(403)), "NewsletterError::Iq");
assert_source_is::<IqError>(&ProfileError::Iq(rejected(403)), "ProfileError::Iq");
assert_source_is::<IqError>(&CommunityError::Iq(rejected(403)), "CommunityError::Iq");
assert_source_is::<IqError>(&TcTokenError::Iq(rejected(403)), "TcTokenError::Iq");
let mex = || MexError::ExtensionError {
code: 1,
message: "denied".to_string(),
};
assert_source_is::<MexError>(&GroupError::Mex(mex()), "GroupError::Mex");
assert_source_is::<MexError>(&CommunityError::Mex(mex()), "CommunityError::Mex");
assert_source_is::<MexError>(&NewsletterError::Mex(mex()), "NewsletterError::Mex");
let client = || ClientError::NotConnected;
assert_source_is::<ClientError>(&PresenceError::Client(client()), "PresenceError::Client");
assert_source_is::<ClientError>(&ChatStateError::Client(client()), "ChatStateError::Client");
assert_source_is::<ClientError>(&ProfileError::Client(client()), "ProfileError::Client");
assert_source_is::<ClientError>(
&NewsletterError::Client(client()),
"NewsletterError::Client",
);
assert_source_is::<ClientError>(
&MediaReuploadError::Client(client()),
"MediaReuploadError::Client",
);
assert_source_is::<ClientError>(
&StanzaResponseError::Client(client()),
"StanzaResponseError::Client",
);
assert_source_is::<ClientError>(&SendError::Client(client()), "SendError::Client");
assert_source_is::<StoreError>(
&TcTokenError::Store(StoreError::DeviceNotFound(1)),
"TcTokenError::Store",
);
assert_source_is::<SendError>(&PollError::Send(SendError::NotLoggedIn), "PollError::Send");
assert_source_is::<GroupError>(
&CommunityError::Group(GroupError::DescriptionConflict),
"CommunityError::Group",
);
}
#[test]
fn group_server_rejection_exposes_code_and_text() {
let err = GroupError::Iq(rejected(403));
let rejection = err
.server_rejection()
.expect("a server rejection is recoverable from a group error");
assert_eq!(rejection.code, 403);
assert_eq!(rejection.text, "forbidden");
assert_eq!(rejection.error_type, Some("cancel"));
let source = StdError::source(&err).expect("source preserved");
assert!(matches!(
source.downcast_ref::<IqError>(),
Some(IqError::ServerError { code: 403, .. })
));
}
#[test]
fn group_description_conflict_409_exposes_its_code() {
let err = GroupError::Iq(IqError::ServerError {
code: 409,
text: "conflict".to_string(),
error_type: None,
backoff: None,
});
let rejection = err.server_rejection().expect("409 is recoverable");
assert_eq!(rejection.code, 409);
assert_eq!(rejection.error_type, None);
}
#[test]
fn wrapping_variants_render_the_wrapped_error_verbatim() {
let inner = rejected(403);
let rendered = inner.to_string();
assert_eq!(GroupError::Iq(rejected(403)).to_string(), rendered);
assert_eq!(ProfileError::Iq(rejected(403)).to_string(), rendered);
assert_eq!(
PresenceError::Client(ClientError::NotConnected).to_string(),
ClientError::NotConnected.to_string()
);
}
fn code_of(err: &(dyn StdError + 'static)) -> Option<u16> {
err.server_rejection().map(|r: ServerRejection<'_>| r.code)
}
#[test]
fn server_rejection_is_recovered_the_same_way_in_every_domain() {
let domains: Vec<Box<dyn StdError>> = vec![
Box::new(GroupError::Iq(rejected(403))),
Box::new(NewsletterError::Iq(rejected(403))),
Box::new(ProfileError::Iq(rejected(403))),
Box::new(BlockingError::Iq(rejected(403))),
Box::new(CommunityError::Iq(rejected(403))),
Box::new(ContactError::Iq(rejected(403))),
Box::new(TcTokenError::Iq(rejected(403))),
Box::new(CommunityError::Group(GroupError::Iq(rejected(403)))),
Box::new(GroupError::Internal(anyhow::Error::new(ServerErrorCode {
code: 403,
text: "forbidden".to_string(),
error_type: None,
backoff: None,
}))),
];
for err in &domains {
assert_eq!(code_of(err.as_ref()), Some(403), "failed for {err:?}");
}
}
fn http_status_of(err: &(dyn StdError + 'static)) -> Option<u16> {
err.http_status()
}
#[test]
fn http_status_is_recovered_wherever_it_comes_from() {
let media = anyhow::Error::new(HttpStatusError { status: 429 })
.context("Download failed with status: 429");
let cause: &(dyn StdError + 'static) = media.as_ref();
assert_eq!(http_status_of(cause), Some(429));
let nested = GroupError::Internal(
anyhow::Error::new(HttpStatusError { status: 429 }).context("Upload failed 429"),
);
assert_eq!(http_status_of(&nested), Some(429));
assert_eq!(HttpStatusError { status: 503 }.http_status(), Some(503));
}
#[test]
fn timeout_is_recovered_across_domains() {
assert!(GroupError::Iq(IqError::Timeout).is_timeout());
assert!(ProfileError::Iq(IqError::Timeout).is_timeout());
assert!(CommunityError::Group(GroupError::Iq(IqError::Timeout)).is_timeout());
assert!(!GroupError::Iq(rejected(403)).is_timeout());
}
#[test]
fn connect_and_handshake_timeouts_are_recovered_too() {
let connect = ConnectError::Timeout {
stage: ConnectStage::Socket,
timeout: Duration::from_secs(10),
};
assert!(connect.is_timeout());
assert!(HandshakeError::Timeout.is_timeout());
assert!(ConnectError::Handshake(HandshakeError::Timeout).is_timeout());
assert!(!ConnectError::AlreadyConnected.is_timeout());
assert!(!HandshakeError::StreamClosed.is_timeout());
assert!(!ConnectError::Handshake(HandshakeError::Disconnected).is_timeout());
}
#[test]
fn transport_loss_is_recovered_across_domains() {
assert!(PresenceError::Client(ClientError::NotConnected).is_transport_unavailable());
assert!(NewsletterError::Client(ClientError::NotConnected).is_transport_unavailable());
assert!(GroupError::Iq(IqError::NotConnected).is_transport_unavailable());
assert!(SendError::Client(ClientError::NotConnected).is_transport_unavailable());
assert!(!GroupError::Iq(rejected(403)).is_transport_unavailable());
}
#[test]
fn store_failure_is_recovered_across_domains() {
let err = TcTokenError::Store(StoreError::DeviceNotFound(7));
assert!(matches!(
err.store_failure(),
Some(StoreError::DeviceNotFound(7))
));
assert!(GroupError::Iq(rejected(403)).store_failure().is_none());
}
#[test]
fn the_wacore_iq_error_answers_identically() {
let core = CoreIqError::ServerError {
code: 401,
text: "unauthorized".to_string(),
error_type: None,
backoff: Some(30),
};
let rejection = core.server_rejection().expect("recoverable");
assert_eq!(rejection.code, 401);
assert_eq!(rejection.backoff, Some(30));
assert!(CoreIqError::Timeout.is_timeout());
assert!(CoreIqError::NotConnected.is_transport_unavailable());
}
#[test]
fn internal_anyhow_still_exposes_its_head() {
let err = GroupError::Internal(anyhow::Error::new(rejected(403)));
let source = StdError::source(&err).expect("anyhow head is exposed");
assert!(source.downcast_ref::<IqError>().is_some());
assert_eq!(code_of(&err), Some(403));
}
#[test]
fn mex_extension_error_is_not_reported_as_a_server_rejection() {
let err = GroupError::Mex(MexError::ExtensionError {
code: 403,
message: "denied".to_string(),
});
assert_eq!(err.server_rejection(), None);
let source = StdError::source(&err).expect("source preserved");
assert!(matches!(
source.downcast_ref::<MexError>(),
Some(MexError::ExtensionError { code: 403, .. })
));
}
#[test]
fn an_iq_rejection_is_not_reported_as_an_http_status() {
let err = GroupError::Iq(rejected(403));
assert_eq!(code_of(&err), Some(403), "still an IQ rejection");
assert_eq!(err.http_status(), None);
}
#[test]
fn an_http_status_is_not_reported_as_a_server_rejection() {
let refused = HttpStatusError { status: 403 };
assert_eq!(refused.http_status(), Some(403));
assert_eq!(refused.server_rejection(), None);
}
#[test]
fn errors_without_a_modelled_category_report_nothing() {
let err = GroupError::InvalidRequest("empty invite code".to_string());
assert_eq!(err.server_rejection(), None);
assert_eq!(err.http_status(), None);
assert!(!err.is_timeout());
assert!(!err.is_transport_unavailable());
assert!(err.store_failure().is_none());
let conflict = GroupError::DescriptionConflict;
assert_eq!(conflict.server_rejection(), None);
assert!(!conflict.is_transport_unavailable());
}
#[test]
fn sources_walks_the_whole_chain_nearest_first() {
let err = CommunityError::Group(GroupError::Iq(rejected(403)));
let chain: Vec<&(dyn StdError + 'static)> = err.sources().collect();
assert_eq!(chain.len(), 3, "community -> group -> iq");
assert!(chain[0].downcast_ref::<CommunityError>().is_some());
assert!(chain[1].downcast_ref::<GroupError>().is_some());
assert!(chain[2].downcast_ref::<IqError>().is_some());
}