1use thiserror::Error;
10
11#[derive(Error, Debug)]
38pub enum SubXError {
39 #[error("I/O error: {0}")]
49 Io(#[from] std::io::Error),
50
51 #[error("Configuration error: {message}")]
55 Config {
56 message: String,
58 },
59
60 #[error("Subtitle format error [{format}]: {message}")]
64 SubtitleFormat {
65 format: String,
67 message: String,
69 },
70
71 #[error("AI service error: {0}")]
75 AiService(String),
76
77 #[error("API error [{source:?}]: {message}")]
82 Api {
83 message: String,
85 source: ApiErrorSource,
87 },
88
89 #[error("Audio processing error: {message}")]
93 AudioProcessing {
94 message: String,
96 },
97
98 #[error("File matching error: {message}")]
102 FileMatching {
103 message: String,
105 },
106 #[error("File already exists: {0}")]
108 FileAlreadyExists(String),
109 #[error("File not found: {0}")]
111 FileNotFound(String),
112 #[error("Invalid file name: {0}")]
114 InvalidFileName(String),
115 #[error("File operation failed: {0}")]
117 FileOperationFailed(String),
118 #[error("{0}")]
120 CommandExecution(String),
121
122 #[error("No input path specified")]
124 NoInputSpecified,
125
126 #[error("Invalid path: {0}")]
128 InvalidPath(std::path::PathBuf),
129
130 #[error("Path not found: {0}")]
132 PathNotFound(std::path::PathBuf),
133
134 #[error("Unable to read directory: {path}")]
136 DirectoryReadError {
137 path: std::path::PathBuf,
139 #[source]
141 source: std::io::Error,
142 },
143
144 #[error(
146 "Invalid sync configuration: please specify video and subtitle files, or use -i parameter for batch processing"
147 )]
148 InvalidSyncConfiguration,
149
150 #[error("Unsupported file type: {0}")]
152 UnsupportedFileType(String),
153
154 #[error(
170 "The '{command}' command does not support --output json; its stdout is a shell-completion script"
171 )]
172 OutputModeUnsupported {
173 command: String,
175 },
176
177 #[error("Unknown error: {0}")]
179 Other(#[from] anyhow::Error),
180}
181
182#[cfg(test)]
184mod tests {
185 use super::*;
186 use std::io;
187 use std::path::PathBuf;
188
189 #[test]
192 fn test_io_error_display() {
193 let err = SubXError::Io(io::Error::new(io::ErrorKind::PermissionDenied, "denied"));
194 assert!(err.to_string().starts_with("I/O error:"));
195 assert!(err.to_string().contains("denied"));
196 }
197
198 #[test]
199 fn test_ai_service_display() {
200 let err = SubXError::AiService("timeout".to_string());
201 assert_eq!(err.to_string(), "AI service error: timeout");
202 }
203
204 #[test]
205 fn test_api_display() {
206 let err = SubXError::Api {
207 message: "bad request".to_string(),
208 source: ApiErrorSource::OpenAI,
209 };
210 let s = err.to_string();
211 assert!(s.contains("API error"));
212 assert!(s.contains("bad request"));
213 assert!(s.contains("OpenAI"));
214 }
215
216 #[test]
217 fn test_file_already_exists_display() {
218 let err = SubXError::FileAlreadyExists("foo.srt".to_string());
219 assert_eq!(err.to_string(), "File already exists: foo.srt");
220 }
221
222 #[test]
223 fn test_file_not_found_display() {
224 let err = SubXError::FileNotFound("bar.srt".to_string());
225 assert_eq!(err.to_string(), "File not found: bar.srt");
226 }
227
228 #[test]
229 fn test_invalid_file_name_display() {
230 let err = SubXError::InvalidFileName("bad?name".to_string());
231 assert_eq!(err.to_string(), "Invalid file name: bad?name");
232 }
233
234 #[test]
235 fn test_file_operation_failed_display() {
236 let err = SubXError::FileOperationFailed("rename failed".to_string());
237 assert_eq!(err.to_string(), "File operation failed: rename failed");
238 }
239
240 #[test]
241 fn test_command_execution_display() {
242 let err = SubXError::CommandExecution("exit 1".to_string());
243 assert_eq!(err.to_string(), "exit 1");
244 }
245
246 #[test]
247 fn test_no_input_specified_display() {
248 let err = SubXError::NoInputSpecified;
249 assert_eq!(err.to_string(), "No input path specified");
250 }
251
252 #[test]
253 fn test_invalid_path_display() {
254 let err = SubXError::InvalidPath(PathBuf::from("/bad/path"));
255 assert!(err.to_string().contains("Invalid path:"));
256 assert!(err.to_string().contains("/bad/path"));
257 }
258
259 #[test]
260 fn test_path_not_found_display() {
261 let err = SubXError::PathNotFound(PathBuf::from("/missing"));
262 assert!(err.to_string().contains("Path not found:"));
263 }
264
265 #[test]
266 fn test_directory_read_error_display() {
267 let err = SubXError::DirectoryReadError {
268 path: PathBuf::from("/locked"),
269 source: io::Error::new(io::ErrorKind::PermissionDenied, "denied"),
270 };
271 assert!(err.to_string().contains("Unable to read directory:"));
272 assert!(err.to_string().contains("/locked"));
273 }
274
275 #[test]
276 fn test_invalid_sync_configuration_display() {
277 let err = SubXError::InvalidSyncConfiguration;
278 assert!(err.to_string().contains("Invalid sync configuration"));
279 }
280
281 #[test]
282 fn test_unsupported_file_type_display() {
283 let err = SubXError::UnsupportedFileType("xyz".to_string());
284 assert_eq!(err.to_string(), "Unsupported file type: xyz");
285 }
286
287 #[test]
288 fn test_other_error_display() {
289 let err = SubXError::Other(anyhow::anyhow!("wrapped error"));
290 assert!(err.to_string().contains("Unknown error:"));
291 assert!(err.to_string().contains("wrapped error"));
292 }
293
294 #[test]
297 fn test_api_error_source_display() {
298 assert_eq!(ApiErrorSource::OpenAI.to_string(), "OpenAI");
299 assert_eq!(ApiErrorSource::Whisper.to_string(), "Whisper");
300 }
301
302 #[test]
305 fn test_config_error_creation() {
306 let error = SubXError::config("test config error");
307 assert!(matches!(error, SubXError::Config { .. }));
308 assert_eq!(error.to_string(), "Configuration error: test config error");
309 }
310
311 #[test]
312 fn test_subtitle_format_error_creation() {
313 let error = SubXError::subtitle_format("SRT", "invalid format");
314 assert!(matches!(error, SubXError::SubtitleFormat { .. }));
315 let msg = error.to_string();
316 assert!(msg.contains("SRT"));
317 assert!(msg.contains("invalid format"));
318 }
319
320 #[test]
321 fn test_audio_processing_error_creation() {
322 let error = SubXError::audio_processing("decode failed");
323 assert!(matches!(error, SubXError::AudioProcessing { .. }));
324 assert_eq!(error.to_string(), "Audio processing error: decode failed");
325 }
326
327 #[test]
328 fn test_file_matching_error_creation() {
329 let error = SubXError::file_matching("match failed");
330 assert!(matches!(error, SubXError::FileMatching { .. }));
331 assert_eq!(error.to_string(), "File matching error: match failed");
332 }
333
334 #[test]
335 fn test_io_error_conversion() {
336 let io_error = io::Error::new(io::ErrorKind::NotFound, "file not found");
337 let subx_error: SubXError = io_error.into();
338 assert!(matches!(subx_error, SubXError::Io(_)));
339 }
340
341 #[test]
344 fn test_ai_service_helper() {
345 let err = SubXError::ai_service("network failure");
346 assert!(matches!(err, SubXError::AiService(_)));
347 assert_eq!(err.to_string(), "AI service error: network failure");
348 }
349
350 #[test]
351 fn test_parallel_processing_helper() {
352 let err = SubXError::parallel_processing("channel closed".to_string());
353 assert!(matches!(err, SubXError::CommandExecution(_)));
354 assert!(err.to_string().contains("Parallel processing error:"));
355 assert!(err.to_string().contains("channel closed"));
356 }
357
358 #[test]
359 fn test_task_execution_failed_helper() {
360 let err = SubXError::task_execution_failed("task-42".to_string(), "panic".to_string());
361 assert!(matches!(err, SubXError::CommandExecution(_)));
362 assert!(err.to_string().contains("task-42"));
363 assert!(err.to_string().contains("panic"));
364 }
365
366 #[test]
367 fn test_worker_pool_exhausted_helper() {
368 let err = SubXError::worker_pool_exhausted();
369 assert!(matches!(err, SubXError::CommandExecution(_)));
370 assert_eq!(err.to_string(), "Worker pool exhausted");
371 }
372
373 #[test]
374 fn test_task_timeout_helper() {
375 let dur = std::time::Duration::from_secs(30);
376 let err = SubXError::task_timeout("task-7".to_string(), dur);
377 assert!(matches!(err, SubXError::CommandExecution(_)));
378 assert!(err.to_string().contains("task-7"));
379 assert!(err.to_string().contains("timed out"));
380 }
381
382 #[test]
383 fn test_dialogue_detection_failed_helper() {
384 let err = SubXError::dialogue_detection_failed("no speech found");
385 assert!(matches!(err, SubXError::AudioProcessing { .. }));
386 assert!(err.to_string().contains("Dialogue detection failed:"));
387 assert!(err.to_string().contains("no speech found"));
388 }
389
390 #[test]
391 fn test_invalid_audio_format_helper() {
392 let err = SubXError::invalid_audio_format("flac");
393 assert!(matches!(err, SubXError::AudioProcessing { .. }));
394 assert!(err.to_string().contains("Unsupported audio format:"));
395 assert!(err.to_string().contains("flac"));
396 }
397
398 #[test]
399 fn test_dialogue_segment_invalid_helper() {
400 let err = SubXError::dialogue_segment_invalid("negative duration");
401 assert!(matches!(err, SubXError::AudioProcessing { .. }));
402 assert!(err.to_string().contains("Invalid dialogue segment:"));
403 assert!(err.to_string().contains("negative duration"));
404 }
405
406 #[test]
407 fn test_whisper_api_helper() {
408 let err = SubXError::whisper_api("rate limited");
409 assert!(matches!(err, SubXError::Api { .. }));
410 let s = err.to_string();
411 assert!(s.contains("Whisper"));
412 assert!(s.contains("rate limited"));
413 }
414
415 #[test]
416 fn test_audio_extraction_helper() {
417 let err = SubXError::audio_extraction("ffmpeg missing");
418 assert!(matches!(err, SubXError::AudioProcessing { .. }));
419 assert!(err.to_string().contains("ffmpeg missing"));
420 }
421
422 #[test]
425 fn test_from_anyhow_error() {
426 let anyhow_err = anyhow::anyhow!("some anyhow error");
427 let err: SubXError = anyhow_err.into();
428 assert!(matches!(err, SubXError::Other(_)));
429 assert!(err.to_string().contains("some anyhow error"));
430 }
431
432 #[test]
433 fn test_from_serde_json_error() {
434 let json_err: serde_json::Error =
435 serde_json::from_str::<serde_json::Value>("not json {{{").unwrap_err();
436 let err: SubXError = json_err.into();
437 assert!(matches!(err, SubXError::Config { .. }));
438 assert!(
439 err.to_string()
440 .contains("JSON serialization/deserialization error:")
441 );
442 }
443
444 #[test]
445 fn test_from_config_error_not_found() {
446 let config_err = config::ConfigError::NotFound("settings.toml".to_string());
447 let err: SubXError = config_err.into();
448 assert!(matches!(err, SubXError::Config { .. }));
449 assert!(err.to_string().contains("Configuration file not found:"));
450 assert!(err.to_string().contains("settings.toml"));
451 }
452
453 #[test]
454 fn test_from_config_error_message() {
455 let config_err = config::ConfigError::Message("bad value".to_string());
456 let err: SubXError = config_err.into();
457 assert!(matches!(err, SubXError::Config { .. }));
458 assert!(err.to_string().contains("bad value"));
459 }
460
461 #[test]
462 fn test_from_config_error_other() {
463 let config_err = config::ConfigError::Foreign(Box::new(io::Error::new(
465 io::ErrorKind::Other,
466 "foreign cfg error",
467 )));
468 let err: SubXError = config_err.into();
469 assert!(matches!(err, SubXError::Config { .. }));
470 assert!(err.to_string().contains("Configuration error:"));
471 }
472
473 #[test]
474 fn test_from_box_dyn_error() {
475 let boxed: Box<dyn std::error::Error> =
476 Box::new(io::Error::new(io::ErrorKind::Other, "boxed error"));
477 let err: SubXError = boxed.into();
478 assert!(matches!(err, SubXError::AudioProcessing { .. }));
479 assert!(err.to_string().contains("Audio processing error:"));
480 assert!(err.to_string().contains("boxed error"));
481 }
482
483 #[test]
484 fn test_from_walkdir_error() {
485 let walk_err = walkdir::WalkDir::new("/nonexistent_subx_test_path_xyz")
487 .into_iter()
488 .filter_map(|e| e.err())
489 .next();
490 if let Some(we) = walk_err {
491 let err: SubXError = we.into();
492 assert!(matches!(err, SubXError::FileMatching { .. }));
493 }
494 }
497
498 #[test]
499 fn test_from_symphonia_error() {
500 use symphonia::core::errors::Error as SymphoniaError;
501 let sym_err = SymphoniaError::DecodeError("bad frame");
502 let err: SubXError = sym_err.into();
503 assert!(matches!(err, SubXError::AudioProcessing { .. }));
504 assert!(err.to_string().contains("Audio processing error:"));
505 }
506
507 #[test]
510 fn test_subx_result_ok() {
511 let result: SubXResult<i32> = Ok(42);
512 assert_eq!(result.unwrap(), 42);
513 }
514
515 #[test]
516 fn test_subx_result_err() {
517 let result: SubXResult<i32> = Err(SubXError::NoInputSpecified);
518 assert!(result.is_err());
519 }
520
521 #[test]
534 fn test_no_api_key_leaks_in_any_variant() {
535 use std::path::PathBuf;
536
537 let variants: Vec<SubXError> = vec![
538 SubXError::Io(io::Error::other("disk error")),
539 SubXError::Config {
540 message: "missing key".to_string(),
541 },
542 SubXError::SubtitleFormat {
543 format: "SRT".to_string(),
544 message: "bad timestamp".to_string(),
545 },
546 SubXError::AiService("upstream service failed".to_string()),
547 SubXError::Api {
548 message: "auth failed".to_string(),
549 source: ApiErrorSource::OpenAI,
550 },
551 SubXError::AudioProcessing {
552 message: "codec failure".to_string(),
553 },
554 SubXError::FileMatching {
555 message: "pattern mismatch".to_string(),
556 },
557 SubXError::FileAlreadyExists("/tmp/example".to_string()),
558 SubXError::FileNotFound("/tmp/example".to_string()),
559 SubXError::InvalidFileName("bad?name".to_string()),
560 SubXError::FileOperationFailed("rename failed".to_string()),
561 SubXError::CommandExecution("exit 1".to_string()),
562 SubXError::NoInputSpecified,
563 SubXError::InvalidPath(PathBuf::from("/tmp/example")),
564 SubXError::PathNotFound(PathBuf::from("/tmp/example")),
565 SubXError::DirectoryReadError {
566 path: PathBuf::from("/tmp/example"),
567 source: io::Error::other("denied"),
568 },
569 SubXError::InvalidSyncConfiguration,
570 SubXError::UnsupportedFileType("xyz".to_string()),
571 SubXError::OutputModeUnsupported {
572 command: "generate-completion".to_string(),
573 },
574 SubXError::Other(anyhow::anyhow!("wrapped")),
575 ];
576
577 for err in &variants {
578 let display = format!("{err}");
579 let debug = format!("{err:?}");
580 for (label, text) in [("Display", &display), ("Debug", &debug)] {
581 assert!(
582 !text.contains("sk-"),
583 "{} surface for variant {err:?} contains `sk-` prefix: {text}",
584 label,
585 );
586 }
587 }
588 }
589}
590
591impl From<reqwest::Error> for SubXError {
593 fn from(err: reqwest::Error) -> Self {
594 let raw = err.to_string();
595 let sanitized = crate::services::ai::error_sanitizer::sanitize_url_in_error(&raw);
599 SubXError::AiService(sanitized)
600 }
601}
602
603impl From<walkdir::Error> for SubXError {
605 fn from(err: walkdir::Error) -> Self {
606 SubXError::FileMatching {
607 message: err.to_string(),
608 }
609 }
610}
611impl From<symphonia::core::errors::Error> for SubXError {
613 fn from(err: symphonia::core::errors::Error) -> Self {
614 SubXError::audio_processing(err.to_string())
615 }
616}
617
618impl From<config::ConfigError> for SubXError {
620 fn from(err: config::ConfigError) -> Self {
621 match err {
622 config::ConfigError::NotFound(path) => SubXError::Config {
623 message: format!("Configuration file not found: {}", path),
624 },
625 config::ConfigError::Message(msg) => SubXError::Config { message: msg },
626 _ => SubXError::Config {
627 message: format!("Configuration error: {}", err),
628 },
629 }
630 }
631}
632
633impl From<serde_json::Error> for SubXError {
634 fn from(err: serde_json::Error) -> Self {
635 SubXError::Config {
636 message: format!("JSON serialization/deserialization error: {}", err),
637 }
638 }
639}
640
641pub type SubXResult<T> = Result<T, SubXError>;
643
644impl SubXError {
645 pub fn config<S: Into<String>>(message: S) -> Self {
655 SubXError::Config {
656 message: message.into(),
657 }
658 }
659
660 pub fn subtitle_format<S1, S2>(format: S1, message: S2) -> Self
670 where
671 S1: Into<String>,
672 S2: Into<String>,
673 {
674 SubXError::SubtitleFormat {
675 format: format.into(),
676 message: message.into(),
677 }
678 }
679
680 pub fn audio_processing<S: Into<String>>(message: S) -> Self {
690 SubXError::AudioProcessing {
691 message: message.into(),
692 }
693 }
694
695 pub fn ai_service<S: Into<String>>(message: S) -> Self {
705 SubXError::AiService(message.into())
706 }
707
708 pub fn file_matching<S: Into<String>>(message: S) -> Self {
718 SubXError::FileMatching {
719 message: message.into(),
720 }
721 }
722 pub fn parallel_processing(msg: String) -> Self {
724 SubXError::CommandExecution(format!("Parallel processing error: {}", msg))
725 }
726 pub fn task_execution_failed(task_id: String, reason: String) -> Self {
728 SubXError::CommandExecution(format!("Task {} execution failed: {}", task_id, reason))
729 }
730 pub fn worker_pool_exhausted() -> Self {
732 SubXError::CommandExecution("Worker pool exhausted".to_string())
733 }
734 pub fn task_timeout(task_id: String, duration: std::time::Duration) -> Self {
736 SubXError::CommandExecution(format!(
737 "Task {} timed out (limit: {:?})",
738 task_id, duration
739 ))
740 }
741 pub fn dialogue_detection_failed<S: Into<String>>(msg: S) -> Self {
743 SubXError::AudioProcessing {
744 message: format!("Dialogue detection failed: {}", msg.into()),
745 }
746 }
747 pub fn invalid_audio_format<S: Into<String>>(format: S) -> Self {
749 SubXError::AudioProcessing {
750 message: format!("Unsupported audio format: {}", format.into()),
751 }
752 }
753 pub fn dialogue_segment_invalid<S: Into<String>>(reason: S) -> Self {
755 SubXError::AudioProcessing {
756 message: format!("Invalid dialogue segment: {}", reason.into()),
757 }
758 }
759 pub fn category(&self) -> &'static str {
765 match self {
766 SubXError::Io(_) => "io",
768 SubXError::Config { .. } => "config",
769 SubXError::SubtitleFormat { .. } => "subtitle_format",
770 SubXError::AiService(_) => "ai_service",
771 SubXError::Api { .. } => "api",
772 SubXError::AudioProcessing { .. } => "audio_processing",
773 SubXError::FileMatching { .. } => "file_matching",
774 SubXError::FileAlreadyExists(_) => "file_already_exists",
775 SubXError::FileNotFound(_) => "file_not_found",
776 SubXError::InvalidFileName(_) => "invalid_file_name",
777 SubXError::FileOperationFailed(_) => "file_operation_failed",
778 SubXError::CommandExecution(_) => "command_execution",
779 SubXError::OutputModeUnsupported { .. } => "command_execution",
783 SubXError::NoInputSpecified => "no_input_specified",
784 SubXError::InvalidPath(_) => "invalid_path",
785 SubXError::PathNotFound(_) => "path_not_found",
786 SubXError::DirectoryReadError { .. } => "directory_read_error",
787 SubXError::InvalidSyncConfiguration => "invalid_sync_configuration",
788 SubXError::UnsupportedFileType(_) => "unsupported_file_type",
789 SubXError::Other(_) => "other",
790 }
791 }
792
793 pub fn machine_code(&self) -> &'static str {
797 match self {
798 SubXError::Io(_) => "E_IO",
799 SubXError::Config { .. } => "E_CONFIG",
800 SubXError::SubtitleFormat { .. } => "E_SUBTITLE_FORMAT",
801 SubXError::AiService(_) => "E_AI_SERVICE",
802 SubXError::Api { .. } => "E_API",
803 SubXError::AudioProcessing { .. } => "E_AUDIO_PROCESSING",
804 SubXError::FileMatching { .. } => "E_FILE_MATCHING",
805 SubXError::FileAlreadyExists(_) => "E_FILE_ALREADY_EXISTS",
806 SubXError::FileNotFound(_) => "E_FILE_NOT_FOUND",
807 SubXError::InvalidFileName(_) => "E_INVALID_FILE_NAME",
808 SubXError::FileOperationFailed(_) => "E_FILE_OPERATION_FAILED",
809 SubXError::CommandExecution(_) => "E_COMMAND_EXECUTION",
810 SubXError::OutputModeUnsupported { .. } => "E_OUTPUT_MODE_UNSUPPORTED",
811 SubXError::NoInputSpecified => "E_NO_INPUT_SPECIFIED",
812 SubXError::InvalidPath(_) => "E_INVALID_PATH",
813 SubXError::PathNotFound(_) => "E_PATH_NOT_FOUND",
814 SubXError::DirectoryReadError { .. } => "E_DIRECTORY_READ_ERROR",
815 SubXError::InvalidSyncConfiguration => "E_INVALID_SYNC_CONFIGURATION",
816 SubXError::UnsupportedFileType(_) => "E_UNSUPPORTED_FILE_TYPE",
817 SubXError::Other(_) => "E_OTHER",
818 }
819 }
820
821 pub fn hint(&self) -> Option<&'static str> {
836 match self {
837 SubXError::Config { .. } => {
838 Some("Run 'subx-cli config --help' for configuration details.")
839 }
840 SubXError::Api { .. } | SubXError::AiService(_) => {
841 Some("Check network connectivity and the configured API key.")
842 }
843 SubXError::SubtitleFormat { .. } => {
844 Some("Check the subtitle file's format and encoding.")
845 }
846 SubXError::AudioProcessing { .. } => {
847 Some("Verify the media file's integrity and supported codecs.")
848 }
849 SubXError::FileMatching { .. } => Some("Verify file paths and patterns."),
850 SubXError::NoInputSpecified => Some("Pass an input path or use the -i/--input flag."),
851 SubXError::InvalidSyncConfiguration => {
852 Some("Specify both video and subtitle files, or use -i for batch processing.")
853 }
854 SubXError::PathNotFound(_) | SubXError::FileNotFound(_) => {
855 Some("Verify the path exists and is accessible.")
856 }
857 SubXError::OutputModeUnsupported { .. } => Some(
858 "Run the command without `--output json` (and without SUBX_OUTPUT=json) to receive the shell-completion script.",
859 ),
860 _ => None,
861 }
862 }
863}
864
865impl SubXError {
867 pub fn whisper_api<T: Into<String>>(message: T) -> Self {
877 Self::Api {
878 message: message.into(),
879 source: ApiErrorSource::Whisper,
880 }
881 }
882
883 pub fn audio_extraction<T: Into<String>>(message: T) -> Self {
893 Self::AudioProcessing {
894 message: message.into(),
895 }
896 }
897}
898
899#[derive(Debug, thiserror::Error)]
904pub enum ApiErrorSource {
905 #[error("OpenAI")]
907 OpenAI,
908 #[error("Whisper")]
910 Whisper,
911}
912
913impl From<Box<dyn std::error::Error>> for SubXError {
915 fn from(err: Box<dyn std::error::Error>) -> Self {
916 SubXError::audio_processing(err.to_string())
917 }
918}