Skip to main content

subx_core/core/sync/
mod.rs

1//! Refactored sync module focused on VAD (Voice Activity Detection).
2//!
3//! Provides unified subtitle synchronization functionality using local
4//! VAD (Voice Activity Detection) for voice detection and sync offset calculation.
5//!
6//! # Core Components
7//!
8//! - [`SyncEngine`] - VAD-based sync engine
9//! - [`SyncMethod`] - Sync method enumeration (VAD and manual)
10//! - [`SyncResult`] - Sync result structure containing offset and confidence
11//! - [`shift_subtitle_timing`] - VAD-independent manual-offset timing transform
12//!
13//! # Usage
14//!
15//! ```no_run
16//! use subx_core::core::sync::{SyncEngine, SyncMethod};
17//! use subx_core::config::SyncConfig;
18//! use std::path::Path;
19//! use subx_core::core::formats::{Subtitle, SubtitleFormatType, SubtitleMetadata};
20//!
21//! # async fn example() -> Result<(), Box<dyn std::error::Error>> {
22//! let engine = SyncEngine::new(SyncConfig::default())?;
23//! let video_path = Path::new("video.mp4");
24//! let metadata = SubtitleMetadata::new(SubtitleFormatType::Srt);
25//! let subtitle = Subtitle::new(SubtitleFormatType::Srt, metadata);
26//! let result = engine.detect_sync_offset(video_path, &subtitle, Some(SyncMethod::LocalVad)).await?;
27//! # Ok(())
28//! # }
29//! ```
30
31pub mod engine;
32
33// Re-export main types
34pub use engine::{MethodSelectionStrategy, SyncEngine, SyncMethod, SyncResult};
35
36use crate::core::formats::Subtitle;
37use crate::core::input::InputPathHandler;
38use crate::error::SubXError;
39use log::debug;
40use serde_json::json;
41use std::path::{Path, PathBuf};
42use std::time::{Duration, Instant};
43
44/// Video container extensions recognised when auto-pairing a sync input.
45///
46/// This is the single definition used by both the pairing resolver
47/// ([`resolve_sync_pairing`]) and the CLI's input-handler construction
48/// (`SyncArgs::get_input_handler`), so the two cannot drift apart.
49pub const SYNC_VIDEO_EXTENSIONS: &[&str] = &["mp4", "mkv", "avi", "mov"];
50
51/// Subtitle extensions recognised when auto-pairing a sync input.
52///
53/// This is the single definition used by both the pairing resolver
54/// ([`resolve_sync_pairing`]) and the CLI's input-handler construction
55/// (`SyncArgs::get_input_handler`), so the two cannot drift apart.
56pub const SYNC_SUBTITLE_EXTENSIONS: &[&str] = &["srt", "ass", "vtt", "sub"];
57
58/// How the caller requested batch processing.
59///
60/// Parser-agnostic replacement for clap's `Option<Option<PathBuf>>` encoding
61/// of `--batch [DIR]` (`num_args = 0..=1`): `Off` is "flag absent", `Auto` is
62/// "flag present without a value", `Directory` is "flag present with a value".
63#[derive(Debug, Clone, PartialEq, Eq, Default)]
64pub enum BatchRequest {
65    /// Batch not requested.
66    #[default]
67    Off,
68    /// Batch requested without an explicit directory.
69    Auto,
70    /// Batch requested for a specific directory.
71    Directory(PathBuf),
72}
73
74/// Parser-agnostic description of one `sync` invocation's inputs.
75///
76/// [`resolve_sync_pairing`] consumes this instead of a clap argument struct,
77/// so mode resolution and auto-pairing are callable without any
78/// argument-parsing type. Derives [`Default`] so callers fill only the fields
79/// their case needs.
80#[derive(Debug, Clone, Default)]
81pub struct SyncPairingRequest {
82    /// Positional file or directory paths in invocation order.
83    pub positional_paths: Vec<PathBuf>,
84    /// Paths supplied via repeated `-i` arguments.
85    pub input_paths: Vec<PathBuf>,
86    /// Explicit `--video` path.
87    pub video: Option<PathBuf>,
88    /// Explicit `--subtitle` path.
89    pub subtitle: Option<PathBuf>,
90    /// How batch processing was requested.
91    pub batch: BatchRequest,
92    /// Whether directory scanning is recursive.
93    pub recursive: bool,
94    /// Whether archive extraction is disabled.
95    pub no_extract: bool,
96    /// Manual-offset mode: a video is not required.
97    pub manual: bool,
98}
99
100/// Sync mode: single file or batch.
101#[derive(Debug)]
102pub enum SyncMode {
103    /// Single file sync mode, specify video and subtitle files
104    Single {
105        /// Video file path
106        video: PathBuf,
107        /// Subtitle file path
108        subtitle: PathBuf,
109    },
110    /// Batch sync mode, using InputPathHandler to process multiple paths
111    Batch(InputPathHandler),
112}
113
114/// Decide whether a sync invocation is a single pair or a batch, auto-pairing
115/// a lone video or subtitle with its sibling on disk.
116///
117/// Algorithm (locked by the `timeline-sync` capability spec):
118/// 1. Any batch trigger (`batch != Off`, non-empty `input_paths`, or an
119///    extension-less positional path) selects [`SyncMode::Batch`], with the
120///    handler's paths ordered batch directory → `-i` paths → positionals and
121///    defaulting to `["."]` when empty.
122/// 2. A lone positional path is classified by its lower-cased extension and
123///    probed for a `<stem>.<ext>` sibling over the declared extension lists.
124/// 3. Two positional paths are classified by extension without probing.
125/// 4. Otherwise the explicit `video`/`subtitle` fields are used.
126/// 5. In manual mode a resolved subtitle with no video yields
127///    [`SyncMode::Single`] with an empty [`PathBuf`] video ("no video
128///    required" sentinel).
129/// 6. Anything else returns [`SubXError::InvalidSyncConfiguration`].
130pub fn resolve_sync_pairing(request: &SyncPairingRequest) -> Result<SyncMode, SubXError> {
131    // Batch mode: process directories or multiple inputs when -b, -i, or directory positional used
132    if request.batch != BatchRequest::Off
133        || !request.input_paths.is_empty()
134        || request
135            .positional_paths
136            .iter()
137            .any(|p| p.extension().is_none())
138    {
139        let mut paths = Vec::new();
140
141        // Include batch directory argument if provided
142        if let BatchRequest::Directory(batch_dir) = &request.batch {
143            paths.push(batch_dir.clone());
144        }
145
146        // Include input paths (-i) and any positional paths
147        paths.extend(request.input_paths.clone());
148        paths.extend(request.positional_paths.clone());
149
150        // If still no paths, use current directory
151        if paths.is_empty() {
152            paths.push(PathBuf::from("."));
153        }
154
155        let handler = InputPathHandler::from_args(&paths, request.recursive)?
156            .with_extensions(&[SYNC_VIDEO_EXTENSIONS, SYNC_SUBTITLE_EXTENSIONS].concat())
157            .with_no_extract(request.no_extract);
158
159        return Ok(SyncMode::Batch(handler));
160    }
161
162    // Single file positional mode: auto-infer video/subtitle pairing
163    if !request.positional_paths.is_empty() {
164        if request.positional_paths.len() == 1 {
165            let path = &request.positional_paths[0];
166            let ext = path
167                .extension()
168                .and_then(|s| s.to_str())
169                .unwrap_or("")
170                .to_lowercase();
171            let mut video = None;
172            let mut subtitle = None;
173            if SYNC_VIDEO_EXTENSIONS.contains(&ext.as_str()) {
174                video = Some(path.clone());
175                if let Some(stem) = path.file_stem().and_then(|s| s.to_str()) {
176                    let dir = path.parent().unwrap_or_else(|| Path::new("."));
177                    for sub_ext in SYNC_SUBTITLE_EXTENSIONS {
178                        let cand = dir.join(format!("{stem}.{sub_ext}"));
179                        if cand.exists() {
180                            subtitle = Some(cand);
181                            break;
182                        }
183                    }
184                }
185            } else if SYNC_SUBTITLE_EXTENSIONS.contains(&ext.as_str()) {
186                subtitle = Some(path.clone());
187                if let Some(stem) = path.file_stem().and_then(|s| s.to_str()) {
188                    let dir = path.parent().unwrap_or_else(|| Path::new("."));
189                    for vid_ext in SYNC_VIDEO_EXTENSIONS {
190                        let cand = dir.join(format!("{stem}.{vid_ext}"));
191                        if cand.exists() {
192                            video = Some(cand);
193                            break;
194                        }
195                    }
196                }
197            }
198            // For manual mode, we don't need video file if we have subtitle
199            if request.manual {
200                if let Some(subtitle_path) = subtitle {
201                    return Ok(SyncMode::Single {
202                        video: PathBuf::new(), // Empty video path for manual mode
203                        subtitle: subtitle_path,
204                    });
205                }
206            }
207            if let (Some(v), Some(s)) = (video, subtitle) {
208                return Ok(SyncMode::Single {
209                    video: v,
210                    subtitle: s,
211                });
212            }
213            return Err(SubXError::InvalidSyncConfiguration);
214        } else if request.positional_paths.len() == 2 {
215            let mut video = None;
216            let mut subtitle = None;
217            for p in &request.positional_paths {
218                if let Some(ext) = p
219                    .extension()
220                    .and_then(|s| s.to_str())
221                    .map(|s| s.to_lowercase())
222                {
223                    if SYNC_VIDEO_EXTENSIONS.contains(&ext.as_str()) {
224                        video = Some(p.clone());
225                    }
226                    if SYNC_SUBTITLE_EXTENSIONS.contains(&ext.as_str()) {
227                        subtitle = Some(p.clone());
228                    }
229                }
230            }
231            if let (Some(v), Some(s)) = (video, subtitle) {
232                return Ok(SyncMode::Single {
233                    video: v,
234                    subtitle: s,
235                });
236            }
237            return Err(SubXError::InvalidSyncConfiguration);
238        }
239    }
240
241    // Explicit mode: video and subtitle options
242    if let (Some(video), Some(subtitle)) = (request.video.as_ref(), request.subtitle.as_ref()) {
243        Ok(SyncMode::Single {
244            video: video.clone(),
245            subtitle: subtitle.clone(),
246        })
247    } else if request.manual {
248        if let Some(subtitle) = request.subtitle.as_ref() {
249            // Manual mode only requires subtitle file
250            Ok(SyncMode::Single {
251                video: PathBuf::new(), // Empty video path for manual mode
252                subtitle: subtitle.clone(),
253            })
254        } else {
255            Err(SubXError::InvalidSyncConfiguration)
256        }
257    } else {
258        Err(SubXError::InvalidSyncConfiguration)
259    }
260}
261
262/// Creates a default output path by appending `_synced` to the file stem.
263///
264/// # Arguments
265///
266/// * `input` - The input subtitle path
267///
268/// # Returns
269///
270/// The input path with its file name replaced by `<stem>_synced.<extension>`,
271/// or the input path unchanged when it has no file stem or no extension.
272///
273/// # Examples
274///
275/// ```
276/// use std::path::PathBuf;
277/// use subx_core::core::sync::create_default_output_path;
278///
279/// assert_eq!(
280///     create_default_output_path(&PathBuf::from("subs/movie.srt")),
281///     PathBuf::from("subs/movie_synced.srt")
282/// );
283/// assert_eq!(
284///     create_default_output_path(&PathBuf::from("noextension")),
285///     PathBuf::from("noextension")
286/// );
287/// ```
288pub fn create_default_output_path(input: &Path) -> PathBuf {
289    let mut output = input.to_path_buf();
290
291    if let Some(stem) = input.file_stem().and_then(|s| s.to_str()) {
292        if let Some(extension) = input.extension().and_then(|s| s.to_str()) {
293            let new_filename = format!("{stem}_synced.{extension}");
294            output.set_file_name(new_filename);
295        }
296    }
297
298    output
299}
300
301/// Shift every subtitle entry's start and end time by a manual offset.
302///
303/// This is the entire manual-offset timing transform, reachable without a
304/// [`SyncEngine`]: `SyncEngine::new` requires a VAD detector even for
305/// callers that only ever apply a manual offset, so a host that never
306/// performs detection would have to satisfy a precondition for a subsystem
307/// it does not use. `SyncEngine::apply_manual_offset` delegates here after
308/// enforcing `sync.max_offset_seconds`, so exactly one implementation of
309/// the shift exists.
310///
311/// A positive offset delays every entry via a checked addition; a negative
312/// offset advances every entry, clamping at [`Duration::ZERO`] rather than
313/// producing negative timestamps.
314///
315/// # Arguments
316///
317/// * `subtitle` - Mutable subtitle data whose entries are shifted in place
318/// * `offset_seconds` - Offset in seconds (positive delays, negative advances)
319///
320/// # Returns
321///
322/// A [`SyncResult`] with the supplied offset, full confidence,
323/// `method_used = SyncMethod::Manual`, an `additional_info` object
324/// recording the applied offset and the number of entries modified, and
325/// the measured processing duration.
326///
327/// # Errors
328///
329/// Returns an [`crate::error::SubXError::AudioProcessing`] error if a
330/// positive offset would overflow any entry's timing (`Duration::MAX`
331/// plus a positive offset).
332///
333/// `sync.max_offset_seconds` is **not** enforced here — this function has
334/// no configuration to read it from. [`SyncEngine::apply_manual_offset`]
335/// is the entry point that enforces the configured maximum; a caller
336/// reaching this function directly is responsible for its own bound.
337///
338/// # Examples
339///
340/// ```
341/// use std::time::Duration;
342/// use subx_core::core::formats::{Subtitle, SubtitleEntry, SubtitleFormatType, SubtitleMetadata};
343/// use subx_core::core::sync::{shift_subtitle_timing, SyncMethod};
344///
345/// let mut subtitle = Subtitle::new(
346///     SubtitleFormatType::Srt,
347///     SubtitleMetadata::default(),
348/// );
349/// subtitle.entries.push(SubtitleEntry::new(
350///     1,
351///     Duration::from_secs(10),
352///     Duration::from_secs(12),
353///     "Hello".to_string(),
354/// ));
355///
356/// let result = shift_subtitle_timing(&mut subtitle, 2.5).unwrap();
357/// assert_eq!(subtitle.entries[0].start_time, Duration::from_secs_f32(12.5));
358/// assert_eq!(result.method_used, SyncMethod::Manual);
359/// assert_eq!(result.confidence, 1.0);
360/// ```
361pub fn shift_subtitle_timing(
362    subtitle: &mut Subtitle,
363    offset_seconds: f32,
364) -> crate::Result<SyncResult> {
365    let start = Instant::now();
366    for entry in &mut subtitle.entries {
367        let offset_dur = Duration::from_secs_f32(offset_seconds.abs());
368        if offset_seconds >= 0.0 {
369            entry.start_time = entry.start_time.checked_add(offset_dur).ok_or_else(|| {
370                SubXError::audio_processing("Invalid offset results in negative time")
371            })?;
372            entry.end_time = entry.end_time.checked_add(offset_dur).ok_or_else(|| {
373                SubXError::audio_processing("Invalid offset results in negative time")
374            })?;
375        } else {
376            // For negative offsets, clamp times to zero instead of erroring on underflow
377            entry.start_time = if entry.start_time > offset_dur {
378                entry.start_time - offset_dur
379            } else {
380                Duration::ZERO
381            };
382            entry.end_time = if entry.end_time > offset_dur {
383                entry.end_time - offset_dur
384            } else {
385                Duration::ZERO
386            };
387        }
388    }
389    debug!(
390        "[SyncEngine] Manual offset applied to all entries | offset_seconds: {:.3}",
391        offset_seconds
392    );
393    Ok(SyncResult {
394        offset_seconds,
395        confidence: 1.0,
396        method_used: SyncMethod::Manual,
397        correlation_peak: 1.0,
398        additional_info: Some(json!({
399            "applied_offset": offset_seconds,
400            "entries_modified": subtitle.entries.len(),
401        })),
402        processing_duration: start.elapsed(),
403        warnings: Vec::new(),
404    })
405}
406
407#[cfg(test)]
408mod tests {
409    use super::*;
410    use crate::core::formats::{SubtitleEntry, SubtitleFormatType, SubtitleMetadata};
411
412    // ── shift_subtitle_timing ────────────────────────────────────────────
413
414    fn shift_test_subtitle(entries: Vec<(std::time::Duration, std::time::Duration)>) -> Subtitle {
415        let mut subtitle = Subtitle::new(SubtitleFormatType::Srt, SubtitleMetadata::default());
416        subtitle.entries = entries
417            .into_iter()
418            .enumerate()
419            .map(|(i, (start, end))| {
420                SubtitleEntry::new(i + 1, start, end, format!("line {}", i + 1))
421            })
422            .collect();
423        subtitle
424    }
425
426    #[test]
427    fn test_shift_subtitle_timing_positive_shifts_both_times() {
428        let mut subtitle =
429            shift_test_subtitle(vec![(Duration::from_secs(10), Duration::from_secs(12))]);
430        let result = shift_subtitle_timing(&mut subtitle, 2.5).unwrap();
431        assert_eq!(
432            subtitle.entries[0].start_time,
433            Duration::from_secs_f32(12.5)
434        );
435        assert_eq!(subtitle.entries[0].end_time, Duration::from_secs_f32(14.5));
436        assert_eq!(result.offset_seconds, 2.5);
437        assert_eq!(result.method_used, SyncMethod::Manual);
438        assert_eq!(result.confidence, 1.0);
439    }
440
441    #[test]
442    fn test_shift_subtitle_timing_negative_clamps_at_zero() {
443        // start_time = 1s advanced by -5s clamps to Duration::ZERO.
444        let mut subtitle =
445            shift_test_subtitle(vec![(Duration::from_secs(1), Duration::from_secs(20))]);
446        shift_subtitle_timing(&mut subtitle, -5.0).unwrap();
447        assert_eq!(subtitle.entries[0].start_time, Duration::ZERO);
448        assert_eq!(subtitle.entries[0].end_time, Duration::from_secs_f32(15.0));
449    }
450
451    #[test]
452    fn test_shift_subtitle_timing_negative_boundary_equal_offset_clamps_to_zero() {
453        // start (2s) is below the 5s magnitude and end (5s) equals it —
454        // the strict `>` clamp branch and its `==` boundary, where the
455        // saturating_sub mirror also lands on Duration::ZERO.
456        let mut subtitle =
457            shift_test_subtitle(vec![(Duration::from_secs(2), Duration::from_secs(5))]);
458        shift_subtitle_timing(&mut subtitle, -5.0).unwrap();
459        assert_eq!(subtitle.entries[0].start_time, Duration::ZERO);
460        assert_eq!(subtitle.entries[0].end_time, Duration::ZERO);
461    }
462
463    #[test]
464    fn test_shift_subtitle_timing_positive_overflow_errors() {
465        // end_time = Duration::MAX (the *Subtitle Timing Application*
466        // scenario); the +1s checked_add on it overflows. start < end so
467        // SubtitleEntry::new's own validation is satisfied.
468        let mut subtitle = shift_test_subtitle(vec![(
469            Duration::MAX - Duration::from_secs(1),
470            Duration::MAX,
471        )]);
472        let err = shift_subtitle_timing(&mut subtitle, 1.0).unwrap_err();
473        assert!(matches!(err, SubXError::AudioProcessing { .. }));
474    }
475
476    #[test]
477    fn test_shift_subtitle_timing_empty_subtitle_succeeds() {
478        let mut subtitle = shift_test_subtitle(vec![]);
479        let result = shift_subtitle_timing(&mut subtitle, 3.0).unwrap();
480        assert_eq!(
481            result.additional_info.unwrap()["entries_modified"]
482                .as_u64()
483                .unwrap(),
484            0
485        );
486    }
487
488    #[test]
489    fn test_shift_subtitle_timing_ignores_max_offset_guard() {
490        // 120s exceeds the default sync.max_offset_seconds (60s) yet the
491        // free function carries no configuration and must not error —
492        // proving the guard belongs to SyncEngine::apply_manual_offset.
493        let mut subtitle =
494            shift_test_subtitle(vec![(Duration::from_secs(1), Duration::from_secs(2))]);
495        let result = shift_subtitle_timing(&mut subtitle, 120.0).unwrap();
496        assert_eq!(result.offset_seconds, 120.0);
497        assert_eq!(
498            subtitle.entries[0].start_time,
499            Duration::from_secs_f32(121.0)
500        );
501    }
502
503    #[test]
504    fn test_apply_manual_offset_delegates_identically_to_shift_subtitle_timing() {
505        use crate::config::TestConfigBuilder;
506
507        let config = TestConfigBuilder::new()
508            .with_vad_enabled(true)
509            .build_config();
510        let engine = SyncEngine::new(config.sync).unwrap();
511
512        let fixture = shift_test_subtitle(vec![
513            (Duration::from_secs(1), Duration::from_secs(3)),
514            (Duration::from_secs(10), Duration::from_secs(14)),
515            (Duration::from_secs(100), Duration::from_secs(120)),
516        ]);
517        let mut via_engine = fixture.clone();
518        let mut via_free_fn = fixture.clone();
519
520        let offset = 2.5f32; // well inside sync.max_offset_seconds
521        let r_engine = engine.apply_manual_offset(&mut via_engine, offset).unwrap();
522        let r_free = shift_subtitle_timing(&mut via_free_fn, offset).unwrap();
523
524        // Identical entry timings…
525        assert_eq!(via_engine.entries.len(), via_free_fn.entries.len());
526        for (a, b) in via_engine.entries.iter().zip(&via_free_fn.entries) {
527            assert_eq!(a.start_time, b.start_time);
528            assert_eq!(a.end_time, b.end_time);
529        }
530        // …and identical SyncResult fields (processing_duration is a
531        // measured wall time and intentionally excluded).
532        assert_eq!(r_engine.offset_seconds, r_free.offset_seconds);
533        assert_eq!(r_engine.confidence, r_free.confidence);
534        assert_eq!(r_engine.method_used, r_free.method_used);
535        assert_eq!(r_engine.correlation_peak, r_free.correlation_peak);
536        assert_eq!(r_engine.additional_info, r_free.additional_info);
537        assert_eq!(r_engine.warnings, r_free.warnings);
538    }
539
540    // ── create_default_output_path ───────────────────────────────────────
541
542    #[test]
543    fn test_create_default_output_path_srt() {
544        let input = PathBuf::from("test.srt");
545        let output = create_default_output_path(&input);
546        assert_eq!(output.file_name().unwrap(), "test_synced.srt");
547    }
548
549    #[test]
550    fn test_create_default_output_path_with_prefix() {
551        let input = PathBuf::from("/path/to/movie.ass");
552        let output = create_default_output_path(&input);
553        assert_eq!(output.file_name().unwrap(), "movie_synced.ass");
554        assert_eq!(output.parent().unwrap(), std::path::Path::new("/path/to"));
555    }
556
557    #[test]
558    fn test_create_default_output_path_vtt() {
559        let input = PathBuf::from("episode.vtt");
560        let output = create_default_output_path(&input);
561        assert_eq!(output.file_name().unwrap(), "episode_synced.vtt");
562    }
563
564    #[test]
565    fn test_create_default_output_path_no_extension() {
566        // File without extension: stem exists but extension does not; path returned unchanged
567        let input = PathBuf::from("noextension");
568        let output = create_default_output_path(&input);
569        assert_eq!(output, PathBuf::from("noextension"));
570    }
571
572    // ── resolve_sync_pairing (re-pointed from the SyncArgs characterisation
573    //    set, task 5.2) ────────────────────────────────────────────────────
574
575    #[test]
576    fn test_resolve_pairing_single_positional_video_probes_subtitle() {
577        let tmp = tempfile::TempDir::new().unwrap();
578        let video = tmp.path().join("movie.mp4");
579        let sub = tmp.path().join("movie.srt");
580        std::fs::write(&video, b"fake video").unwrap();
581        std::fs::write(&sub, b"1\n00:00:01,000 --> 00:00:02,000\nHi\n").unwrap();
582
583        let request = SyncPairingRequest {
584            positional_paths: vec![video.clone()],
585            ..Default::default()
586        };
587        match resolve_sync_pairing(&request).unwrap() {
588            SyncMode::Single {
589                video: v,
590                subtitle: s,
591            } => {
592                assert_eq!(v, video);
593                assert_eq!(s, sub);
594            }
595            other => panic!("Expected Single mode, got {other:?}"),
596        }
597    }
598
599    #[test]
600    fn test_resolve_pairing_single_positional_subtitle_probes_video() {
601        let tmp = tempfile::TempDir::new().unwrap();
602        let video = tmp.path().join("movie.mp4");
603        let sub = tmp.path().join("movie.srt");
604        std::fs::write(&video, b"fake video").unwrap();
605        std::fs::write(&sub, b"1\n00:00:01,000 --> 00:00:02,000\nHi\n").unwrap();
606
607        let request = SyncPairingRequest {
608            positional_paths: vec![sub.clone()],
609            ..Default::default()
610        };
611        match resolve_sync_pairing(&request).unwrap() {
612            SyncMode::Single {
613                video: v,
614                subtitle: s,
615            } => {
616                assert_eq!(v, video);
617                assert_eq!(s, sub);
618            }
619            other => panic!("Expected Single mode, got {other:?}"),
620        }
621    }
622
623    #[test]
624    fn test_resolve_pairing_probe_order_prefers_srt_over_ass() {
625        let tmp = tempfile::TempDir::new().unwrap();
626        let video = tmp.path().join("movie.mp4");
627        let srt = tmp.path().join("movie.srt");
628        let ass = tmp.path().join("movie.ass");
629        std::fs::write(&video, b"fake video").unwrap();
630        std::fs::write(&srt, b"1\n00:00:01,000 --> 00:00:02,000\nHi\n").unwrap();
631        std::fs::write(&ass, b"[Script Info]\n").unwrap();
632
633        let request = SyncPairingRequest {
634            positional_paths: vec![video],
635            ..Default::default()
636        };
637        match resolve_sync_pairing(&request).unwrap() {
638            SyncMode::Single { subtitle, .. } => assert_eq!(subtitle, srt),
639            other => panic!("Expected Single mode, got {other:?}"),
640        }
641    }
642
643    #[test]
644    fn test_resolve_pairing_manual_mode_subtitle_only_positional_empty_video() {
645        let tmp = tempfile::TempDir::new().unwrap();
646        let sub = tmp.path().join("movie.srt");
647        std::fs::write(&sub, b"1\n00:00:01,000 --> 00:00:02,000\nHi\n").unwrap();
648
649        let request = SyncPairingRequest {
650            positional_paths: vec![sub.clone()],
651            manual: true,
652            ..Default::default()
653        };
654        match resolve_sync_pairing(&request).unwrap() {
655            SyncMode::Single { video, subtitle } => {
656                assert_eq!(video, PathBuf::new());
657                assert_eq!(subtitle, sub);
658            }
659            other => panic!("Expected Single mode, got {other:?}"),
660        }
661    }
662
663    #[test]
664    fn test_resolve_pairing_two_positionals_classified_without_probing() {
665        // Neither file needs to exist: two positionals are classified purely
666        // by extension, with no filesystem probing.
667        let request = SyncPairingRequest {
668            positional_paths: vec![
669                PathBuf::from("nowhere/movie.srt"),
670                PathBuf::from("nowhere/movie.mp4"),
671            ],
672            ..Default::default()
673        };
674        match resolve_sync_pairing(&request).unwrap() {
675            SyncMode::Single { video, subtitle } => {
676                assert_eq!(video, PathBuf::from("nowhere/movie.mp4"));
677                assert_eq!(subtitle, PathBuf::from("nowhere/movie.srt"));
678            }
679            other => panic!("Expected Single mode, got {other:?}"),
680        }
681    }
682
683    #[test]
684    fn test_resolve_pairing_unpairable_single_positional_errors() {
685        let tmp = tempfile::TempDir::new().unwrap();
686        let video = tmp.path().join("movie.mp4");
687        std::fs::write(&video, b"fake video").unwrap();
688        // No subtitle sibling on disk, non-manual mode.
689        let request = SyncPairingRequest {
690            positional_paths: vec![video],
691            ..Default::default()
692        };
693        assert!(matches!(
694            resolve_sync_pairing(&request),
695            Err(SubXError::InvalidSyncConfiguration)
696        ));
697    }
698
699    // ── batch selection (task 5.3) ───────────────────────────────────────
700
701    #[test]
702    fn test_resolve_pairing_batch_directory() {
703        let tmp = tempfile::TempDir::new().unwrap();
704        let dir = tmp.path().to_path_buf();
705        // from_args validates existence of every path it receives.
706        std::fs::write(dir.join("extra"), b"x").unwrap();
707        std::fs::write(dir.join("pos.srt"), b"x").unwrap();
708        let request = SyncPairingRequest {
709            batch: BatchRequest::Directory(dir.clone()),
710            input_paths: vec![dir.join("extra")],
711            positional_paths: vec![dir.join("pos.srt")],
712            recursive: true,
713            no_extract: true,
714            ..Default::default()
715        };
716        match resolve_sync_pairing(&request).unwrap() {
717            SyncMode::Batch(handler) => {
718                // Ordered: batch directory, then -i paths, then positionals.
719                assert_eq!(
720                    handler.paths,
721                    vec![dir.clone(), dir.join("extra"), dir.join("pos.srt")]
722                );
723                assert!(handler.recursive);
724                assert!(handler.no_extract);
725                let mut expected_ext: Vec<String> = SYNC_VIDEO_EXTENSIONS
726                    .iter()
727                    .chain(SYNC_SUBTITLE_EXTENSIONS)
728                    .map(|s| s.to_string())
729                    .collect();
730                expected_ext.sort();
731                let mut actual_ext = handler.file_extensions.clone();
732                actual_ext.sort();
733                assert_eq!(actual_ext, expected_ext);
734            }
735            other => panic!("Expected Batch mode, got {other:?}"),
736        }
737    }
738
739    #[test]
740    fn test_resolve_pairing_batch_via_input_paths() {
741        let tmp = tempfile::TempDir::new().unwrap();
742        let dir = tmp.path().to_path_buf();
743        let request = SyncPairingRequest {
744            input_paths: vec![dir],
745            ..Default::default()
746        };
747        assert!(matches!(
748            resolve_sync_pairing(&request).unwrap(),
749            SyncMode::Batch(_)
750        ));
751    }
752
753    #[test]
754    fn test_resolve_pairing_batch_via_extensionless_positional() {
755        let tmp = tempfile::TempDir::new().unwrap();
756        let request = SyncPairingRequest {
757            positional_paths: vec![tmp.path().to_path_buf()],
758            ..Default::default()
759        };
760        assert!(matches!(
761            resolve_sync_pairing(&request).unwrap(),
762            SyncMode::Batch(_)
763        ));
764    }
765
766    #[test]
767    fn test_resolve_pairing_batch_auto_without_paths_defaults_to_cwd() {
768        let request = SyncPairingRequest {
769            batch: BatchRequest::Auto,
770            ..Default::default()
771        };
772        match resolve_sync_pairing(&request).unwrap() {
773            SyncMode::Batch(handler) => {
774                assert_eq!(handler.paths, vec![PathBuf::from(".")]);
775            }
776            other => panic!("Expected Batch mode, got {other:?}"),
777        }
778    }
779}