#[derive(Debug, thiserror::Error)]
pub enum ConfigError {
#[error("missing required config field: {field}")]
MissingField {
field: String,
},
#[error("invalid value for config field '{field}': {message}")]
InvalidValue {
field: String,
message: String,
},
#[error("config file not found: {path}")]
FileNotFound {
path: String,
},
#[error("failed to parse config file: {source}")]
ParseError {
#[source]
source: toml::de::Error,
},
}
#[derive(Debug, thiserror::Error)]
pub enum XApiError {
#[error("X API rate limited{}", match .retry_after {
Some(secs) => format!(", retry after {secs}s"),
None => String::new(),
})]
RateLimited {
retry_after: Option<u64>,
},
#[error("X API authentication expired, re-authentication required")]
AuthExpired,
#[error("X API account restricted: {message}")]
AccountRestricted {
message: String,
},
#[error("X API forbidden: {message}")]
Forbidden {
message: String,
},
#[error("X API scope insufficient: {message}")]
ScopeInsufficient {
message: String,
},
#[error("X API network error: {source}")]
Network {
#[source]
source: reqwest::Error,
},
#[error("X API error (HTTP {status}): {message}")]
ApiError {
status: u16,
message: String,
},
#[error("media upload failed: {message}")]
MediaUploadError {
message: String,
},
#[error("media processing timed out after {seconds}s")]
MediaProcessingTimeout {
seconds: u64,
},
#[error("scraper mutation blocked: {message}. Enable scraper_allow_mutations in config or switch to provider_backend = \"x_api\"")]
ScraperMutationBlocked {
message: String,
},
#[error("scraper transport unavailable: {message}")]
ScraperTransportUnavailable {
message: String,
},
#[error("feature requires X API authentication: {message}. Switch to provider_backend = \"x_api\" to use this feature")]
FeatureRequiresAuth {
message: String,
},
}
impl XApiError {
pub fn is_retryable(&self) -> bool {
match self {
XApiError::RateLimited { .. } => true,
XApiError::Network { .. } => true,
XApiError::ScraperTransportUnavailable { .. } => true,
XApiError::ApiError { status, .. } => *status >= 500,
XApiError::AuthExpired => false,
XApiError::AccountRestricted { .. } => false,
XApiError::Forbidden { .. } => false,
XApiError::ScopeInsufficient { .. } => false,
XApiError::FeatureRequiresAuth { .. } => false,
XApiError::ScraperMutationBlocked { .. } => false,
XApiError::MediaUploadError { .. } => false,
XApiError::MediaProcessingTimeout { .. } => false,
}
}
}
#[derive(Debug, thiserror::Error)]
pub enum LlmError {
#[error("LLM HTTP request failed: {0}")]
Request(#[from] reqwest::Error),
#[error("LLM API error (status {status}): {message}")]
Api {
status: u16,
message: String,
},
#[error("LLM rate limited, retry after {retry_after_secs} seconds")]
RateLimited {
retry_after_secs: u64,
},
#[error("failed to parse LLM response: {0}")]
Parse(String),
#[error("no LLM provider configured")]
NotConfigured,
#[error("content generation failed: {0}")]
GenerationFailed(String),
}
#[derive(Debug, thiserror::Error)]
pub enum StorageError {
#[error("database connection error: {source}")]
Connection {
#[source]
source: sqlx::Error,
},
#[error("database migration error: {source}")]
Migration {
#[source]
source: sqlx::migrate::MigrateError,
},
#[error("database query error: {source}")]
Query {
#[source]
source: sqlx::Error,
},
#[error("item {id} has already been reviewed (current status: {current_status})")]
AlreadyReviewed {
id: i64,
current_status: String,
},
}
#[derive(Debug, thiserror::Error)]
pub enum ScoringError {
#[error("invalid tweet data for scoring: {message}")]
InvalidTweetData {
message: String,
},
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn config_error_missing_field_message() {
let err = ConfigError::MissingField {
field: "business.product_name".to_string(),
};
assert_eq!(
err.to_string(),
"missing required config field: business.product_name"
);
}
#[test]
fn config_error_invalid_value_message() {
let err = ConfigError::InvalidValue {
field: "llm.provider".to_string(),
message: "must be openai, anthropic, or ollama".to_string(),
};
assert_eq!(
err.to_string(),
"invalid value for config field 'llm.provider': must be openai, anthropic, or ollama"
);
}
#[test]
fn config_error_file_not_found_message() {
let err = ConfigError::FileNotFound {
path: "/home/user/.tuitbot/config.toml".to_string(),
};
assert_eq!(
err.to_string(),
"config file not found: /home/user/.tuitbot/config.toml"
);
}
#[test]
fn x_api_error_rate_limited_with_retry() {
let err = XApiError::RateLimited {
retry_after: Some(30),
};
assert_eq!(err.to_string(), "X API rate limited, retry after 30s");
}
#[test]
fn x_api_error_rate_limited_without_retry() {
let err = XApiError::RateLimited { retry_after: None };
assert_eq!(err.to_string(), "X API rate limited");
}
#[test]
fn x_api_error_auth_expired_message() {
let err = XApiError::AuthExpired;
assert_eq!(
err.to_string(),
"X API authentication expired, re-authentication required"
);
}
#[test]
fn x_api_error_api_error_message() {
let err = XApiError::ApiError {
status: 403,
message: "Forbidden".to_string(),
};
assert_eq!(err.to_string(), "X API error (HTTP 403): Forbidden");
}
#[test]
fn x_api_error_scope_insufficient_message() {
let err = XApiError::ScopeInsufficient {
message: "missing tweet.write".to_string(),
};
assert_eq!(
err.to_string(),
"X API scope insufficient: missing tweet.write"
);
}
#[test]
fn llm_error_not_configured_message() {
let err = LlmError::NotConfigured;
assert_eq!(err.to_string(), "no LLM provider configured");
}
#[test]
fn llm_error_rate_limited_message() {
let err = LlmError::RateLimited {
retry_after_secs: 30,
};
assert_eq!(err.to_string(), "LLM rate limited, retry after 30 seconds");
}
#[test]
fn llm_error_parse_failure_message() {
let err = LlmError::Parse("unexpected JSON structure".to_string());
assert_eq!(
err.to_string(),
"failed to parse LLM response: unexpected JSON structure"
);
}
#[test]
fn llm_error_api_error_message() {
let err = LlmError::Api {
status: 401,
message: "Invalid API key".to_string(),
};
assert_eq!(
err.to_string(),
"LLM API error (status 401): Invalid API key"
);
}
#[test]
fn storage_error_already_reviewed_message() {
let err = StorageError::AlreadyReviewed {
id: 42,
current_status: "approved".to_string(),
};
assert_eq!(
err.to_string(),
"item 42 has already been reviewed (current status: approved)"
);
}
#[test]
fn scoring_error_invalid_tweet_data_message() {
let err = ScoringError::InvalidTweetData {
message: "missing author_id".to_string(),
};
assert_eq!(
err.to_string(),
"invalid tweet data for scoring: missing author_id"
);
}
#[test]
fn x_api_error_media_upload_message() {
let err = XApiError::MediaUploadError {
message: "file too large".to_string(),
};
assert_eq!(err.to_string(), "media upload failed: file too large");
}
#[test]
fn x_api_error_media_processing_timeout_message() {
let err = XApiError::MediaProcessingTimeout { seconds: 300 };
assert_eq!(err.to_string(), "media processing timed out after 300s");
}
#[test]
fn x_api_error_scraper_mutation_blocked_message() {
let err = XApiError::ScraperMutationBlocked {
message: "post_tweet".to_string(),
};
assert_eq!(
err.to_string(),
"scraper mutation blocked: post_tweet. Enable scraper_allow_mutations in config or switch to provider_backend = \"x_api\""
);
}
#[test]
fn x_api_error_scraper_transport_unavailable_message() {
let err = XApiError::ScraperTransportUnavailable {
message: "search_tweets: scraper transport not yet implemented".to_string(),
};
assert_eq!(
err.to_string(),
"scraper transport unavailable: search_tweets: scraper transport not yet implemented"
);
}
#[test]
fn x_api_error_feature_requires_auth_message() {
let err = XApiError::FeatureRequiresAuth {
message: "get_me requires authenticated API access".to_string(),
};
assert_eq!(
err.to_string(),
"feature requires X API authentication: get_me requires authenticated API access. Switch to provider_backend = \"x_api\" to use this feature"
);
}
#[test]
fn llm_error_generation_failed_message() {
let err = LlmError::GenerationFailed("max retries exceeded".to_string());
assert_eq!(
err.to_string(),
"content generation failed: max retries exceeded"
);
}
#[test]
fn x_api_error_account_restricted_message() {
let err = XApiError::AccountRestricted {
message: "Account suspended".to_string(),
};
assert_eq!(
err.to_string(),
"X API account restricted: Account suspended"
);
}
#[test]
fn x_api_error_forbidden_message() {
let err = XApiError::Forbidden {
message: "Basic tier cannot access this endpoint".to_string(),
};
assert_eq!(
err.to_string(),
"X API forbidden: Basic tier cannot access this endpoint"
);
}
#[test]
fn storage_error_connection_message() {
let err = StorageError::Connection {
source: sqlx::Error::Configuration("test config error".into()),
};
let msg = err.to_string();
assert!(msg.contains("database connection error"));
}
#[test]
fn storage_error_query_message() {
let err = StorageError::Query {
source: sqlx::Error::ColumnNotFound("missing_col".to_string()),
};
let msg = err.to_string();
assert!(msg.contains("database query error"));
}
#[test]
fn config_error_is_debug() {
let err = ConfigError::MissingField {
field: "test".to_string(),
};
let debug = format!("{err:?}");
assert!(debug.contains("MissingField"));
}
#[test]
fn x_api_error_is_debug() {
let err = XApiError::AuthExpired;
let debug = format!("{err:?}");
assert!(debug.contains("AuthExpired"));
}
#[test]
fn llm_error_is_debug() {
let err = LlmError::NotConfigured;
let debug = format!("{err:?}");
assert!(debug.contains("NotConfigured"));
}
#[test]
fn storage_error_is_debug() {
let err = StorageError::AlreadyReviewed {
id: 1,
current_status: "approved".to_string(),
};
let debug = format!("{err:?}");
assert!(debug.contains("AlreadyReviewed"));
}
#[test]
fn scoring_error_is_debug() {
let err = ScoringError::InvalidTweetData {
message: "test".to_string(),
};
let debug = format!("{err:?}");
assert!(debug.contains("InvalidTweetData"));
}
#[test]
fn x_api_error_rate_limited_with_retry_formats_seconds() {
let err = XApiError::RateLimited {
retry_after: Some(120),
};
assert!(err.to_string().contains("120s"));
}
#[test]
fn llm_error_api_error_includes_status_code() {
let err = LlmError::Api {
status: 500,
message: "Internal server error".to_string(),
};
let msg = err.to_string();
assert!(msg.contains("500"));
assert!(msg.contains("Internal server error"));
}
}