1#[derive(Debug, Clone, ValueEnum, PartialEq)]
20pub enum SyncMethodArg {
21 Vad,
23 Manual,
25}
26
27impl From<SyncMethodArg> for subx_core::core::sync::SyncMethod {
28 fn from(arg: SyncMethodArg) -> Self {
29 match arg {
30 SyncMethodArg::Vad => Self::LocalVad,
31 SyncMethodArg::Manual => Self::Manual,
32 }
33 }
34}
35
36use clap::{Args, ValueEnum};
37use std::path::PathBuf;
38use subx_core::core::input::InputPathHandler;
39use subx_core::core::sync::{
40 BatchRequest, SYNC_SUBTITLE_EXTENSIONS, SYNC_VIDEO_EXTENSIONS, SyncMode, SyncPairingRequest,
41 resolve_sync_pairing,
42};
43use subx_core::error::{SubXError, SubXResult};
44
45#[derive(Args, Debug, Clone)]
47pub struct SyncArgs {
48 #[arg(value_name = "PATH", num_args = 0..)]
50 pub positional_paths: Vec<PathBuf>,
51
52 #[arg(
54 short = 'v',
55 long = "video",
56 value_name = "VIDEO",
57 help = "Video file path (optional if using positional or manual offset)"
58 )]
59 pub video: Option<PathBuf>,
60
61 #[arg(
63 short = 's',
64 long = "subtitle",
65 value_name = "SUBTITLE",
66 help = "Subtitle file path (optional if using positional or manual offset)"
67 )]
68 pub subtitle: Option<PathBuf>,
69 #[arg(short = 'i', long = "input", value_name = "PATH")]
71 pub input_paths: Vec<PathBuf>,
72
73 #[arg(short, long)]
75 pub recursive: bool,
76
77 #[arg(
79 long,
80 value_name = "SECONDS",
81 help = "Manual offset in seconds (positive delays subtitles, negative advances them)"
82 )]
83 pub offset: Option<f32>,
84
85 #[arg(short, long, value_enum, help = "Synchronization method")]
87 pub method: Option<SyncMethodArg>,
88
89 #[arg(
91 short = 'w',
92 long,
93 value_name = "SECONDS",
94 default_value = "30",
95 help = "Time window around first subtitle for analysis (seconds)"
96 )]
97 pub window: u32,
98
99 #[arg(
102 long,
103 value_name = "SENSITIVITY",
104 help = "VAD sensitivity threshold (0.0-1.0)"
105 )]
106 pub vad_sensitivity: Option<f32>,
107
108 #[arg(
111 short = 'o',
112 long,
113 value_name = "PATH",
114 help = "Output file path (default: input_synced.ext)"
115 )]
116 pub output: Option<PathBuf>,
117
118 #[arg(
120 long,
121 help = "Enable verbose output with detailed progress information"
122 )]
123 pub verbose: bool,
124
125 #[arg(long, help = "Analyze and display results but don't save output file")]
127 pub dry_run: bool,
128
129 #[arg(long, help = "Overwrite existing output file without confirmation")]
131 pub force: bool,
132
133 #[arg(
135 short = 'b',
136 long = "batch",
137 value_name = "DIRECTORY",
138 help = "Enable batch processing mode. Can optionally specify a directory path.",
139 num_args = 0..=1,
140 require_equals = false
141 )]
142 pub batch: Option<Option<PathBuf>>,
143
144 #[arg(long, default_value_t = false)]
146 pub no_extract: bool,
147 }
149
150#[derive(Debug, Clone, PartialEq)]
152pub enum SyncMethod {
153 Auto,
155 Manual,
157}
158
159impl SyncArgs {
160 pub fn validate(&self) -> Result<(), String> {
162 if let Some(SyncMethodArg::Manual) = &self.method {
164 if self.offset.is_none() {
165 return Err("Manual method requires --offset parameter.".to_string());
166 }
167 }
168
169 if self.batch.is_some() {
171 let has_input_paths = !self.input_paths.is_empty();
172 let has_positional = !self.positional_paths.is_empty();
173 let has_video_or_subtitle = self.video.is_some() || self.subtitle.is_some();
174 let has_batch_directory = matches!(&self.batch, Some(Some(_)));
175
176 if has_input_paths || has_positional || has_video_or_subtitle || has_batch_directory {
178 return Ok(());
179 }
180
181 return Err("Batch mode requires at least one input source.\n\n\
182Usage:\n\
183• Batch with directory: subx sync -b <directory>\n\
184• Batch with input paths: subx sync -b -i <path>\n\
185• Batch with positional: subx sync -b <path>\n\n\
186Need help? Run: subx sync --help"
187 .to_string());
188 }
189
190 let has_video = self.video.is_some();
192 let has_subtitle = self.subtitle.is_some();
193 let has_positional = !self.positional_paths.is_empty();
194 let is_manual = self.offset.is_some();
195
196 if is_manual {
198 if has_subtitle || has_positional {
199 return Ok(());
200 }
201 return Err("Manual sync mode requires subtitle file.\n\n\
202Usage:\n\
203• Manual sync: subx sync --offset <seconds> <subtitle>\n\
204• Manual sync: subx sync --offset <seconds> -s <subtitle>\n\n\
205Need help? Run: subx sync --help"
206 .to_string());
207 }
208
209 if has_video || has_positional {
211 if self.vad_sensitivity.is_some() {
213 if let Some(SyncMethodArg::Manual) = &self.method {
214 return Err("VAD options can only be used with --method vad.".to_string());
215 }
216 }
217 return Ok(());
218 }
219
220 Err("Auto sync mode requires video file or positional path.\n\n\
221Usage:\n\
222• Auto sync: subx sync <video> <subtitle> or subx sync <video_path>\n\
223• Auto sync: subx sync -v <video> -s <subtitle>\n\
224• Manual sync: subx sync --offset <seconds> <subtitle>\n\
225• Batch mode: subx sync -b [directory]\n\n\
226Need help? Run: subx sync --help"
227 .to_string())
228 }
229
230 pub fn get_output_path(&self) -> Option<PathBuf> {
232 if let Some(ref output) = self.output {
233 Some(output.clone())
234 } else {
235 self.subtitle
236 .as_ref()
237 .map(|subtitle| subx_core::core::sync::create_default_output_path(subtitle))
238 }
239 }
240
241 pub fn is_manual_mode(&self) -> bool {
243 self.offset.is_some() || matches!(self.method, Some(SyncMethodArg::Manual))
244 }
245
246 pub fn sync_method(&self) -> SyncMethod {
248 if self.offset.is_some() {
249 SyncMethod::Manual
250 } else {
251 SyncMethod::Auto
252 }
253 }
254
255 pub fn validate_compat(&self) -> SubXResult<()> {
257 if self.offset.is_none() && self.video.is_none() && !self.positional_paths.is_empty() {
259 return Ok(());
260 }
261 match (self.offset.is_some(), self.video.is_some()) {
262 (true, _) => Ok(()),
264 (false, true) => Ok(()),
266 (false, false) => Err(SubXError::CommandExecution(
268 "Auto sync mode requires video file.\n\n\
269Usage:\n\
270• Auto sync: subx sync <video> <subtitle>\n\
271• Manual sync: subx sync --offset <seconds> <subtitle>\n\n\
272Need help? Run: subx sync --help"
273 .to_string(),
274 )),
275 }
276 }
277
278 #[allow(dead_code)]
280 pub fn requires_video(&self) -> bool {
281 self.offset.is_none()
282 }
283
284 pub fn get_input_handler(&self) -> Result<InputPathHandler, SubXError> {
287 let optional_paths = vec![self.video.clone(), self.subtitle.clone()];
288 let string_paths: Vec<String> = self
289 .positional_paths
290 .iter()
291 .map(|p| p.to_string_lossy().to_string())
292 .collect();
293 let merged_paths = InputPathHandler::merge_paths_from_multiple_sources(
294 &optional_paths,
295 &self.input_paths,
296 &string_paths,
297 )?;
298
299 Ok(InputPathHandler::from_args(&merged_paths, self.recursive)?
300 .with_extensions(&[SYNC_VIDEO_EXTENSIONS, SYNC_SUBTITLE_EXTENSIONS].concat())
301 .with_no_extract(self.no_extract))
302 }
303
304 pub fn get_sync_mode(&self) -> Result<SyncMode, SubXError> {
311 resolve_sync_pairing(&SyncPairingRequest {
312 positional_paths: self.positional_paths.clone(),
313 input_paths: self.input_paths.clone(),
314 video: self.video.clone(),
315 subtitle: self.subtitle.clone(),
316 batch: match &self.batch {
317 None => BatchRequest::Off,
318 Some(None) => BatchRequest::Auto,
319 Some(Some(dir)) => BatchRequest::Directory(dir.clone()),
320 },
321 recursive: self.recursive,
322 no_extract: self.no_extract,
323 manual: self.is_manual_mode(),
324 })
325 }
326}
327
328pub use subx_core::core::sync::create_default_output_path;
334
335#[cfg(test)]
336mod tests {
337 use super::*;
338 use crate::cli::{Cli, Commands};
339 use clap::Parser;
340 use std::path::PathBuf;
341 use tempfile::TempDir;
342
343 fn default_args() -> SyncArgs {
346 SyncArgs {
347 positional_paths: Vec::new(),
348 video: None,
349 subtitle: None,
350 input_paths: Vec::new(),
351 recursive: false,
352 offset: None,
353 method: None,
354 window: 30,
355 vad_sensitivity: None,
356 output: None,
357 verbose: false,
358 dry_run: false,
359 force: false,
360 batch: None,
361 no_extract: false,
362 }
363 }
364
365 #[test]
368 fn test_sync_method_selection_manual() {
369 let args = SyncArgs {
370 video: Some(PathBuf::from("video.mp4")),
371 subtitle: Some(PathBuf::from("subtitle.srt")),
372 offset: Some(2.5),
373 ..default_args()
374 };
375 assert_eq!(args.sync_method(), SyncMethod::Manual);
376 }
377
378 #[test]
379 fn test_sync_method_selection_auto() {
380 let args = SyncArgs {
381 video: Some(PathBuf::from("video.mp4")),
382 subtitle: Some(PathBuf::from("subtitle.srt")),
383 ..default_args()
384 };
385 assert_eq!(args.sync_method(), SyncMethod::Auto);
386 }
387
388 #[test]
389 fn test_method_arg_conversion() {
390 assert_eq!(
391 subx_core::core::sync::SyncMethod::from(SyncMethodArg::Vad),
392 subx_core::core::sync::SyncMethod::LocalVad
393 );
394 assert_eq!(
395 subx_core::core::sync::SyncMethod::from(SyncMethodArg::Manual),
396 subx_core::core::sync::SyncMethod::Manual
397 );
398 }
399
400 #[test]
401 fn test_sync_method_arg_debug_clone() {
402 let m = SyncMethodArg::Vad;
403 let c = m.clone();
404 assert_eq!(m, c);
405 assert_eq!(format!("{c:?}"), "Vad");
406 let m2 = SyncMethodArg::Manual;
407 assert_eq!(format!("{m2:?}"), "Manual");
408 }
409
410 #[test]
413 fn test_sync_method_enum_debug_clone() {
414 let m = SyncMethod::Auto;
415 let c = m.clone();
416 assert_eq!(m, c);
417 assert_eq!(format!("{c:?}"), "Auto");
418 let m2 = SyncMethod::Manual;
419 assert_eq!(format!("{m2:?}"), "Manual");
420 }
421
422 #[test]
425 fn test_is_manual_mode_with_offset() {
426 let args = SyncArgs {
427 offset: Some(1.0),
428 ..default_args()
429 };
430 assert!(args.is_manual_mode());
431 }
432
433 #[test]
434 fn test_is_manual_mode_with_method_manual() {
435 let args = SyncArgs {
436 method: Some(SyncMethodArg::Manual),
437 ..default_args()
438 };
439 assert!(args.is_manual_mode());
440 }
441
442 #[test]
443 fn test_is_manual_mode_false() {
444 let args = default_args();
445 assert!(!args.is_manual_mode());
446 }
447
448 #[test]
449 fn test_is_manual_mode_false_with_vad_method() {
450 let args = SyncArgs {
451 method: Some(SyncMethodArg::Vad),
452 ..default_args()
453 };
454 assert!(!args.is_manual_mode());
455 }
456
457 #[test]
460 fn test_requires_video_true_without_offset() {
461 let args = default_args();
462 assert!(args.requires_video());
463 }
464
465 #[test]
466 fn test_requires_video_false_with_offset() {
467 let args = SyncArgs {
468 offset: Some(-1.5),
469 ..default_args()
470 };
471 assert!(!args.requires_video());
472 }
473
474 #[test]
477 fn test_get_output_path_explicit() {
478 let args = SyncArgs {
479 output: Some(PathBuf::from("out.srt")),
480 subtitle: Some(PathBuf::from("sub.srt")),
481 ..default_args()
482 };
483 assert_eq!(args.get_output_path(), Some(PathBuf::from("out.srt")));
484 }
485
486 #[test]
487 fn test_get_output_path_default_from_subtitle() {
488 let args = SyncArgs {
489 subtitle: Some(PathBuf::from("movie.srt")),
490 ..default_args()
491 };
492 let out = args.get_output_path().unwrap();
493 assert_eq!(out.file_name().unwrap(), "movie_synced.srt");
494 }
495
496 #[test]
497 fn test_get_output_path_none_without_subtitle() {
498 let args = default_args();
499 assert_eq!(args.get_output_path(), None);
500 }
501
502 #[test]
505 fn test_validate_manual_method_requires_offset() {
506 let args = SyncArgs {
507 method: Some(SyncMethodArg::Manual),
508 video: Some(PathBuf::from("v.mp4")),
509 ..default_args()
510 };
511 let result = args.validate();
512 assert!(result.is_err());
513 assert!(
514 result
515 .unwrap_err()
516 .contains("Manual method requires --offset")
517 );
518 }
519
520 #[test]
521 fn test_validate_manual_method_with_offset_ok() {
522 let args = SyncArgs {
523 method: Some(SyncMethodArg::Manual),
524 offset: Some(1.0),
525 subtitle: Some(PathBuf::from("sub.srt")),
526 ..default_args()
527 };
528 assert!(args.validate().is_ok());
529 }
530
531 #[test]
532 fn test_validate_batch_with_input_paths_ok() {
533 let args = SyncArgs {
534 batch: Some(None),
535 input_paths: vec![PathBuf::from("dir")],
536 ..default_args()
537 };
538 assert!(args.validate().is_ok());
539 }
540
541 #[test]
542 fn test_validate_batch_with_positional_ok() {
543 let args = SyncArgs {
544 batch: Some(None),
545 positional_paths: vec![PathBuf::from("dir")],
546 ..default_args()
547 };
548 assert!(args.validate().is_ok());
549 }
550
551 #[test]
552 fn test_validate_batch_with_video_ok() {
553 let args = SyncArgs {
554 batch: Some(None),
555 video: Some(PathBuf::from("v.mp4")),
556 ..default_args()
557 };
558 assert!(args.validate().is_ok());
559 }
560
561 #[test]
562 fn test_validate_batch_with_subtitle_ok() {
563 let args = SyncArgs {
564 batch: Some(None),
565 subtitle: Some(PathBuf::from("s.srt")),
566 ..default_args()
567 };
568 assert!(args.validate().is_ok());
569 }
570
571 #[test]
572 fn test_validate_batch_with_directory_ok() {
573 let args = SyncArgs {
574 batch: Some(Some(PathBuf::from("mydir"))),
575 ..default_args()
576 };
577 assert!(args.validate().is_ok());
578 }
579
580 #[test]
581 fn test_validate_batch_no_inputs_err() {
582 let args = SyncArgs {
583 batch: Some(None),
584 ..default_args()
585 };
586 let result = args.validate();
587 assert!(result.is_err());
588 assert!(result.unwrap_err().contains("Batch mode requires"));
589 }
590
591 #[test]
592 fn test_validate_manual_offset_with_subtitle_ok() {
593 let args = SyncArgs {
594 offset: Some(2.0),
595 subtitle: Some(PathBuf::from("sub.srt")),
596 ..default_args()
597 };
598 assert!(args.validate().is_ok());
599 }
600
601 #[test]
602 fn test_validate_manual_offset_with_positional_ok() {
603 let args = SyncArgs {
604 offset: Some(2.0),
605 positional_paths: vec![PathBuf::from("sub.srt")],
606 ..default_args()
607 };
608 assert!(args.validate().is_ok());
609 }
610
611 #[test]
612 fn test_validate_manual_offset_without_subtitle_err() {
613 let args = SyncArgs {
614 offset: Some(2.0),
615 ..default_args()
616 };
617 let result = args.validate();
618 assert!(result.is_err());
619 assert!(
620 result
621 .unwrap_err()
622 .contains("Manual sync mode requires subtitle")
623 );
624 }
625
626 #[test]
627 fn test_validate_auto_with_video_ok() {
628 let args = SyncArgs {
629 video: Some(PathBuf::from("v.mp4")),
630 ..default_args()
631 };
632 assert!(args.validate().is_ok());
633 }
634
635 #[test]
636 fn test_validate_auto_with_positional_ok() {
637 let args = SyncArgs {
638 positional_paths: vec![PathBuf::from("v.mp4")],
639 ..default_args()
640 };
641 assert!(args.validate().is_ok());
642 }
643
644 #[test]
645 fn test_validate_auto_vad_sensitivity_with_manual_method_err() {
646 let args2 = SyncArgs {
650 video: Some(PathBuf::from("v.mp4")),
651 method: Some(SyncMethodArg::Manual),
652 vad_sensitivity: Some(0.5),
653 offset: None, ..default_args()
655 };
656 assert!(args2.validate().is_err());
657 }
658
659 #[test]
660 fn test_validate_vad_sensitivity_with_vad_method_and_video_ok() {
661 let args = SyncArgs {
663 video: Some(PathBuf::from("v.mp4")),
664 method: Some(SyncMethodArg::Vad),
665 vad_sensitivity: Some(0.7),
666 ..default_args()
667 };
668 assert!(args.validate().is_ok());
669 }
670
671 #[test]
672 fn test_validate_auto_no_inputs_err() {
673 let args = default_args();
674 let result = args.validate();
675 assert!(result.is_err());
676 assert!(
677 result
678 .unwrap_err()
679 .contains("Auto sync mode requires video file")
680 );
681 }
682
683 #[test]
686 fn test_validate_compat_with_positional_no_video_no_offset_ok() {
687 let args = SyncArgs {
688 positional_paths: vec![PathBuf::from("movie.mp4")],
689 ..default_args()
690 };
691 assert!(args.validate_compat().is_ok());
692 }
693
694 #[test]
695 fn test_validate_compat_with_offset_ok() {
696 let args = SyncArgs {
697 offset: Some(1.0),
698 ..default_args()
699 };
700 assert!(args.validate_compat().is_ok());
701 }
702
703 #[test]
704 fn test_validate_compat_with_video_ok() {
705 let args = SyncArgs {
706 video: Some(PathBuf::from("v.mp4")),
707 ..default_args()
708 };
709 assert!(args.validate_compat().is_ok());
710 }
711
712 #[test]
713 fn test_validate_compat_no_offset_no_video_no_positional_err() {
714 let args = default_args();
715 assert!(args.validate_compat().is_err());
716 }
717
718 #[test]
719 fn test_validate_compat_with_offset_and_video_ok() {
720 let args = SyncArgs {
721 offset: Some(2.5),
722 video: Some(PathBuf::from("v.mp4")),
723 ..default_args()
724 };
725 assert!(args.validate_compat().is_ok());
726 }
727
728 #[test]
731 fn test_get_sync_mode_batch_explicit_batch_flag() {
732 let tmp = TempDir::new().unwrap();
733 let args = SyncArgs {
734 batch: Some(None),
735 input_paths: vec![tmp.path().to_path_buf()],
736 ..default_args()
737 };
738 let mode = args.get_sync_mode().unwrap();
739 assert!(matches!(mode, SyncMode::Batch(_)));
740 }
741
742 #[test]
743 fn test_get_sync_mode_batch_with_directory() {
744 let tmp = TempDir::new().unwrap();
745 let args = SyncArgs {
746 batch: Some(Some(tmp.path().to_path_buf())),
747 ..default_args()
748 };
749 let mode = args.get_sync_mode().unwrap();
750 assert!(matches!(mode, SyncMode::Batch(_)));
751 }
752
753 #[test]
754 fn test_get_sync_mode_batch_from_input_paths() {
755 let tmp = TempDir::new().unwrap();
756 let args = SyncArgs {
757 input_paths: vec![tmp.path().to_path_buf()],
758 ..default_args()
759 };
760 let mode = args.get_sync_mode().unwrap();
761 assert!(matches!(mode, SyncMode::Batch(_)));
762 }
763
764 #[test]
765 fn test_get_sync_mode_batch_uses_current_dir_when_no_paths() {
766 let args = SyncArgs {
767 batch: Some(None),
768 ..default_args()
769 };
770 let mode = args.get_sync_mode().unwrap();
771 assert!(matches!(mode, SyncMode::Batch(_)));
772 }
773
774 #[test]
775 fn test_get_sync_mode_single_from_two_positionals() {
776 let args = SyncArgs {
777 positional_paths: vec![PathBuf::from("movie.mp4"), PathBuf::from("movie.srt")],
778 ..default_args()
779 };
780 let mode = args.get_sync_mode().unwrap();
781 match mode {
782 SyncMode::Single { video, subtitle } => {
783 assert_eq!(video, PathBuf::from("movie.mp4"));
784 assert_eq!(subtitle, PathBuf::from("movie.srt"));
785 }
786 _ => panic!("Expected Single mode"),
787 }
788 }
789
790 #[test]
791 fn test_get_sync_mode_single_two_positionals_wrong_extensions_err() {
792 let args = SyncArgs {
793 positional_paths: vec![PathBuf::from("file1.txt"), PathBuf::from("file2.doc")],
794 ..default_args()
795 };
796 assert!(args.get_sync_mode().is_err());
797 }
798
799 #[test]
800 fn test_get_sync_mode_single_one_positional_no_extension_is_batch() {
801 let tmp = TempDir::new().unwrap();
803 let args = SyncArgs {
804 positional_paths: vec![tmp.path().to_path_buf()],
805 ..default_args()
806 };
807 let mode = args.get_sync_mode().unwrap();
808 assert!(matches!(mode, SyncMode::Batch(_)));
809 }
810
811 #[test]
812 fn test_get_sync_mode_single_positional_video_no_subtitle_err() {
813 let args = SyncArgs {
815 positional_paths: vec![PathBuf::from("nonexistent_movie.mp4")],
816 ..default_args()
817 };
818 assert!(args.get_sync_mode().is_err());
819 }
820
821 #[test]
822 fn test_get_sync_mode_single_positional_subtitle_finds_video() {
823 let tmp = TempDir::new().unwrap();
824 let video_path = tmp.path().join("clip.mp4");
825 let sub_path = tmp.path().join("clip.srt");
826 std::fs::write(&video_path, b"fake video").unwrap();
827 std::fs::write(&sub_path, b"1\n00:00:01,000 --> 00:00:02,000\nHello\n").unwrap();
828
829 let args = SyncArgs {
830 positional_paths: vec![sub_path.clone()],
831 ..default_args()
832 };
833 let mode = args.get_sync_mode().unwrap();
834 match mode {
835 SyncMode::Single { video, subtitle } => {
836 assert_eq!(video, video_path);
837 assert_eq!(subtitle, sub_path);
838 }
839 _ => panic!("Expected Single mode"),
840 }
841 }
842
843 #[test]
844 fn test_get_sync_mode_single_positional_video_finds_subtitle() {
845 let tmp = TempDir::new().unwrap();
846 let video_path = tmp.path().join("film.mkv");
847 let sub_path = tmp.path().join("film.ass");
848 std::fs::write(&video_path, b"fake video").unwrap();
849 std::fs::write(&sub_path, b"[Script Info]\n").unwrap();
850
851 let args = SyncArgs {
852 positional_paths: vec![video_path.clone()],
853 ..default_args()
854 };
855 let mode = args.get_sync_mode().unwrap();
856 match mode {
857 SyncMode::Single { video, subtitle } => {
858 assert_eq!(video, video_path);
859 assert_eq!(subtitle, sub_path);
860 }
861 _ => panic!("Expected Single mode"),
862 }
863 }
864
865 #[test]
866 fn test_get_sync_mode_single_positional_manual_mode_subtitle_only() {
867 let tmp = TempDir::new().unwrap();
868 let sub_path = tmp.path().join("orphan.srt");
869 std::fs::write(&sub_path, b"1\n00:00:01,000 --> 00:00:02,000\nHi\n").unwrap();
870
871 let args = SyncArgs {
872 positional_paths: vec![sub_path.clone()],
873 offset: Some(-0.5),
874 ..default_args()
875 };
876 let mode = args.get_sync_mode().unwrap();
877 match mode {
878 SyncMode::Single { video, subtitle } => {
879 assert_eq!(video, PathBuf::new());
880 assert_eq!(subtitle, sub_path);
881 }
882 _ => panic!("Expected Single mode"),
883 }
884 }
885
886 #[test]
887 fn test_get_sync_mode_explicit_video_and_subtitle() {
888 let args = SyncArgs {
889 video: Some(PathBuf::from("v.mp4")),
890 subtitle: Some(PathBuf::from("s.srt")),
891 ..default_args()
892 };
893 let mode = args.get_sync_mode().unwrap();
894 match mode {
895 SyncMode::Single { video, subtitle } => {
896 assert_eq!(video, PathBuf::from("v.mp4"));
897 assert_eq!(subtitle, PathBuf::from("s.srt"));
898 }
899 _ => panic!("Expected Single mode"),
900 }
901 }
902
903 #[test]
904 fn test_get_sync_mode_manual_explicit_subtitle_only() {
905 let args = SyncArgs {
906 offset: Some(1.0),
907 subtitle: Some(PathBuf::from("s.srt")),
908 ..default_args()
909 };
910 let mode = args.get_sync_mode().unwrap();
911 match mode {
912 SyncMode::Single { video, subtitle } => {
913 assert_eq!(video, PathBuf::new());
914 assert_eq!(subtitle, PathBuf::from("s.srt"));
915 }
916 _ => panic!("Expected Single mode"),
917 }
918 }
919
920 #[test]
921 fn test_get_sync_mode_manual_no_subtitle_err() {
922 let args = SyncArgs {
923 offset: Some(1.0),
924 ..default_args()
925 };
926 assert!(args.get_sync_mode().is_err());
927 }
928
929 #[test]
930 fn test_get_sync_mode_no_inputs_err() {
931 let args = default_args();
932 assert!(args.get_sync_mode().is_err());
933 }
934
935 #[test]
944 fn test_get_sync_mode_adapter_agrees_with_core() {
945 let tmp = TempDir::new().unwrap();
946 let video = tmp.path().join("movie.mp4");
947 let sub = tmp.path().join("movie.srt");
948 std::fs::write(&video, b"fake video").unwrap();
949 std::fs::write(&sub, b"1\n00:00:01,000 --> 00:00:02,000\nHi\n").unwrap();
950
951 let args = SyncArgs {
953 positional_paths: vec![video.clone()],
954 ..default_args()
955 };
956 let direct = SyncPairingRequest {
957 positional_paths: vec![video.clone()],
958 ..Default::default()
959 };
960 match (
961 args.get_sync_mode().unwrap(),
962 resolve_sync_pairing(&direct).unwrap(),
963 ) {
964 (
965 SyncMode::Single {
966 video: v1,
967 subtitle: s1,
968 },
969 SyncMode::Single {
970 video: v2,
971 subtitle: s2,
972 },
973 ) => {
974 assert_eq!(v1, v2);
975 assert_eq!(s1, s2);
976 }
977 other => panic!("adapter/core disagree: {other:?}"),
978 }
979
980 let args = SyncArgs {
983 batch: Some(Some(tmp.path().to_path_buf())),
984 recursive: true,
985 ..default_args()
986 };
987 let direct = SyncPairingRequest {
988 batch: BatchRequest::Directory(tmp.path().to_path_buf()),
989 recursive: true,
990 ..Default::default()
991 };
992 match (
993 args.get_sync_mode().unwrap(),
994 resolve_sync_pairing(&direct).unwrap(),
995 ) {
996 (SyncMode::Batch(h1), SyncMode::Batch(h2)) => {
997 assert_eq!(h1.paths, h2.paths);
998 assert_eq!(h1.recursive, h2.recursive);
999 assert_eq!(h1.file_extensions, h2.file_extensions);
1000 assert_eq!(h1.no_extract, h2.no_extract);
1001 }
1002 other => panic!("adapter/core disagree: {other:?}"),
1003 }
1004
1005 let args = default_args();
1007 let direct = SyncPairingRequest::default();
1008 assert!(matches!(
1009 args.get_sync_mode(),
1010 Err(SubXError::InvalidSyncConfiguration)
1011 ));
1012 assert!(matches!(
1013 resolve_sync_pairing(&direct),
1014 Err(SubXError::InvalidSyncConfiguration)
1015 ));
1016 }
1017
1018 #[test]
1021 fn test_sync_args_batch_input() {
1022 let cli = Cli::try_parse_from([
1023 "subx-cli",
1024 "sync",
1025 "-i",
1026 "dir",
1027 "--batch",
1028 "--recursive",
1029 "--video",
1030 "video.mp4",
1031 ])
1032 .unwrap();
1033 let args = match cli.command {
1034 Commands::Sync(a) => a,
1035 _ => panic!("Expected Sync command"),
1036 };
1037 assert_eq!(args.input_paths, vec![PathBuf::from("dir")]);
1038 assert!(args.batch.is_some());
1039 assert!(args.recursive);
1040 assert_eq!(args.video, Some(PathBuf::from("video.mp4")));
1041 }
1042
1043 #[test]
1044 fn test_sync_args_invalid_combinations() {
1045 let cli = Cli::try_parse_from(["subx-cli", "sync", "--batch", "-i", "dir"]).unwrap();
1046 let args = match cli.command {
1047 Commands::Sync(a) => a,
1048 _ => panic!("Expected Sync command"),
1049 };
1050 assert!(args.validate().is_ok());
1051
1052 let args_invalid = SyncArgs {
1053 batch: Some(None),
1054 ..default_args()
1055 };
1056 assert!(args_invalid.validate().is_err());
1057 }
1058
1059 #[test]
1060 fn test_cli_parse_offset_and_subtitle() {
1061 let cli = Cli::try_parse_from([
1062 "subx-cli",
1063 "sync",
1064 "--offset",
1065 "3.5",
1066 "--subtitle",
1067 "sub.srt",
1068 ])
1069 .unwrap();
1070 let args = match cli.command {
1071 Commands::Sync(a) => a,
1072 _ => panic!("Expected Sync command"),
1073 };
1074 assert_eq!(args.offset, Some(3.5));
1075 assert_eq!(args.subtitle, Some(PathBuf::from("sub.srt")));
1076 }
1077
1078 #[test]
1079 fn test_cli_parse_negative_offset() {
1080 let cli =
1081 Cli::try_parse_from(["subx-cli", "sync", "--offset=-2.0", "-s", "sub.srt"]).unwrap();
1082 let args = match cli.command {
1083 Commands::Sync(a) => a,
1084 _ => panic!("Expected Sync command"),
1085 };
1086 assert_eq!(args.offset, Some(-2.0));
1087 }
1088
1089 #[test]
1090 fn test_cli_parse_method_vad() {
1091 let cli = Cli::try_parse_from(["subx-cli", "sync", "--method", "vad", "--video", "v.mp4"])
1092 .unwrap();
1093 let args = match cli.command {
1094 Commands::Sync(a) => a,
1095 _ => panic!("Expected Sync command"),
1096 };
1097 assert_eq!(args.method, Some(SyncMethodArg::Vad));
1098 }
1099
1100 #[test]
1101 fn test_cli_parse_method_manual() {
1102 let cli = Cli::try_parse_from([
1103 "subx-cli",
1104 "sync",
1105 "--method",
1106 "manual",
1107 "--offset",
1108 "1.0",
1109 "--subtitle",
1110 "sub.srt",
1111 ])
1112 .unwrap();
1113 let args = match cli.command {
1114 Commands::Sync(a) => a,
1115 _ => panic!("Expected Sync command"),
1116 };
1117 assert_eq!(args.method, Some(SyncMethodArg::Manual));
1118 }
1119
1120 #[test]
1121 fn test_cli_parse_default_window() {
1122 let cli = Cli::try_parse_from(["subx-cli", "sync", "--video", "v.mp4"]).unwrap();
1123 let args = match cli.command {
1124 Commands::Sync(a) => a,
1125 _ => panic!("Expected Sync command"),
1126 };
1127 assert_eq!(args.window, 30);
1128 }
1129
1130 #[test]
1131 fn test_cli_parse_custom_window() {
1132 let cli = Cli::try_parse_from(["subx-cli", "sync", "--video", "v.mp4", "--window", "60"])
1133 .unwrap();
1134 let args = match cli.command {
1135 Commands::Sync(a) => a,
1136 _ => panic!("Expected Sync command"),
1137 };
1138 assert_eq!(args.window, 60);
1139 }
1140
1141 #[test]
1142 fn test_cli_parse_flags_verbose_dry_run_force() {
1143 let cli = Cli::try_parse_from([
1144 "subx-cli",
1145 "sync",
1146 "--video",
1147 "v.mp4",
1148 "--verbose",
1149 "--dry-run",
1150 "--force",
1151 ])
1152 .unwrap();
1153 let args = match cli.command {
1154 Commands::Sync(a) => a,
1155 _ => panic!("Expected Sync command"),
1156 };
1157 assert!(args.verbose);
1158 assert!(args.dry_run);
1159 assert!(args.force);
1160 }
1161
1162 #[test]
1163 fn test_cli_parse_no_extract_flag() {
1164 let cli =
1165 Cli::try_parse_from(["subx-cli", "sync", "--video", "v.mp4", "--no-extract"]).unwrap();
1166 let args = match cli.command {
1167 Commands::Sync(a) => a,
1168 _ => panic!("Expected Sync command"),
1169 };
1170 assert!(args.no_extract);
1171 }
1172
1173 #[test]
1174 fn test_cli_parse_output_path() {
1175 let cli = Cli::try_parse_from([
1176 "subx-cli",
1177 "sync",
1178 "--video",
1179 "v.mp4",
1180 "--output",
1181 "result.srt",
1182 ])
1183 .unwrap();
1184 let args = match cli.command {
1185 Commands::Sync(a) => a,
1186 _ => panic!("Expected Sync command"),
1187 };
1188 assert_eq!(args.output, Some(PathBuf::from("result.srt")));
1189 }
1190
1191 #[test]
1192 fn test_cli_parse_vad_sensitivity() {
1193 let cli = Cli::try_parse_from([
1194 "subx-cli",
1195 "sync",
1196 "--video",
1197 "v.mp4",
1198 "--vad-sensitivity",
1199 "0.8",
1200 ])
1201 .unwrap();
1202 let args = match cli.command {
1203 Commands::Sync(a) => a,
1204 _ => panic!("Expected Sync command"),
1205 };
1206 assert_eq!(args.vad_sensitivity, Some(0.8));
1207 }
1208
1209 #[test]
1210 fn test_cli_parse_batch_with_directory() {
1211 let cli = Cli::try_parse_from(["subx-cli", "sync", "--batch", "mydir"]).unwrap();
1212 let args = match cli.command {
1213 Commands::Sync(a) => a,
1214 _ => panic!("Expected Sync command"),
1215 };
1216 assert_eq!(args.batch, Some(Some(PathBuf::from("mydir"))));
1217 }
1218
1219 #[test]
1220 fn test_cli_parse_positional_paths() {
1221 let cli = Cli::try_parse_from(["subx-cli", "sync", "video.mp4", "subtitle.srt"]).unwrap();
1222 let args = match cli.command {
1223 Commands::Sync(a) => a,
1224 _ => panic!("Expected Sync command"),
1225 };
1226 assert_eq!(
1227 args.positional_paths,
1228 vec![PathBuf::from("video.mp4"), PathBuf::from("subtitle.srt")]
1229 );
1230 }
1231
1232 #[test]
1233 fn test_cli_parse_short_flags() {
1234 let cli = Cli::try_parse_from([
1235 "subx-cli",
1236 "sync",
1237 "-v",
1238 "video.mp4",
1239 "-s",
1240 "sub.srt",
1241 "-r",
1242 "-b",
1243 ])
1244 .unwrap();
1245 let args = match cli.command {
1246 Commands::Sync(a) => a,
1247 _ => panic!("Expected Sync command"),
1248 };
1249 assert_eq!(args.video, Some(PathBuf::from("video.mp4")));
1250 assert_eq!(args.subtitle, Some(PathBuf::from("sub.srt")));
1251 assert!(args.recursive);
1252 assert!(args.batch.is_some());
1253 }
1254}