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    /// Video file path for audio analysis.
45    #[arg(
46        short = 'v',
47        long = "video",
48        value_name = "VIDEO",
49        help = "Video file path (required for auto sync, optional for manual offset)",
50        required_unless_present = "offset"
51    )]
52    pub video: Option<PathBuf>,
53
54    /// Subtitle file path to synchronize.
55    #[arg(
56        short = 's',
57        long = "subtitle",
58        value_name = "SUBTITLE",
59        help = "Subtitle file path (required for single file, optional for batch mode)",
60        required_unless_present_any = ["input_paths", "batch"]
61    )]
62    pub subtitle: Option<PathBuf>,
63    /// Specify file or directory paths to process (new parameter), can be used multiple times
64    #[arg(short = 'i', long = "input", value_name = "PATH")]
65    pub input_paths: Vec<PathBuf>,
66
67    /// Recursively process subdirectories (new parameter)
68    #[arg(short, long)]
69    pub recursive: bool,
70
71    /// Manual time offset in seconds (positive delays subtitles, negative advances them).
72    #[arg(
73        long,
74        value_name = "SECONDS",
75        help = "Manual offset in seconds (positive delays subtitles, negative advances them)",
76        conflicts_with_all = ["method", "window", "vad_sensitivity"]
77    )]
78    pub offset: Option<f32>,
79
80    /// Sync method selection.
81    #[arg(short, long, value_enum, help = "Synchronization method")]
82    pub method: Option<SyncMethodArg>,
83
84    /// Analysis time window in seconds.
85    #[arg(
86        short = 'w',
87        long,
88        value_name = "SECONDS",
89        default_value = "30",
90        help = "Time window around first subtitle for analysis (seconds)"
91    )]
92    pub window: u32,
93
94    // === VAD Options ===
95    /// VAD sensitivity threshold.
96    #[arg(
97        long,
98        value_name = "SENSITIVITY",
99        help = "VAD sensitivity threshold (0.0-1.0)"
100    )]
101    pub vad_sensitivity: Option<f32>,
102
103    // === Output Options ===
104    /// Output file path.
105    #[arg(
106        short = 'o',
107        long,
108        value_name = "PATH",
109        help = "Output file path (default: input_synced.ext)"
110    )]
111    pub output: Option<PathBuf>,
112
113    /// Verbose output.
114    #[arg(
115        long,
116        help = "Enable verbose output with detailed progress information"
117    )]
118    pub verbose: bool,
119
120    /// Dry run mode.
121    #[arg(long, help = "Analyze and display results but don't save output file")]
122    pub dry_run: bool,
123
124    /// Force overwrite existing output file.
125    #[arg(long, help = "Overwrite existing output file without confirmation")]
126    pub force: bool,
127
128    /// Enable batch processing mode.
129    #[arg(short, long, help = "Enable batch processing mode")]
130    pub batch: bool,
131
132    // === Legacy/Hidden Options (Deprecated) ===
133    /// Maximum offset search range (deprecated, use configuration file).
134    #[arg(long, hide = true)]
135    #[deprecated(note = "Use configuration file instead")]
136    pub range: Option<f32>,
137
138    /// Minimum correlation threshold (deprecated, use configuration file).
139    #[arg(long, hide = true)]
140    #[deprecated(note = "Use configuration file instead")]
141    pub threshold: Option<f32>,
142}
143
144/// Sync method enumeration (backward compatible).
145#[derive(Debug, Clone, PartialEq)]
146pub enum SyncMethod {
147    /// Automatic sync using audio analysis.
148    Auto,
149    /// Manual sync using specified time offset.
150    Manual,
151}
152
153impl SyncArgs {
154    /// Validate parameter combination validity.
155    pub fn validate(&self) -> Result<(), String> {
156        // Check manual mode parameter combination
157        if let Some(SyncMethodArg::Manual) = &self.method {
158            if self.offset.is_none() {
159                return Err("Manual method requires --offset parameter.".to_string());
160            }
161        }
162
163        // Check auto mode requires video file
164        if self.offset.is_none() && self.video.is_none() {
165            return Err("Auto sync mode requires video file.\n\n\
166Usage:\n\
167• Auto sync: subx sync <video> <subtitle>\n\
168• Manual sync: subx sync --offset <seconds> <subtitle>\n\n\
169Need help? Run: subx sync --help"
170                .to_string());
171        }
172
173        // Check VAD sensitivity option only used with VAD method
174        if self.vad_sensitivity.is_some() {
175            match &self.method {
176                Some(SyncMethodArg::Vad) => {}
177                _ => return Err("VAD options can only be used with --method vad.".to_string()),
178            }
179        }
180
181        Ok(())
182    }
183
184    /// Get output file path.
185    pub fn get_output_path(&self) -> Option<PathBuf> {
186        if let Some(ref output) = self.output {
187            Some(output.clone())
188        } else {
189            self.subtitle
190                .as_ref()
191                .map(|subtitle| create_default_output_path(subtitle))
192        }
193    }
194
195    /// Check if in manual mode.
196    pub fn is_manual_mode(&self) -> bool {
197        self.offset.is_some() || matches!(self.method, Some(SyncMethodArg::Manual))
198    }
199
200    /// Determine sync method (backward compatible).
201    pub fn sync_method(&self) -> SyncMethod {
202        if self.offset.is_some() {
203            SyncMethod::Manual
204        } else {
205            SyncMethod::Auto
206        }
207    }
208
209    /// Validate parameters (backward compatible method).
210    pub fn validate_compat(&self) -> SubXResult<()> {
211        match (self.offset.is_some(), self.video.is_some()) {
212            // Manual mode: offset provided, video optional
213            (true, _) => Ok(()),
214            // Auto mode: no offset, video required
215            (false, true) => Ok(()),
216            // Auto mode without video: invalid
217            (false, false) => Err(SubXError::CommandExecution(
218                "Auto sync mode requires video file.\n\n\
219Usage:\n\
220• Auto sync: subx sync <video> <subtitle>\n\
221• Manual sync: subx sync --offset <seconds> <subtitle>\n\n\
222Need help? Run: subx sync --help"
223                    .to_string(),
224            )),
225        }
226    }
227
228    /// Return whether video file is required (auto sync).
229    #[allow(dead_code)]
230    pub fn requires_video(&self) -> bool {
231        self.offset.is_none()
232    }
233
234    /// Get all input paths, combining video, subtitle and input_paths parameters
235    /// Note: For sync command, both video and subtitle are valid input paths
236    pub fn get_input_handler(&self) -> Result<InputPathHandler, SubXError> {
237        let optional_paths = vec![self.video.clone(), self.subtitle.clone()];
238        let merged_paths = InputPathHandler::merge_paths_from_multiple_sources(
239            &optional_paths,
240            &self.input_paths,
241            &[],
242        )?;
243
244        Ok(InputPathHandler::from_args(&merged_paths, self.recursive)?
245            .with_extensions(&["mp4", "mkv", "avi", "mov", "srt", "ass", "vtt", "sub"]))
246    }
247
248    /// Get sync mode: single file or batch
249    pub fn get_sync_mode(&self) -> Result<SyncMode, SubXError> {
250        if !self.input_paths.is_empty() || self.batch {
251            let paths = if !self.input_paths.is_empty() {
252                self.input_paths.clone()
253            } else if let Some(video) = &self.video {
254                vec![video.clone()]
255            } else {
256                return Err(SubXError::NoInputSpecified);
257            };
258
259            let handler = InputPathHandler::from_args(&paths, self.recursive)?
260                .with_extensions(&["mp4", "mkv", "avi", "mov", "srt", "ass", "vtt", "sub"]);
261
262            Ok(SyncMode::Batch(handler))
263        } else if let (Some(video), Some(subtitle)) = (self.video.as_ref(), self.subtitle.as_ref())
264        {
265            Ok(SyncMode::Single {
266                video: video.clone(),
267                subtitle: subtitle.clone(),
268            })
269        } else if let Some(subtitle) = self.subtitle.as_ref() {
270            // Manual mode only requires subtitle file
271            if self.offset.is_some() || matches!(self.method, Some(SyncMethodArg::Manual)) {
272                // Create virtual video path for manual mode
273                Ok(SyncMode::Single {
274                    video: PathBuf::from(""), // Virtual video path, won't be used
275                    subtitle: subtitle.clone(),
276                })
277            } else {
278                Err(SubXError::InvalidSyncConfiguration)
279            }
280        } else {
281            Err(SubXError::InvalidSyncConfiguration)
282        }
283    }
284}
285
286/// Sync mode: single file or batch
287#[derive(Debug)]
288pub enum SyncMode {
289    /// Single file sync mode, specify video and subtitle files
290    Single {
291        /// Video file path
292        video: PathBuf,
293        /// Subtitle file path
294        subtitle: PathBuf,
295    },
296    /// Batch sync mode, using InputPathHandler to process multiple paths
297    Batch(InputPathHandler),
298}
299
300// Helper functions
301
302fn create_default_output_path(input: &Path) -> PathBuf {
303    let mut output = input.to_path_buf();
304
305    if let Some(stem) = input.file_stem().and_then(|s| s.to_str()) {
306        if let Some(extension) = input.extension().and_then(|s| s.to_str()) {
307            let new_filename = format!("{}_synced.{}", stem, extension);
308            output.set_file_name(new_filename);
309        }
310    }
311
312    output
313}
314
315#[cfg(test)]
316mod tests {
317    use super::*;
318    use crate::cli::{Cli, Commands};
319    use clap::Parser;
320    use std::path::PathBuf;
321
322    #[test]
323    fn test_sync_method_selection_manual() {
324        let args = SyncArgs {
325            video: Some(PathBuf::from("video.mp4")),
326            subtitle: Some(PathBuf::from("subtitle.srt")),
327            input_paths: Vec::new(),
328            recursive: false,
329            offset: Some(2.5),
330            method: None,
331            window: 30,
332            vad_sensitivity: None,
333            output: None,
334            verbose: false,
335            dry_run: false,
336            force: false,
337            batch: false,
338            #[allow(deprecated)]
339            range: None,
340            #[allow(deprecated)]
341            threshold: None,
342        };
343        assert_eq!(args.sync_method(), SyncMethod::Manual);
344    }
345
346    #[test]
347    fn test_sync_args_batch_input() {
348        let cli = Cli::try_parse_from([
349            "subx-cli",
350            "sync",
351            "-i",
352            "dir",
353            "--batch",
354            "--recursive",
355            "--video",
356            "video.mp4",
357        ])
358        .unwrap();
359        let args = match cli.command {
360            Commands::Sync(a) => a,
361            _ => panic!("Expected Sync command"),
362        };
363        assert_eq!(args.input_paths, vec![PathBuf::from("dir")]);
364        assert!(args.batch);
365        assert!(args.recursive);
366        assert_eq!(args.video, Some(PathBuf::from("video.mp4")));
367    }
368
369    #[test]
370    fn test_sync_args_invalid_combinations() {
371        // batch mode requires video parameter
372        let res = Cli::try_parse_from(["subx-cli", "sync", "--batch", "-i", "dir"]);
373        assert!(res.is_err());
374    }
375
376    #[test]
377    fn test_sync_method_selection_auto() {
378        let args = SyncArgs {
379            video: Some(PathBuf::from("video.mp4")),
380            subtitle: Some(PathBuf::from("subtitle.srt")),
381            input_paths: Vec::new(),
382            recursive: false,
383            offset: None,
384            method: None,
385            window: 30,
386            vad_sensitivity: None,
387            output: None,
388            verbose: false,
389            dry_run: false,
390            force: false,
391            batch: false,
392            #[allow(deprecated)]
393            range: None,
394            #[allow(deprecated)]
395            threshold: None,
396        };
397        assert_eq!(args.sync_method(), SyncMethod::Auto);
398    }
399
400    #[test]
401    fn test_method_arg_conversion() {
402        assert_eq!(
403            crate::core::sync::SyncMethod::from(SyncMethodArg::Vad),
404            crate::core::sync::SyncMethod::LocalVad
405        );
406        assert_eq!(
407            crate::core::sync::SyncMethod::from(SyncMethodArg::Manual),
408            crate::core::sync::SyncMethod::Manual
409        );
410    }
411
412    #[test]
413    fn test_create_default_output_path() {
414        let input = PathBuf::from("test.srt");
415        let output = create_default_output_path(&input);
416        assert_eq!(output.file_name().unwrap(), "test_synced.srt");
417
418        let input = PathBuf::from("/path/to/movie.ass");
419        let output = create_default_output_path(&input);
420        assert_eq!(output.file_name().unwrap(), "movie_synced.ass");
421    }
422}