use thiserror::Error;
#[derive(Error, Debug)]
pub enum SubXError {
#[error("I/O error: {0}")]
Io(#[from] std::io::Error),
#[error("Configuration error: {message}")]
Config {
message: String,
},
#[error("Subtitle format error [{format}]: {message}")]
SubtitleFormat {
format: String,
message: String,
},
#[error("AI service error: {0}")]
AiService(String),
#[error("API error [{source:?}]: {message}")]
Api {
message: String,
source: ApiErrorSource,
},
#[error("Audio processing error: {message}")]
AudioProcessing {
message: String,
},
#[error("File matching error: {message}")]
FileMatching {
message: String,
},
#[error("File already exists: {0}")]
FileAlreadyExists(String),
#[error("File not found: {0}")]
FileNotFound(String),
#[error("Invalid file name: {0}")]
InvalidFileName(String),
#[error("File operation failed: {0}")]
FileOperationFailed(String),
#[error("{0}")]
CommandExecution(String),
#[error("No input path specified")]
NoInputSpecified,
#[error("Invalid path: {0}")]
InvalidPath(std::path::PathBuf),
#[error("Path not found: {0}")]
PathNotFound(std::path::PathBuf),
#[error("Unable to read directory: {path}")]
DirectoryReadError {
path: std::path::PathBuf,
#[source]
source: std::io::Error,
},
#[error(
"Invalid sync configuration: please specify video and subtitle files, or use -i parameter for batch processing"
)]
InvalidSyncConfiguration,
#[error("Unsupported file type: {0}")]
UnsupportedFileType(String),
#[error("Unknown error: {0}")]
Other(#[from] anyhow::Error),
}
#[cfg(test)]
mod tests {
use super::*;
use std::io;
#[test]
fn test_config_error_creation() {
let error = SubXError::config("test config error");
assert!(matches!(error, SubXError::Config { .. }));
assert_eq!(error.to_string(), "Configuration error: test config error");
}
#[test]
fn test_subtitle_format_error_creation() {
let error = SubXError::subtitle_format("SRT", "invalid format");
assert!(matches!(error, SubXError::SubtitleFormat { .. }));
let msg = error.to_string();
assert!(msg.contains("SRT"));
assert!(msg.contains("invalid format"));
}
#[test]
fn test_audio_processing_error_creation() {
let error = SubXError::audio_processing("decode failed");
assert!(matches!(error, SubXError::AudioProcessing { .. }));
assert_eq!(error.to_string(), "Audio processing error: decode failed");
}
#[test]
fn test_file_matching_error_creation() {
let error = SubXError::file_matching("match failed");
assert!(matches!(error, SubXError::FileMatching { .. }));
assert_eq!(error.to_string(), "File matching error: match failed");
}
#[test]
fn test_io_error_conversion() {
let io_error = io::Error::new(io::ErrorKind::NotFound, "file not found");
let subx_error: SubXError = io_error.into();
assert!(matches!(subx_error, SubXError::Io(_)));
}
#[test]
fn test_exit_codes() {
assert_eq!(SubXError::config("test").exit_code(), 2);
assert_eq!(SubXError::subtitle_format("SRT", "test").exit_code(), 4);
assert_eq!(SubXError::audio_processing("test").exit_code(), 5);
assert_eq!(SubXError::file_matching("test").exit_code(), 6);
}
#[test]
fn test_user_friendly_messages() {
let config_error = SubXError::config("missing key");
let message = config_error.user_friendly_message();
assert!(message.contains("Configuration error:"));
assert!(message.contains("subx-cli config --help"));
let ai_error = SubXError::ai_service("network failure".to_string());
let message = ai_error.user_friendly_message();
assert!(message.contains("AI service error:"));
assert!(message.contains("check network connection"));
}
#[test]
fn test_no_api_key_leaks_in_any_variant() {
use crate::services::ai::error_sanitizer::{
DEFAULT_ERROR_BODY_MAX_LEN, sanitize_url_in_error, truncate_error_body,
};
use std::path::PathBuf;
let variants: Vec<SubXError> = vec![
SubXError::Io(io::Error::other("disk error")),
SubXError::Config {
message: "missing key".to_string(),
},
SubXError::SubtitleFormat {
format: "SRT".to_string(),
message: "bad timestamp".to_string(),
},
SubXError::AiService("upstream service failed".to_string()),
SubXError::Api {
message: "auth failed".to_string(),
source: ApiErrorSource::OpenAI,
},
SubXError::AudioProcessing {
message: "codec failure".to_string(),
},
SubXError::FileMatching {
message: "pattern mismatch".to_string(),
},
SubXError::FileAlreadyExists("/tmp/example".to_string()),
SubXError::FileNotFound("/tmp/example".to_string()),
SubXError::InvalidFileName("bad?name".to_string()),
SubXError::FileOperationFailed("rename failed".to_string()),
SubXError::CommandExecution("exit 1".to_string()),
SubXError::NoInputSpecified,
SubXError::InvalidPath(PathBuf::from("/tmp/example")),
SubXError::PathNotFound(PathBuf::from("/tmp/example")),
SubXError::DirectoryReadError {
path: PathBuf::from("/tmp/example"),
source: io::Error::other("denied"),
},
SubXError::InvalidSyncConfiguration,
SubXError::UnsupportedFileType("xyz".to_string()),
SubXError::Other(anyhow::anyhow!("wrapped")),
];
for err in &variants {
let display = format!("{}", err);
let debug = format!("{:?}", err);
let friendly = err.user_friendly_message();
for (label, text) in [
("Display", &display),
("Debug", &debug),
("friendly", &friendly),
] {
assert!(
!text.contains("sk-"),
"{} surface for variant {:?} contains `sk-` prefix: {}",
label,
err,
text,
);
}
}
const SECRET: &str = "sk-test-key-12345";
let upstream_body = format!(
"{{\"error\": \"invalid\", \"echoed\": \"Bearer {}\"}}",
SECRET
);
let truncated = truncate_error_body(&upstream_body, DEFAULT_ERROR_BODY_MAX_LEN);
assert!(truncated.contains(SECRET));
let url_leak = format!(
"request error: https://api.example.com/v1/chat?api-key={}",
SECRET
);
let cleaned = sanitize_url_in_error(&url_leak);
assert!(!cleaned.contains("sk-test-key"));
let wrapped = SubXError::AiService(cleaned);
assert!(!format!("{}", wrapped).contains("sk-test-key"));
assert!(!format!("{:?}", wrapped).contains("sk-test-key"));
}
}
impl From<reqwest::Error> for SubXError {
fn from(err: reqwest::Error) -> Self {
let raw = err.to_string();
let sanitized = crate::services::ai::error_sanitizer::sanitize_url_in_error(&raw);
SubXError::AiService(sanitized)
}
}
impl From<walkdir::Error> for SubXError {
fn from(err: walkdir::Error) -> Self {
SubXError::FileMatching {
message: err.to_string(),
}
}
}
impl From<symphonia::core::errors::Error> for SubXError {
fn from(err: symphonia::core::errors::Error) -> Self {
SubXError::audio_processing(err.to_string())
}
}
impl From<config::ConfigError> for SubXError {
fn from(err: config::ConfigError) -> Self {
match err {
config::ConfigError::NotFound(path) => SubXError::Config {
message: format!("Configuration file not found: {}", path),
},
config::ConfigError::Message(msg) => SubXError::Config { message: msg },
_ => SubXError::Config {
message: format!("Configuration error: {}", err),
},
}
}
}
impl From<serde_json::Error> for SubXError {
fn from(err: serde_json::Error) -> Self {
SubXError::Config {
message: format!("JSON serialization/deserialization error: {}", err),
}
}
}
pub type SubXResult<T> = Result<T, SubXError>;
impl SubXError {
pub fn config<S: Into<String>>(message: S) -> Self {
SubXError::Config {
message: message.into(),
}
}
pub fn subtitle_format<S1, S2>(format: S1, message: S2) -> Self
where
S1: Into<String>,
S2: Into<String>,
{
SubXError::SubtitleFormat {
format: format.into(),
message: message.into(),
}
}
pub fn audio_processing<S: Into<String>>(message: S) -> Self {
SubXError::AudioProcessing {
message: message.into(),
}
}
pub fn ai_service<S: Into<String>>(message: S) -> Self {
SubXError::AiService(message.into())
}
pub fn file_matching<S: Into<String>>(message: S) -> Self {
SubXError::FileMatching {
message: message.into(),
}
}
pub fn parallel_processing(msg: String) -> Self {
SubXError::CommandExecution(format!("Parallel processing error: {}", msg))
}
pub fn task_execution_failed(task_id: String, reason: String) -> Self {
SubXError::CommandExecution(format!("Task {} execution failed: {}", task_id, reason))
}
pub fn worker_pool_exhausted() -> Self {
SubXError::CommandExecution("Worker pool exhausted".to_string())
}
pub fn task_timeout(task_id: String, duration: std::time::Duration) -> Self {
SubXError::CommandExecution(format!(
"Task {} timed out (limit: {:?})",
task_id, duration
))
}
pub fn dialogue_detection_failed<S: Into<String>>(msg: S) -> Self {
SubXError::AudioProcessing {
message: format!("Dialogue detection failed: {}", msg.into()),
}
}
pub fn invalid_audio_format<S: Into<String>>(format: S) -> Self {
SubXError::AudioProcessing {
message: format!("Unsupported audio format: {}", format.into()),
}
}
pub fn dialogue_segment_invalid<S: Into<String>>(reason: S) -> Self {
SubXError::AudioProcessing {
message: format!("Invalid dialogue segment: {}", reason.into()),
}
}
pub fn exit_code(&self) -> i32 {
match self {
SubXError::Io(_) => 1,
SubXError::Config { .. } => 2,
SubXError::Api { .. } => 3,
SubXError::AiService(_) => 3,
SubXError::SubtitleFormat { .. } => 4,
SubXError::AudioProcessing { .. } => 5,
SubXError::FileMatching { .. } => 6,
_ => 1,
}
}
pub fn user_friendly_message(&self) -> String {
match self {
SubXError::Io(e) => format!("File operation error: {}", e),
SubXError::Config { message } => format!(
"Configuration error: {}\nHint: run 'subx-cli config --help' for details",
message
),
SubXError::Api { message, source } => format!(
"API error ({:?}): {}\nHint: check network connection and API key settings",
source, message
),
SubXError::AiService(msg) => format!(
"AI service error: {}\nHint: check network connection and API key settings",
msg
),
SubXError::SubtitleFormat { message, .. } => format!(
"Subtitle processing error: {}\nHint: check file format and encoding",
message
),
SubXError::AudioProcessing { message } => format!(
"Audio processing error: {}\nHint: ensure media file integrity and support",
message
),
SubXError::FileMatching { message } => format!(
"File matching error: {}\nHint: verify file paths and patterns",
message
),
SubXError::FileAlreadyExists(path) => format!("File already exists: {}", path),
SubXError::FileNotFound(path) => format!("File not found: {}", path),
SubXError::InvalidFileName(name) => format!("Invalid file name: {}", name),
SubXError::FileOperationFailed(msg) => format!("File operation failed: {}", msg),
SubXError::CommandExecution(msg) => msg.clone(),
SubXError::Other(err) => {
format!("Unknown error: {}\nHint: please report this issue", err)
}
_ => format!("Error: {}", self),
}
}
}
impl SubXError {
pub fn whisper_api<T: Into<String>>(message: T) -> Self {
Self::Api {
message: message.into(),
source: ApiErrorSource::Whisper,
}
}
pub fn audio_extraction<T: Into<String>>(message: T) -> Self {
Self::AudioProcessing {
message: message.into(),
}
}
}
#[derive(Debug, thiserror::Error)]
pub enum ApiErrorSource {
#[error("OpenAI")]
OpenAI,
#[error("Whisper")]
Whisper,
}
impl From<Box<dyn std::error::Error>> for SubXError {
fn from(err: Box<dyn std::error::Error>) -> Self {
SubXError::audio_processing(err.to_string())
}
}