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    /// VAD chunk size.
104    #[arg(
105        long,
106        value_name = "SIZE",
107        help = "VAD audio chunk size (number of samples)",
108        value_parser = validate_chunk_size
109    )]
110    pub vad_chunk_size: Option<usize>,
111
112    // === Output Options ===
113    /// Output file path.
114    #[arg(
115        short = 'o',
116        long,
117        value_name = "PATH",
118        help = "Output file path (default: input_synced.ext)"
119    )]
120    pub output: Option<PathBuf>,
121
122    /// Verbose output.
123    #[arg(
124        long,
125        help = "Enable verbose output with detailed progress information"
126    )]
127    pub verbose: bool,
128
129    /// Dry run mode.
130    #[arg(long, help = "Analyze and display results but don't save output file")]
131    pub dry_run: bool,
132
133    /// Force overwrite existing output file.
134    #[arg(long, help = "Overwrite existing output file without confirmation")]
135    pub force: bool,
136
137    /// Enable batch processing mode.
138    #[arg(short, long, help = "Enable batch processing mode")]
139    pub batch: bool,
140
141    // === Legacy/Hidden Options (Deprecated) ===
142    /// Maximum offset search range (deprecated, use configuration file).
143    #[arg(long, hide = true)]
144    #[deprecated(note = "Use configuration file instead")]
145    pub range: Option<f32>,
146
147    /// Minimum correlation threshold (deprecated, use configuration file).
148    #[arg(long, hide = true)]
149    #[deprecated(note = "Use configuration file instead")]
150    pub threshold: Option<f32>,
151}
152
153/// Sync method enumeration (backward compatible).
154#[derive(Debug, Clone, PartialEq)]
155pub enum SyncMethod {
156    /// Automatic sync using audio analysis.
157    Auto,
158    /// Manual sync using specified time offset.
159    Manual,
160}
161
162impl SyncArgs {
163    /// Validate parameter combination validity.
164    pub fn validate(&self) -> Result<(), String> {
165        // Check manual mode parameter combination
166        if let Some(SyncMethodArg::Manual) = &self.method {
167            if self.offset.is_none() {
168                return Err("Manual method requires --offset parameter.".to_string());
169            }
170        }
171
172        // Check auto mode requires video file
173        if self.offset.is_none() && self.video.is_none() {
174            return Err("Auto sync mode requires video file.\n\n\
175Usage:\n\
176• Auto sync: subx sync <video> <subtitle>\n\
177• Manual sync: subx sync --offset <seconds> <subtitle>\n\n\
178Need help? Run: subx sync --help"
179                .to_string());
180        }
181
182        // Check VAD parameters are only used with VAD method
183        if self.vad_sensitivity.is_some() || self.vad_chunk_size.is_some() {
184            match &self.method {
185                Some(SyncMethodArg::Vad) => {}
186                _ => return Err("VAD options can only be used with --method vad.".to_string()),
187            }
188        }
189
190        Ok(())
191    }
192
193    /// Get output file path.
194    pub fn get_output_path(&self) -> Option<PathBuf> {
195        if let Some(ref output) = self.output {
196            Some(output.clone())
197        } else {
198            self.subtitle
199                .as_ref()
200                .map(|subtitle| create_default_output_path(subtitle))
201        }
202    }
203
204    /// Check if in manual mode.
205    pub fn is_manual_mode(&self) -> bool {
206        self.offset.is_some() || matches!(self.method, Some(SyncMethodArg::Manual))
207    }
208
209    /// Determine sync method (backward compatible).
210    pub fn sync_method(&self) -> SyncMethod {
211        if self.offset.is_some() {
212            SyncMethod::Manual
213        } else {
214            SyncMethod::Auto
215        }
216    }
217
218    /// Validate parameters (backward compatible method).
219    pub fn validate_compat(&self) -> SubXResult<()> {
220        match (self.offset.is_some(), self.video.is_some()) {
221            // Manual mode: offset provided, video optional
222            (true, _) => Ok(()),
223            // Auto mode: no offset, video required
224            (false, true) => Ok(()),
225            // Auto mode without video: invalid
226            (false, false) => Err(SubXError::CommandExecution(
227                "Auto sync mode requires video file.\n\n\
228Usage:\n\
229• Auto sync: subx sync <video> <subtitle>\n\
230• Manual sync: subx sync --offset <seconds> <subtitle>\n\n\
231Need help? Run: subx sync --help"
232                    .to_string(),
233            )),
234        }
235    }
236
237    /// Return whether video file is required (auto sync).
238    #[allow(dead_code)]
239    pub fn requires_video(&self) -> bool {
240        self.offset.is_none()
241    }
242
243    /// Get all input paths, combining video, subtitle and input_paths parameters
244    /// Note: For sync command, both video and subtitle are valid input paths
245    pub fn get_input_handler(&self) -> Result<InputPathHandler, SubXError> {
246        let optional_paths = vec![self.video.clone(), self.subtitle.clone()];
247        let merged_paths = InputPathHandler::merge_paths_from_multiple_sources(
248            &optional_paths,
249            &self.input_paths,
250            &[],
251        )?;
252
253        Ok(InputPathHandler::from_args(&merged_paths, self.recursive)?
254            .with_extensions(&["mp4", "mkv", "avi", "mov", "srt", "ass", "vtt", "sub"]))
255    }
256
257    /// Get sync mode: single file or batch
258    pub fn get_sync_mode(&self) -> Result<SyncMode, SubXError> {
259        if !self.input_paths.is_empty() || self.batch {
260            let paths = if !self.input_paths.is_empty() {
261                self.input_paths.clone()
262            } else if let Some(video) = &self.video {
263                vec![video.clone()]
264            } else {
265                return Err(SubXError::NoInputSpecified);
266            };
267
268            let handler = InputPathHandler::from_args(&paths, self.recursive)?
269                .with_extensions(&["mp4", "mkv", "avi", "mov", "srt", "ass", "vtt", "sub"]);
270
271            Ok(SyncMode::Batch(handler))
272        } else if let (Some(video), Some(subtitle)) = (self.video.as_ref(), self.subtitle.as_ref())
273        {
274            Ok(SyncMode::Single {
275                video: video.clone(),
276                subtitle: subtitle.clone(),
277            })
278        } else if let Some(subtitle) = self.subtitle.as_ref() {
279            // Manual mode only requires subtitle file
280            if self.offset.is_some() || matches!(self.method, Some(SyncMethodArg::Manual)) {
281                // Create virtual video path for manual mode
282                Ok(SyncMode::Single {
283                    video: PathBuf::from(""), // Virtual video path, won't be used
284                    subtitle: subtitle.clone(),
285                })
286            } else {
287                Err(SubXError::InvalidSyncConfiguration)
288            }
289        } else {
290            Err(SubXError::InvalidSyncConfiguration)
291        }
292    }
293}
294
295/// Sync mode: single file or batch
296#[derive(Debug)]
297pub enum SyncMode {
298    /// Single file sync mode, specify video and subtitle files
299    Single {
300        /// Video file path
301        video: PathBuf,
302        /// Subtitle file path
303        subtitle: PathBuf,
304    },
305    /// Batch sync mode, using InputPathHandler to process multiple paths
306    Batch(InputPathHandler),
307}
308
309// Helper functions
310fn validate_chunk_size(s: &str) -> Result<usize, String> {
311    let size: usize = s.parse().map_err(|_| "Invalid chunk size")?;
312
313    if !(256..=2048).contains(&size) {
314        return Err("Chunk size must be between 256 and 2048".to_string());
315    }
316
317    if !size.is_power_of_two() {
318        return Err("Chunk size must be a power of 2".to_string());
319    }
320
321    Ok(size)
322}
323
324fn create_default_output_path(input: &Path) -> PathBuf {
325    let mut output = input.to_path_buf();
326
327    if let Some(stem) = input.file_stem().and_then(|s| s.to_str()) {
328        if let Some(extension) = input.extension().and_then(|s| s.to_str()) {
329            let new_filename = format!("{}_synced.{}", stem, extension);
330            output.set_file_name(new_filename);
331        }
332    }
333
334    output
335}
336
337#[cfg(test)]
338mod tests {
339    use super::*;
340    use crate::cli::{Cli, Commands};
341    use clap::Parser;
342    use std::path::PathBuf;
343
344    #[test]
345    fn test_sync_method_selection_manual() {
346        let args = SyncArgs {
347            video: Some(PathBuf::from("video.mp4")),
348            subtitle: Some(PathBuf::from("subtitle.srt")),
349            input_paths: Vec::new(),
350            recursive: false,
351            offset: Some(2.5),
352            method: None,
353            window: 30,
354            vad_sensitivity: None,
355            vad_chunk_size: None,
356            output: None,
357            verbose: false,
358            dry_run: false,
359            force: false,
360            batch: false,
361            #[allow(deprecated)]
362            range: None,
363            #[allow(deprecated)]
364            threshold: None,
365        };
366        assert_eq!(args.sync_method(), SyncMethod::Manual);
367    }
368
369    #[test]
370    fn test_sync_args_batch_input() {
371        let cli = Cli::try_parse_from([
372            "subx-cli",
373            "sync",
374            "-i",
375            "dir",
376            "--batch",
377            "--recursive",
378            "--video",
379            "video.mp4",
380        ])
381        .unwrap();
382        let args = match cli.command {
383            Commands::Sync(a) => a,
384            _ => panic!("Expected Sync command"),
385        };
386        assert_eq!(args.input_paths, vec![PathBuf::from("dir")]);
387        assert!(args.batch);
388        assert!(args.recursive);
389        assert_eq!(args.video, Some(PathBuf::from("video.mp4")));
390    }
391
392    #[test]
393    fn test_sync_args_invalid_combinations() {
394        // batch mode requires video parameter
395        let res = Cli::try_parse_from(["subx-cli", "sync", "--batch", "-i", "dir"]);
396        assert!(res.is_err());
397    }
398
399    #[test]
400    fn test_sync_method_selection_auto() {
401        let args = SyncArgs {
402            video: Some(PathBuf::from("video.mp4")),
403            subtitle: Some(PathBuf::from("subtitle.srt")),
404            input_paths: Vec::new(),
405            recursive: false,
406            offset: None,
407            method: None,
408            window: 30,
409            vad_sensitivity: None,
410            vad_chunk_size: None,
411            output: None,
412            verbose: false,
413            dry_run: false,
414            force: false,
415            batch: false,
416            #[allow(deprecated)]
417            range: None,
418            #[allow(deprecated)]
419            threshold: None,
420        };
421        assert_eq!(args.sync_method(), SyncMethod::Auto);
422    }
423
424    #[test]
425    fn test_method_arg_conversion() {
426        assert_eq!(
427            crate::core::sync::SyncMethod::from(SyncMethodArg::Vad),
428            crate::core::sync::SyncMethod::LocalVad
429        );
430        assert_eq!(
431            crate::core::sync::SyncMethod::from(SyncMethodArg::Manual),
432            crate::core::sync::SyncMethod::Manual
433        );
434    }
435
436    #[test]
437    fn test_validate_chunk_size() {
438        assert!(validate_chunk_size("512").is_ok());
439        assert!(validate_chunk_size("1024").is_ok());
440        assert!(validate_chunk_size("256").is_ok());
441
442        // Too small
443        assert!(validate_chunk_size("128").is_err());
444        // Too large
445        assert!(validate_chunk_size("4096").is_err());
446        // Not a power of 2
447        assert!(validate_chunk_size("500").is_err());
448        // Invalid number
449        assert!(validate_chunk_size("abc").is_err());
450    }
451
452    #[test]
453    fn test_create_default_output_path() {
454        let input = PathBuf::from("test.srt");
455        let output = create_default_output_path(&input);
456        assert_eq!(output.file_name().unwrap(), "test_synced.srt");
457
458        let input = PathBuf::from("/path/to/movie.ass");
459        let output = create_default_output_path(&input);
460        assert_eq!(output.file_name().unwrap(), "movie_synced.ass");
461    }
462}