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        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), // needed so manual method validation passes first
784            ..default_args()
785        };
786        // manual method check fires first (offset required) - here offset is provided,
787        // but we test the vad_sensitivity path, which requires no manual method *and* has video
788        // Re-test: auto path with vad_sensitivity set and manual method
789        let args2 = SyncArgs {
790            video: Some(PathBuf::from("v.mp4")),
791            method: Some(SyncMethodArg::Manual),
792            vad_sensitivity: Some(0.5),
793            offset: None, // manual without offset → first error fires
794            ..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        // vad_sensitivity is fine when method is Vad (or None)
802        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    // ── validate_compat ───────────────────────────────────────────────────────
824
825    #[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    // ── get_sync_mode ─────────────────────────────────────────────────────────
869
870    #[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        // A directory path (no extension) is treated as batch mode
942        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        // File has video extension but no matching subtitle on disk
954        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    // ── create_default_output_path ────────────────────────────────────────────
1076
1077    #[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        // File without extension: stem exists but extension does not; path returned unchanged
1102        let input = PathBuf::from("noextension");
1103        let output = create_default_output_path(&input);
1104        assert_eq!(output, PathBuf::from("noextension"));
1105    }
1106
1107    // ── CLI parsing ───────────────────────────────────────────────────────────
1108
1109    #[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}