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    // === Legacy/Hidden Options (Deprecated) ===
140}
141
142/// Sync method enumeration (backward compatible).
143#[derive(Debug, Clone, PartialEq)]
144pub enum SyncMethod {
145    /// Automatic sync using audio analysis.
146    Auto,
147    /// Manual sync using specified time offset.
148    Manual,
149}
150
151impl SyncArgs {
152    /// Validate parameter combination validity.
153    pub fn validate(&self) -> Result<(), String> {
154        // Check manual mode parameter combination
155        if let Some(SyncMethodArg::Manual) = &self.method {
156            if self.offset.is_none() {
157                return Err("Manual method requires --offset parameter.".to_string());
158            }
159        }
160
161        // In batch mode, check if we have some input source
162        if self.batch.is_some() {
163            let has_input_paths = !self.input_paths.is_empty();
164            let has_positional = !self.positional_paths.is_empty();
165            let has_video_or_subtitle = self.video.is_some() || self.subtitle.is_some();
166            let has_batch_directory = matches!(&self.batch, Some(Some(_)));
167
168            // Batch mode needs at least one input source
169            if has_input_paths || has_positional || has_video_or_subtitle || has_batch_directory {
170                return Ok(());
171            }
172
173            return Err("Batch mode requires at least one input source.\n\n\
174Usage:\n\
175• Batch with directory: subx sync -b <directory>\n\
176• Batch with input paths: subx sync -b -i <path>\n\
177• Batch with positional: subx sync -b <path>\n\n\
178Need help? Run: subx sync --help"
179                .to_string());
180        }
181
182        // For single file mode, check if we have enough information
183        let has_video = self.video.is_some();
184        let has_subtitle = self.subtitle.is_some();
185        let has_positional = !self.positional_paths.is_empty();
186        let is_manual = self.offset.is_some();
187
188        // Manual mode only requires subtitle (can be provided via positional args)
189        if is_manual {
190            if has_subtitle || has_positional {
191                return Ok(());
192            }
193            return Err("Manual sync mode requires subtitle file.\n\n\
194Usage:\n\
195• Manual sync: subx sync --offset <seconds> <subtitle>\n\
196• Manual sync: subx sync --offset <seconds> -s <subtitle>\n\n\
197Need help? Run: subx sync --help"
198                .to_string());
199        }
200
201        // Auto mode: needs video, or positional args
202        if has_video || has_positional {
203            // Check VAD sensitivity option only used with VAD method
204            if self.vad_sensitivity.is_some() {
205                if let Some(SyncMethodArg::Manual) = &self.method {
206                    return Err("VAD options can only be used with --method vad.".to_string());
207                }
208            }
209            return Ok(());
210        }
211
212        Err("Auto sync mode requires video file or positional path.\n\n\
213Usage:\n\
214• Auto sync: subx sync <video> <subtitle> or subx sync <video_path>\n\
215• Auto sync: subx sync -v <video> -s <subtitle>\n\
216• Manual sync: subx sync --offset <seconds> <subtitle>\n\
217• Batch mode: subx sync -b [directory]\n\n\
218Need help? Run: subx sync --help"
219            .to_string())
220    }
221
222    /// Get output file path.
223    pub fn get_output_path(&self) -> Option<PathBuf> {
224        if let Some(ref output) = self.output {
225            Some(output.clone())
226        } else {
227            self.subtitle
228                .as_ref()
229                .map(|subtitle| create_default_output_path(subtitle))
230        }
231    }
232
233    /// Check if in manual mode.
234    pub fn is_manual_mode(&self) -> bool {
235        self.offset.is_some() || matches!(self.method, Some(SyncMethodArg::Manual))
236    }
237
238    /// Determine sync method (backward compatible).
239    pub fn sync_method(&self) -> SyncMethod {
240        if self.offset.is_some() {
241            SyncMethod::Manual
242        } else {
243            SyncMethod::Auto
244        }
245    }
246
247    /// Validate parameters (backward compatible method).
248    pub fn validate_compat(&self) -> SubXResult<()> {
249        // Allow positional path for auto sync without explicit video
250        if self.offset.is_none() && self.video.is_none() && !self.positional_paths.is_empty() {
251            return Ok(());
252        }
253        match (self.offset.is_some(), self.video.is_some()) {
254            // Manual mode: offset provided, video optional
255            (true, _) => Ok(()),
256            // Auto mode: no offset, video required
257            (false, true) => Ok(()),
258            // Auto mode without video: invalid
259            (false, false) => Err(SubXError::CommandExecution(
260                "Auto sync mode requires video file.\n\n\
261Usage:\n\
262• Auto sync: subx sync <video> <subtitle>\n\
263• Manual sync: subx sync --offset <seconds> <subtitle>\n\n\
264Need help? Run: subx sync --help"
265                    .to_string(),
266            )),
267        }
268    }
269
270    /// Return whether video file is required (auto sync).
271    #[allow(dead_code)]
272    pub fn requires_video(&self) -> bool {
273        self.offset.is_none()
274    }
275
276    /// Get all input paths, combining video, subtitle and input_paths parameters
277    /// Note: For sync command, both video and subtitle are valid input paths
278    pub fn get_input_handler(&self) -> Result<InputPathHandler, SubXError> {
279        let optional_paths = vec![self.video.clone(), self.subtitle.clone()];
280        let string_paths: Vec<String> = self
281            .positional_paths
282            .iter()
283            .map(|p| p.to_string_lossy().to_string())
284            .collect();
285        let merged_paths = InputPathHandler::merge_paths_from_multiple_sources(
286            &optional_paths,
287            &self.input_paths,
288            &string_paths,
289        )?;
290
291        Ok(InputPathHandler::from_args(&merged_paths, self.recursive)?
292            .with_extensions(&["mp4", "mkv", "avi", "mov", "srt", "ass", "vtt", "sub"]))
293    }
294
295    /// Get sync mode: single file or batch
296    pub fn get_sync_mode(&self) -> Result<SyncMode, SubXError> {
297        // Batch mode: process directories or multiple inputs when -b, -i, or directory positional used
298        if self.batch.is_some()
299            || !self.input_paths.is_empty()
300            || self
301                .positional_paths
302                .iter()
303                .any(|p| p.extension().is_none())
304        {
305            let mut paths = Vec::new();
306
307            // Include batch directory argument if provided
308            if let Some(Some(batch_dir)) = &self.batch {
309                paths.push(batch_dir.clone());
310            }
311
312            // Include input paths (-i) and any positional paths
313            paths.extend(self.input_paths.clone());
314            paths.extend(self.positional_paths.clone());
315
316            // If still no paths, use current directory
317            if paths.is_empty() {
318                paths.push(PathBuf::from("."));
319            }
320
321            let handler = InputPathHandler::from_args(&paths, self.recursive)?
322                .with_extensions(&["mp4", "mkv", "avi", "mov", "srt", "ass", "vtt", "sub"]);
323
324            return Ok(SyncMode::Batch(handler));
325        }
326
327        // Single file positional mode: auto-infer video/subtitle pairing
328        if !self.positional_paths.is_empty() {
329            if self.positional_paths.len() == 1 {
330                let path = &self.positional_paths[0];
331                let ext = path
332                    .extension()
333                    .and_then(|s| s.to_str())
334                    .unwrap_or("")
335                    .to_lowercase();
336                let mut video = None;
337                let mut subtitle = None;
338                match ext.as_str() {
339                    "mp4" | "mkv" | "avi" | "mov" => {
340                        video = Some(path.clone());
341                        if let Some(stem) = path.file_stem().and_then(|s| s.to_str()) {
342                            let dir = path.parent().unwrap_or_else(|| Path::new("."));
343                            for sub_ext in &["srt", "ass", "vtt", "sub"] {
344                                let cand = dir.join(format!("{stem}.{sub_ext}"));
345                                if cand.exists() {
346                                    subtitle = Some(cand);
347                                    break;
348                                }
349                            }
350                        }
351                    }
352                    "srt" | "ass" | "vtt" | "sub" => {
353                        subtitle = Some(path.clone());
354                        if let Some(stem) = path.file_stem().and_then(|s| s.to_str()) {
355                            let dir = path.parent().unwrap_or_else(|| Path::new("."));
356                            for vid_ext in &["mp4", "mkv", "avi", "mov"] {
357                                let cand = dir.join(format!("{stem}.{vid_ext}"));
358                                if cand.exists() {
359                                    video = Some(cand);
360                                    break;
361                                }
362                            }
363                        }
364                    }
365                    _ => {}
366                }
367                // For manual mode, we don't need video file if we have subtitle
368                if self.is_manual_mode() {
369                    if let Some(subtitle_path) = subtitle {
370                        return Ok(SyncMode::Single {
371                            video: PathBuf::new(), // Empty video path for manual mode
372                            subtitle: subtitle_path,
373                        });
374                    }
375                }
376                if let (Some(v), Some(s)) = (video, subtitle) {
377                    return Ok(SyncMode::Single {
378                        video: v,
379                        subtitle: s,
380                    });
381                }
382                return Err(SubXError::InvalidSyncConfiguration);
383            } else if self.positional_paths.len() == 2 {
384                let mut video = None;
385                let mut subtitle = None;
386                for p in &self.positional_paths {
387                    if let Some(ext) = p
388                        .extension()
389                        .and_then(|s| s.to_str())
390                        .map(|s| s.to_lowercase())
391                    {
392                        if ["mp4", "mkv", "avi", "mov"].contains(&ext.as_str()) {
393                            video = Some(p.clone());
394                        }
395                        if ["srt", "ass", "vtt", "sub"].contains(&ext.as_str()) {
396                            subtitle = Some(p.clone());
397                        }
398                    }
399                }
400                if let (Some(v), Some(s)) = (video, subtitle) {
401                    return Ok(SyncMode::Single {
402                        video: v,
403                        subtitle: s,
404                    });
405                }
406                return Err(SubXError::InvalidSyncConfiguration);
407            }
408        }
409
410        // Explicit mode: video and subtitle options
411        if let (Some(video), Some(subtitle)) = (self.video.as_ref(), self.subtitle.as_ref()) {
412            Ok(SyncMode::Single {
413                video: video.clone(),
414                subtitle: subtitle.clone(),
415            })
416        } else if self.is_manual_mode() && self.subtitle.is_some() {
417            // Manual mode only requires subtitle file
418            Ok(SyncMode::Single {
419                video: PathBuf::new(), // Empty video path for manual mode
420                subtitle: self.subtitle.as_ref().unwrap().clone(),
421            })
422        } else {
423            Err(SubXError::InvalidSyncConfiguration)
424        }
425    }
426}
427
428/// Sync mode: single file or batch
429#[derive(Debug)]
430pub enum SyncMode {
431    /// Single file sync mode, specify video and subtitle files
432    Single {
433        /// Video file path
434        video: PathBuf,
435        /// Subtitle file path
436        subtitle: PathBuf,
437    },
438    /// Batch sync mode, using InputPathHandler to process multiple paths
439    Batch(InputPathHandler),
440}
441
442// Helper functions
443
444fn create_default_output_path(input: &Path) -> PathBuf {
445    let mut output = input.to_path_buf();
446
447    if let Some(stem) = input.file_stem().and_then(|s| s.to_str()) {
448        if let Some(extension) = input.extension().and_then(|s| s.to_str()) {
449            let new_filename = format!("{stem}_synced.{extension}");
450            output.set_file_name(new_filename);
451        }
452    }
453
454    output
455}
456
457#[cfg(test)]
458mod tests {
459    use super::*;
460    use crate::cli::{Cli, Commands};
461    use clap::Parser;
462    use std::path::PathBuf;
463
464    #[test]
465    fn test_sync_method_selection_manual() {
466        let args = SyncArgs {
467            positional_paths: Vec::new(),
468            video: Some(PathBuf::from("video.mp4")),
469            subtitle: Some(PathBuf::from("subtitle.srt")),
470            input_paths: Vec::new(),
471            recursive: false,
472            offset: Some(2.5),
473            method: None,
474            window: 30,
475            vad_sensitivity: None,
476            output: None,
477            verbose: false,
478            dry_run: false,
479            force: false,
480            batch: None,
481        };
482        assert_eq!(args.sync_method(), SyncMethod::Manual);
483    }
484
485    #[test]
486    fn test_sync_args_batch_input() {
487        let cli = Cli::try_parse_from([
488            "subx-cli",
489            "sync",
490            "-i",
491            "dir",
492            "--batch",
493            "--recursive",
494            "--video",
495            "video.mp4",
496        ])
497        .unwrap();
498        let args = match cli.command {
499            Commands::Sync(a) => a,
500            _ => panic!("Expected Sync command"),
501        };
502        assert_eq!(args.input_paths, vec![PathBuf::from("dir")]);
503        assert!(args.batch.is_some());
504        assert!(args.recursive);
505        assert_eq!(args.video, Some(PathBuf::from("video.mp4")));
506    }
507
508    #[test]
509    fn test_sync_args_invalid_combinations() {
510        // batch mode with input paths should be valid now
511        let cli = Cli::try_parse_from(["subx-cli", "sync", "--batch", "-i", "dir"]).unwrap();
512        let args = match cli.command {
513            Commands::Sync(a) => a,
514            _ => panic!("Expected Sync command"),
515        };
516
517        // This should now be valid
518        assert!(args.validate().is_ok());
519
520        // Test a truly invalid combination: batch mode with no input sources
521        let args_invalid = SyncArgs {
522            positional_paths: Vec::new(),
523            video: None,
524            subtitle: None,
525            input_paths: Vec::new(),
526            recursive: false,
527            offset: None,
528            method: None,
529            window: 30,
530            vad_sensitivity: None,
531            output: None,
532            verbose: false,
533            dry_run: false,
534            force: false,
535            batch: Some(None), // batch mode but no inputs
536        };
537
538        assert!(args_invalid.validate().is_err());
539    }
540
541    #[test]
542    fn test_sync_method_selection_auto() {
543        let args = SyncArgs {
544            positional_paths: Vec::new(),
545            video: Some(PathBuf::from("video.mp4")),
546            subtitle: Some(PathBuf::from("subtitle.srt")),
547            input_paths: Vec::new(),
548            recursive: false,
549            offset: None,
550            method: None,
551            window: 30,
552            vad_sensitivity: None,
553            output: None,
554            verbose: false,
555            dry_run: false,
556            force: false,
557            batch: None,
558        };
559        assert_eq!(args.sync_method(), SyncMethod::Auto);
560    }
561
562    #[test]
563    fn test_method_arg_conversion() {
564        assert_eq!(
565            crate::core::sync::SyncMethod::from(SyncMethodArg::Vad),
566            crate::core::sync::SyncMethod::LocalVad
567        );
568        assert_eq!(
569            crate::core::sync::SyncMethod::from(SyncMethodArg::Manual),
570            crate::core::sync::SyncMethod::Manual
571        );
572    }
573
574    #[test]
575    fn test_create_default_output_path() {
576        let input = PathBuf::from("test.srt");
577        let output = create_default_output_path(&input);
578        assert_eq!(output.file_name().unwrap(), "test_synced.srt");
579
580        let input = PathBuf::from("/path/to/movie.ass");
581        let output = create_default_output_path(&input);
582        assert_eq!(output.file_name().unwrap(), "movie_synced.ass");
583    }
584}