Skip to main content

subx_cli/core/matcher/
engine.rs

1//! File matching engine that uses AI content analysis to align video and subtitle files.
2//!
3//! This module provides the `MatchEngine`, which orchestrates discovery,
4//! content sampling, AI analysis, and caching to generate subtitle matching operations.
5//!
6//! # Examples
7//!
8//! ```rust,ignore
9//! use subx_cli::core::matcher::engine::{MatchEngine, MatchConfig};
10//! // Create a match engine with default configuration
11//! let config = MatchConfig { confidence_threshold: 0.8, max_sample_length: 1024, enable_content_analysis: true, backup_enabled: false };
12//! let engine = MatchEngine::new(Box::new(DummyAI), config);
13//! ```
14
15use crate::services::ai::{AIProvider, AnalysisRequest, ContentSample, MatchResult};
16use std::path::PathBuf;
17
18use crate::Result;
19use crate::core::language::LanguageDetector;
20use crate::core::matcher::cache::{CacheData, OpItem, SnapshotItem};
21use crate::core::matcher::discovery::generate_file_id;
22use crate::core::matcher::journal::{
23    JournalData, JournalEntry, JournalEntryStatus, JournalOperationType, journal_path,
24};
25use crate::core::matcher::{FileDiscovery, MediaFile, MediaFileType};
26use crate::core::parallel::{FileProcessingTask, ProcessingOperation, Task, TaskResult};
27use crate::error::SubXError;
28use dirs;
29use serde_json;
30
31/// File relocation mode for matched subtitle files
32#[derive(Debug, Clone, PartialEq)]
33pub enum FileRelocationMode {
34    /// No file relocation
35    None,
36    /// Copy subtitle files to video folders
37    Copy,
38    /// Move subtitle files to video folders
39    Move,
40}
41
42/// Strategy for handling filename conflicts during relocation
43#[derive(Debug, Clone)]
44pub enum ConflictResolution {
45    /// Skip relocation if conflict exists
46    Skip,
47    /// Automatically rename with numeric suffix
48    AutoRename,
49    /// Prompt user for decision (interactive mode only)
50    Prompt,
51}
52
53/// Configuration settings for the file matching engine.
54///
55/// Controls various aspects of the subtitle-to-video matching process,
56/// including confidence thresholds and analysis options.
57#[derive(Debug, Clone)]
58pub struct MatchConfig {
59    /// Minimum confidence score required for a successful match (0.0 to 1.0)
60    pub confidence_threshold: f32,
61    /// Maximum number of characters to sample from subtitle content
62    pub max_sample_length: usize,
63    /// Whether to enable advanced content analysis for matching
64    pub enable_content_analysis: bool,
65    /// Whether to create backup files before operations
66    pub backup_enabled: bool,
67    /// File relocation mode
68    pub relocation_mode: FileRelocationMode,
69    /// Strategy for handling filename conflicts during relocation
70    pub conflict_resolution: ConflictResolution,
71    /// AI model name used for analysis
72    pub ai_model: String,
73    /// Maximum subtitle file size in bytes accepted for content sampling.
74    /// Populated from `GeneralConfig.max_subtitle_bytes`.
75    pub max_subtitle_bytes: u64,
76}
77
78#[cfg(test)]
79mod language_name_tests {
80    use super::*;
81    use crate::core::matcher::discovery::{MediaFile, MediaFileType};
82    use crate::services::ai::{
83        AIProvider, AnalysisRequest, ConfidenceScore, MatchResult, VerificationRequest,
84    };
85    use async_trait::async_trait;
86    use std::path::PathBuf;
87
88    struct DummyAI;
89    #[async_trait]
90    impl AIProvider for DummyAI {
91        async fn analyze_content(&self, _req: AnalysisRequest) -> crate::Result<MatchResult> {
92            unimplemented!()
93        }
94        async fn verify_match(&self, _req: VerificationRequest) -> crate::Result<ConfidenceScore> {
95            unimplemented!()
96        }
97    }
98
99    #[test]
100    fn test_generate_subtitle_name_with_directory_language() {
101        let engine = MatchEngine::new(
102            Box::new(DummyAI),
103            MatchConfig {
104                confidence_threshold: 0.0,
105                max_sample_length: 0,
106                enable_content_analysis: false,
107                backup_enabled: false,
108                relocation_mode: FileRelocationMode::None,
109                conflict_resolution: ConflictResolution::Skip,
110                ai_model: "test-model".to_string(),
111                max_subtitle_bytes: 52_428_800,
112            },
113        );
114        let video = MediaFile {
115            id: "".to_string(),
116            relative_path: "".to_string(),
117            path: PathBuf::from("movie01.mp4"),
118            file_type: MediaFileType::Video,
119            size: 0,
120            name: "movie01".to_string(),
121            extension: "mp4".to_string(),
122        };
123        let subtitle = MediaFile {
124            id: "".to_string(),
125            relative_path: "".to_string(),
126            path: PathBuf::from("tc/subtitle01.ass"),
127            file_type: MediaFileType::Subtitle,
128            size: 0,
129            name: "subtitle01".to_string(),
130            extension: "ass".to_string(),
131        };
132        let new_name = engine.generate_subtitle_name(&video, &subtitle);
133        assert_eq!(new_name, "movie01.tc.ass");
134    }
135
136    #[test]
137    fn test_generate_subtitle_name_with_filename_language() {
138        let engine = MatchEngine::new(
139            Box::new(DummyAI),
140            MatchConfig {
141                confidence_threshold: 0.0,
142                max_sample_length: 0,
143                enable_content_analysis: false,
144                backup_enabled: false,
145                relocation_mode: FileRelocationMode::None,
146                conflict_resolution: ConflictResolution::Skip,
147                ai_model: "test-model".to_string(),
148                max_subtitle_bytes: 52_428_800,
149            },
150        );
151        let video = MediaFile {
152            id: "".to_string(),
153            relative_path: "".to_string(),
154            path: PathBuf::from("movie02.mp4"),
155            file_type: MediaFileType::Video,
156            size: 0,
157            name: "movie02".to_string(),
158            extension: "mp4".to_string(),
159        };
160        let subtitle = MediaFile {
161            id: "".to_string(),
162            relative_path: "".to_string(),
163            path: PathBuf::from("subtitle02.sc.ass"),
164            file_type: MediaFileType::Subtitle,
165            size: 0,
166            name: "subtitle02".to_string(),
167            extension: "ass".to_string(),
168        };
169        let new_name = engine.generate_subtitle_name(&video, &subtitle);
170        assert_eq!(new_name, "movie02.sc.ass");
171    }
172
173    #[test]
174    fn test_generate_subtitle_name_without_language() {
175        let engine = MatchEngine::new(
176            Box::new(DummyAI),
177            MatchConfig {
178                confidence_threshold: 0.0,
179                max_sample_length: 0,
180                enable_content_analysis: false,
181                backup_enabled: false,
182                relocation_mode: FileRelocationMode::None,
183                conflict_resolution: ConflictResolution::Skip,
184                ai_model: "test-model".to_string(),
185                max_subtitle_bytes: 52_428_800,
186            },
187        );
188        let video = MediaFile {
189            id: "".to_string(),
190            relative_path: "".to_string(),
191            path: PathBuf::from("movie03.mp4"),
192            file_type: MediaFileType::Video,
193            size: 0,
194            name: "movie03".to_string(),
195            extension: "mp4".to_string(),
196        };
197        let subtitle = MediaFile {
198            id: "".to_string(),
199            relative_path: "".to_string(),
200            path: PathBuf::from("subtitle03.ass"),
201            file_type: MediaFileType::Subtitle,
202            size: 0,
203            name: "subtitle03".to_string(),
204            extension: "ass".to_string(),
205        };
206        let new_name = engine.generate_subtitle_name(&video, &subtitle);
207        assert_eq!(new_name, "movie03.ass");
208    }
209    #[test]
210    fn test_generate_subtitle_name_removes_video_extension() {
211        let engine = MatchEngine::new(
212            Box::new(DummyAI),
213            MatchConfig {
214                confidence_threshold: 0.0,
215                max_sample_length: 0,
216                enable_content_analysis: false,
217                backup_enabled: false,
218                relocation_mode: FileRelocationMode::None,
219                conflict_resolution: ConflictResolution::Skip,
220                ai_model: "test-model".to_string(),
221                max_subtitle_bytes: 52_428_800,
222            },
223        );
224        let video = MediaFile {
225            id: "".to_string(),
226            relative_path: "".to_string(),
227            path: PathBuf::from("movie.mkv"),
228            file_type: MediaFileType::Video,
229            size: 0,
230            name: "movie.mkv".to_string(),
231            extension: "mkv".to_string(),
232        };
233        let subtitle = MediaFile {
234            id: "".to_string(),
235            relative_path: "".to_string(),
236            path: PathBuf::from("subtitle.srt"),
237            file_type: MediaFileType::Subtitle,
238            size: 0,
239            name: "subtitle".to_string(),
240            extension: "srt".to_string(),
241        };
242        let new_name = engine.generate_subtitle_name(&video, &subtitle);
243        assert_eq!(new_name, "movie.srt");
244    }
245
246    #[test]
247    fn test_generate_subtitle_name_with_language_removes_video_extension() {
248        let engine = MatchEngine::new(
249            Box::new(DummyAI),
250            MatchConfig {
251                confidence_threshold: 0.0,
252                max_sample_length: 0,
253                enable_content_analysis: false,
254                backup_enabled: false,
255                relocation_mode: FileRelocationMode::None,
256                conflict_resolution: ConflictResolution::Skip,
257                ai_model: "test-model".to_string(),
258                max_subtitle_bytes: 52_428_800,
259            },
260        );
261        let video = MediaFile {
262            id: "".to_string(),
263            relative_path: "".to_string(),
264            path: PathBuf::from("movie.mkv"),
265            file_type: MediaFileType::Video,
266            size: 0,
267            name: "movie.mkv".to_string(),
268            extension: "mkv".to_string(),
269        };
270        let subtitle = MediaFile {
271            id: "".to_string(),
272            relative_path: "".to_string(),
273            path: PathBuf::from("tc/subtitle.srt"),
274            file_type: MediaFileType::Subtitle,
275            size: 0,
276            name: "subtitle".to_string(),
277            extension: "srt".to_string(),
278        };
279        let new_name = engine.generate_subtitle_name(&video, &subtitle);
280        assert_eq!(new_name, "movie.tc.srt");
281    }
282
283    #[test]
284    fn test_generate_subtitle_name_edge_cases() {
285        let engine = MatchEngine::new(
286            Box::new(DummyAI),
287            MatchConfig {
288                confidence_threshold: 0.0,
289                max_sample_length: 0,
290                enable_content_analysis: false,
291                backup_enabled: false,
292                relocation_mode: FileRelocationMode::None,
293                conflict_resolution: ConflictResolution::Skip,
294                ai_model: "test-model".to_string(),
295                max_subtitle_bytes: 52_428_800,
296            },
297        );
298        // File name contains multiple dots and no extension case
299        let video = MediaFile {
300            id: "".to_string(),
301            relative_path: "".to_string(),
302            path: PathBuf::from("a.b.c"),
303            file_type: MediaFileType::Video,
304            size: 0,
305            name: "a.b.c".to_string(),
306            extension: "".to_string(),
307        };
308        let subtitle = MediaFile {
309            id: "".to_string(),
310            relative_path: "".to_string(),
311            path: PathBuf::from("sub.srt"),
312            file_type: MediaFileType::Subtitle,
313            size: 0,
314            name: "sub".to_string(),
315            extension: "srt".to_string(),
316        };
317        let new_name = engine.generate_subtitle_name(&video, &subtitle);
318        assert_eq!(new_name, "a.b.c.srt");
319    }
320
321    #[tokio::test]
322    async fn test_rename_file_displays_success_check_mark() {
323        use std::fs;
324        use tempfile::TempDir;
325
326        let temp_dir = TempDir::new().unwrap();
327        let temp_path = temp_dir.path();
328
329        // Create a test file
330        let original_file = temp_path.join("original.srt");
331        fs::write(
332            &original_file,
333            "1\n00:00:01,000 --> 00:00:02,000\nTest subtitle",
334        )
335        .unwrap();
336
337        // Create a test MatchEngine
338        let engine = MatchEngine::new(
339            Box::new(DummyAI),
340            MatchConfig {
341                confidence_threshold: 0.0,
342                max_sample_length: 0,
343                enable_content_analysis: false,
344                backup_enabled: false,
345                relocation_mode: FileRelocationMode::None,
346                conflict_resolution: ConflictResolution::Skip,
347                ai_model: "test-model".to_string(),
348                max_subtitle_bytes: 52_428_800,
349            },
350        );
351
352        // Create a MatchOperation
353        let subtitle_file = MediaFile {
354            id: "test_id".to_string(),
355            relative_path: "original.srt".to_string(),
356            path: original_file.clone(),
357            file_type: MediaFileType::Subtitle,
358            size: 40,
359            name: "original".to_string(),
360            extension: "srt".to_string(),
361        };
362
363        let match_op = MatchOperation {
364            video_file: MediaFile {
365                id: "video_id".to_string(),
366                relative_path: "test.mp4".to_string(),
367                path: temp_path.join("test.mp4"),
368                file_type: MediaFileType::Video,
369                size: 1000,
370                name: "test".to_string(),
371                extension: "mp4".to_string(),
372            },
373            subtitle_file,
374            new_subtitle_name: "renamed.srt".to_string(),
375            confidence: 95.0,
376            reasoning: vec!["Test match".to_string()],
377            requires_relocation: false,
378            relocation_target_path: None,
379            relocation_mode: FileRelocationMode::None,
380        };
381
382        // Execute the rename operation
383        let result = engine.rename_file(&match_op).await;
384
385        // Verify the operation was successful
386        assert!(result.is_ok());
387
388        // Verify the file has been renamed
389        let renamed_file = temp_path.join("renamed.srt");
390        assert!(renamed_file.exists(), "The renamed file should exist");
391        assert!(
392            !original_file.exists(),
393            "The original file should have been renamed"
394        );
395
396        // Verify the file content is correct
397        let content = fs::read_to_string(&renamed_file).unwrap();
398        assert!(content.contains("Test subtitle"));
399    }
400
401    #[tokio::test]
402    async fn test_rename_file_displays_error_cross_mark_when_file_not_exists() {
403        use std::fs;
404        use tempfile::TempDir;
405
406        let temp_dir = TempDir::new().unwrap();
407        let temp_path = temp_dir.path();
408
409        // Create test file
410        let original_file = temp_path.join("original.srt");
411        fs::write(
412            &original_file,
413            "1\n00:00:01,000 --> 00:00:02,000\nTest subtitle",
414        )
415        .unwrap();
416
417        // Create a test MatchEngine
418        let engine = MatchEngine::new(
419            Box::new(DummyAI),
420            MatchConfig {
421                confidence_threshold: 0.0,
422                max_sample_length: 0,
423                enable_content_analysis: false,
424                backup_enabled: false,
425                relocation_mode: FileRelocationMode::None,
426                conflict_resolution: ConflictResolution::Skip,
427                ai_model: "test-model".to_string(),
428                max_subtitle_bytes: 52_428_800,
429            },
430        );
431
432        // Create a MatchOperation
433        let subtitle_file = MediaFile {
434            id: "test_id".to_string(),
435            relative_path: "original.srt".to_string(),
436            path: original_file.clone(),
437            file_type: MediaFileType::Subtitle,
438            size: 40,
439            name: "original".to_string(),
440            extension: "srt".to_string(),
441        };
442
443        let match_op = MatchOperation {
444            video_file: MediaFile {
445                id: "video_id".to_string(),
446                relative_path: "test.mp4".to_string(),
447                path: temp_path.join("test.mp4"),
448                file_type: MediaFileType::Video,
449                size: 1000,
450                name: "test".to_string(),
451                extension: "mp4".to_string(),
452            },
453            subtitle_file,
454            new_subtitle_name: "renamed.srt".to_string(),
455            confidence: 95.0,
456            reasoning: vec!["Test match".to_string()],
457            requires_relocation: false,
458            relocation_target_path: None,
459            relocation_mode: FileRelocationMode::None,
460        };
461
462        // Simulate file not existing after operation
463        // First, execute the rename operation normally
464        let result = engine.rename_file(&match_op).await;
465        assert!(result.is_ok());
466
467        // Manually delete the renamed file to simulate failure
468        let renamed_file = temp_path.join("renamed.srt");
469        if renamed_file.exists() {
470            fs::remove_file(&renamed_file).unwrap();
471        }
472
473        // Recreate the original file for the second test
474        fs::write(
475            &original_file,
476            "1\n00:00:01,000 --> 00:00:02,000\nTest subtitle",
477        )
478        .unwrap();
479
480        // Create a rename operation that will fail, by overwriting the rename implementation
481        // Since we cannot directly simulate std::fs::rename failure with file not existing,
482        // we test the scenario where the file is manually removed after the operation completes
483        let result = engine.rename_file(&match_op).await;
484        assert!(result.is_ok());
485
486        // Manually delete the file again
487        let renamed_file = temp_path.join("renamed.srt");
488        if renamed_file.exists() {
489            fs::remove_file(&renamed_file).unwrap();
490        }
491
492        // This test mainly verifies the code structure is correct, the actual error message display needs to be validated through integration tests
493        // Because we cannot easily simulate the scenario where the file system operation succeeds but the file does not exist
494    }
495
496    #[test]
497    fn test_file_operation_message_format() {
498        // Test error message format
499        let source_name = "test.srt";
500        let target_name = "renamed.srt";
501
502        // Simulate success message format
503        let success_msg = format!("  āœ“ Renamed: {} -> {}", source_name, target_name);
504        assert!(success_msg.contains("āœ“"));
505        assert!(success_msg.contains("Renamed:"));
506        assert!(success_msg.contains(source_name));
507        assert!(success_msg.contains(target_name));
508
509        // Simulate failure message format
510        let error_msg = format!(
511            "  āœ— Rename failed: {} -> {} (target file does not exist after operation)",
512            source_name, target_name
513        );
514        assert!(error_msg.contains("āœ—"));
515        assert!(error_msg.contains("Rename failed:"));
516        assert!(error_msg.contains("target file does not exist"));
517        assert!(error_msg.contains(source_name));
518        assert!(error_msg.contains(target_name));
519    }
520
521    #[test]
522    fn test_copy_operation_message_format() {
523        // Test copy operation message format
524        let source_name = "subtitle.srt";
525        let target_name = "video.srt";
526
527        // Simulate success message format
528        let success_msg = format!("  āœ“ Copied: {} -> {}", source_name, target_name);
529        assert!(success_msg.contains("āœ“"));
530        assert!(success_msg.contains("Copied:"));
531
532        // Simulate failure message format
533        let error_msg = format!(
534            "  āœ— Copy failed: {} -> {} (target file does not exist after operation)",
535            source_name, target_name
536        );
537        assert!(error_msg.contains("āœ—"));
538        assert!(error_msg.contains("Copy failed:"));
539        assert!(error_msg.contains("target file does not exist"));
540    }
541
542    #[test]
543    fn test_move_operation_message_format() {
544        // Test move operation message format
545        let source_name = "subtitle.srt";
546        let target_name = "video.srt";
547
548        // Simulate success message format
549        let success_msg = format!("  āœ“ Moved: {} -> {}", source_name, target_name);
550        assert!(success_msg.contains("āœ“"));
551        assert!(success_msg.contains("Moved:"));
552
553        // Simulate failure message format
554        let error_msg = format!(
555            "  āœ— Move failed: {} -> {} (target file does not exist after operation)",
556            source_name, target_name
557        );
558        assert!(error_msg.contains("āœ—"));
559        assert!(error_msg.contains("Move failed:"));
560        assert!(error_msg.contains("target file does not exist"));
561    }
562}
563
564/// Match operation result representing a single video-subtitle match.
565///
566/// Contains all information about a successful match between a video file
567/// and a subtitle file, including confidence metrics and reasoning.
568#[derive(Debug)]
569pub struct MatchOperation {
570    /// The matched video file
571    pub video_file: MediaFile,
572    /// The matched subtitle file
573    pub subtitle_file: MediaFile,
574    /// The new filename for the subtitle file
575    pub new_subtitle_name: String,
576    /// Confidence score of the match (0.0 to 1.0)
577    pub confidence: f32,
578    /// List of reasons supporting this match
579    pub reasoning: Vec<String>,
580    /// File relocation mode for this operation
581    pub relocation_mode: FileRelocationMode,
582    /// Target relocation path if operation is needed
583    pub relocation_target_path: Option<std::path::PathBuf>,
584    /// Whether relocation operation is needed (different folders)
585    pub requires_relocation: bool,
586}
587
588/// Engine for matching video and subtitle files using AI analysis.
589pub struct MatchEngine {
590    ai_client: Box<dyn AIProvider>,
591    discovery: FileDiscovery,
592    config: MatchConfig,
593}
594
595impl MatchEngine {
596    /// Creates a new `MatchEngine` with the given AI provider and configuration.
597    pub fn new(ai_client: Box<dyn AIProvider>, config: MatchConfig) -> Self {
598        Self {
599            ai_client,
600            discovery: FileDiscovery::new(),
601            config,
602        }
603    }
604
605    /// Matches video and subtitle files from a specified list of files.
606    ///
607    /// This method processes a user-provided list of files, filtering them into
608    /// video and subtitle files, then performing AI-powered matching analysis.
609    /// This is useful when users specify exact files via -i parameters.
610    ///
611    /// # Arguments
612    ///
613    /// * `file_paths` - A slice of file paths to process for matching
614    ///
615    /// # Returns
616    ///
617    /// A list of `MatchOperation` entries that meet the confidence threshold.
618    pub async fn match_file_list(&self, file_paths: &[PathBuf]) -> Result<Vec<MatchOperation>> {
619        // 1. Process the file list to create MediaFile objects
620        let files = self.discovery.scan_file_list(file_paths)?;
621
622        let videos: Vec<_> = files
623            .iter()
624            .filter(|f| matches!(f.file_type, MediaFileType::Video))
625            .collect();
626        let subtitles: Vec<_> = files
627            .iter()
628            .filter(|f| matches!(f.file_type, MediaFileType::Subtitle))
629            .collect();
630
631        if videos.is_empty() || subtitles.is_empty() {
632            return Ok(Vec::new());
633        }
634
635        // 2. Check if we can use cache for file list operations
636        // Create a stable cache key based on sorted file paths and their metadata
637        let cache_key = self.calculate_file_list_cache_key(file_paths)?;
638        if let Some(ops) = self.check_file_list_cache(&cache_key).await? {
639            return Ok(ops);
640        }
641
642        // 3. Content sampling
643        let content_samples = if self.config.enable_content_analysis {
644            self.extract_content_samples(&subtitles).await?
645        } else {
646            Vec::new()
647        };
648
649        // 4. AI analysis request
650        // Generate AI analysis request: include file IDs for precise matching
651        let video_files: Vec<String> = videos
652            .iter()
653            .map(|v| format!("ID:{} | Name:{} | Path:{}", v.id, v.name, v.relative_path))
654            .collect();
655        let subtitle_files: Vec<String> = subtitles
656            .iter()
657            .map(|s| format!("ID:{} | Name:{} | Path:{}", s.id, s.name, s.relative_path))
658            .collect();
659
660        let analysis_request = AnalysisRequest {
661            video_files,
662            subtitle_files,
663            content_samples,
664        };
665
666        // 5. Query AI service
667        let match_result = self.ai_client.analyze_content(analysis_request).await?;
668
669        // Debug: Log AI analysis results
670        eprintln!("šŸ” AI Analysis Results:");
671        eprintln!("   - Total matches: {}", match_result.matches.len());
672        eprintln!(
673            "   - Confidence threshold: {:.2}",
674            self.config.confidence_threshold
675        );
676        for ai_match in &match_result.matches {
677            eprintln!(
678                "   - {} -> {} (confidence: {:.2})",
679                ai_match.video_file_id, ai_match.subtitle_file_id, ai_match.confidence
680            );
681        }
682
683        // 6. Assemble match operation list
684        let mut operations = Vec::new();
685
686        for ai_match in match_result.matches {
687            if ai_match.confidence >= self.config.confidence_threshold {
688                let video_match =
689                    Self::find_media_file_by_id_or_path(&videos, &ai_match.video_file_id, None);
690                let subtitle_match = Self::find_media_file_by_id_or_path(
691                    &subtitles,
692                    &ai_match.subtitle_file_id,
693                    None,
694                );
695                match (video_match, subtitle_match) {
696                    (Some(video), Some(subtitle)) => {
697                        let new_name = self.generate_subtitle_name(video, subtitle);
698
699                        // Determine if relocation is needed
700                        let requires_relocation = self.config.relocation_mode
701                            != FileRelocationMode::None
702                            && subtitle.path.parent() != video.path.parent();
703
704                        let relocation_target_path = if requires_relocation {
705                            let video_dir = video.path.parent().unwrap();
706                            Some(video_dir.join(&new_name))
707                        } else {
708                            None
709                        };
710
711                        operations.push(MatchOperation {
712                            video_file: (*video).clone(),
713                            subtitle_file: (*subtitle).clone(),
714                            new_subtitle_name: new_name,
715                            confidence: ai_match.confidence,
716                            reasoning: ai_match.match_factors,
717                            relocation_mode: self.config.relocation_mode.clone(),
718                            relocation_target_path,
719                            requires_relocation,
720                        });
721                    }
722                    _ => {
723                        eprintln!(
724                            "āš ļø  Cannot find AI-suggested file pair:\n     Video ID: '{}'\n     Subtitle ID: '{}'",
725                            ai_match.video_file_id, ai_match.subtitle_file_id
726                        );
727                        eprintln!("āŒ No matching files found that meet the criteria");
728                        eprintln!("šŸ” Available file statistics:");
729                        eprintln!("   Video files ({} files):", videos.len());
730                        for video in &videos {
731                            eprintln!("     - ID: {} | {}", video.id, video.name);
732                        }
733                        eprintln!("   Subtitle files ({} files):", subtitles.len());
734                        for subtitle in &subtitles {
735                            eprintln!("     - ID: {} | {}", subtitle.id, subtitle.name);
736                        }
737                    }
738                }
739            }
740        }
741
742        // 7. Save to cache for future use
743        self.save_file_list_cache(&cache_key, &operations).await?;
744
745        Ok(operations)
746    }
747
748    async fn extract_content_samples(
749        &self,
750        subtitles: &[&MediaFile],
751    ) -> Result<Vec<ContentSample>> {
752        let mut samples = Vec::new();
753
754        for subtitle in subtitles {
755            let path = subtitle.path.clone();
756            crate::core::fs_util::check_file_size(
757                &path,
758                self.config.max_subtitle_bytes,
759                "Subtitle",
760            )
761            .map_err(SubXError::Io)?;
762            let content = tokio::task::spawn_blocking(move || std::fs::read_to_string(&path))
763                .await
764                .map_err(|e| SubXError::Io(std::io::Error::other(e.to_string())))??;
765            let preview = self.create_content_preview(&content);
766
767            samples.push(ContentSample {
768                filename: subtitle.name.clone(),
769                content_preview: preview,
770                file_size: subtitle.size,
771            });
772        }
773
774        Ok(samples)
775    }
776
777    fn create_content_preview(&self, content: &str) -> String {
778        let lines: Vec<&str> = content.lines().take(20).collect();
779        let preview = lines.join("\n");
780
781        if preview.len() > self.config.max_sample_length {
782            format!("{}...", &preview[..self.config.max_sample_length])
783        } else {
784            preview
785        }
786    }
787
788    fn generate_subtitle_name(&self, video: &MediaFile, subtitle: &MediaFile) -> String {
789        let detector = LanguageDetector::new();
790
791        // Remove the extension from the video file name (if any)
792        let video_base_name = if !video.extension.is_empty() {
793            video
794                .name
795                .strip_suffix(&format!(".{}", video.extension))
796                .unwrap_or(&video.name)
797        } else {
798            &video.name
799        };
800
801        if let Some(code) = detector.get_primary_language(&subtitle.path) {
802            format!("{}.{}.{}", video_base_name, code, subtitle.extension)
803        } else {
804            format!("{}.{}", video_base_name, subtitle.extension)
805        }
806    }
807
808    /// Execute match operations with dry-run mode support.
809    ///
810    /// When `dry_run` is false, a transactional journal is written to
811    /// [`journal_path`] recording every successfully completed operation.
812    /// The journal is saved atomically after each operation so that a
813    /// crash mid-batch still leaves a consistent, resumable on-disk
814    /// record. No journal is written in dry-run mode or when the batch
815    /// performs zero operations.
816    pub async fn execute_operations(
817        &self,
818        operations: &[MatchOperation],
819        dry_run: bool,
820    ) -> Result<()> {
821        if dry_run {
822            for op in operations {
823                println!(
824                    "Preview: {} -> {}",
825                    op.subtitle_file.name, op.new_subtitle_name
826                );
827                if op.requires_relocation {
828                    if let Some(target_path) = &op.relocation_target_path {
829                        let operation_verb = match op.relocation_mode {
830                            FileRelocationMode::Copy => "Copy",
831                            FileRelocationMode::Move => "Move",
832                            _ => "",
833                        };
834                        println!(
835                            "Preview: {} {} to {}",
836                            operation_verb,
837                            op.subtitle_file.path.display(),
838                            target_path.display()
839                        );
840                    }
841                }
842            }
843            return Ok(());
844        }
845
846        // Prepare a fresh journal for this batch. The journal is saved
847        // atomically after each successful operation so that the on-disk
848        // record never diverges from the in-memory state by more than a
849        // single completed entry.
850        let created_at = std::time::SystemTime::now()
851            .duration_since(std::time::UNIX_EPOCH)
852            .map(|d| d.as_secs())
853            .unwrap_or(0);
854        let batch_id = {
855            use std::collections::hash_map::DefaultHasher;
856            use std::hash::{Hash, Hasher};
857            let mut hasher = DefaultHasher::new();
858            created_at.hash(&mut hasher);
859            operations.len().hash(&mut hasher);
860            for op in operations {
861                op.subtitle_file.path.hash(&mut hasher);
862                op.new_subtitle_name.hash(&mut hasher);
863            }
864            format!("{:016x}", hasher.finish())
865        };
866        let mut journal = JournalData {
867            batch_id,
868            created_at,
869            entries: Vec::new(),
870        };
871        let journal_file = journal_path().ok();
872
873        let mut first_error: Option<SubXError> = None;
874
875        for op in operations {
876            // Build the task list the same way the previous implementation
877            // did so relocation, backup and rename semantics are preserved.
878            let mut backup_path: Option<PathBuf> = None;
879
880            if op.relocation_mode == FileRelocationMode::Move && self.config.backup_enabled {
881                let backup_task =
882                    self.create_backup_task(&op.subtitle_file.path, &op.subtitle_file.extension);
883                if let ProcessingOperation::CreateBackup { backup, .. } = &backup_task.operation {
884                    backup_path = Some(backup.clone());
885                }
886                if let TaskResult::Failed(err) = backup_task.execute().await {
887                    first_error = Some(SubXError::FileOperationFailed(err));
888                    break;
889                }
890            }
891
892            // Either a copy-with-rename task (Copy mode) or a rename task
893            // (Move / None modes) produces the primary journal entry for
894            // this operation.
895            let primary_task = if op.relocation_mode == FileRelocationMode::Copy {
896                self.create_copy_task(op)
897            } else {
898                self.create_rename_task(op)
899            };
900
901            let (journal_source, journal_destination, journal_kind) = match &primary_task.operation
902            {
903                ProcessingOperation::CopyWithRename { source, target }
904                | ProcessingOperation::CopyToVideoFolder { source, target } => {
905                    (source.clone(), target.clone(), JournalOperationType::Copied)
906                }
907                ProcessingOperation::MoveToVideoFolder { source, target } => {
908                    (source.clone(), target.clone(), JournalOperationType::Moved)
909                }
910                ProcessingOperation::RenameFile { source, target } => {
911                    let kind = match op.relocation_mode {
912                        FileRelocationMode::Move => JournalOperationType::Moved,
913                        _ => JournalOperationType::Renamed,
914                    };
915                    (source.clone(), target.clone(), kind)
916                }
917                _ => (
918                    op.subtitle_file.path.clone(),
919                    op.relocation_target_path.clone().unwrap_or_else(|| {
920                        op.subtitle_file.path.with_file_name(&op.new_subtitle_name)
921                    }),
922                    JournalOperationType::Renamed,
923                ),
924            };
925
926            // Capture source metadata before execution because move/rename
927            // will invalidate the source path afterwards.
928            let (pre_file_size, pre_file_mtime) = journal_source
929                .metadata()
930                .ok()
931                .map(|m| {
932                    let mtime = m
933                        .modified()
934                        .ok()
935                        .and_then(|t| t.duration_since(std::time::UNIX_EPOCH).ok())
936                        .map(|d| d.as_secs())
937                        .unwrap_or(0);
938                    (m.len(), mtime)
939                })
940                .unwrap_or((0, 0));
941
942            if let TaskResult::Failed(err) = primary_task.execute().await {
943                first_error = Some(SubXError::FileOperationFailed(err));
944                break;
945            }
946
947            // Record destination metadata after execution so that rollback
948            // integrity checks compare against the actual destination state.
949            let (file_size, file_mtime) = journal_destination
950                .metadata()
951                .ok()
952                .map(|m| {
953                    let mtime = m
954                        .modified()
955                        .ok()
956                        .and_then(|t| t.duration_since(std::time::UNIX_EPOCH).ok())
957                        .map(|d| d.as_secs())
958                        .unwrap_or(0);
959                    (m.len(), mtime)
960                })
961                .unwrap_or((pre_file_size, pre_file_mtime));
962
963            journal.entries.push(JournalEntry {
964                operation_type: journal_kind,
965                source: journal_source,
966                destination: journal_destination,
967                backup_path: backup_path.clone(),
968                status: JournalEntryStatus::Completed,
969                file_size,
970                file_mtime,
971            });
972
973            if let Some(path) = journal_file.as_ref() {
974                // Persist after every successful operation so interruption
975                // leaves the on-disk journal consistent with the file system.
976                journal.save(path).await?;
977            }
978        }
979
980        if let Some(err) = first_error {
981            return Err(err);
982        }
983        Ok(())
984    }
985
986    /// Rename subtitle file by delegating to FileProcessingTask
987    async fn rename_file(&self, op: &MatchOperation) -> Result<()> {
988        let task = self.create_rename_task(op);
989        match task.execute().await {
990            TaskResult::Success(_) => Ok(()),
991            TaskResult::Failed(err) => Err(SubXError::FileOperationFailed(err)),
992            other => Err(SubXError::FileOperationFailed(format!(
993                "Unexpected rename result: {:?}",
994                other
995            ))),
996        }
997    }
998
999    /// Resolve filename conflicts by adding numeric suffix
1000    fn resolve_filename_conflict(&self, target: std::path::PathBuf) -> Result<std::path::PathBuf> {
1001        if !target.exists() {
1002            return Ok(target);
1003        }
1004        match self.config.conflict_resolution {
1005            ConflictResolution::Skip => {
1006                eprintln!(
1007                    "Warning: Skipping relocation due to existing file: {}",
1008                    target.display()
1009                );
1010                Ok(target)
1011            }
1012            ConflictResolution::AutoRename => {
1013                let file_stem = target
1014                    .file_stem()
1015                    .and_then(|s| s.to_str())
1016                    .unwrap_or("file");
1017                let extension = target.extension().and_then(|s| s.to_str()).unwrap_or("");
1018                let parent = target.parent().unwrap_or_else(|| std::path::Path::new("."));
1019                // Try the base name first via atomic create; if it succeeds we drop
1020                // the handle immediately since the downstream FileProcessingTask
1021                // performs its own I/O against this path.
1022                match crate::core::fs_util::atomic_create_file(&target) {
1023                    Ok(_f) => {
1024                        // Remove the placeholder so the downstream task can create it.
1025                        let _ = std::fs::remove_file(&target);
1026                        return Ok(target);
1027                    }
1028                    Err(e) if e.kind() == std::io::ErrorKind::AlreadyExists => {}
1029                    Err(e) => return Err(SubXError::from(e)),
1030                }
1031                for i in 1..1000 {
1032                    let new_name = if extension.is_empty() {
1033                        format!("{}.{}", file_stem, i)
1034                    } else {
1035                        format!("{}.{}.{}", file_stem, i, extension)
1036                    };
1037                    let new_path = parent.join(new_name);
1038                    match crate::core::fs_util::atomic_create_file(&new_path) {
1039                        Ok(_f) => {
1040                            let _ = std::fs::remove_file(&new_path);
1041                            return Ok(new_path);
1042                        }
1043                        Err(e) if e.kind() == std::io::ErrorKind::AlreadyExists => continue,
1044                        Err(e) => return Err(SubXError::from(e)),
1045                    }
1046                }
1047                Err(SubXError::FileOperationFailed(
1048                    "Could not resolve filename conflict".to_string(),
1049                ))
1050            }
1051            ConflictResolution::Prompt => {
1052                eprintln!("Warning: Conflict resolution prompt not implemented, using auto-rename");
1053                self.resolve_filename_conflict(target)
1054            }
1055        }
1056    }
1057
1058    /// Create a task to copy (or rename) a file with new name
1059    fn create_copy_task(&self, op: &MatchOperation) -> FileProcessingTask {
1060        // In copy mode, always use the original subtitle file as source
1061        let source = op.subtitle_file.path.clone();
1062        let target_base = op.relocation_target_path.clone().unwrap();
1063        let final_target = self.resolve_filename_conflict(target_base).unwrap();
1064        FileProcessingTask::new(
1065            source.clone(),
1066            Some(final_target.clone()),
1067            ProcessingOperation::CopyWithRename {
1068                source,
1069                target: final_target,
1070            },
1071        )
1072    }
1073
1074    /// Create a task to backup a file
1075    fn create_backup_task(&self, source: &std::path::Path, ext: &str) -> FileProcessingTask {
1076        let backup_path = source.with_extension(format!("{}.backup", ext));
1077        FileProcessingTask::new(
1078            source.to_path_buf(),
1079            Some(backup_path.clone()),
1080            ProcessingOperation::CreateBackup {
1081                source: source.to_path_buf(),
1082                backup: backup_path,
1083            },
1084        )
1085    }
1086
1087    /// Create a task to rename (move) a file
1088    fn create_rename_task(&self, op: &MatchOperation) -> FileProcessingTask {
1089        let old = op.subtitle_file.path.clone();
1090        // If relocation is required, use the relocation target path
1091        let new_path = if op.requires_relocation && op.relocation_target_path.is_some() {
1092            let target_base = op.relocation_target_path.clone().unwrap();
1093            self.resolve_filename_conflict(target_base).unwrap()
1094        } else {
1095            old.with_file_name(&op.new_subtitle_name)
1096        };
1097
1098        FileProcessingTask::new(
1099            old.clone(),
1100            Some(new_path.clone()),
1101            ProcessingOperation::RenameFile {
1102                source: old,
1103                target: new_path,
1104            },
1105        )
1106    }
1107
1108    /// Calculate cache key for file list operations
1109    fn calculate_file_list_cache_key(&self, file_paths: &[PathBuf]) -> Result<String> {
1110        use std::collections::BTreeMap;
1111        use std::collections::hash_map::DefaultHasher;
1112        use std::hash::{Hash, Hasher};
1113
1114        // Sort paths to ensure consistent key generation
1115        let mut path_metadata = BTreeMap::new();
1116        for path in file_paths {
1117            if let Ok(metadata) = path.metadata() {
1118                let canonical = path.canonicalize().unwrap_or_else(|_| path.to_path_buf());
1119                path_metadata.insert(
1120                    canonical.to_string_lossy().to_string(),
1121                    (metadata.len(), metadata.modified().ok()),
1122                );
1123            }
1124        }
1125
1126        // Include config hash to invalidate cache when configuration changes
1127        let config_hash = self.calculate_config_hash()?;
1128
1129        let mut hasher = DefaultHasher::new();
1130        path_metadata.hash(&mut hasher);
1131        config_hash.hash(&mut hasher);
1132
1133        Ok(format!("filelist_{:016x}", hasher.finish()))
1134    }
1135
1136    /// Check cache for file list operations
1137    async fn check_file_list_cache(&self, cache_key: &str) -> Result<Option<Vec<MatchOperation>>> {
1138        let cache_file_path = self.get_cache_file_path()?;
1139        let cache_data = CacheData::load(&cache_file_path).ok();
1140
1141        if let Some(cache_data) = cache_data {
1142            if cache_data.directory == cache_key {
1143                // Rebuild match operation list for file list cache
1144                let mut ops = Vec::new();
1145                for item in cache_data.match_operations {
1146                    // For file list operations, we reconstruct operations from cached data
1147                    let video_path = PathBuf::from(&item.video_file);
1148                    let subtitle_path = PathBuf::from(&item.subtitle_file);
1149
1150                    if video_path.exists() && subtitle_path.exists() {
1151                        // Create minimal MediaFile objects for the operation
1152                        let video_meta = video_path.metadata()?;
1153                        let subtitle_meta = subtitle_path.metadata()?;
1154
1155                        let video_file = MediaFile {
1156                            id: generate_file_id(&video_path, video_meta.len()),
1157                            path: video_path.clone(),
1158                            file_type: MediaFileType::Video,
1159                            size: video_meta.len(),
1160                            name: video_path
1161                                .file_name()
1162                                .unwrap()
1163                                .to_string_lossy()
1164                                .to_string(),
1165                            extension: video_path
1166                                .extension()
1167                                .unwrap_or_default()
1168                                .to_string_lossy()
1169                                .to_lowercase(),
1170                            relative_path: video_path
1171                                .file_name()
1172                                .unwrap()
1173                                .to_string_lossy()
1174                                .to_string(),
1175                        };
1176
1177                        let subtitle_file = MediaFile {
1178                            id: generate_file_id(&subtitle_path, subtitle_meta.len()),
1179                            path: subtitle_path.clone(),
1180                            file_type: MediaFileType::Subtitle,
1181                            size: subtitle_meta.len(),
1182                            name: subtitle_path
1183                                .file_name()
1184                                .unwrap()
1185                                .to_string_lossy()
1186                                .to_string(),
1187                            extension: subtitle_path
1188                                .extension()
1189                                .unwrap_or_default()
1190                                .to_string_lossy()
1191                                .to_lowercase(),
1192                            relative_path: subtitle_path
1193                                .file_name()
1194                                .unwrap()
1195                                .to_string_lossy()
1196                                .to_string(),
1197                        };
1198
1199                        // Recalculate relocation information based on current configuration
1200                        let requires_relocation = self.config.relocation_mode
1201                            != FileRelocationMode::None
1202                            && subtitle_file.path.parent() != video_file.path.parent();
1203
1204                        let relocation_target_path = if requires_relocation {
1205                            let video_dir = video_file.path.parent().unwrap();
1206                            Some(video_dir.join(&item.new_subtitle_name))
1207                        } else {
1208                            None
1209                        };
1210
1211                        ops.push(MatchOperation {
1212                            video_file,
1213                            subtitle_file,
1214                            new_subtitle_name: item.new_subtitle_name,
1215                            confidence: item.confidence,
1216                            reasoning: item.reasoning,
1217                            relocation_mode: self.config.relocation_mode.clone(),
1218                            relocation_target_path,
1219                            requires_relocation,
1220                        });
1221                    }
1222                }
1223                return Ok(Some(ops));
1224            }
1225        }
1226        Ok(None)
1227    }
1228
1229    /// Save cache for file list operations
1230    async fn save_file_list_cache(
1231        &self,
1232        cache_key: &str,
1233        operations: &[MatchOperation],
1234    ) -> Result<()> {
1235        let cache_file_path = self.get_cache_file_path()?;
1236        let config_hash = self.calculate_config_hash()?;
1237
1238        let mut cache_items = Vec::new();
1239        for op in operations {
1240            cache_items.push(OpItem {
1241                video_file: op.video_file.path.to_string_lossy().to_string(),
1242                subtitle_file: op.subtitle_file.path.to_string_lossy().to_string(),
1243                new_subtitle_name: op.new_subtitle_name.clone(),
1244                confidence: op.confidence,
1245                reasoning: op.reasoning.clone(),
1246            });
1247        }
1248
1249        // Build file snapshot with canonical paths, sizes, and mtimes
1250        let mut snapshot_items = Vec::new();
1251        let mut seen_paths = std::collections::HashSet::new();
1252        for op in operations {
1253            for path in [&op.video_file.path, &op.subtitle_file.path] {
1254                let canonical = path.canonicalize().unwrap_or_else(|_| path.clone());
1255                let key = canonical.to_string_lossy().to_string();
1256                if seen_paths.insert(key.clone()) {
1257                    if let Ok(meta) = std::fs::metadata(&canonical) {
1258                        let mtime = meta
1259                            .modified()
1260                            .ok()
1261                            .and_then(|t| t.duration_since(std::time::UNIX_EPOCH).ok())
1262                            .map(|d| d.as_secs())
1263                            .unwrap_or(0);
1264                        snapshot_items.push(SnapshotItem {
1265                            path: key,
1266                            name: canonical
1267                                .file_name()
1268                                .unwrap_or_default()
1269                                .to_string_lossy()
1270                                .to_string(),
1271                            size: meta.len(),
1272                            mtime,
1273                            file_type: if canonical.extension().is_some_and(|e| {
1274                                ["srt", "ass", "ssa", "vtt", "sub"]
1275                                    .contains(&e.to_string_lossy().to_lowercase().as_str())
1276                            }) {
1277                                "subtitle".to_string()
1278                            } else {
1279                                "video".to_string()
1280                            },
1281                        });
1282                    }
1283                }
1284            }
1285        }
1286
1287        let cache_data = CacheData {
1288            cache_version: "1.0".to_string(),
1289            directory: cache_key.to_string(),
1290            file_snapshot: snapshot_items,
1291            match_operations: cache_items,
1292            created_at: std::time::SystemTime::now()
1293                .duration_since(std::time::UNIX_EPOCH)
1294                .unwrap()
1295                .as_secs(),
1296            ai_model_used: self.config.ai_model.clone(),
1297            config_hash,
1298            original_relocation_mode: format!("{:?}", self.config.relocation_mode),
1299            original_backup_enabled: self.config.backup_enabled,
1300        };
1301
1302        // Save cache data to file
1303        let cache_dir = cache_file_path.parent().unwrap().to_path_buf();
1304        let cache_json = serde_json::to_string_pretty(&cache_data)?;
1305        let cache_file_path_clone = cache_file_path.clone();
1306        tokio::task::spawn_blocking(move || -> std::io::Result<()> {
1307            std::fs::create_dir_all(&cache_dir)?;
1308            std::fs::write(&cache_file_path_clone, cache_json)?;
1309            Ok(())
1310        })
1311        .await
1312        .map_err(|e| SubXError::Io(std::io::Error::other(e.to_string())))??;
1313
1314        Ok(())
1315    }
1316
1317    /// Get cache file path
1318    fn get_cache_file_path(&self) -> Result<std::path::PathBuf> {
1319        // First check XDG_CONFIG_HOME environment variable (used for testing)
1320        let dir = if let Some(xdg_config) = std::env::var_os("XDG_CONFIG_HOME") {
1321            std::path::PathBuf::from(xdg_config)
1322        } else {
1323            dirs::config_dir()
1324                .ok_or_else(|| SubXError::config("Unable to determine cache directory"))?
1325        };
1326        Ok(dir.join("subx").join("match_cache.json"))
1327    }
1328
1329    /// Calculate current configuration hash for cache validation
1330    fn calculate_config_hash(&self) -> Result<String> {
1331        use std::collections::hash_map::DefaultHasher;
1332        use std::hash::{Hash, Hasher};
1333
1334        let mut hasher = DefaultHasher::new();
1335        // Add configuration items that affect cache validity to the hash
1336        format!("{:?}", self.config.relocation_mode).hash(&mut hasher);
1337        self.config.backup_enabled.hash(&mut hasher);
1338        // Add other relevant configuration items
1339
1340        Ok(format!("{:016x}", hasher.finish()))
1341    }
1342
1343    /// Find a media file by ID, with an optional fallback to relative path or name.
1344    fn find_media_file_by_id_or_path<'a>(
1345        files: &'a [&MediaFile],
1346        file_id: &str,
1347        fallback_path: Option<&str>,
1348    ) -> Option<&'a MediaFile> {
1349        if let Some(file) = files.iter().find(|f| f.id == file_id) {
1350            return Some(*file);
1351        }
1352        if let Some(path) = fallback_path {
1353            if let Some(file) = files.iter().find(|f| f.relative_path == path) {
1354                return Some(*file);
1355            }
1356            files.iter().find(|f| f.name == path).copied()
1357        } else {
1358            None
1359        }
1360    }
1361
1362    /// Log available files to assist debugging when a match is not found.
1363    fn log_available_files(&self, files: &[&MediaFile], file_type: &str) {
1364        eprintln!("   Available {} files:", file_type);
1365        for f in files {
1366            eprintln!(
1367                "     - ID: {} | Name: {} | Path: {}",
1368                f.id, f.name, f.relative_path
1369            );
1370        }
1371    }
1372
1373    /// Provide detailed information when no matches are found.
1374    fn log_no_matches_found(
1375        &self,
1376        match_result: &MatchResult,
1377        videos: &[MediaFile],
1378        subtitles: &[MediaFile],
1379    ) {
1380        eprintln!("\nāŒ No matching files found that meet the criteria");
1381        eprintln!("šŸ” AI analysis results:");
1382        eprintln!("   - Total matches: {}", match_result.matches.len());
1383        eprintln!(
1384            "   - Confidence threshold: {:.2}",
1385            self.config.confidence_threshold
1386        );
1387        eprintln!(
1388            "   - Matches meeting threshold: {}",
1389            match_result
1390                .matches
1391                .iter()
1392                .filter(|m| m.confidence >= self.config.confidence_threshold)
1393                .count()
1394        );
1395        eprintln!("\nšŸ“‚ Scanned files:");
1396        eprintln!("   Video files ({} files):", videos.len());
1397        for v in videos {
1398            eprintln!("     - ID: {} | {}", v.id, v.relative_path);
1399        }
1400        eprintln!("   Subtitle files ({} files):", subtitles.len());
1401        for s in subtitles {
1402            eprintln!("     - ID: {} | {}", s.id, s.relative_path);
1403        }
1404    }
1405}
1406
1407/// Replay a frozen set of cached match operations.
1408///
1409/// This helper powers the `cache apply` command. It reconstructs
1410/// [`MatchOperation`] values from the paths recorded in `cache`, without
1411/// performing any additional AI analysis or validation, and then feeds
1412/// them through the standard [`MatchEngine::execute_operations`] pipeline
1413/// so the same journal and file-system guarantees apply.
1414///
1415/// The provided `config` determines runtime behaviour such as relocation
1416/// mode, backup handling and conflict resolution. Callers are expected to
1417/// synchronise the config with the original cache's recorded values when
1418/// strict replay fidelity is required.
1419///
1420/// # Errors
1421///
1422/// Returns an error if any cached source path cannot be read or if the
1423/// underlying execution pipeline fails.
1424pub async fn apply_cached_operations(cache: &CacheData, config: &MatchConfig) -> Result<()> {
1425    let operations = reconstruct_operations_from_cache(cache, config)?;
1426    let engine = MatchEngine::new(Box::new(NoOpAIProvider), config.clone());
1427    engine.execute_operations(&operations, false).await
1428}
1429
1430/// Rebuild [`MatchOperation`] values from a [`CacheData`] payload.
1431///
1432/// Silently skips entries whose source files no longer exist so the replay
1433/// is resilient to partial state (e.g., an earlier apply completed some
1434/// operations but was interrupted).
1435fn reconstruct_operations_from_cache(
1436    cache: &CacheData,
1437    config: &MatchConfig,
1438) -> Result<Vec<MatchOperation>> {
1439    let mut ops = Vec::new();
1440    for item in &cache.match_operations {
1441        let video_path = PathBuf::from(&item.video_file);
1442        let subtitle_path = PathBuf::from(&item.subtitle_file);
1443
1444        if !video_path.exists() || !subtitle_path.exists() {
1445            continue;
1446        }
1447
1448        let video_meta = video_path.metadata()?;
1449        let subtitle_meta = subtitle_path.metadata()?;
1450
1451        let video_file = MediaFile {
1452            id: generate_file_id(&video_path, video_meta.len()),
1453            path: video_path.clone(),
1454            file_type: MediaFileType::Video,
1455            size: video_meta.len(),
1456            name: video_path
1457                .file_name()
1458                .unwrap_or_default()
1459                .to_string_lossy()
1460                .to_string(),
1461            extension: video_path
1462                .extension()
1463                .unwrap_or_default()
1464                .to_string_lossy()
1465                .to_lowercase(),
1466            relative_path: video_path
1467                .file_name()
1468                .unwrap_or_default()
1469                .to_string_lossy()
1470                .to_string(),
1471        };
1472
1473        let subtitle_file = MediaFile {
1474            id: generate_file_id(&subtitle_path, subtitle_meta.len()),
1475            path: subtitle_path.clone(),
1476            file_type: MediaFileType::Subtitle,
1477            size: subtitle_meta.len(),
1478            name: subtitle_path
1479                .file_name()
1480                .unwrap_or_default()
1481                .to_string_lossy()
1482                .to_string(),
1483            extension: subtitle_path
1484                .extension()
1485                .unwrap_or_default()
1486                .to_string_lossy()
1487                .to_lowercase(),
1488            relative_path: subtitle_path
1489                .file_name()
1490                .unwrap_or_default()
1491                .to_string_lossy()
1492                .to_string(),
1493        };
1494
1495        let requires_relocation = config.relocation_mode != FileRelocationMode::None
1496            && subtitle_file.path.parent() != video_file.path.parent();
1497        let relocation_target_path = if requires_relocation {
1498            video_file
1499                .path
1500                .parent()
1501                .map(|p| p.join(&item.new_subtitle_name))
1502        } else {
1503            None
1504        };
1505
1506        ops.push(MatchOperation {
1507            video_file,
1508            subtitle_file,
1509            new_subtitle_name: item.new_subtitle_name.clone(),
1510            confidence: item.confidence,
1511            reasoning: item.reasoning.clone(),
1512            relocation_mode: config.relocation_mode.clone(),
1513            relocation_target_path,
1514            requires_relocation,
1515        });
1516    }
1517    Ok(ops)
1518}
1519
1520/// AI provider stub used by [`apply_cached_operations`].
1521///
1522/// `execute_operations` never calls the AI service, so replaying a cached
1523/// plan does not require a real provider; this stub panics defensively if
1524/// accidentally invoked.
1525struct NoOpAIProvider;
1526
1527#[async_trait::async_trait]
1528impl AIProvider for NoOpAIProvider {
1529    async fn analyze_content(&self, _request: AnalysisRequest) -> crate::Result<MatchResult> {
1530        Err(SubXError::config(
1531            "AI analysis is not available while replaying cached operations",
1532        ))
1533    }
1534
1535    async fn verify_match(
1536        &self,
1537        _verification: crate::services::ai::VerificationRequest,
1538    ) -> crate::Result<crate::services::ai::ConfidenceScore> {
1539        Err(SubXError::config(
1540            "AI verification is not available while replaying cached operations",
1541        ))
1542    }
1543}