Skip to main content

subx_cli/cli/
sync_args.rs

1//! Refactored sync command CLI argument definitions.
2//!
3//! Supports multiple synchronization methods: OpenAI Whisper API, local VAD,
4//! automatic selection, and manual offset. Provides fine-grained parameter
5//! control and intelligent defaults.
6//!
7//! # Synchronization Methods
8//!
9//! ## OpenAI Whisper API
10//! Cloud transcription service providing high-precision speech detection.
11//!
12//! ## Local VAD
13//! Voice Activity Detection using local processing for privacy and speed.
14//!
15//! ## Manual Offset
16//! Direct time offset specification for precise manual synchronization.
17
18/// Synchronization method selection for CLI arguments.
19#[derive(Debug, Clone, ValueEnum, PartialEq)]
20pub enum SyncMethodArg {
21    /// Use local voice activity detection only
22    Vad,
23    /// Apply manual offset (requires --offset parameter)
24    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/// Refactored sync command arguments supporting multiple sync methods.
42#[derive(Args, Debug, Clone)]
43pub struct SyncArgs {
44    /// Positional file or directory paths to process. Can include video, subtitle, or directories.
45    #[arg(value_name = "PATH", num_args = 0..)]
46    pub positional_paths: Vec<PathBuf>,
47
48    /// Video file path (optional if using positional paths or manual offset).
49    #[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    /// Subtitle file path (optional if using positional paths or manual offset).
58    #[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    /// Specify file or directory paths to process (via -i), can be used multiple times
66    #[arg(short = 'i', long = "input", value_name = "PATH")]
67    pub input_paths: Vec<PathBuf>,
68
69    /// Recursively process subdirectories (new parameter)
70    #[arg(short, long)]
71    pub recursive: bool,
72
73    /// Manual time offset in seconds (positive delays subtitles, negative advances them).
74    #[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    /// Sync method selection.
82    #[arg(short, long, value_enum, help = "Synchronization method")]
83    pub method: Option<SyncMethodArg>,
84
85    /// Analysis time window in seconds.
86    #[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    // === VAD Options ===
96    /// VAD sensitivity threshold.
97    #[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    // === Output Options ===
105    /// Output file path.
106    #[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    /// Verbose output.
115    #[arg(
116        long,
117        help = "Enable verbose output with detailed progress information"
118    )]
119    pub verbose: bool,
120
121    /// Dry run mode.
122    #[arg(long, help = "Analyze and display results but don't save output file")]
123    pub dry_run: bool,
124
125    /// Force overwrite existing output file.
126    #[arg(long, help = "Overwrite existing output file without confirmation")]
127    pub force: bool,
128
129    /// Enable batch processing mode. Can optionally specify a directory path.
130    #[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    /// Disable automatic archive extraction for `-i` inputs
141    #[arg(long, default_value_t = false)]
142    pub no_extract: bool,
143    // === Legacy/Hidden Options (Deprecated) ===
144}
145
146/// Sync method enumeration (backward compatible).
147#[derive(Debug, Clone, PartialEq)]
148pub enum SyncMethod {
149    /// Automatic sync using audio analysis.
150    Auto,
151    /// Manual sync using specified time offset.
152    Manual,
153}
154
155impl SyncArgs {
156    /// Validate parameter combination validity.
157    pub fn validate(&self) -> Result<(), String> {
158        // Check manual mode parameter combination
159        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        // In batch mode, check if we have some input source
166        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            // Batch mode needs at least one input source
173            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        // For single file mode, check if we have enough information
187        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        // Manual mode only requires subtitle (can be provided via positional args)
193        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        // Auto mode: needs video, or positional args
206        if has_video || has_positional {
207            // Check VAD sensitivity option only used with VAD method
208            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    /// Get output file path.
227    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    /// Check if in manual mode.
238    pub fn is_manual_mode(&self) -> bool {
239        self.offset.is_some() || matches!(self.method, Some(SyncMethodArg::Manual))
240    }
241
242    /// Determine sync method (backward compatible).
243    pub fn sync_method(&self) -> SyncMethod {
244        if self.offset.is_some() {
245            SyncMethod::Manual
246        } else {
247            SyncMethod::Auto
248        }
249    }
250
251    /// Validate parameters (backward compatible method).
252    pub fn validate_compat(&self) -> SubXResult<()> {
253        // Allow positional path for auto sync without explicit video
254        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            // Manual mode: offset provided, video optional
259            (true, _) => Ok(()),
260            // Auto mode: no offset, video required
261            (false, true) => Ok(()),
262            // Auto mode without video: invalid
263            (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    /// Return whether video file is required (auto sync).
275    #[allow(dead_code)]
276    pub fn requires_video(&self) -> bool {
277        self.offset.is_none()
278    }
279
280    /// Get all input paths, combining video, subtitle and input_paths parameters
281    /// Note: For sync command, both video and subtitle are valid input paths
282    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    /// Get sync mode: single file or batch
301    pub fn get_sync_mode(&self) -> Result<SyncMode, SubXError> {
302        // Batch mode: process directories or multiple inputs when -b, -i, or directory positional used
303        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            // Include batch directory argument if provided
313            if let Some(Some(batch_dir)) = &self.batch {
314                paths.push(batch_dir.clone());
315            }
316
317            // Include input paths (-i) and any positional paths
318            paths.extend(self.input_paths.clone());
319            paths.extend(self.positional_paths.clone());
320
321            // If still no paths, use current directory
322            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        // Single file positional mode: auto-infer video/subtitle pairing
334        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                // For manual mode, we don't need video file if we have subtitle
374                if self.is_manual_mode() {
375                    if let Some(subtitle_path) = subtitle {
376                        return Ok(SyncMode::Single {
377                            video: PathBuf::new(), // Empty video path for manual mode
378                            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        // Explicit mode: video and subtitle options
417        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                // Manual mode only requires subtitle file
425                Ok(SyncMode::Single {
426                    video: PathBuf::new(), // Empty video path for manual mode
427                    subtitle: subtitle.clone(),
428                })
429            } else {
430                Err(SubXError::InvalidSyncConfiguration)
431            }
432        } else {
433            Err(SubXError::InvalidSyncConfiguration)
434        }
435    }
436}
437
438/// Sync mode: single file or batch
439#[derive(Debug)]
440pub enum SyncMode {
441    /// Single file sync mode, specify video and subtitle files
442    Single {
443        /// Video file path
444        video: PathBuf,
445        /// Subtitle file path
446        subtitle: PathBuf,
447    },
448    /// Batch sync mode, using InputPathHandler to process multiple paths
449    Batch(InputPathHandler),
450}
451
452// Helper functions
453
454/// Creates a default output path by appending `_synced` to the file stem.
455pub 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    // ── helpers ──────────────────────────────────────────────────────────────
477
478    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    // ── SyncMethodArg / From conversion ──────────────────────────────────────
499
500    #[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    // ── SyncMethod enum ───────────────────────────────────────────────────────
544
545    #[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    // ── is_manual_mode ────────────────────────────────────────────────────────
556
557    #[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    // ── requires_video ────────────────────────────────────────────────────────
591
592    #[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    // ── get_output_path ───────────────────────────────────────────────────────
608
609    #[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    // ── validate ──────────────────────────────────────────────────────────────
636
637    #[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        // manual method check fires first (offset required) - this path
780        // tests the vad_sensitivity guard, which requires the auto method
781        // *and* a video to be set.
782        let args2 = SyncArgs {
783            video: Some(PathBuf::from("v.mp4")),
784            method: Some(SyncMethodArg::Manual),
785            vad_sensitivity: Some(0.5),
786            offset: None, // manual without offset → first error fires
787            ..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        // vad_sensitivity is fine when method is Vad (or None)
795        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    // ── validate_compat ───────────────────────────────────────────────────────
817
818    #[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    // ── get_sync_mode ─────────────────────────────────────────────────────────
862
863    #[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        // A directory path (no extension) is treated as batch mode
935        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        // File has video extension but no matching subtitle on disk
947        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    // ── create_default_output_path ────────────────────────────────────────────
1069
1070    #[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        // File without extension: stem exists but extension does not; path returned unchanged
1095        let input = PathBuf::from("noextension");
1096        let output = create_default_output_path(&input);
1097        assert_eq!(output, PathBuf::from("noextension"));
1098    }
1099
1100    // ── CLI parsing ───────────────────────────────────────────────────────────
1101
1102    #[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}