1use thiserror::Error;
10
11#[derive(Error, Debug)]
33pub enum SubXError {
34 #[error("I/O error: {0}")]
44 Io(#[from] std::io::Error),
45
46 #[error("Configuration error: {message}")]
50 Config {
51 message: String,
53 },
54
55 #[error("Subtitle format error [{format}]: {message}")]
59 SubtitleFormat {
60 format: String,
62 message: String,
64 },
65
66 #[error("AI service error: {0}")]
70 AiService(String),
71
72 #[error("API error [{source:?}]: {message}")]
77 Api {
78 message: String,
80 source: ApiErrorSource,
82 },
83
84 #[error("Audio processing error: {message}")]
88 AudioProcessing {
89 message: String,
91 },
92
93 #[error("File matching error: {message}")]
97 FileMatching {
98 message: String,
100 },
101 #[error("File already exists: {0}")]
103 FileAlreadyExists(String),
104 #[error("File not found: {0}")]
106 FileNotFound(String),
107 #[error("Invalid file name: {0}")]
109 InvalidFileName(String),
110 #[error("File operation failed: {0}")]
112 FileOperationFailed(String),
113 #[error("{0}")]
115 CommandExecution(String),
116
117 #[error("No input path specified")]
119 NoInputSpecified,
120
121 #[error("Invalid path: {0}")]
123 InvalidPath(std::path::PathBuf),
124
125 #[error("Path not found: {0}")]
127 PathNotFound(std::path::PathBuf),
128
129 #[error("Unable to read directory: {path}")]
131 DirectoryReadError {
132 path: std::path::PathBuf,
134 #[source]
136 source: std::io::Error,
137 },
138
139 #[error(
141 "Invalid sync configuration: please specify video and subtitle files, or use -i parameter for batch processing"
142 )]
143 InvalidSyncConfiguration,
144
145 #[error("Unsupported file type: {0}")]
147 UnsupportedFileType(String),
148
149 #[error("Unknown error: {0}")]
151 Other(#[from] anyhow::Error),
152}
153
154#[cfg(test)]
156mod tests {
157 use super::*;
158 use std::io;
159
160 #[test]
161 fn test_config_error_creation() {
162 let error = SubXError::config("test config error");
163 assert!(matches!(error, SubXError::Config { .. }));
164 assert_eq!(error.to_string(), "Configuration error: test config error");
165 }
166
167 #[test]
168 fn test_subtitle_format_error_creation() {
169 let error = SubXError::subtitle_format("SRT", "invalid format");
170 assert!(matches!(error, SubXError::SubtitleFormat { .. }));
171 let msg = error.to_string();
172 assert!(msg.contains("SRT"));
173 assert!(msg.contains("invalid format"));
174 }
175
176 #[test]
177 fn test_audio_processing_error_creation() {
178 let error = SubXError::audio_processing("decode failed");
179 assert!(matches!(error, SubXError::AudioProcessing { .. }));
180 assert_eq!(error.to_string(), "Audio processing error: decode failed");
181 }
182
183 #[test]
184 fn test_file_matching_error_creation() {
185 let error = SubXError::file_matching("match failed");
186 assert!(matches!(error, SubXError::FileMatching { .. }));
187 assert_eq!(error.to_string(), "File matching error: match failed");
188 }
189
190 #[test]
191 fn test_io_error_conversion() {
192 let io_error = io::Error::new(io::ErrorKind::NotFound, "file not found");
193 let subx_error: SubXError = io_error.into();
194 assert!(matches!(subx_error, SubXError::Io(_)));
195 }
196
197 #[test]
198 fn test_exit_codes() {
199 assert_eq!(SubXError::config("test").exit_code(), 2);
200 assert_eq!(SubXError::subtitle_format("SRT", "test").exit_code(), 4);
201 assert_eq!(SubXError::audio_processing("test").exit_code(), 5);
202 assert_eq!(SubXError::file_matching("test").exit_code(), 6);
203 }
204
205 #[test]
206 fn test_user_friendly_messages() {
207 let config_error = SubXError::config("missing key");
208 let message = config_error.user_friendly_message();
209 assert!(message.contains("Configuration error:"));
210 assert!(message.contains("subx-cli config --help"));
211
212 let ai_error = SubXError::ai_service("network failure".to_string());
213 let message = ai_error.user_friendly_message();
214 assert!(message.contains("AI service error:"));
215 assert!(message.contains("check network connection"));
216 }
217
218 #[test]
230 fn test_no_api_key_leaks_in_any_variant() {
231 use crate::services::ai::error_sanitizer::{
232 DEFAULT_ERROR_BODY_MAX_LEN, sanitize_url_in_error, truncate_error_body,
233 };
234 use std::path::PathBuf;
235
236 let variants: Vec<SubXError> = vec![
239 SubXError::Io(io::Error::other("disk error")),
240 SubXError::Config {
241 message: "missing key".to_string(),
242 },
243 SubXError::SubtitleFormat {
244 format: "SRT".to_string(),
245 message: "bad timestamp".to_string(),
246 },
247 SubXError::AiService("upstream service failed".to_string()),
248 SubXError::Api {
249 message: "auth failed".to_string(),
250 source: ApiErrorSource::OpenAI,
251 },
252 SubXError::AudioProcessing {
253 message: "codec failure".to_string(),
254 },
255 SubXError::FileMatching {
256 message: "pattern mismatch".to_string(),
257 },
258 SubXError::FileAlreadyExists("/tmp/example".to_string()),
259 SubXError::FileNotFound("/tmp/example".to_string()),
260 SubXError::InvalidFileName("bad?name".to_string()),
261 SubXError::FileOperationFailed("rename failed".to_string()),
262 SubXError::CommandExecution("exit 1".to_string()),
263 SubXError::NoInputSpecified,
264 SubXError::InvalidPath(PathBuf::from("/tmp/example")),
265 SubXError::PathNotFound(PathBuf::from("/tmp/example")),
266 SubXError::DirectoryReadError {
267 path: PathBuf::from("/tmp/example"),
268 source: io::Error::other("denied"),
269 },
270 SubXError::InvalidSyncConfiguration,
271 SubXError::UnsupportedFileType("xyz".to_string()),
272 SubXError::Other(anyhow::anyhow!("wrapped")),
273 ];
274
275 for err in &variants {
276 let display = format!("{}", err);
277 let debug = format!("{:?}", err);
278 let friendly = err.user_friendly_message();
279 for (label, text) in [
280 ("Display", &display),
281 ("Debug", &debug),
282 ("friendly", &friendly),
283 ] {
284 assert!(
285 !text.contains("sk-"),
286 "{} surface for variant {:?} contains `sk-` prefix: {}",
287 label,
288 err,
289 text,
290 );
291 }
292 }
293
294 const SECRET: &str = "sk-test-key-12345";
298 let upstream_body = format!(
299 "{{\"error\": \"invalid\", \"echoed\": \"Bearer {}\"}}",
300 SECRET
301 );
302 let truncated = truncate_error_body(&upstream_body, DEFAULT_ERROR_BODY_MAX_LEN);
303 assert!(truncated.contains(SECRET));
307
308 let url_leak = format!(
309 "request error: https://api.example.com/v1/chat?api-key={}",
310 SECRET
311 );
312 let cleaned = sanitize_url_in_error(&url_leak);
313 assert!(!cleaned.contains("sk-test-key"));
314 let wrapped = SubXError::AiService(cleaned);
315 assert!(!format!("{}", wrapped).contains("sk-test-key"));
316 assert!(!format!("{:?}", wrapped).contains("sk-test-key"));
317 }
318}
319
320impl From<reqwest::Error> for SubXError {
322 fn from(err: reqwest::Error) -> Self {
323 let raw = err.to_string();
324 let sanitized = crate::services::ai::error_sanitizer::sanitize_url_in_error(&raw);
328 SubXError::AiService(sanitized)
329 }
330}
331
332impl From<walkdir::Error> for SubXError {
334 fn from(err: walkdir::Error) -> Self {
335 SubXError::FileMatching {
336 message: err.to_string(),
337 }
338 }
339}
340impl From<symphonia::core::errors::Error> for SubXError {
342 fn from(err: symphonia::core::errors::Error) -> Self {
343 SubXError::audio_processing(err.to_string())
344 }
345}
346
347impl From<config::ConfigError> for SubXError {
349 fn from(err: config::ConfigError) -> Self {
350 match err {
351 config::ConfigError::NotFound(path) => SubXError::Config {
352 message: format!("Configuration file not found: {}", path),
353 },
354 config::ConfigError::Message(msg) => SubXError::Config { message: msg },
355 _ => SubXError::Config {
356 message: format!("Configuration error: {}", err),
357 },
358 }
359 }
360}
361
362impl From<serde_json::Error> for SubXError {
363 fn from(err: serde_json::Error) -> Self {
364 SubXError::Config {
365 message: format!("JSON serialization/deserialization error: {}", err),
366 }
367 }
368}
369
370pub type SubXResult<T> = Result<T, SubXError>;
372
373impl SubXError {
374 pub fn config<S: Into<String>>(message: S) -> Self {
384 SubXError::Config {
385 message: message.into(),
386 }
387 }
388
389 pub fn subtitle_format<S1, S2>(format: S1, message: S2) -> Self
399 where
400 S1: Into<String>,
401 S2: Into<String>,
402 {
403 SubXError::SubtitleFormat {
404 format: format.into(),
405 message: message.into(),
406 }
407 }
408
409 pub fn audio_processing<S: Into<String>>(message: S) -> Self {
419 SubXError::AudioProcessing {
420 message: message.into(),
421 }
422 }
423
424 pub fn ai_service<S: Into<String>>(message: S) -> Self {
434 SubXError::AiService(message.into())
435 }
436
437 pub fn file_matching<S: Into<String>>(message: S) -> Self {
447 SubXError::FileMatching {
448 message: message.into(),
449 }
450 }
451 pub fn parallel_processing(msg: String) -> Self {
453 SubXError::CommandExecution(format!("Parallel processing error: {}", msg))
454 }
455 pub fn task_execution_failed(task_id: String, reason: String) -> Self {
457 SubXError::CommandExecution(format!("Task {} execution failed: {}", task_id, reason))
458 }
459 pub fn worker_pool_exhausted() -> Self {
461 SubXError::CommandExecution("Worker pool exhausted".to_string())
462 }
463 pub fn task_timeout(task_id: String, duration: std::time::Duration) -> Self {
465 SubXError::CommandExecution(format!(
466 "Task {} timed out (limit: {:?})",
467 task_id, duration
468 ))
469 }
470 pub fn dialogue_detection_failed<S: Into<String>>(msg: S) -> Self {
472 SubXError::AudioProcessing {
473 message: format!("Dialogue detection failed: {}", msg.into()),
474 }
475 }
476 pub fn invalid_audio_format<S: Into<String>>(format: S) -> Self {
478 SubXError::AudioProcessing {
479 message: format!("Unsupported audio format: {}", format.into()),
480 }
481 }
482 pub fn dialogue_segment_invalid<S: Into<String>>(reason: S) -> Self {
484 SubXError::AudioProcessing {
485 message: format!("Invalid dialogue segment: {}", reason.into()),
486 }
487 }
488 pub fn exit_code(&self) -> i32 {
497 match self {
498 SubXError::Io(_) => 1,
499 SubXError::Config { .. } => 2,
500 SubXError::Api { .. } => 3,
501 SubXError::AiService(_) => 3,
502 SubXError::SubtitleFormat { .. } => 4,
503 SubXError::AudioProcessing { .. } => 5,
504 SubXError::FileMatching { .. } => 6,
505 _ => 1,
506 }
507 }
508
509 pub fn user_friendly_message(&self) -> String {
519 match self {
520 SubXError::Io(e) => format!("File operation error: {}", e),
521 SubXError::Config { message } => format!(
522 "Configuration error: {}\nHint: run 'subx-cli config --help' for details",
523 message
524 ),
525 SubXError::Api { message, source } => format!(
526 "API error ({:?}): {}\nHint: check network connection and API key settings",
527 source, message
528 ),
529 SubXError::AiService(msg) => format!(
530 "AI service error: {}\nHint: check network connection and API key settings",
531 msg
532 ),
533 SubXError::SubtitleFormat { message, .. } => format!(
534 "Subtitle processing error: {}\nHint: check file format and encoding",
535 message
536 ),
537 SubXError::AudioProcessing { message } => format!(
538 "Audio processing error: {}\nHint: ensure media file integrity and support",
539 message
540 ),
541 SubXError::FileMatching { message } => format!(
542 "File matching error: {}\nHint: verify file paths and patterns",
543 message
544 ),
545 SubXError::FileAlreadyExists(path) => format!("File already exists: {}", path),
546 SubXError::FileNotFound(path) => format!("File not found: {}", path),
547 SubXError::InvalidFileName(name) => format!("Invalid file name: {}", name),
548 SubXError::FileOperationFailed(msg) => format!("File operation failed: {}", msg),
549 SubXError::CommandExecution(msg) => msg.clone(),
550 SubXError::Other(err) => {
551 format!("Unknown error: {}\nHint: please report this issue", err)
552 }
553 _ => format!("Error: {}", self),
554 }
555 }
556}
557
558impl SubXError {
560 pub fn whisper_api<T: Into<String>>(message: T) -> Self {
570 Self::Api {
571 message: message.into(),
572 source: ApiErrorSource::Whisper,
573 }
574 }
575
576 pub fn audio_extraction<T: Into<String>>(message: T) -> Self {
586 Self::AudioProcessing {
587 message: message.into(),
588 }
589 }
590}
591
592#[derive(Debug, thiserror::Error)]
597pub enum ApiErrorSource {
598 #[error("OpenAI")]
600 OpenAI,
601 #[error("Whisper")]
603 Whisper,
604}
605
606impl From<Box<dyn std::error::Error>> for SubXError {
608 fn from(err: Box<dyn std::error::Error>) -> Self {
609 SubXError::audio_processing(err.to_string())
610 }
611}