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