use std::process::{ExitCode, Termination};
use thiserror::Error;
pub mod sysexits {
pub const EX_USAGE: u8 = 64;
pub const EX_DATAERR: u8 = 65;
pub const EX_NOINPUT: u8 = 66;
pub const EX_UNAVAILABLE: u8 = 69;
pub const EX_SOFTWARE: u8 = 70;
}
#[doc(alias = "error")]
#[doc(alias = "Error")]
#[doc(alias = "cli_error")]
#[doc(alias = "exit code")]
#[doc(alias = "sysexits")]
#[doc(alias = "BSD")]
#[doc(alias = "error type")]
#[doc(alias = "thiserror")]
#[derive(Debug, Error)]
#[non_exhaustive]
pub enum AppError {
#[error("invalid usage: {0}")]
InvalidUsage(String),
#[error("invalid input: {0}")]
InvalidInput(String),
#[error("stdin is empty")]
StdinEmpty,
#[error("invalid url: {0}")]
InvalidUrl(String),
#[error("no subtitle: {0}")]
NoSubtitle(NoSubtitleReason),
#[error("providers unavailable")]
ProviderUnavailable,
#[error("rate limited by provider (HTTP 429)")]
RateLimited {
retry_after_secs: Option<u64>,
},
#[error("timeout: {0}")]
Timeout(String),
#[error("io error: {0}")]
Io(#[from] std::io::Error),
#[error("http error: {0}")]
Http(#[from] reqwest::Error),
#[error("url parse error: {0}")]
UrlParse(#[from] url::ParseError),
#[error("serialization error: {0}")]
Serde(#[from] serde_json::Error),
#[error("crypto error: {0}")]
Crypto(String),
#[error("subtitle exceeds limit: {0} bytes")]
SubtitleTooLarge(usize),
#[error("internal error: {0}")]
Internal(String),
}
#[derive(Debug, Clone, Copy, Error, PartialEq, Eq)]
#[non_exhaustive]
pub enum NoSubtitleReason {
#[error("video is private or age-restricted (HTTP 403)")]
PrivateOrAgeRestricted,
#[error("video not found (HTTP 404)")]
NotFound,
#[error("video removed by author (HTTP 410)")]
Gone,
#[error("video unavailable for legal reasons (HTTP 451)")]
UnavailableForLegalReasons,
#[error("no captions published for this video")]
NotPublished,
#[error("requested language is unavailable")]
LanguageUnavailable,
}
impl NoSubtitleReason {
pub fn from_status(status: u16) -> Option<Self> {
match status {
403 => Some(Self::PrivateOrAgeRestricted),
404 => Some(Self::NotFound),
410 => Some(Self::Gone),
451 => Some(Self::UnavailableForLegalReasons),
_ => None,
}
}
}
impl AppError {
pub fn exit_code(&self) -> u8 {
use sysexits::*;
match self {
AppError::InvalidUsage(_) | AppError::InvalidInput(_) | AppError::StdinEmpty => {
EX_USAGE
}
AppError::InvalidUrl(_) | AppError::UrlParse(_) => EX_DATAERR,
AppError::NoSubtitle(_) => EX_NOINPUT,
AppError::ProviderUnavailable | AppError::RateLimited { .. } => EX_UNAVAILABLE,
AppError::Timeout(_)
| AppError::Io(_)
| AppError::Http(_)
| AppError::Serde(_)
| AppError::Crypto(_)
| AppError::SubtitleTooLarge(_)
| AppError::Internal(_) => EX_SOFTWARE,
}
}
pub fn reason(&self) -> NoSubtitleReason {
if let AppError::NoSubtitle(r) = self {
*r
} else {
NoSubtitleReason::NotPublished
}
}
}
impl Termination for AppError {
fn report(self) -> ExitCode {
tracing::error!(target: "user_error", code = self.exit_code(), "{}", self);
ExitCode::from(self.exit_code())
}
}
impl From<AppError> for ExitCode {
fn from(err: AppError) -> Self {
ExitCode::from(err.exit_code())
}
}
pub type AppResult<T> = Result<T, AppError>;
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn no_subtitle_reason_from_status() {
assert_eq!(
NoSubtitleReason::from_status(403),
Some(NoSubtitleReason::PrivateOrAgeRestricted)
);
assert_eq!(
NoSubtitleReason::from_status(404),
Some(NoSubtitleReason::NotFound)
);
assert_eq!(
NoSubtitleReason::from_status(410),
Some(NoSubtitleReason::Gone)
);
assert_eq!(
NoSubtitleReason::from_status(451),
Some(NoSubtitleReason::UnavailableForLegalReasons)
);
assert_eq!(NoSubtitleReason::from_status(500), None);
}
#[test]
fn no_subtitle_exit_code_is_66() {
let err = AppError::NoSubtitle(NoSubtitleReason::NotPublished);
assert_eq!(err.exit_code(), 66);
}
#[test]
fn stdin_empty_exit_code_is_64() {
assert_eq!(AppError::StdinEmpty.exit_code(), 64);
}
#[test]
fn subtitle_too_large_exit_code_is_70() {
assert_eq!(AppError::SubtitleTooLarge(60_000_000).exit_code(), 70);
}
#[test]
fn timeout_exit_code_is_70() {
assert_eq!(AppError::Timeout("after 30s".to_string()).exit_code(), 70);
}
#[test]
fn provider_unavailable_exit_code_is_69() {
assert_eq!(AppError::ProviderUnavailable.exit_code(), 69);
}
#[test]
fn rate_limited_exit_code_is_69() {
let err = AppError::RateLimited {
retry_after_secs: Some(60),
};
assert_eq!(err.exit_code(), 69);
}
#[test]
fn invalid_url_exit_code_is_65() {
assert_eq!(AppError::InvalidUrl("bad".to_string()).exit_code(), 65);
}
#[test]
fn internal_error_exit_code_is_70() {
assert_eq!(AppError::Internal("oops".to_string()).exit_code(), 70);
}
#[test]
fn all_exit_codes_are_in_sysexits_range() {
let errs = vec![
AppError::InvalidUsage("x".into()),
AppError::InvalidInput("x".into()),
AppError::StdinEmpty,
AppError::InvalidUrl("x".into()),
AppError::UrlParse(url::ParseError::EmptyHost),
AppError::NoSubtitle(NoSubtitleReason::NotPublished),
AppError::ProviderUnavailable,
AppError::RateLimited {
retry_after_secs: None,
},
AppError::Timeout("x".into()),
AppError::Internal("x".into()),
];
for e in errs {
let code = e.exit_code();
assert!(
(64..=78).contains(&code),
"exit code {code} out of sysexits range 64-78 for {e:?}"
);
}
}
#[test]
fn reason_helper_returns_inner_reason() {
let err = AppError::NoSubtitle(NoSubtitleReason::NotFound);
assert_eq!(err.reason(), NoSubtitleReason::NotFound);
}
#[test]
fn reason_helper_defaults_to_not_published() {
let err = AppError::Timeout("x".to_string());
assert_eq!(err.reason(), NoSubtitleReason::NotPublished);
}
#[test]
fn no_subtitle_reason_messages_are_human_readable() {
assert!(NoSubtitleReason::PrivateOrAgeRestricted
.to_string()
.contains("403"));
assert!(NoSubtitleReason::UnavailableForLegalReasons
.to_string()
.contains("451"));
}
}