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(
156 "The '{command}' command does not support --output json; its stdout is a shell-completion script"
157 )]
158 OutputModeUnsupported {
159 command: String,
161 },
162
163 #[error("Unknown error: {0}")]
165 Other(#[from] anyhow::Error),
166}
167
168#[cfg(test)]
170mod tests {
171 use super::*;
172 use std::io;
173 use std::path::PathBuf;
174
175 #[test]
178 fn test_io_error_display() {
179 let err = SubXError::Io(io::Error::new(io::ErrorKind::PermissionDenied, "denied"));
180 assert!(err.to_string().starts_with("I/O error:"));
181 assert!(err.to_string().contains("denied"));
182 }
183
184 #[test]
185 fn test_ai_service_display() {
186 let err = SubXError::AiService("timeout".to_string());
187 assert_eq!(err.to_string(), "AI service error: timeout");
188 }
189
190 #[test]
191 fn test_api_display() {
192 let err = SubXError::Api {
193 message: "bad request".to_string(),
194 source: ApiErrorSource::OpenAI,
195 };
196 let s = err.to_string();
197 assert!(s.contains("API error"));
198 assert!(s.contains("bad request"));
199 assert!(s.contains("OpenAI"));
200 }
201
202 #[test]
203 fn test_file_already_exists_display() {
204 let err = SubXError::FileAlreadyExists("foo.srt".to_string());
205 assert_eq!(err.to_string(), "File already exists: foo.srt");
206 }
207
208 #[test]
209 fn test_file_not_found_display() {
210 let err = SubXError::FileNotFound("bar.srt".to_string());
211 assert_eq!(err.to_string(), "File not found: bar.srt");
212 }
213
214 #[test]
215 fn test_invalid_file_name_display() {
216 let err = SubXError::InvalidFileName("bad?name".to_string());
217 assert_eq!(err.to_string(), "Invalid file name: bad?name");
218 }
219
220 #[test]
221 fn test_file_operation_failed_display() {
222 let err = SubXError::FileOperationFailed("rename failed".to_string());
223 assert_eq!(err.to_string(), "File operation failed: rename failed");
224 }
225
226 #[test]
227 fn test_command_execution_display() {
228 let err = SubXError::CommandExecution("exit 1".to_string());
229 assert_eq!(err.to_string(), "exit 1");
230 }
231
232 #[test]
233 fn test_no_input_specified_display() {
234 let err = SubXError::NoInputSpecified;
235 assert_eq!(err.to_string(), "No input path specified");
236 }
237
238 #[test]
239 fn test_invalid_path_display() {
240 let err = SubXError::InvalidPath(PathBuf::from("/bad/path"));
241 assert!(err.to_string().contains("Invalid path:"));
242 assert!(err.to_string().contains("/bad/path"));
243 }
244
245 #[test]
246 fn test_path_not_found_display() {
247 let err = SubXError::PathNotFound(PathBuf::from("/missing"));
248 assert!(err.to_string().contains("Path not found:"));
249 }
250
251 #[test]
252 fn test_directory_read_error_display() {
253 let err = SubXError::DirectoryReadError {
254 path: PathBuf::from("/locked"),
255 source: io::Error::new(io::ErrorKind::PermissionDenied, "denied"),
256 };
257 assert!(err.to_string().contains("Unable to read directory:"));
258 assert!(err.to_string().contains("/locked"));
259 }
260
261 #[test]
262 fn test_invalid_sync_configuration_display() {
263 let err = SubXError::InvalidSyncConfiguration;
264 assert!(err.to_string().contains("Invalid sync configuration"));
265 }
266
267 #[test]
268 fn test_unsupported_file_type_display() {
269 let err = SubXError::UnsupportedFileType("xyz".to_string());
270 assert_eq!(err.to_string(), "Unsupported file type: xyz");
271 }
272
273 #[test]
274 fn test_other_error_display() {
275 let err = SubXError::Other(anyhow::anyhow!("wrapped error"));
276 assert!(err.to_string().contains("Unknown error:"));
277 assert!(err.to_string().contains("wrapped error"));
278 }
279
280 #[test]
283 fn test_api_error_source_display() {
284 assert_eq!(ApiErrorSource::OpenAI.to_string(), "OpenAI");
285 assert_eq!(ApiErrorSource::Whisper.to_string(), "Whisper");
286 }
287
288 #[test]
291 fn test_config_error_creation() {
292 let error = SubXError::config("test config error");
293 assert!(matches!(error, SubXError::Config { .. }));
294 assert_eq!(error.to_string(), "Configuration error: test config error");
295 }
296
297 #[test]
298 fn test_subtitle_format_error_creation() {
299 let error = SubXError::subtitle_format("SRT", "invalid format");
300 assert!(matches!(error, SubXError::SubtitleFormat { .. }));
301 let msg = error.to_string();
302 assert!(msg.contains("SRT"));
303 assert!(msg.contains("invalid format"));
304 }
305
306 #[test]
307 fn test_audio_processing_error_creation() {
308 let error = SubXError::audio_processing("decode failed");
309 assert!(matches!(error, SubXError::AudioProcessing { .. }));
310 assert_eq!(error.to_string(), "Audio processing error: decode failed");
311 }
312
313 #[test]
314 fn test_file_matching_error_creation() {
315 let error = SubXError::file_matching("match failed");
316 assert!(matches!(error, SubXError::FileMatching { .. }));
317 assert_eq!(error.to_string(), "File matching error: match failed");
318 }
319
320 #[test]
321 fn test_io_error_conversion() {
322 let io_error = io::Error::new(io::ErrorKind::NotFound, "file not found");
323 let subx_error: SubXError = io_error.into();
324 assert!(matches!(subx_error, SubXError::Io(_)));
325 }
326
327 #[test]
328 fn test_exit_codes() {
329 assert_eq!(SubXError::config("test").exit_code(), 2);
330 assert_eq!(SubXError::subtitle_format("SRT", "test").exit_code(), 4);
331 assert_eq!(SubXError::audio_processing("test").exit_code(), 5);
332 assert_eq!(SubXError::file_matching("test").exit_code(), 6);
333 }
334
335 #[test]
336 fn test_user_friendly_messages() {
337 let config_error = SubXError::config("missing key");
338 let message = config_error.user_friendly_message();
339 assert!(message.contains("Configuration error:"));
340 assert!(message.contains("subx-cli config --help"));
341
342 let ai_error = SubXError::ai_service("network failure".to_string());
343 let message = ai_error.user_friendly_message();
344 assert!(message.contains("AI service error:"));
345 assert!(message.contains("check network connection"));
346 }
347
348 #[test]
360 fn test_no_api_key_leaks_in_any_variant() {
361 use crate::services::ai::error_sanitizer::{
362 DEFAULT_ERROR_BODY_MAX_LEN, sanitize_url_in_error, truncate_error_body,
363 };
364 use std::path::PathBuf;
365
366 let variants: Vec<SubXError> = vec![
369 SubXError::Io(io::Error::other("disk error")),
370 SubXError::Config {
371 message: "missing key".to_string(),
372 },
373 SubXError::SubtitleFormat {
374 format: "SRT".to_string(),
375 message: "bad timestamp".to_string(),
376 },
377 SubXError::AiService("upstream service failed".to_string()),
378 SubXError::Api {
379 message: "auth failed".to_string(),
380 source: ApiErrorSource::OpenAI,
381 },
382 SubXError::AudioProcessing {
383 message: "codec failure".to_string(),
384 },
385 SubXError::FileMatching {
386 message: "pattern mismatch".to_string(),
387 },
388 SubXError::FileAlreadyExists("/tmp/example".to_string()),
389 SubXError::FileNotFound("/tmp/example".to_string()),
390 SubXError::InvalidFileName("bad?name".to_string()),
391 SubXError::FileOperationFailed("rename failed".to_string()),
392 SubXError::CommandExecution("exit 1".to_string()),
393 SubXError::NoInputSpecified,
394 SubXError::InvalidPath(PathBuf::from("/tmp/example")),
395 SubXError::PathNotFound(PathBuf::from("/tmp/example")),
396 SubXError::DirectoryReadError {
397 path: PathBuf::from("/tmp/example"),
398 source: io::Error::other("denied"),
399 },
400 SubXError::InvalidSyncConfiguration,
401 SubXError::UnsupportedFileType("xyz".to_string()),
402 SubXError::OutputModeUnsupported {
403 command: "generate-completion".to_string(),
404 },
405 SubXError::Other(anyhow::anyhow!("wrapped")),
406 ];
407
408 for err in &variants {
409 let display = format!("{}", err);
410 let debug = format!("{:?}", err);
411 let friendly = err.user_friendly_message();
412 for (label, text) in [
413 ("Display", &display),
414 ("Debug", &debug),
415 ("friendly", &friendly),
416 ] {
417 assert!(
418 !text.contains("sk-"),
419 "{} surface for variant {:?} contains `sk-` prefix: {}",
420 label,
421 err,
422 text,
423 );
424 }
425 }
426
427 const SECRET: &str = "sk-test-key-12345";
431 let upstream_body = format!(
432 "{{\"error\": \"invalid\", \"echoed\": \"Bearer {}\"}}",
433 SECRET
434 );
435 let truncated = truncate_error_body(&upstream_body, DEFAULT_ERROR_BODY_MAX_LEN);
436 assert!(truncated.contains(SECRET));
440
441 let url_leak = format!(
442 "request error: https://api.example.com/v1/chat?api-key={}",
443 SECRET
444 );
445 let cleaned = sanitize_url_in_error(&url_leak);
446 assert!(!cleaned.contains("sk-test-key"));
447 let wrapped = SubXError::AiService(cleaned);
448 assert!(!format!("{}", wrapped).contains("sk-test-key"));
449 assert!(!format!("{:?}", wrapped).contains("sk-test-key"));
450 }
451
452 #[test]
455 fn test_exit_code_io() {
456 let err = SubXError::Io(io::Error::new(io::ErrorKind::NotFound, "x"));
457 assert_eq!(err.exit_code(), 1);
458 }
459
460 #[test]
461 fn test_exit_code_api() {
462 let err = SubXError::Api {
463 message: "x".to_string(),
464 source: ApiErrorSource::OpenAI,
465 };
466 assert_eq!(err.exit_code(), 3);
467 }
468
469 #[test]
470 fn test_exit_code_ai_service() {
471 let err = SubXError::AiService("x".to_string());
472 assert_eq!(err.exit_code(), 3);
473 }
474
475 #[test]
476 fn test_exit_code_catchall_variants() {
477 assert_eq!(SubXError::FileAlreadyExists("f".to_string()).exit_code(), 1);
478 assert_eq!(SubXError::FileNotFound("f".to_string()).exit_code(), 1);
479 assert_eq!(SubXError::InvalidFileName("f".to_string()).exit_code(), 1);
480 assert_eq!(
481 SubXError::FileOperationFailed("f".to_string()).exit_code(),
482 1
483 );
484 assert_eq!(SubXError::CommandExecution("f".to_string()).exit_code(), 1);
485 assert_eq!(SubXError::NoInputSpecified.exit_code(), 1);
486 assert_eq!(SubXError::InvalidPath(PathBuf::from("/x")).exit_code(), 1);
487 assert_eq!(SubXError::PathNotFound(PathBuf::from("/x")).exit_code(), 1);
488 assert_eq!(SubXError::InvalidSyncConfiguration.exit_code(), 1);
489 assert_eq!(
490 SubXError::UnsupportedFileType("xyz".to_string()).exit_code(),
491 1
492 );
493 assert_eq!(SubXError::Other(anyhow::anyhow!("other")).exit_code(), 1);
494 }
495
496 #[test]
504 fn test_category_and_machine_code_contract() {
505 let cases: Vec<(SubXError, &'static str, &'static str, i32)> = vec![
506 (SubXError::Io(io::Error::other("x")), "io", "E_IO", 1),
507 (
508 SubXError::Config {
509 message: "x".into(),
510 },
511 "config",
512 "E_CONFIG",
513 2,
514 ),
515 (
516 SubXError::SubtitleFormat {
517 format: "SRT".into(),
518 message: "x".into(),
519 },
520 "subtitle_format",
521 "E_SUBTITLE_FORMAT",
522 4,
523 ),
524 (
525 SubXError::AiService("x".into()),
526 "ai_service",
527 "E_AI_SERVICE",
528 3,
529 ),
530 (
531 SubXError::Api {
532 message: "x".into(),
533 source: ApiErrorSource::OpenAI,
534 },
535 "api",
536 "E_API",
537 3,
538 ),
539 (
540 SubXError::AudioProcessing {
541 message: "x".into(),
542 },
543 "audio_processing",
544 "E_AUDIO_PROCESSING",
545 5,
546 ),
547 (
548 SubXError::FileMatching {
549 message: "x".into(),
550 },
551 "file_matching",
552 "E_FILE_MATCHING",
553 6,
554 ),
555 (
556 SubXError::FileAlreadyExists("x".into()),
557 "file_already_exists",
558 "E_FILE_ALREADY_EXISTS",
559 1,
560 ),
561 (
562 SubXError::FileNotFound("x".into()),
563 "file_not_found",
564 "E_FILE_NOT_FOUND",
565 1,
566 ),
567 (
568 SubXError::InvalidFileName("x".into()),
569 "invalid_file_name",
570 "E_INVALID_FILE_NAME",
571 1,
572 ),
573 (
574 SubXError::FileOperationFailed("x".into()),
575 "file_operation_failed",
576 "E_FILE_OPERATION_FAILED",
577 1,
578 ),
579 (
580 SubXError::CommandExecution("x".into()),
581 "command_execution",
582 "E_COMMAND_EXECUTION",
583 1,
584 ),
585 (
586 SubXError::NoInputSpecified,
587 "no_input_specified",
588 "E_NO_INPUT_SPECIFIED",
589 1,
590 ),
591 (
592 SubXError::InvalidPath(PathBuf::from("/x")),
593 "invalid_path",
594 "E_INVALID_PATH",
595 1,
596 ),
597 (
598 SubXError::PathNotFound(PathBuf::from("/x")),
599 "path_not_found",
600 "E_PATH_NOT_FOUND",
601 1,
602 ),
603 (
604 SubXError::DirectoryReadError {
605 path: PathBuf::from("/x"),
606 source: io::Error::other("denied"),
607 },
608 "directory_read_error",
609 "E_DIRECTORY_READ_ERROR",
610 1,
611 ),
612 (
613 SubXError::InvalidSyncConfiguration,
614 "invalid_sync_configuration",
615 "E_INVALID_SYNC_CONFIGURATION",
616 1,
617 ),
618 (
619 SubXError::UnsupportedFileType("xyz".into()),
620 "unsupported_file_type",
621 "E_UNSUPPORTED_FILE_TYPE",
622 1,
623 ),
624 (
625 SubXError::OutputModeUnsupported {
626 command: "generate-completion".into(),
627 },
628 "command_execution",
629 "E_OUTPUT_MODE_UNSUPPORTED",
630 1,
631 ),
632 (
633 SubXError::Other(anyhow::anyhow!("x")),
634 "other",
635 "E_OTHER",
636 1,
637 ),
638 ];
639
640 for (err, cat, code, exit) in &cases {
641 assert_eq!(err.category(), *cat, "category mismatch for {:?}", err);
642 assert_eq!(
643 err.machine_code(),
644 *code,
645 "machine_code mismatch for {:?}",
646 err
647 );
648 assert_eq!(err.exit_code(), *exit, "exit_code mismatch for {:?}", err);
649 assert!(!err.category().is_empty());
650 assert!(err.machine_code().starts_with("E_"));
651 }
652 }
653
654 #[test]
657 fn test_user_friendly_message_io() {
658 let err = SubXError::Io(io::Error::new(io::ErrorKind::PermissionDenied, "denied"));
659 let msg = err.user_friendly_message();
660 assert!(msg.contains("File operation error:"));
661 assert!(msg.contains("denied"));
662 }
663
664 #[test]
665 fn test_user_friendly_message_api() {
666 let err = SubXError::Api {
667 message: "forbidden".to_string(),
668 source: ApiErrorSource::OpenAI,
669 };
670 let msg = err.user_friendly_message();
671 assert!(msg.contains("API error"));
672 assert!(msg.contains("forbidden"));
673 assert!(msg.contains("check network connection"));
674 }
675
676 #[test]
677 fn test_user_friendly_message_subtitle_format() {
678 let err = SubXError::subtitle_format("ASS", "bad encoding");
679 let msg = err.user_friendly_message();
680 assert!(msg.contains("Subtitle processing error:"));
681 assert!(msg.contains("bad encoding"));
682 assert!(msg.contains("check file format"));
683 }
684
685 #[test]
686 fn test_user_friendly_message_audio_processing() {
687 let err = SubXError::audio_processing("corrupt frame");
688 let msg = err.user_friendly_message();
689 assert!(msg.contains("Audio processing error:"));
690 assert!(msg.contains("corrupt frame"));
691 assert!(msg.contains("media file integrity"));
692 }
693
694 #[test]
695 fn test_user_friendly_message_file_matching() {
696 let err = SubXError::file_matching("pattern mismatch");
697 let msg = err.user_friendly_message();
698 assert!(msg.contains("File matching error:"));
699 assert!(msg.contains("pattern mismatch"));
700 assert!(msg.contains("verify file paths"));
701 }
702
703 #[test]
704 fn test_user_friendly_message_file_already_exists() {
705 let err = SubXError::FileAlreadyExists("output.srt".to_string());
706 assert_eq!(
707 err.user_friendly_message(),
708 "File already exists: output.srt"
709 );
710 }
711
712 #[test]
713 fn test_user_friendly_message_file_not_found() {
714 let err = SubXError::FileNotFound("input.srt".to_string());
715 assert_eq!(err.user_friendly_message(), "File not found: input.srt");
716 }
717
718 #[test]
719 fn test_user_friendly_message_invalid_file_name() {
720 let err = SubXError::InvalidFileName("bad?name".to_string());
721 assert_eq!(err.user_friendly_message(), "Invalid file name: bad?name");
722 }
723
724 #[test]
725 fn test_user_friendly_message_file_operation_failed() {
726 let err = SubXError::FileOperationFailed("rename failed".to_string());
727 assert_eq!(
728 err.user_friendly_message(),
729 "File operation failed: rename failed"
730 );
731 }
732
733 #[test]
734 fn test_user_friendly_message_command_execution() {
735 let err = SubXError::CommandExecution("process died".to_string());
736 assert_eq!(err.user_friendly_message(), "process died");
737 }
738
739 #[test]
740 fn test_user_friendly_message_other() {
741 let err = SubXError::Other(anyhow::anyhow!("mystery"));
742 let msg = err.user_friendly_message();
743 assert!(msg.contains("Unknown error:"));
744 assert!(msg.contains("mystery"));
745 assert!(msg.contains("please report this issue"));
746 }
747
748 #[test]
749 fn test_user_friendly_message_catchall_variants() {
750 let cases: Vec<SubXError> = vec![
752 SubXError::NoInputSpecified,
753 SubXError::InvalidPath(PathBuf::from("/bad")),
754 SubXError::PathNotFound(PathBuf::from("/missing")),
755 SubXError::DirectoryReadError {
756 path: PathBuf::from("/locked"),
757 source: io::Error::new(io::ErrorKind::PermissionDenied, "denied"),
758 },
759 SubXError::InvalidSyncConfiguration,
760 SubXError::UnsupportedFileType("xyz".to_string()),
761 ];
762 for err in &cases {
763 let msg = err.user_friendly_message();
764 assert!(
765 msg.starts_with("Error:"),
766 "Expected 'Error:' prefix for {:?}, got: {}",
767 err,
768 msg
769 );
770 }
771 }
772
773 #[test]
776 fn test_ai_service_helper() {
777 let err = SubXError::ai_service("network failure");
778 assert!(matches!(err, SubXError::AiService(_)));
779 assert_eq!(err.to_string(), "AI service error: network failure");
780 }
781
782 #[test]
783 fn test_parallel_processing_helper() {
784 let err = SubXError::parallel_processing("channel closed".to_string());
785 assert!(matches!(err, SubXError::CommandExecution(_)));
786 assert!(err.to_string().contains("Parallel processing error:"));
787 assert!(err.to_string().contains("channel closed"));
788 }
789
790 #[test]
791 fn test_task_execution_failed_helper() {
792 let err = SubXError::task_execution_failed("task-42".to_string(), "panic".to_string());
793 assert!(matches!(err, SubXError::CommandExecution(_)));
794 assert!(err.to_string().contains("task-42"));
795 assert!(err.to_string().contains("panic"));
796 }
797
798 #[test]
799 fn test_worker_pool_exhausted_helper() {
800 let err = SubXError::worker_pool_exhausted();
801 assert!(matches!(err, SubXError::CommandExecution(_)));
802 assert_eq!(err.to_string(), "Worker pool exhausted");
803 }
804
805 #[test]
806 fn test_task_timeout_helper() {
807 let dur = std::time::Duration::from_secs(30);
808 let err = SubXError::task_timeout("task-7".to_string(), dur);
809 assert!(matches!(err, SubXError::CommandExecution(_)));
810 assert!(err.to_string().contains("task-7"));
811 assert!(err.to_string().contains("timed out"));
812 }
813
814 #[test]
815 fn test_dialogue_detection_failed_helper() {
816 let err = SubXError::dialogue_detection_failed("no speech found");
817 assert!(matches!(err, SubXError::AudioProcessing { .. }));
818 assert!(err.to_string().contains("Dialogue detection failed:"));
819 assert!(err.to_string().contains("no speech found"));
820 }
821
822 #[test]
823 fn test_invalid_audio_format_helper() {
824 let err = SubXError::invalid_audio_format("flac");
825 assert!(matches!(err, SubXError::AudioProcessing { .. }));
826 assert!(err.to_string().contains("Unsupported audio format:"));
827 assert!(err.to_string().contains("flac"));
828 }
829
830 #[test]
831 fn test_dialogue_segment_invalid_helper() {
832 let err = SubXError::dialogue_segment_invalid("negative duration");
833 assert!(matches!(err, SubXError::AudioProcessing { .. }));
834 assert!(err.to_string().contains("Invalid dialogue segment:"));
835 assert!(err.to_string().contains("negative duration"));
836 }
837
838 #[test]
839 fn test_whisper_api_helper() {
840 let err = SubXError::whisper_api("rate limited");
841 assert!(matches!(err, SubXError::Api { .. }));
842 let s = err.to_string();
843 assert!(s.contains("Whisper"));
844 assert!(s.contains("rate limited"));
845 }
846
847 #[test]
848 fn test_audio_extraction_helper() {
849 let err = SubXError::audio_extraction("ffmpeg missing");
850 assert!(matches!(err, SubXError::AudioProcessing { .. }));
851 assert!(err.to_string().contains("ffmpeg missing"));
852 }
853
854 #[test]
857 fn test_from_anyhow_error() {
858 let anyhow_err = anyhow::anyhow!("some anyhow error");
859 let err: SubXError = anyhow_err.into();
860 assert!(matches!(err, SubXError::Other(_)));
861 assert!(err.to_string().contains("some anyhow error"));
862 }
863
864 #[test]
865 fn test_from_serde_json_error() {
866 let json_err: serde_json::Error =
867 serde_json::from_str::<serde_json::Value>("not json {{{").unwrap_err();
868 let err: SubXError = json_err.into();
869 assert!(matches!(err, SubXError::Config { .. }));
870 assert!(
871 err.to_string()
872 .contains("JSON serialization/deserialization error:")
873 );
874 }
875
876 #[test]
877 fn test_from_config_error_not_found() {
878 let config_err = config::ConfigError::NotFound("settings.toml".to_string());
879 let err: SubXError = config_err.into();
880 assert!(matches!(err, SubXError::Config { .. }));
881 assert!(err.to_string().contains("Configuration file not found:"));
882 assert!(err.to_string().contains("settings.toml"));
883 }
884
885 #[test]
886 fn test_from_config_error_message() {
887 let config_err = config::ConfigError::Message("bad value".to_string());
888 let err: SubXError = config_err.into();
889 assert!(matches!(err, SubXError::Config { .. }));
890 assert!(err.to_string().contains("bad value"));
891 }
892
893 #[test]
894 fn test_from_config_error_other() {
895 let config_err = config::ConfigError::Foreign(Box::new(io::Error::new(
897 io::ErrorKind::Other,
898 "foreign cfg error",
899 )));
900 let err: SubXError = config_err.into();
901 assert!(matches!(err, SubXError::Config { .. }));
902 assert!(err.to_string().contains("Configuration error:"));
903 }
904
905 #[test]
906 fn test_from_box_dyn_error() {
907 let boxed: Box<dyn std::error::Error> =
908 Box::new(io::Error::new(io::ErrorKind::Other, "boxed error"));
909 let err: SubXError = boxed.into();
910 assert!(matches!(err, SubXError::AudioProcessing { .. }));
911 assert!(err.to_string().contains("Audio processing error:"));
912 assert!(err.to_string().contains("boxed error"));
913 }
914
915 #[test]
916 fn test_from_walkdir_error() {
917 let walk_err = walkdir::WalkDir::new("/nonexistent_subx_test_path_xyz")
919 .into_iter()
920 .filter_map(|e| e.err())
921 .next();
922 if let Some(we) = walk_err {
923 let err: SubXError = we.into();
924 assert!(matches!(err, SubXError::FileMatching { .. }));
925 }
926 }
929
930 #[test]
931 fn test_from_symphonia_error() {
932 use symphonia::core::errors::Error as SymphoniaError;
933 let sym_err = SymphoniaError::DecodeError("bad frame");
934 let err: SubXError = sym_err.into();
935 assert!(matches!(err, SubXError::AudioProcessing { .. }));
936 assert!(err.to_string().contains("Audio processing error:"));
937 }
938
939 #[test]
942 fn test_subx_result_ok() {
943 let result: SubXResult<i32> = Ok(42);
944 assert_eq!(result.unwrap(), 42);
945 }
946
947 #[test]
948 fn test_subx_result_err() {
949 let result: SubXResult<i32> = Err(SubXError::NoInputSpecified);
950 assert!(result.is_err());
951 }
952}
953
954impl From<reqwest::Error> for SubXError {
956 fn from(err: reqwest::Error) -> Self {
957 let raw = err.to_string();
958 let sanitized = crate::services::ai::error_sanitizer::sanitize_url_in_error(&raw);
962 SubXError::AiService(sanitized)
963 }
964}
965
966impl From<walkdir::Error> for SubXError {
968 fn from(err: walkdir::Error) -> Self {
969 SubXError::FileMatching {
970 message: err.to_string(),
971 }
972 }
973}
974impl From<symphonia::core::errors::Error> for SubXError {
976 fn from(err: symphonia::core::errors::Error) -> Self {
977 SubXError::audio_processing(err.to_string())
978 }
979}
980
981impl From<config::ConfigError> for SubXError {
983 fn from(err: config::ConfigError) -> Self {
984 match err {
985 config::ConfigError::NotFound(path) => SubXError::Config {
986 message: format!("Configuration file not found: {}", path),
987 },
988 config::ConfigError::Message(msg) => SubXError::Config { message: msg },
989 _ => SubXError::Config {
990 message: format!("Configuration error: {}", err),
991 },
992 }
993 }
994}
995
996impl From<serde_json::Error> for SubXError {
997 fn from(err: serde_json::Error) -> Self {
998 SubXError::Config {
999 message: format!("JSON serialization/deserialization error: {}", err),
1000 }
1001 }
1002}
1003
1004pub type SubXResult<T> = Result<T, SubXError>;
1006
1007impl SubXError {
1008 pub fn config<S: Into<String>>(message: S) -> Self {
1018 SubXError::Config {
1019 message: message.into(),
1020 }
1021 }
1022
1023 pub fn subtitle_format<S1, S2>(format: S1, message: S2) -> Self
1033 where
1034 S1: Into<String>,
1035 S2: Into<String>,
1036 {
1037 SubXError::SubtitleFormat {
1038 format: format.into(),
1039 message: message.into(),
1040 }
1041 }
1042
1043 pub fn audio_processing<S: Into<String>>(message: S) -> Self {
1053 SubXError::AudioProcessing {
1054 message: message.into(),
1055 }
1056 }
1057
1058 pub fn ai_service<S: Into<String>>(message: S) -> Self {
1068 SubXError::AiService(message.into())
1069 }
1070
1071 pub fn file_matching<S: Into<String>>(message: S) -> Self {
1081 SubXError::FileMatching {
1082 message: message.into(),
1083 }
1084 }
1085 pub fn parallel_processing(msg: String) -> Self {
1087 SubXError::CommandExecution(format!("Parallel processing error: {}", msg))
1088 }
1089 pub fn task_execution_failed(task_id: String, reason: String) -> Self {
1091 SubXError::CommandExecution(format!("Task {} execution failed: {}", task_id, reason))
1092 }
1093 pub fn worker_pool_exhausted() -> Self {
1095 SubXError::CommandExecution("Worker pool exhausted".to_string())
1096 }
1097 pub fn task_timeout(task_id: String, duration: std::time::Duration) -> Self {
1099 SubXError::CommandExecution(format!(
1100 "Task {} timed out (limit: {:?})",
1101 task_id, duration
1102 ))
1103 }
1104 pub fn dialogue_detection_failed<S: Into<String>>(msg: S) -> Self {
1106 SubXError::AudioProcessing {
1107 message: format!("Dialogue detection failed: {}", msg.into()),
1108 }
1109 }
1110 pub fn invalid_audio_format<S: Into<String>>(format: S) -> Self {
1112 SubXError::AudioProcessing {
1113 message: format!("Unsupported audio format: {}", format.into()),
1114 }
1115 }
1116 pub fn dialogue_segment_invalid<S: Into<String>>(reason: S) -> Self {
1118 SubXError::AudioProcessing {
1119 message: format!("Invalid dialogue segment: {}", reason.into()),
1120 }
1121 }
1122 pub fn exit_code(&self) -> i32 {
1131 match self {
1132 SubXError::Io(_) => 1,
1133 SubXError::Config { .. } => 2,
1134 SubXError::Api { .. } => 3,
1135 SubXError::AiService(_) => 3,
1136 SubXError::SubtitleFormat { .. } => 4,
1137 SubXError::AudioProcessing { .. } => 5,
1138 SubXError::FileMatching { .. } => 6,
1139 _ => 1,
1140 }
1141 }
1142
1143 pub fn category(&self) -> &'static str {
1149 match self {
1150 SubXError::Io(_) => "io",
1152 SubXError::Config { .. } => "config",
1153 SubXError::SubtitleFormat { .. } => "subtitle_format",
1154 SubXError::AiService(_) => "ai_service",
1155 SubXError::Api { .. } => "api",
1156 SubXError::AudioProcessing { .. } => "audio_processing",
1157 SubXError::FileMatching { .. } => "file_matching",
1158 SubXError::FileAlreadyExists(_) => "file_already_exists",
1159 SubXError::FileNotFound(_) => "file_not_found",
1160 SubXError::InvalidFileName(_) => "invalid_file_name",
1161 SubXError::FileOperationFailed(_) => "file_operation_failed",
1162 SubXError::CommandExecution(_) => "command_execution",
1163 SubXError::OutputModeUnsupported { .. } => "command_execution",
1167 SubXError::NoInputSpecified => "no_input_specified",
1168 SubXError::InvalidPath(_) => "invalid_path",
1169 SubXError::PathNotFound(_) => "path_not_found",
1170 SubXError::DirectoryReadError { .. } => "directory_read_error",
1171 SubXError::InvalidSyncConfiguration => "invalid_sync_configuration",
1172 SubXError::UnsupportedFileType(_) => "unsupported_file_type",
1173 SubXError::Other(_) => "other",
1174 }
1175 }
1176
1177 pub fn machine_code(&self) -> &'static str {
1181 match self {
1182 SubXError::Io(_) => "E_IO",
1183 SubXError::Config { .. } => "E_CONFIG",
1184 SubXError::SubtitleFormat { .. } => "E_SUBTITLE_FORMAT",
1185 SubXError::AiService(_) => "E_AI_SERVICE",
1186 SubXError::Api { .. } => "E_API",
1187 SubXError::AudioProcessing { .. } => "E_AUDIO_PROCESSING",
1188 SubXError::FileMatching { .. } => "E_FILE_MATCHING",
1189 SubXError::FileAlreadyExists(_) => "E_FILE_ALREADY_EXISTS",
1190 SubXError::FileNotFound(_) => "E_FILE_NOT_FOUND",
1191 SubXError::InvalidFileName(_) => "E_INVALID_FILE_NAME",
1192 SubXError::FileOperationFailed(_) => "E_FILE_OPERATION_FAILED",
1193 SubXError::CommandExecution(_) => "E_COMMAND_EXECUTION",
1194 SubXError::OutputModeUnsupported { .. } => "E_OUTPUT_MODE_UNSUPPORTED",
1195 SubXError::NoInputSpecified => "E_NO_INPUT_SPECIFIED",
1196 SubXError::InvalidPath(_) => "E_INVALID_PATH",
1197 SubXError::PathNotFound(_) => "E_PATH_NOT_FOUND",
1198 SubXError::DirectoryReadError { .. } => "E_DIRECTORY_READ_ERROR",
1199 SubXError::InvalidSyncConfiguration => "E_INVALID_SYNC_CONFIGURATION",
1200 SubXError::UnsupportedFileType(_) => "E_UNSUPPORTED_FILE_TYPE",
1201 SubXError::Other(_) => "E_OTHER",
1202 }
1203 }
1204
1205 pub fn hint(&self) -> Option<&'static str> {
1211 match self {
1212 SubXError::Config { .. } => {
1213 Some("Run 'subx-cli config --help' for configuration details.")
1214 }
1215 SubXError::Api { .. } | SubXError::AiService(_) => {
1216 Some("Check network connectivity and the configured API key.")
1217 }
1218 SubXError::SubtitleFormat { .. } => {
1219 Some("Check the subtitle file's format and encoding.")
1220 }
1221 SubXError::AudioProcessing { .. } => {
1222 Some("Verify the media file's integrity and supported codecs.")
1223 }
1224 SubXError::FileMatching { .. } => Some("Verify file paths and patterns."),
1225 SubXError::NoInputSpecified => Some("Pass an input path or use the -i/--input flag."),
1226 SubXError::InvalidSyncConfiguration => {
1227 Some("Specify both video and subtitle files, or use -i for batch processing.")
1228 }
1229 SubXError::PathNotFound(_) | SubXError::FileNotFound(_) => {
1230 Some("Verify the path exists and is accessible.")
1231 }
1232 SubXError::OutputModeUnsupported { .. } => Some(
1233 "Run the command without `--output json` (and without SUBX_OUTPUT=json) to receive the shell-completion script.",
1234 ),
1235 _ => None,
1236 }
1237 }
1238
1239 pub fn user_friendly_message(&self) -> String {
1249 match self {
1250 SubXError::Io(e) => format!("File operation error: {}", e),
1251 SubXError::Config { message } => format!(
1252 "Configuration error: {}\nHint: run 'subx-cli config --help' for details",
1253 message
1254 ),
1255 SubXError::Api { message, source } => format!(
1256 "API error ({:?}): {}\nHint: check network connection and API key settings",
1257 source, message
1258 ),
1259 SubXError::AiService(msg) => format!(
1260 "AI service error: {}\nHint: check network connection and API key settings",
1261 msg
1262 ),
1263 SubXError::SubtitleFormat { message, .. } => format!(
1264 "Subtitle processing error: {}\nHint: check file format and encoding",
1265 message
1266 ),
1267 SubXError::AudioProcessing { message } => format!(
1268 "Audio processing error: {}\nHint: ensure media file integrity and support",
1269 message
1270 ),
1271 SubXError::FileMatching { message } => format!(
1272 "File matching error: {}\nHint: verify file paths and patterns",
1273 message
1274 ),
1275 SubXError::FileAlreadyExists(path) => format!("File already exists: {}", path),
1276 SubXError::FileNotFound(path) => format!("File not found: {}", path),
1277 SubXError::InvalidFileName(name) => format!("Invalid file name: {}", name),
1278 SubXError::FileOperationFailed(msg) => format!("File operation failed: {}", msg),
1279 SubXError::CommandExecution(msg) => msg.clone(),
1280 SubXError::OutputModeUnsupported { command } => format!(
1281 "The '{}' command does not support --output json; its stdout is a shell-completion script.\nHint: rerun without --output json (and ensure SUBX_OUTPUT is unset)",
1282 command
1283 ),
1284 SubXError::Other(err) => {
1285 format!("Unknown error: {}\nHint: please report this issue", err)
1286 }
1287 _ => format!("Error: {}", self),
1288 }
1289 }
1290}
1291
1292impl SubXError {
1294 pub fn whisper_api<T: Into<String>>(message: T) -> Self {
1304 Self::Api {
1305 message: message.into(),
1306 source: ApiErrorSource::Whisper,
1307 }
1308 }
1309
1310 pub fn audio_extraction<T: Into<String>>(message: T) -> Self {
1320 Self::AudioProcessing {
1321 message: message.into(),
1322 }
1323 }
1324}
1325
1326#[derive(Debug, thiserror::Error)]
1331pub enum ApiErrorSource {
1332 #[error("OpenAI")]
1334 OpenAI,
1335 #[error("Whisper")]
1337 Whisper,
1338}
1339
1340impl From<Box<dyn std::error::Error>> for SubXError {
1342 fn from(err: Box<dyn std::error::Error>) -> Self {
1343 SubXError::audio_processing(err.to_string())
1344 }
1345}