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 args = SyncArgs {
780 video: Some(PathBuf::from("v.mp4")),
781 method: Some(SyncMethodArg::Manual),
782 vad_sensitivity: Some(0.5),
783 offset: Some(1.0), ..default_args()
785 };
786 let args2 = SyncArgs {
790 video: Some(PathBuf::from("v.mp4")),
791 method: Some(SyncMethodArg::Manual),
792 vad_sensitivity: Some(0.5),
793 offset: None, ..default_args()
795 };
796 assert!(args2.validate().is_err());
797 }
798
799 #[test]
800 fn test_validate_vad_sensitivity_with_vad_method_and_video_ok() {
801 let args = SyncArgs {
803 video: Some(PathBuf::from("v.mp4")),
804 method: Some(SyncMethodArg::Vad),
805 vad_sensitivity: Some(0.7),
806 ..default_args()
807 };
808 assert!(args.validate().is_ok());
809 }
810
811 #[test]
812 fn test_validate_auto_no_inputs_err() {
813 let args = default_args();
814 let result = args.validate();
815 assert!(result.is_err());
816 assert!(
817 result
818 .unwrap_err()
819 .contains("Auto sync mode requires video file")
820 );
821 }
822
823 #[test]
826 fn test_validate_compat_with_positional_no_video_no_offset_ok() {
827 let args = SyncArgs {
828 positional_paths: vec![PathBuf::from("movie.mp4")],
829 ..default_args()
830 };
831 assert!(args.validate_compat().is_ok());
832 }
833
834 #[test]
835 fn test_validate_compat_with_offset_ok() {
836 let args = SyncArgs {
837 offset: Some(1.0),
838 ..default_args()
839 };
840 assert!(args.validate_compat().is_ok());
841 }
842
843 #[test]
844 fn test_validate_compat_with_video_ok() {
845 let args = SyncArgs {
846 video: Some(PathBuf::from("v.mp4")),
847 ..default_args()
848 };
849 assert!(args.validate_compat().is_ok());
850 }
851
852 #[test]
853 fn test_validate_compat_no_offset_no_video_no_positional_err() {
854 let args = default_args();
855 assert!(args.validate_compat().is_err());
856 }
857
858 #[test]
859 fn test_validate_compat_with_offset_and_video_ok() {
860 let args = SyncArgs {
861 offset: Some(2.5),
862 video: Some(PathBuf::from("v.mp4")),
863 ..default_args()
864 };
865 assert!(args.validate_compat().is_ok());
866 }
867
868 #[test]
871 fn test_get_sync_mode_batch_explicit_batch_flag() {
872 let tmp = TempDir::new().unwrap();
873 let args = SyncArgs {
874 batch: Some(None),
875 input_paths: vec![tmp.path().to_path_buf()],
876 ..default_args()
877 };
878 let mode = args.get_sync_mode().unwrap();
879 assert!(matches!(mode, SyncMode::Batch(_)));
880 }
881
882 #[test]
883 fn test_get_sync_mode_batch_with_directory() {
884 let tmp = TempDir::new().unwrap();
885 let args = SyncArgs {
886 batch: Some(Some(tmp.path().to_path_buf())),
887 ..default_args()
888 };
889 let mode = args.get_sync_mode().unwrap();
890 assert!(matches!(mode, SyncMode::Batch(_)));
891 }
892
893 #[test]
894 fn test_get_sync_mode_batch_from_input_paths() {
895 let tmp = TempDir::new().unwrap();
896 let args = SyncArgs {
897 input_paths: vec![tmp.path().to_path_buf()],
898 ..default_args()
899 };
900 let mode = args.get_sync_mode().unwrap();
901 assert!(matches!(mode, SyncMode::Batch(_)));
902 }
903
904 #[test]
905 fn test_get_sync_mode_batch_uses_current_dir_when_no_paths() {
906 let args = SyncArgs {
907 batch: Some(None),
908 ..default_args()
909 };
910 let mode = args.get_sync_mode().unwrap();
911 assert!(matches!(mode, SyncMode::Batch(_)));
912 }
913
914 #[test]
915 fn test_get_sync_mode_single_from_two_positionals() {
916 let args = SyncArgs {
917 positional_paths: vec![PathBuf::from("movie.mp4"), PathBuf::from("movie.srt")],
918 ..default_args()
919 };
920 let mode = args.get_sync_mode().unwrap();
921 match mode {
922 SyncMode::Single { video, subtitle } => {
923 assert_eq!(video, PathBuf::from("movie.mp4"));
924 assert_eq!(subtitle, PathBuf::from("movie.srt"));
925 }
926 _ => panic!("Expected Single mode"),
927 }
928 }
929
930 #[test]
931 fn test_get_sync_mode_single_two_positionals_wrong_extensions_err() {
932 let args = SyncArgs {
933 positional_paths: vec![PathBuf::from("file1.txt"), PathBuf::from("file2.doc")],
934 ..default_args()
935 };
936 assert!(args.get_sync_mode().is_err());
937 }
938
939 #[test]
940 fn test_get_sync_mode_single_one_positional_no_extension_is_batch() {
941 let tmp = TempDir::new().unwrap();
943 let args = SyncArgs {
944 positional_paths: vec![tmp.path().to_path_buf()],
945 ..default_args()
946 };
947 let mode = args.get_sync_mode().unwrap();
948 assert!(matches!(mode, SyncMode::Batch(_)));
949 }
950
951 #[test]
952 fn test_get_sync_mode_single_positional_video_no_subtitle_err() {
953 let args = SyncArgs {
955 positional_paths: vec![PathBuf::from("nonexistent_movie.mp4")],
956 ..default_args()
957 };
958 assert!(args.get_sync_mode().is_err());
959 }
960
961 #[test]
962 fn test_get_sync_mode_single_positional_subtitle_finds_video() {
963 let tmp = TempDir::new().unwrap();
964 let video_path = tmp.path().join("clip.mp4");
965 let sub_path = tmp.path().join("clip.srt");
966 std::fs::write(&video_path, b"fake video").unwrap();
967 std::fs::write(&sub_path, b"1\n00:00:01,000 --> 00:00:02,000\nHello\n").unwrap();
968
969 let args = SyncArgs {
970 positional_paths: vec![sub_path.clone()],
971 ..default_args()
972 };
973 let mode = args.get_sync_mode().unwrap();
974 match mode {
975 SyncMode::Single { video, subtitle } => {
976 assert_eq!(video, video_path);
977 assert_eq!(subtitle, sub_path);
978 }
979 _ => panic!("Expected Single mode"),
980 }
981 }
982
983 #[test]
984 fn test_get_sync_mode_single_positional_video_finds_subtitle() {
985 let tmp = TempDir::new().unwrap();
986 let video_path = tmp.path().join("film.mkv");
987 let sub_path = tmp.path().join("film.ass");
988 std::fs::write(&video_path, b"fake video").unwrap();
989 std::fs::write(&sub_path, b"[Script Info]\n").unwrap();
990
991 let args = SyncArgs {
992 positional_paths: vec![video_path.clone()],
993 ..default_args()
994 };
995 let mode = args.get_sync_mode().unwrap();
996 match mode {
997 SyncMode::Single { video, subtitle } => {
998 assert_eq!(video, video_path);
999 assert_eq!(subtitle, sub_path);
1000 }
1001 _ => panic!("Expected Single mode"),
1002 }
1003 }
1004
1005 #[test]
1006 fn test_get_sync_mode_single_positional_manual_mode_subtitle_only() {
1007 let tmp = TempDir::new().unwrap();
1008 let sub_path = tmp.path().join("orphan.srt");
1009 std::fs::write(&sub_path, b"1\n00:00:01,000 --> 00:00:02,000\nHi\n").unwrap();
1010
1011 let args = SyncArgs {
1012 positional_paths: vec![sub_path.clone()],
1013 offset: Some(-0.5),
1014 ..default_args()
1015 };
1016 let mode = args.get_sync_mode().unwrap();
1017 match mode {
1018 SyncMode::Single { video, subtitle } => {
1019 assert_eq!(video, PathBuf::new());
1020 assert_eq!(subtitle, sub_path);
1021 }
1022 _ => panic!("Expected Single mode"),
1023 }
1024 }
1025
1026 #[test]
1027 fn test_get_sync_mode_explicit_video_and_subtitle() {
1028 let args = SyncArgs {
1029 video: Some(PathBuf::from("v.mp4")),
1030 subtitle: Some(PathBuf::from("s.srt")),
1031 ..default_args()
1032 };
1033 let mode = args.get_sync_mode().unwrap();
1034 match mode {
1035 SyncMode::Single { video, subtitle } => {
1036 assert_eq!(video, PathBuf::from("v.mp4"));
1037 assert_eq!(subtitle, PathBuf::from("s.srt"));
1038 }
1039 _ => panic!("Expected Single mode"),
1040 }
1041 }
1042
1043 #[test]
1044 fn test_get_sync_mode_manual_explicit_subtitle_only() {
1045 let args = SyncArgs {
1046 offset: Some(1.0),
1047 subtitle: Some(PathBuf::from("s.srt")),
1048 ..default_args()
1049 };
1050 let mode = args.get_sync_mode().unwrap();
1051 match mode {
1052 SyncMode::Single { video, subtitle } => {
1053 assert_eq!(video, PathBuf::new());
1054 assert_eq!(subtitle, PathBuf::from("s.srt"));
1055 }
1056 _ => panic!("Expected Single mode"),
1057 }
1058 }
1059
1060 #[test]
1061 fn test_get_sync_mode_manual_no_subtitle_err() {
1062 let args = SyncArgs {
1063 offset: Some(1.0),
1064 ..default_args()
1065 };
1066 assert!(args.get_sync_mode().is_err());
1067 }
1068
1069 #[test]
1070 fn test_get_sync_mode_no_inputs_err() {
1071 let args = default_args();
1072 assert!(args.get_sync_mode().is_err());
1073 }
1074
1075 #[test]
1078 fn test_create_default_output_path_srt() {
1079 let input = PathBuf::from("test.srt");
1080 let output = create_default_output_path(&input);
1081 assert_eq!(output.file_name().unwrap(), "test_synced.srt");
1082 }
1083
1084 #[test]
1085 fn test_create_default_output_path_with_prefix() {
1086 let input = PathBuf::from("/path/to/movie.ass");
1087 let output = create_default_output_path(&input);
1088 assert_eq!(output.file_name().unwrap(), "movie_synced.ass");
1089 assert_eq!(output.parent().unwrap(), std::path::Path::new("/path/to"));
1090 }
1091
1092 #[test]
1093 fn test_create_default_output_path_vtt() {
1094 let input = PathBuf::from("episode.vtt");
1095 let output = create_default_output_path(&input);
1096 assert_eq!(output.file_name().unwrap(), "episode_synced.vtt");
1097 }
1098
1099 #[test]
1100 fn test_create_default_output_path_no_extension() {
1101 let input = PathBuf::from("noextension");
1103 let output = create_default_output_path(&input);
1104 assert_eq!(output, PathBuf::from("noextension"));
1105 }
1106
1107 #[test]
1110 fn test_sync_args_batch_input() {
1111 let cli = Cli::try_parse_from([
1112 "subx-cli",
1113 "sync",
1114 "-i",
1115 "dir",
1116 "--batch",
1117 "--recursive",
1118 "--video",
1119 "video.mp4",
1120 ])
1121 .unwrap();
1122 let args = match cli.command {
1123 Commands::Sync(a) => a,
1124 _ => panic!("Expected Sync command"),
1125 };
1126 assert_eq!(args.input_paths, vec![PathBuf::from("dir")]);
1127 assert!(args.batch.is_some());
1128 assert!(args.recursive);
1129 assert_eq!(args.video, Some(PathBuf::from("video.mp4")));
1130 }
1131
1132 #[test]
1133 fn test_sync_args_invalid_combinations() {
1134 let cli = Cli::try_parse_from(["subx-cli", "sync", "--batch", "-i", "dir"]).unwrap();
1135 let args = match cli.command {
1136 Commands::Sync(a) => a,
1137 _ => panic!("Expected Sync command"),
1138 };
1139 assert!(args.validate().is_ok());
1140
1141 let args_invalid = SyncArgs {
1142 batch: Some(None),
1143 ..default_args()
1144 };
1145 assert!(args_invalid.validate().is_err());
1146 }
1147
1148 #[test]
1149 fn test_cli_parse_offset_and_subtitle() {
1150 let cli = Cli::try_parse_from([
1151 "subx-cli",
1152 "sync",
1153 "--offset",
1154 "3.5",
1155 "--subtitle",
1156 "sub.srt",
1157 ])
1158 .unwrap();
1159 let args = match cli.command {
1160 Commands::Sync(a) => a,
1161 _ => panic!("Expected Sync command"),
1162 };
1163 assert_eq!(args.offset, Some(3.5));
1164 assert_eq!(args.subtitle, Some(PathBuf::from("sub.srt")));
1165 }
1166
1167 #[test]
1168 fn test_cli_parse_negative_offset() {
1169 let cli =
1170 Cli::try_parse_from(["subx-cli", "sync", "--offset=-2.0", "-s", "sub.srt"]).unwrap();
1171 let args = match cli.command {
1172 Commands::Sync(a) => a,
1173 _ => panic!("Expected Sync command"),
1174 };
1175 assert_eq!(args.offset, Some(-2.0));
1176 }
1177
1178 #[test]
1179 fn test_cli_parse_method_vad() {
1180 let cli = Cli::try_parse_from(["subx-cli", "sync", "--method", "vad", "--video", "v.mp4"])
1181 .unwrap();
1182 let args = match cli.command {
1183 Commands::Sync(a) => a,
1184 _ => panic!("Expected Sync command"),
1185 };
1186 assert_eq!(args.method, Some(SyncMethodArg::Vad));
1187 }
1188
1189 #[test]
1190 fn test_cli_parse_method_manual() {
1191 let cli = Cli::try_parse_from([
1192 "subx-cli",
1193 "sync",
1194 "--method",
1195 "manual",
1196 "--offset",
1197 "1.0",
1198 "--subtitle",
1199 "sub.srt",
1200 ])
1201 .unwrap();
1202 let args = match cli.command {
1203 Commands::Sync(a) => a,
1204 _ => panic!("Expected Sync command"),
1205 };
1206 assert_eq!(args.method, Some(SyncMethodArg::Manual));
1207 }
1208
1209 #[test]
1210 fn test_cli_parse_default_window() {
1211 let cli = Cli::try_parse_from(["subx-cli", "sync", "--video", "v.mp4"]).unwrap();
1212 let args = match cli.command {
1213 Commands::Sync(a) => a,
1214 _ => panic!("Expected Sync command"),
1215 };
1216 assert_eq!(args.window, 30);
1217 }
1218
1219 #[test]
1220 fn test_cli_parse_custom_window() {
1221 let cli = Cli::try_parse_from(["subx-cli", "sync", "--video", "v.mp4", "--window", "60"])
1222 .unwrap();
1223 let args = match cli.command {
1224 Commands::Sync(a) => a,
1225 _ => panic!("Expected Sync command"),
1226 };
1227 assert_eq!(args.window, 60);
1228 }
1229
1230 #[test]
1231 fn test_cli_parse_flags_verbose_dry_run_force() {
1232 let cli = Cli::try_parse_from([
1233 "subx-cli",
1234 "sync",
1235 "--video",
1236 "v.mp4",
1237 "--verbose",
1238 "--dry-run",
1239 "--force",
1240 ])
1241 .unwrap();
1242 let args = match cli.command {
1243 Commands::Sync(a) => a,
1244 _ => panic!("Expected Sync command"),
1245 };
1246 assert!(args.verbose);
1247 assert!(args.dry_run);
1248 assert!(args.force);
1249 }
1250
1251 #[test]
1252 fn test_cli_parse_no_extract_flag() {
1253 let cli =
1254 Cli::try_parse_from(["subx-cli", "sync", "--video", "v.mp4", "--no-extract"]).unwrap();
1255 let args = match cli.command {
1256 Commands::Sync(a) => a,
1257 _ => panic!("Expected Sync command"),
1258 };
1259 assert!(args.no_extract);
1260 }
1261
1262 #[test]
1263 fn test_cli_parse_output_path() {
1264 let cli = Cli::try_parse_from([
1265 "subx-cli",
1266 "sync",
1267 "--video",
1268 "v.mp4",
1269 "--output",
1270 "result.srt",
1271 ])
1272 .unwrap();
1273 let args = match cli.command {
1274 Commands::Sync(a) => a,
1275 _ => panic!("Expected Sync command"),
1276 };
1277 assert_eq!(args.output, Some(PathBuf::from("result.srt")));
1278 }
1279
1280 #[test]
1281 fn test_cli_parse_vad_sensitivity() {
1282 let cli = Cli::try_parse_from([
1283 "subx-cli",
1284 "sync",
1285 "--video",
1286 "v.mp4",
1287 "--vad-sensitivity",
1288 "0.8",
1289 ])
1290 .unwrap();
1291 let args = match cli.command {
1292 Commands::Sync(a) => a,
1293 _ => panic!("Expected Sync command"),
1294 };
1295 assert_eq!(args.vad_sensitivity, Some(0.8));
1296 }
1297
1298 #[test]
1299 fn test_cli_parse_batch_with_directory() {
1300 let cli = Cli::try_parse_from(["subx-cli", "sync", "--batch", "mydir"]).unwrap();
1301 let args = match cli.command {
1302 Commands::Sync(a) => a,
1303 _ => panic!("Expected Sync command"),
1304 };
1305 assert_eq!(args.batch, Some(Some(PathBuf::from("mydir"))));
1306 }
1307
1308 #[test]
1309 fn test_cli_parse_positional_paths() {
1310 let cli = Cli::try_parse_from(["subx-cli", "sync", "video.mp4", "subtitle.srt"]).unwrap();
1311 let args = match cli.command {
1312 Commands::Sync(a) => a,
1313 _ => panic!("Expected Sync command"),
1314 };
1315 assert_eq!(
1316 args.positional_paths,
1317 vec![PathBuf::from("video.mp4"), PathBuf::from("subtitle.srt")]
1318 );
1319 }
1320
1321 #[test]
1322 fn test_cli_parse_short_flags() {
1323 let cli = Cli::try_parse_from([
1324 "subx-cli",
1325 "sync",
1326 "-v",
1327 "video.mp4",
1328 "-s",
1329 "sub.srt",
1330 "-r",
1331 "-b",
1332 ])
1333 .unwrap();
1334 let args = match cli.command {
1335 Commands::Sync(a) => a,
1336 _ => panic!("Expected Sync command"),
1337 };
1338 assert_eq!(args.video, Some(PathBuf::from("video.mp4")));
1339 assert_eq!(args.subtitle, Some(PathBuf::from("sub.srt")));
1340 assert!(args.recursive);
1341 assert!(args.batch.is_some());
1342 }
1343}