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
475    #[test]
476    fn test_sync_method_selection_manual() {
477        let args = SyncArgs {
478            positional_paths: Vec::new(),
479            video: Some(PathBuf::from("video.mp4")),
480            subtitle: Some(PathBuf::from("subtitle.srt")),
481            input_paths: Vec::new(),
482            recursive: false,
483            offset: Some(2.5),
484            method: None,
485            window: 30,
486            vad_sensitivity: None,
487            output: None,
488            verbose: false,
489            dry_run: false,
490            force: false,
491            batch: None,
492            no_extract: false,
493        };
494        assert_eq!(args.sync_method(), SyncMethod::Manual);
495    }
496
497    #[test]
498    fn test_sync_args_batch_input() {
499        let cli = Cli::try_parse_from([
500            "subx-cli",
501            "sync",
502            "-i",
503            "dir",
504            "--batch",
505            "--recursive",
506            "--video",
507            "video.mp4",
508        ])
509        .unwrap();
510        let args = match cli.command {
511            Commands::Sync(a) => a,
512            _ => panic!("Expected Sync command"),
513        };
514        assert_eq!(args.input_paths, vec![PathBuf::from("dir")]);
515        assert!(args.batch.is_some());
516        assert!(args.recursive);
517        assert_eq!(args.video, Some(PathBuf::from("video.mp4")));
518    }
519
520    #[test]
521    fn test_sync_args_invalid_combinations() {
522        // batch mode with input paths should be valid now
523        let cli = Cli::try_parse_from(["subx-cli", "sync", "--batch", "-i", "dir"]).unwrap();
524        let args = match cli.command {
525            Commands::Sync(a) => a,
526            _ => panic!("Expected Sync command"),
527        };
528
529        // This should now be valid
530        assert!(args.validate().is_ok());
531
532        // Test a truly invalid combination: batch mode with no input sources
533        let args_invalid = SyncArgs {
534            positional_paths: Vec::new(),
535            video: None,
536            subtitle: None,
537            input_paths: Vec::new(),
538            recursive: false,
539            offset: None,
540            method: None,
541            window: 30,
542            vad_sensitivity: None,
543            output: None,
544            verbose: false,
545            dry_run: false,
546            force: false,
547            batch: Some(None), // batch mode but no inputs,
548            no_extract: false,
549        };
550
551        assert!(args_invalid.validate().is_err());
552    }
553
554    #[test]
555    fn test_sync_method_selection_auto() {
556        let args = SyncArgs {
557            positional_paths: Vec::new(),
558            video: Some(PathBuf::from("video.mp4")),
559            subtitle: Some(PathBuf::from("subtitle.srt")),
560            input_paths: Vec::new(),
561            recursive: false,
562            offset: None,
563            method: None,
564            window: 30,
565            vad_sensitivity: None,
566            output: None,
567            verbose: false,
568            dry_run: false,
569            force: false,
570            batch: None,
571            no_extract: false,
572        };
573        assert_eq!(args.sync_method(), SyncMethod::Auto);
574    }
575
576    #[test]
577    fn test_method_arg_conversion() {
578        assert_eq!(
579            crate::core::sync::SyncMethod::from(SyncMethodArg::Vad),
580            crate::core::sync::SyncMethod::LocalVad
581        );
582        assert_eq!(
583            crate::core::sync::SyncMethod::from(SyncMethodArg::Manual),
584            crate::core::sync::SyncMethod::Manual
585        );
586    }
587
588    #[test]
589    fn test_create_default_output_path() {
590        let input = PathBuf::from("test.srt");
591        let output = create_default_output_path(&input);
592        assert_eq!(output.file_name().unwrap(), "test_synced.srt");
593
594        let input = PathBuf::from("/path/to/movie.ass");
595        let output = create_default_output_path(&input);
596        assert_eq!(output.file_name().unwrap(), "movie_synced.ass");
597    }
598}