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