use anyhow::Result;
use serde::Serialize;
use std::fmt;
#[derive(Debug, Serialize)]
pub enum AppError {
InvalidInput(String),
DidResolveFailed(String),
RepoFetchFailed(String),
RepoParseFailed(String),
NotFound(String),
Timeout(String),
CacheError(String),
HttpClientInitialization(String),
NetworkError(String),
Authentication(String),
ConfigError(String),
ParseError(String),
Internal(String),
}
impl fmt::Display for AppError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
AppError::InvalidInput(msg) => write!(f, "Invalid input: {}", msg),
AppError::DidResolveFailed(msg) => write!(f, "DID resolution failed: {}", msg),
AppError::RepoFetchFailed(msg) => write!(f, "Repository fetch failed: {}", msg),
AppError::RepoParseFailed(msg) => write!(f, "Repository parse failed: {}", msg),
AppError::NotFound(msg) => write!(f, "Not found: {}", msg),
AppError::Timeout(msg) => write!(f, "Timeout: {}", msg),
AppError::CacheError(msg) => write!(f, "Cache error: {}", msg),
AppError::HttpClientInitialization(msg) => {
write!(f, "HTTP client initialization failed: {}", msg)
}
AppError::NetworkError(msg) => write!(f, "Network error: {}", msg),
AppError::Authentication(msg) => write!(f, "Authentication error: {}", msg),
AppError::ConfigError(msg) => write!(f, "Configuration error: {}", msg),
AppError::ParseError(msg) => write!(f, "Parse error: {}", msg),
AppError::Internal(msg) => write!(f, "Internal error: {}", msg),
}
}
}
impl std::error::Error for AppError {}
impl AppError {
pub fn error_code(&self) -> &'static str {
match self {
AppError::InvalidInput(_) => "invalid_input",
AppError::DidResolveFailed(_) => "did_resolve_failed",
AppError::RepoFetchFailed(_) => "repo_fetch_failed",
AppError::RepoParseFailed(_) => "repo_parse_failed",
AppError::NotFound(_) => "not_found",
AppError::Timeout(_) => "timeout",
AppError::CacheError(_) => "cache_error",
AppError::HttpClientInitialization(_) => "http_client_initialization",
AppError::NetworkError(_) => "network_error",
AppError::Authentication(_) => "authentication_error",
AppError::ConfigError(_) => "config_error",
AppError::ParseError(_) => "parse_error",
AppError::Internal(_) => "internal_error",
}
}
pub fn message(&self) -> String {
self.to_string()
}
}
impl From<anyhow::Error> for AppError {
fn from(err: anyhow::Error) -> Self {
AppError::Internal(err.to_string())
}
}
impl From<reqwest::Error> for AppError {
fn from(err: reqwest::Error) -> Self {
if err.is_timeout() {
AppError::Timeout(err.to_string())
} else if err.is_connect() || err.is_request() {
AppError::RepoFetchFailed(err.to_string())
} else {
AppError::Internal(err.to_string())
}
}
}
impl From<serde_json::Error> for AppError {
fn from(err: serde_json::Error) -> Self {
AppError::RepoParseFailed(err.to_string())
}
}
impl From<std::io::Error> for AppError {
fn from(err: std::io::Error) -> Self {
AppError::CacheError(err.to_string())
}
}
impl From<crate::car::CarError> for AppError {
fn from(err: crate::car::CarError) -> Self {
AppError::RepoParseFailed(format!("CAR parsing error: {}", err))
}
}
pub fn validate_account(account: &str) -> Result<(), AppError> {
if account.is_empty() {
return Err(AppError::InvalidInput(
"Account cannot be empty".to_string(),
));
}
if let Some(stripped) = account.strip_prefix("did:plc:") {
if account.len() != 32 || !stripped.chars().all(|c| c.is_ascii_alphanumeric()) {
return Err(AppError::InvalidInput("Invalid DID format".to_string()));
}
return Ok(());
}
if let Some(rest) = account.strip_prefix("did:web:") {
if rest.is_empty() {
return Err(AppError::InvalidInput("Invalid did:web format".to_string()));
}
return Ok(());
}
if !account.contains('.') {
return Err(AppError::InvalidInput(
"Invalid handle format, must contain domain".to_string(),
));
}
let parts: Vec<&str> = account.split('.').collect();
if parts.len() < 2 || parts.iter().any(|part| part.is_empty()) {
return Err(AppError::InvalidInput("Invalid handle format".to_string()));
}
Ok(())
}
pub fn validate_query(query: &str) -> Result<(), AppError> {
if query.is_empty() {
return Err(AppError::InvalidInput("Query cannot be empty".to_string()));
}
if query.len() > 500 {
return Err(AppError::InvalidInput(
"Query too long, maximum 500 characters".to_string(),
));
}
Ok(())
}
pub fn normalize_text(text: &str) -> String {
use unicode_normalization::UnicodeNormalization;
text.nfkc().collect::<String>().trim().to_string()
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_app_error_display() {
let tests = vec![
(
AppError::InvalidInput("test".to_string()),
"Invalid input: test",
),
(
AppError::DidResolveFailed("resolve error".to_string()),
"DID resolution failed: resolve error",
),
(
AppError::RepoFetchFailed("fetch error".to_string()),
"Repository fetch failed: fetch error",
),
(
AppError::RepoParseFailed("parse error".to_string()),
"Repository parse failed: parse error",
),
(
AppError::NotFound("not found".to_string()),
"Not found: not found",
),
(
AppError::Timeout("timeout error".to_string()),
"Timeout: timeout error",
),
(
AppError::CacheError("cache error".to_string()),
"Cache error: cache error",
),
(
AppError::HttpClientInitialization("http client error".to_string()),
"HTTP client initialization failed: http client error",
),
(
AppError::NetworkError("network error".to_string()),
"Network error: network error",
),
(
AppError::Internal("internal error".to_string()),
"Internal error: internal error",
),
];
for (error, expected) in tests {
assert_eq!(error.to_string(), expected);
}
}
#[test]
fn test_app_error_codes() {
let tests = vec![
(AppError::InvalidInput("test".to_string()), "invalid_input"),
(
AppError::DidResolveFailed("test".to_string()),
"did_resolve_failed",
),
(
AppError::RepoFetchFailed("test".to_string()),
"repo_fetch_failed",
),
(
AppError::RepoParseFailed("test".to_string()),
"repo_parse_failed",
),
(AppError::NotFound("test".to_string()), "not_found"),
(AppError::Timeout("test".to_string()), "timeout"),
(AppError::CacheError("test".to_string()), "cache_error"),
(
AppError::HttpClientInitialization("test".to_string()),
"http_client_initialization",
),
(AppError::NetworkError("test".to_string()), "network_error"),
(AppError::Internal("test".to_string()), "internal_error"),
];
for (error, expected_code) in tests {
assert_eq!(error.error_code(), expected_code);
}
}
#[test]
fn test_from_reqwest_error() {
let json_err = serde_json::from_str::<serde_json::Value>("invalid").unwrap_err();
let app_err: AppError = json_err.into();
assert_eq!(app_err.error_code(), "repo_parse_failed");
}
#[test]
fn test_from_serde_json_error() {
let json_err = serde_json::from_str::<serde_json::Value>("invalid json").unwrap_err();
let app_err: AppError = json_err.into();
assert_eq!(app_err.error_code(), "repo_parse_failed");
}
#[test]
fn test_from_io_error() {
let io_err = std::io::Error::new(std::io::ErrorKind::NotFound, "file not found");
let app_err: AppError = io_err.into();
assert_eq!(app_err.error_code(), "cache_error");
}
#[test]
fn test_from_anyhow_error() {
let anyhow_err = anyhow::anyhow!("generic error");
let app_err: AppError = anyhow_err.into();
assert_eq!(app_err.error_code(), "internal_error");
}
#[test]
fn test_validate_account_empty() {
let result = validate_account("");
assert!(result.is_err());
match result.unwrap_err() {
AppError::InvalidInput(msg) => assert!(msg.contains("cannot be empty")),
_ => panic!("Expected InvalidInput error"),
}
}
#[test]
fn test_validate_account_valid_plc_did() {
let valid_dids = vec![
"did:plc:abcdefghijklmnopqrstuvwx", "did:plc:123456789012345678901234", ];
for did in valid_dids {
assert!(validate_account(did).is_ok());
}
}
#[test]
fn test_validate_account_invalid_plc_did() {
let invalid_dids = vec![
"did:plc:tooshort", "did:plc:toolong123456789012345678901", "did:plc:has-invalid-chars123456789!", ];
for did in invalid_dids {
let result = validate_account(did);
assert!(result.is_err());
match result.unwrap_err() {
AppError::InvalidInput(msg) => assert!(msg.contains("Invalid DID format")),
_ => panic!("Expected InvalidInput error for {}", did),
}
}
}
#[test]
fn test_validate_account_valid_web_did() {
let valid_web_dids = vec![
"did:web:example.com",
"did:web:example.com:user:alice",
"did:web:subdomain.example.org:some:path",
];
for did in valid_web_dids {
assert!(validate_account(did).is_ok());
}
}
#[test]
fn test_validate_account_invalid_web_did() {
let result = validate_account("did:web:");
assert!(result.is_err());
match result.unwrap_err() {
AppError::InvalidInput(msg) => assert!(msg.contains("Invalid did:web format")),
_ => panic!("Expected InvalidInput error"),
}
}
#[test]
fn test_validate_account_valid_handles() {
let valid_handles = vec![
"alice.bsky.social",
"bob.example.com",
"user.subdomain.example.org",
"@alice.bsky.social", ];
for handle in valid_handles {
assert!(validate_account(handle).is_ok());
}
}
#[test]
fn test_validate_account_invalid_handles() {
let invalid_handles = vec![
"nodomain", "empty.", ".empty", "double..domain", ];
for handle in invalid_handles {
let result = validate_account(handle);
assert!(result.is_err());
match result.unwrap_err() {
AppError::InvalidInput(_) => {} _ => panic!("Expected InvalidInput error for {}", handle),
}
}
}
#[test]
fn test_validate_query_valid() {
let max_length_query = "a".repeat(500);
let valid_queries = vec![
"hello",
"hello world",
&max_length_query, "unicode: 🚀 ñoño",
];
for query in valid_queries {
assert!(validate_query(query).is_ok());
}
}
#[test]
fn test_validate_query_empty() {
let result = validate_query("");
assert!(result.is_err());
match result.unwrap_err() {
AppError::InvalidInput(msg) => assert!(msg.contains("cannot be empty")),
_ => panic!("Expected InvalidInput error"),
}
}
#[test]
fn test_validate_query_too_long() {
let long_query = "a".repeat(501); let result = validate_query(&long_query);
assert!(result.is_err());
match result.unwrap_err() {
AppError::InvalidInput(msg) => assert!(msg.contains("too long")),
_ => panic!("Expected InvalidInput error"),
}
}
#[test]
fn test_normalize_text_basic() {
assert_eq!(normalize_text("hello world"), "hello world");
assert_eq!(normalize_text(" hello world "), "hello world");
assert_eq!(normalize_text(""), "");
}
#[test]
fn test_normalize_text_unicode() {
let text1 = "café"; let text2 = "cafe\u{0301}";
let normalized1 = normalize_text(text1);
let normalized2 = normalize_text(text2);
assert_eq!(normalized1, normalized2);
assert_eq!(normalized1, "café");
}
#[test]
fn test_normalize_text_whitespace() {
let inputs = vec!["\t hello \n", "\r\n hello \t", " hello "];
for input in inputs {
assert_eq!(normalize_text(input), "hello");
}
}
#[test]
fn test_normalize_text_compatibility() {
assert_eq!(normalize_text("â… "), "I");
assert_eq!(normalize_text("A"), "A");
}
}