Skip to main content

subx_core/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_core::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::input::CollectedFiles;
20use crate::core::language::LanguageDetector;
21use crate::core::matcher::cache::{CacheData, OpItem, SnapshotItem};
22use crate::core::matcher::discovery::generate_file_id;
23use crate::core::matcher::journal::{
24    JournalData, JournalEntry, JournalEntryStatus, JournalOperationType, journal_path,
25};
26use crate::core::matcher::{FileDiscovery, MediaFile, MediaFileType};
27use crate::core::parallel::{FileProcessingTask, ProcessingOperation, Task, TaskResult};
28use crate::core::report::ProgressEvent;
29use crate::core::uuidv7::Uuidv7Generator;
30use crate::error::SubXError;
31use dirs;
32use serde_json;
33
34/// Current on-disk match-cache schema version.
35///
36/// Bumped whenever the prompt structure or cache layout changes in a
37/// way that invalidates earlier entries. Historical values: `"1.0"`
38/// (pre-XML prompt rewrite); `"2.0"` (current — XML-tagged prompt with
39/// optional `language` / `target_filename_suffix`).
40pub(crate) const CURRENT_CACHE_VERSION: &str = "2.0";
41
42/// Returns whether a cache file with the given `cache_version` string is
43/// considered compatible with the current code. Used by
44/// [`MatchEngine::check_file_list_cache`] to reject entries written by
45/// earlier prompt schemas.
46pub(crate) fn is_cache_version_current(version: &str) -> bool {
47    version == CURRENT_CACHE_VERSION
48}
49
50/// Sanitize an AI-supplied filename suffix.
51///
52/// Accepts only ASCII alphanumerics, underscore, and hyphen. The check is
53/// applied to the **whole string**: any disallowed character (including
54/// path separators or `.`) causes outright rejection rather than silent
55/// stripping, so payloads such as `"../etc"` cannot smuggle a usable
56/// `"etc"` token through.
57///
58/// # Arguments
59///
60/// * `raw` - Raw suffix supplied by the AI (e.g. `"tc"`, `"english"`,
61///   `"../etc"`).
62///
63/// # Returns
64///
65/// `Some(raw)` when the input is non-empty, no longer than 16 bytes, and
66/// composed entirely of `[A-Za-z0-9_-]`; `None` otherwise.
67pub(crate) fn sanitize_suffix(raw: &str) -> Option<String> {
68    if raw.is_empty() || raw.len() > 16 {
69        return None;
70    }
71    if raw
72        .chars()
73        .all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '-')
74    {
75        Some(raw.to_string())
76    } else {
77        None
78    }
79}
80
81/// Normalize an AI-supplied language label without going through the
82/// strict ASCII gate of [`sanitize_suffix`].
83///
84/// This helper preserves Unicode (so labels such as `繁中` or `简中` reach
85/// [`LanguageDetector::normalize`]), only lower-casing ASCII letters and
86/// trimming whitespace before lookup. Sentinel values (`""` and `und`,
87/// case-insensitive) return `None`. After detector lookup, the returned
88/// canonical code is finally validated through [`sanitize_suffix`] so the
89/// downstream filename insertion remains safe.
90///
91/// # Arguments
92///
93/// * `detector` - Configured [`LanguageDetector`] used for synonym lookup.
94/// * `raw` - Raw label as supplied by the AI.
95///
96/// # Returns
97///
98/// `Some(code)` with a sanitized canonical short code (e.g. `"tc"`,
99/// `"en"`); `None` for empty input, the sentinel `und`, or labels that
100/// resolve to something unsafe for filenames.
101pub(crate) fn normalize_ai_language(detector: &LanguageDetector, raw: &str) -> Option<String> {
102    let trimmed = raw.trim();
103    if trimmed.is_empty() || trimmed.eq_ignore_ascii_case("und") {
104        return None;
105    }
106    let lowered: String = trimmed
107        .chars()
108        .map(|c| {
109            if c.is_ascii_uppercase() {
110                c.to_ascii_lowercase()
111            } else {
112                c
113            }
114        })
115        .collect();
116    let resolved = detector.normalize(&lowered)?;
117    sanitize_suffix(&resolved)
118}
119
120/// Globally enforce unique final target paths across a batch of
121/// [`MatchOperation`] values.
122///
123/// Run **after** [`apply_archive_origin_relocation`] (and any other
124/// post-engine relocation rewrites) so the uniqueness guarantee holds at
125/// the actual destination paths — the allocator's guarantee is about final
126/// destinations, not pre-rewrite candidates. The
127/// allocator iterates operations sorted by `(target_directory,
128/// subtitle_file.relative_path)` for stability across reruns and
129/// probes `<base>.<n>.<ext>` (preserving any language segment) starting
130/// at `n = 2` until a free path is found, mutating both
131/// `new_subtitle_name` and `relocation_target_path` so downstream
132/// consumers see consistent values.
133///
134/// See [`apply_archive_origin_relocation`] for the required call order.
135///
136/// # Arguments
137///
138/// * `operations` - Mutable slice of operations to deduplicate in place.
139pub fn apply_unique_target_paths(operations: &mut [MatchOperation]) {
140    use std::collections::HashSet;
141
142    fn final_target(op: &MatchOperation) -> PathBuf {
143        if let Some(p) = &op.relocation_target_path {
144            p.clone()
145        } else {
146            let parent = op
147                .subtitle_file
148                .path
149                .parent()
150                .unwrap_or_else(|| std::path::Path::new("."));
151            parent.join(&op.new_subtitle_name)
152        }
153    }
154
155    fn split_filename(name: &str) -> (String, String) {
156        // Split filename into (stem, ".ext"); preserves multi-dot stems.
157        if let Some(idx) = name.rfind('.') {
158            if idx > 0 {
159                return (name[..idx].to_string(), name[idx..].to_string());
160            }
161        }
162        (name.to_string(), String::new())
163    }
164
165    /// Strip a trailing `.<digits>` segment from `stem`, returning
166    /// `(base_stem, existing_counter)`. Used so that when the AI (or a
167    /// prior pass) already produced `movie.2.srt`, the next collision
168    /// becomes `movie.3.srt` rather than `movie.2.2.srt`.
169    fn split_numeric_tail(stem: &str) -> (String, Option<u32>) {
170        if let Some(idx) = stem.rfind('.') {
171            let (head, tail) = (&stem[..idx], &stem[idx + 1..]);
172            if !tail.is_empty() && tail.chars().all(|c| c.is_ascii_digit()) {
173                if let Ok(n) = tail.parse::<u32>() {
174                    return (head.to_string(), Some(n));
175                }
176            }
177        }
178        (stem.to_string(), None)
179    }
180
181    // Sort indices so we can mutate operations in-place but iterate in a
182    // deterministic order independent of the AI's response ordering.
183    let mut indices: Vec<usize> = (0..operations.len()).collect();
184    indices.sort_by(|&a, &b| {
185        let pa = final_target(&operations[a]);
186        let pb = final_target(&operations[b]);
187        let dir_a = pa.parent().map(|p| p.to_path_buf()).unwrap_or_default();
188        let dir_b = pb.parent().map(|p| p.to_path_buf()).unwrap_or_default();
189        dir_a.cmp(&dir_b).then_with(|| {
190            operations[a]
191                .subtitle_file
192                .relative_path
193                .cmp(&operations[b].subtitle_file.relative_path)
194        })
195    });
196
197    // Reserve every operation's *original* candidate so that two
198    // operations cannot both claim a numerically-suffixed slot that a
199    // third operation already legitimately needed. See OpenSpec scenario
200    // "Three-way duplicates and a pre-existing numeric suffix" — without
201    // this guard `[movie.srt, movie.srt, movie.2.srt]` would steal
202    // `movie.2.srt` from the third op while resolving the second.
203    let reserved: HashSet<PathBuf> = (0..operations.len())
204        .map(|i| final_target(&operations[i]))
205        .collect();
206
207    let mut claimed: HashSet<PathBuf> = HashSet::new();
208
209    for idx in indices {
210        let candidate = final_target(&operations[idx]);
211        let parent = candidate
212            .parent()
213            .map(|p| p.to_path_buf())
214            .unwrap_or_default();
215        let filename = candidate
216            .file_name()
217            .map(|f| f.to_string_lossy().to_string())
218            .unwrap_or_default();
219        let (stem, ext) = split_filename(&filename);
220        let (base_stem, existing_counter) = split_numeric_tail(&stem);
221        let source_path = operations[idx].subtitle_file.path.clone();
222
223        // A probe path is taken if another op has already committed it
224        // (via `claimed`), if some *other* op still holds it as its
225        // original candidate (via `reserved`, minus the current op's own
226        // candidate so the current op can keep its preferred slot when
227        // free), or if a file already exists on disk at that path
228        // (excluding the operation's own source file so a no-op rename
229        // can keep its current name).
230        let is_taken = |p: &PathBuf| -> bool {
231            claimed.contains(p)
232                || (p != &candidate && reserved.contains(p))
233                || (p != &source_path && p.exists())
234        };
235
236        let mut resolved = candidate.clone();
237        let mut resolved_name = filename.clone();
238        if is_taken(&resolved) {
239            let mut counter = existing_counter.map(|n| n + 1).unwrap_or(2).max(2);
240            loop {
241                let new_name = format!("{}.{}{}", base_stem, counter, ext);
242                let probe = parent.join(&new_name);
243                if !is_taken(&probe) {
244                    resolved = probe;
245                    resolved_name = new_name;
246                    break;
247                }
248                counter = counter.saturating_add(1);
249                if counter > 9999 {
250                    break;
251                }
252            }
253        }
254
255        let op = &mut operations[idx];
256        op.new_subtitle_name = resolved_name;
257        if op.relocation_target_path.is_some() {
258            op.relocation_target_path = Some(resolved.clone());
259        }
260        claimed.insert(resolved);
261    }
262}
263
264/// Force subtitles extracted from an archive to copy beside their matched
265/// video's directory.
266///
267/// A subtitle whose [`CollectedFiles::archive_origin`] is set lives in a
268/// temporary extraction directory that is deleted when the
269/// [`CollectedFiles`] is dropped, so an in-place rename would write into a
270/// location that is about to disappear. For every operation whose subtitle
271/// has an archive origin and which does **not** already require relocation
272/// (a caller-chosen `--copy`/`--move` already owns that operation's
273/// relocation fields), this rewrites `relocation_target_path` to the
274/// matched video's parent directory joined with `new_subtitle_name`, sets
275/// `requires_relocation` to `true`, and sets `relocation_mode` to
276/// [`FileRelocationMode::Copy`]. An operation whose video path has no
277/// parent is left untouched.
278///
279/// This function MUST be called **before** [`apply_unique_target_paths`]:
280/// the allocator's uniqueness guarantee holds at final destination paths,
281/// and the rewrite changes the destination — allocating first would
282/// guarantee uniqueness at candidates the rewrite then discards. The two
283/// functions are neighbours in this module because they are only correct
284/// in that order.
285///
286/// # Arguments
287///
288/// * `operations` - Mutable slice of operations to rewrite in place.
289/// * `collected` - The collected-file context carrying archive provenance.
290///
291/// # Examples
292///
293/// ```
294/// use std::collections::HashMap;
295/// use std::path::PathBuf;
296/// use subx_core::core::input::CollectedFiles;
297/// use subx_core::core::matcher::engine::{
298///     apply_archive_origin_relocation, apply_unique_target_paths,
299/// };
300/// # fn example(operations: &mut [subx_core::core::matcher::engine::MatchOperation]) {
301/// let mut origins = HashMap::new();
302/// origins.insert(
303///     PathBuf::from("/tmp/subx-XXXX"),
304///     PathBuf::from("/data/subs.zip"),
305/// );
306/// let collected = CollectedFiles::with_archives(
307///     vec![PathBuf::from("/tmp/subx-XXXX/movie.srt")],
308///     Vec::new(),
309///     origins,
310/// );
311/// apply_archive_origin_relocation(operations, &collected);
312/// // … and only then the allocator, so uniqueness holds at the rewritten paths:
313/// apply_unique_target_paths(operations);
314/// # }
315/// ```
316pub fn apply_archive_origin_relocation(
317    operations: &mut [MatchOperation],
318    collected: &CollectedFiles,
319) {
320    for op in operations {
321        if collected.archive_origin(&op.subtitle_file.path).is_some() && !op.requires_relocation {
322            if let Some(video_dir) = op.video_file.path.parent() {
323                op.relocation_target_path = Some(video_dir.join(&op.new_subtitle_name));
324                op.requires_relocation = true;
325                op.relocation_mode = FileRelocationMode::Copy;
326            }
327        }
328    }
329}
330
331/// File relocation mode for matched subtitle files
332#[derive(Debug, Clone, PartialEq)]
333pub enum FileRelocationMode {
334    /// No file relocation
335    None,
336    /// Copy subtitle files to video folders
337    Copy,
338    /// Move subtitle files to video folders
339    Move,
340}
341
342/// Strategy for handling filename conflicts during relocation
343#[derive(Debug, Clone)]
344pub enum ConflictResolution {
345    /// Skip relocation if conflict exists
346    Skip,
347    /// Automatically rename with numeric suffix
348    AutoRename,
349    /// Prompt user for decision (interactive mode only)
350    Prompt,
351}
352
353/// Configuration settings for the file matching engine.
354///
355/// Controls various aspects of the subtitle-to-video matching process,
356/// including confidence thresholds and analysis options.
357///
358/// # Construction stability
359///
360/// Every field is public and the struct is exhaustively constructible, so
361/// adding a ninth field is a **major-version change** for every struct
362/// literal a caller writes. [`crate::core::factory::ComponentFactory::match_config`]
363/// is the route that removes that exposure for config-driven callers —
364/// mutate its return value instead of writing a literal — and once no
365/// caller writes literals, adding `#[non_exhaustive]` here becomes a
366/// cost-free tightening a future change may take.
367#[derive(Debug, Clone)]
368pub struct MatchConfig {
369    /// Minimum confidence score required for a successful match (0.0 to 1.0)
370    pub confidence_threshold: f32,
371    /// Maximum number of characters to sample from subtitle content
372    pub max_sample_length: usize,
373    /// Whether to enable advanced content analysis for matching
374    pub enable_content_analysis: bool,
375    /// Whether to create backup files before operations
376    pub backup_enabled: bool,
377    /// File relocation mode
378    pub relocation_mode: FileRelocationMode,
379    /// Strategy for handling filename conflicts during relocation
380    pub conflict_resolution: ConflictResolution,
381    /// AI model name used for analysis
382    pub ai_model: String,
383    /// Maximum subtitle file size in bytes accepted for content sampling.
384    /// Populated from `GeneralConfig.max_subtitle_bytes`.
385    pub max_subtitle_bytes: u64,
386}
387
388#[cfg(test)]
389mod language_name_tests {
390    use super::*;
391    use crate::core::matcher::discovery::{MediaFile, MediaFileType};
392    use crate::services::ai::{
393        AIProvider, AnalysisRequest, ConfidenceScore, FileMatch, MatchResult, VerificationRequest,
394    };
395    use async_trait::async_trait;
396    use std::path::PathBuf;
397
398    fn legacy_match() -> FileMatch {
399        FileMatch {
400            video_file_id: "v".into(),
401            subtitle_file_id: "s".into(),
402            confidence: 1.0,
403            match_factors: vec![],
404            language: None,
405            target_filename_suffix: None,
406        }
407    }
408
409    fn match_with_language(language: Option<&str>, suffix: Option<&str>) -> FileMatch {
410        FileMatch {
411            video_file_id: "v".into(),
412            subtitle_file_id: "s".into(),
413            confidence: 1.0,
414            match_factors: vec![],
415            language: language.map(|s| s.to_string()),
416            target_filename_suffix: suffix.map(|s| s.to_string()),
417        }
418    }
419
420    struct DummyAI;
421    #[async_trait]
422    impl AIProvider for DummyAI {
423        async fn analyze_content(&self, _req: AnalysisRequest) -> crate::Result<MatchResult> {
424            unimplemented!()
425        }
426        async fn verify_match(&self, _req: VerificationRequest) -> crate::Result<ConfidenceScore> {
427            unimplemented!()
428        }
429    }
430
431    #[test]
432    fn test_generate_subtitle_name_with_directory_language() {
433        let engine = MatchEngine::new(
434            Box::new(DummyAI),
435            MatchConfig {
436                confidence_threshold: 0.0,
437                max_sample_length: 0,
438                enable_content_analysis: false,
439                backup_enabled: false,
440                relocation_mode: FileRelocationMode::None,
441                conflict_resolution: ConflictResolution::Skip,
442                ai_model: "test-model".to_string(),
443                max_subtitle_bytes: 52_428_800,
444            },
445        );
446        let video = MediaFile {
447            id: "".to_string(),
448            relative_path: "".to_string(),
449            path: PathBuf::from("movie01.mp4"),
450            file_type: MediaFileType::Video,
451            size: 0,
452            name: "movie01".to_string(),
453            extension: "mp4".to_string(),
454        };
455        let subtitle = MediaFile {
456            id: "".to_string(),
457            relative_path: "".to_string(),
458            path: PathBuf::from("tc/subtitle01.ass"),
459            file_type: MediaFileType::Subtitle,
460            size: 0,
461            name: "subtitle01".to_string(),
462            extension: "ass".to_string(),
463        };
464        let new_name = engine.generate_subtitle_name(&video, &subtitle, &legacy_match());
465        assert_eq!(new_name, "movie01.tc.ass");
466    }
467
468    #[test]
469    fn test_generate_subtitle_name_with_filename_language() {
470        let engine = MatchEngine::new(
471            Box::new(DummyAI),
472            MatchConfig {
473                confidence_threshold: 0.0,
474                max_sample_length: 0,
475                enable_content_analysis: false,
476                backup_enabled: false,
477                relocation_mode: FileRelocationMode::None,
478                conflict_resolution: ConflictResolution::Skip,
479                ai_model: "test-model".to_string(),
480                max_subtitle_bytes: 52_428_800,
481            },
482        );
483        let video = MediaFile {
484            id: "".to_string(),
485            relative_path: "".to_string(),
486            path: PathBuf::from("movie02.mp4"),
487            file_type: MediaFileType::Video,
488            size: 0,
489            name: "movie02".to_string(),
490            extension: "mp4".to_string(),
491        };
492        let subtitle = MediaFile {
493            id: "".to_string(),
494            relative_path: "".to_string(),
495            path: PathBuf::from("subtitle02.sc.ass"),
496            file_type: MediaFileType::Subtitle,
497            size: 0,
498            name: "subtitle02".to_string(),
499            extension: "ass".to_string(),
500        };
501        let new_name = engine.generate_subtitle_name(&video, &subtitle, &legacy_match());
502        assert_eq!(new_name, "movie02.sc.ass");
503    }
504
505    #[test]
506    fn test_generate_subtitle_name_without_language() {
507        let engine = MatchEngine::new(
508            Box::new(DummyAI),
509            MatchConfig {
510                confidence_threshold: 0.0,
511                max_sample_length: 0,
512                enable_content_analysis: false,
513                backup_enabled: false,
514                relocation_mode: FileRelocationMode::None,
515                conflict_resolution: ConflictResolution::Skip,
516                ai_model: "test-model".to_string(),
517                max_subtitle_bytes: 52_428_800,
518            },
519        );
520        let video = MediaFile {
521            id: "".to_string(),
522            relative_path: "".to_string(),
523            path: PathBuf::from("movie03.mp4"),
524            file_type: MediaFileType::Video,
525            size: 0,
526            name: "movie03".to_string(),
527            extension: "mp4".to_string(),
528        };
529        let subtitle = MediaFile {
530            id: "".to_string(),
531            relative_path: "".to_string(),
532            path: PathBuf::from("subtitle03.ass"),
533            file_type: MediaFileType::Subtitle,
534            size: 0,
535            name: "subtitle03".to_string(),
536            extension: "ass".to_string(),
537        };
538        let new_name = engine.generate_subtitle_name(&video, &subtitle, &legacy_match());
539        assert_eq!(new_name, "movie03.ass");
540    }
541    #[test]
542    fn test_generate_subtitle_name_removes_video_extension() {
543        let engine = MatchEngine::new(
544            Box::new(DummyAI),
545            MatchConfig {
546                confidence_threshold: 0.0,
547                max_sample_length: 0,
548                enable_content_analysis: false,
549                backup_enabled: false,
550                relocation_mode: FileRelocationMode::None,
551                conflict_resolution: ConflictResolution::Skip,
552                ai_model: "test-model".to_string(),
553                max_subtitle_bytes: 52_428_800,
554            },
555        );
556        let video = MediaFile {
557            id: "".to_string(),
558            relative_path: "".to_string(),
559            path: PathBuf::from("movie.mkv"),
560            file_type: MediaFileType::Video,
561            size: 0,
562            name: "movie.mkv".to_string(),
563            extension: "mkv".to_string(),
564        };
565        let subtitle = MediaFile {
566            id: "".to_string(),
567            relative_path: "".to_string(),
568            path: PathBuf::from("subtitle.srt"),
569            file_type: MediaFileType::Subtitle,
570            size: 0,
571            name: "subtitle".to_string(),
572            extension: "srt".to_string(),
573        };
574        let new_name = engine.generate_subtitle_name(&video, &subtitle, &legacy_match());
575        assert_eq!(new_name, "movie.srt");
576    }
577
578    #[test]
579    fn test_generate_subtitle_name_with_language_removes_video_extension() {
580        let engine = MatchEngine::new(
581            Box::new(DummyAI),
582            MatchConfig {
583                confidence_threshold: 0.0,
584                max_sample_length: 0,
585                enable_content_analysis: false,
586                backup_enabled: false,
587                relocation_mode: FileRelocationMode::None,
588                conflict_resolution: ConflictResolution::Skip,
589                ai_model: "test-model".to_string(),
590                max_subtitle_bytes: 52_428_800,
591            },
592        );
593        let video = MediaFile {
594            id: "".to_string(),
595            relative_path: "".to_string(),
596            path: PathBuf::from("movie.mkv"),
597            file_type: MediaFileType::Video,
598            size: 0,
599            name: "movie.mkv".to_string(),
600            extension: "mkv".to_string(),
601        };
602        let subtitle = MediaFile {
603            id: "".to_string(),
604            relative_path: "".to_string(),
605            path: PathBuf::from("tc/subtitle.srt"),
606            file_type: MediaFileType::Subtitle,
607            size: 0,
608            name: "subtitle".to_string(),
609            extension: "srt".to_string(),
610        };
611        let new_name = engine.generate_subtitle_name(&video, &subtitle, &legacy_match());
612        assert_eq!(new_name, "movie.tc.srt");
613    }
614
615    #[test]
616    fn test_generate_subtitle_name_edge_cases() {
617        let engine = MatchEngine::new(
618            Box::new(DummyAI),
619            MatchConfig {
620                confidence_threshold: 0.0,
621                max_sample_length: 0,
622                enable_content_analysis: false,
623                backup_enabled: false,
624                relocation_mode: FileRelocationMode::None,
625                conflict_resolution: ConflictResolution::Skip,
626                ai_model: "test-model".to_string(),
627                max_subtitle_bytes: 52_428_800,
628            },
629        );
630        // File name contains multiple dots and no extension case
631        let video = MediaFile {
632            id: "".to_string(),
633            relative_path: "".to_string(),
634            path: PathBuf::from("a.b.c"),
635            file_type: MediaFileType::Video,
636            size: 0,
637            name: "a.b.c".to_string(),
638            extension: "".to_string(),
639        };
640        let subtitle = MediaFile {
641            id: "".to_string(),
642            relative_path: "".to_string(),
643            path: PathBuf::from("sub.srt"),
644            file_type: MediaFileType::Subtitle,
645            size: 0,
646            name: "sub".to_string(),
647            extension: "srt".to_string(),
648        };
649        let new_name = engine.generate_subtitle_name(&video, &subtitle, &legacy_match());
650        assert_eq!(new_name, "a.b.c.srt");
651    }
652
653    fn make_engine() -> MatchEngine {
654        MatchEngine::new(
655            Box::new(DummyAI),
656            MatchConfig {
657                confidence_threshold: 0.0,
658                max_sample_length: 0,
659                enable_content_analysis: false,
660                backup_enabled: false,
661                relocation_mode: FileRelocationMode::None,
662                conflict_resolution: ConflictResolution::Skip,
663                ai_model: "test-model".to_string(),
664                max_subtitle_bytes: 52_428_800,
665            },
666        )
667    }
668
669    fn media(path: &str, name: &str, ext: &str, ty: MediaFileType) -> MediaFile {
670        MediaFile {
671            id: "".into(),
672            relative_path: path.into(),
673            path: PathBuf::from(path),
674            file_type: ty,
675            size: 0,
676            name: name.into(),
677            extension: ext.into(),
678        }
679    }
680
681    #[test]
682    fn test_generate_subtitle_name_ai_suffix_wins() {
683        let engine = make_engine();
684        let video = media("movie.mkv", "movie.mkv", "mkv", MediaFileType::Video);
685        let subtitle = media("tc/subs.srt", "subs", "srt", MediaFileType::Subtitle);
686        let m = match_with_language(Some("en"), Some("tc"));
687        assert_eq!(
688            engine.generate_subtitle_name(&video, &subtitle, &m),
689            "movie.tc.srt"
690        );
691    }
692
693    #[test]
694    fn test_generate_subtitle_name_ai_language_used() {
695        let engine = make_engine();
696        let video = media("movie.mkv", "movie.mkv", "mkv", MediaFileType::Video);
697        let subtitle = media("subs/movie.srt", "movie", "srt", MediaFileType::Subtitle);
698        let m = match_with_language(Some("ja"), None);
699        assert_eq!(
700            engine.generate_subtitle_name(&video, &subtitle, &m),
701            "movie.ja.srt"
702        );
703    }
704
705    #[test]
706    fn test_generate_subtitle_name_language_synonym_normalized() {
707        let engine = make_engine();
708        let video = media("movie.mkv", "movie.mkv", "mkv", MediaFileType::Video);
709        let subtitle = media("subs/movie.srt", "movie", "srt", MediaFileType::Subtitle);
710        for variant in &["english", "eng", "EN"] {
711            let m = match_with_language(Some(variant), None);
712            assert_eq!(
713                engine.generate_subtitle_name(&video, &subtitle, &m),
714                "movie.en.srt",
715                "variant {variant} should normalize to en"
716            );
717        }
718    }
719
720    #[test]
721    fn test_generate_subtitle_name_und_collapses_to_no_tag() {
722        let engine = make_engine();
723        let video = media("movie.mkv", "movie.mkv", "mkv", MediaFileType::Video);
724        let subtitle = media("subs/movie.srt", "movie", "srt", MediaFileType::Subtitle);
725        let m = match_with_language(Some("und"), None);
726        assert_eq!(
727            engine.generate_subtitle_name(&video, &subtitle, &m),
728            "movie.srt"
729        );
730    }
731
732    #[test]
733    fn test_generate_subtitle_name_sanitization_drops_path_traversal() {
734        let engine = make_engine();
735        let video = media("movie.mkv", "movie.mkv", "mkv", MediaFileType::Video);
736        let subtitle = media("subs/movie.srt", "movie", "srt", MediaFileType::Subtitle);
737        // Whole-string rejection: any disallowed character (including
738        // `.` and `/`) causes `sanitize_suffix` to return None, so a
739        // payload like "../etc" cannot smuggle "etc" through.
740        let m = match_with_language(None, Some("../etc"));
741        assert_eq!(
742            engine.generate_subtitle_name(&video, &subtitle, &m),
743            "movie.srt"
744        );
745    }
746
747    #[test]
748    fn test_sanitize_suffix_helper() {
749        assert_eq!(super::sanitize_suffix(""), None);
750        assert_eq!(super::sanitize_suffix("../"), None);
751        // Whole-string rejection: presence of `.` or `/` aborts.
752        assert_eq!(super::sanitize_suffix("../etc"), None);
753        assert_eq!(super::sanitize_suffix("a-b_c"), Some("a-b_c".into()));
754        assert_eq!(super::sanitize_suffix("繁中"), None);
755        // Length cap: anything > 16 bytes is rejected outright (no
756        // truncation, since silent truncation could mask injection).
757        assert_eq!(super::sanitize_suffix("0123456789abcdefGHIJKL"), None);
758        assert_eq!(
759            super::sanitize_suffix("0123456789abcdef"),
760            Some("0123456789abcdef".into())
761        );
762    }
763
764    #[test]
765    fn test_normalize_ai_language_helper() {
766        let det = LanguageDetector::new();
767        assert_eq!(
768            super::normalize_ai_language(&det, "english"),
769            Some("en".into())
770        );
771        assert_eq!(super::normalize_ai_language(&det, "ENG"), Some("en".into()));
772        assert_eq!(super::normalize_ai_language(&det, "EN"), Some("en".into()));
773        assert_eq!(super::normalize_ai_language(&det, "und"), None);
774        assert_eq!(super::normalize_ai_language(&det, "UND"), None);
775        assert_eq!(super::normalize_ai_language(&det, ""), None);
776        assert_eq!(super::normalize_ai_language(&det, "cht"), Some("tc".into()));
777        assert_eq!(super::normalize_ai_language(&det, "chs"), Some("sc".into()));
778        // Unicode label preserved long enough to reach the language map.
779        assert_eq!(
780            super::normalize_ai_language(&det, "繁中"),
781            Some("tc".into())
782        );
783        assert_eq!(
784            super::normalize_ai_language(&det, "简中"),
785            Some("sc".into())
786        );
787        // Hyphenated and underscored regional aliases.
788        assert_eq!(
789            super::normalize_ai_language(&det, "traditional-chinese"),
790            Some("tc".into())
791        );
792        assert_eq!(
793            super::normalize_ai_language(&det, "Traditional_Chinese"),
794            Some("tc".into())
795        );
796        assert_eq!(
797            super::normalize_ai_language(&det, "zh-Hant"),
798            Some("tc".into())
799        );
800        assert_eq!(
801            super::normalize_ai_language(&det, "zh_hans"),
802            Some("sc".into())
803        );
804        // Pass-through for unknown but otherwise valid short codes.
805        assert_eq!(super::normalize_ai_language(&det, "vi"), Some("vi".into()));
806        assert_eq!(super::normalize_ai_language(&det, "ID"), Some("id".into()));
807    }
808
809    fn op(parent: &str, name: &str, sub_relpath: &str, relocate: bool) -> MatchOperation {
810        let video_path = PathBuf::from(parent).join("movie.mkv");
811        let subtitle_path = PathBuf::from(sub_relpath);
812        let relocation_target_path = if relocate {
813            Some(PathBuf::from(parent).join(name))
814        } else {
815            None
816        };
817        MatchOperation {
818            video_file: MediaFile {
819                id: "v".into(),
820                relative_path: video_path.to_string_lossy().to_string(),
821                path: video_path,
822                file_type: MediaFileType::Video,
823                size: 0,
824                name: "movie.mkv".into(),
825                extension: "mkv".into(),
826            },
827            subtitle_file: MediaFile {
828                id: "s".into(),
829                relative_path: sub_relpath.into(),
830                path: subtitle_path,
831                file_type: MediaFileType::Subtitle,
832                size: 0,
833                name: name.into(),
834                extension: "srt".into(),
835            },
836            new_subtitle_name: name.into(),
837            confidence: 1.0,
838            reasoning: vec![],
839            relocation_mode: if relocate {
840                FileRelocationMode::Copy
841            } else {
842                FileRelocationMode::None
843            },
844            relocation_target_path,
845            requires_relocation: relocate,
846        }
847    }
848
849    #[test]
850    fn test_unique_target_paths_two_duplicates_same_dir() {
851        let mut ops = vec![
852            op("/d", "movie.srt", "/d/a.srt", false),
853            op("/d", "movie.srt", "/d/b.srt", false),
854        ];
855        super::apply_unique_target_paths(&mut ops);
856        assert_eq!(ops[0].new_subtitle_name, "movie.srt");
857        assert_eq!(ops[1].new_subtitle_name, "movie.2.srt");
858    }
859
860    #[test]
861    fn test_unique_target_paths_three_way_with_existing_two() {
862        // Sorted order: /d/a.srt → movie.srt, /d/b.srt → movie.srt
863        // (clones), /d/c.srt → movie.2.srt (already-unique candidate).
864        // Spec requires the *third* op to keep its preferred slot
865        // movie.2.srt, so the second op must skip past it (yielding
866        // movie.3.srt) instead of stealing it. See OpenSpec scenario
867        // "Three-way duplicates and a pre-existing numeric suffix".
868        let mut ops = vec![
869            op("/d", "movie.srt", "/d/a.srt", false),
870            op("/d", "movie.srt", "/d/b.srt", false),
871            op("/d", "movie.2.srt", "/d/c.srt", false),
872        ];
873        super::apply_unique_target_paths(&mut ops);
874        assert_eq!(ops[0].new_subtitle_name, "movie.srt");
875        assert_eq!(ops[1].new_subtitle_name, "movie.3.srt");
876        assert_eq!(ops[2].new_subtitle_name, "movie.2.srt");
877    }
878
879    #[test]
880    fn test_unique_target_paths_idempotent() {
881        // Running the allocator twice on the same batch must not change
882        // the result; the engine pre-pass plus the CLI post-pass rely on
883        // this.
884        let mut ops = vec![
885            op("/d", "movie.srt", "/d/a.srt", false),
886            op("/d", "movie.srt", "/d/b.srt", false),
887            op("/d", "movie.2.srt", "/d/c.srt", false),
888        ];
889        super::apply_unique_target_paths(&mut ops);
890        let snapshot: Vec<String> = ops.iter().map(|o| o.new_subtitle_name.clone()).collect();
891        super::apply_unique_target_paths(&mut ops);
892        let after: Vec<String> = ops.iter().map(|o| o.new_subtitle_name.clone()).collect();
893        assert_eq!(snapshot, after);
894    }
895
896    #[test]
897    fn test_unique_target_paths_two_languages_preserved() {
898        let mut ops = vec![
899            op("/d", "movie.tc.srt", "/d/a.srt", false),
900            op("/d", "movie.sc.srt", "/d/b.srt", false),
901        ];
902        super::apply_unique_target_paths(&mut ops);
903        assert_eq!(ops[0].new_subtitle_name, "movie.tc.srt");
904        assert_eq!(ops[1].new_subtitle_name, "movie.sc.srt");
905    }
906
907    #[test]
908    fn test_unique_target_paths_cross_video_collision_under_copy() {
909        // Two different videos in different source dirs both relocate
910        // into /shared with the same candidate filename "subs.srt".
911        let mut ops = vec![
912            op("/shared", "subs.srt", "/src1/subs.srt", true),
913            op("/shared", "subs.srt", "/src2/subs.srt", true),
914        ];
915        super::apply_unique_target_paths(&mut ops);
916        assert_eq!(ops[0].new_subtitle_name, "subs.srt");
917        assert_eq!(ops[1].new_subtitle_name, "subs.2.srt");
918        // relocation_target_path must be kept consistent.
919        assert_eq!(
920            ops[0].relocation_target_path.as_ref().unwrap(),
921            &PathBuf::from("/shared/subs.srt")
922        );
923        assert_eq!(
924            ops[1].relocation_target_path.as_ref().unwrap(),
925            &PathBuf::from("/shared/subs.2.srt")
926        );
927    }
928
929    #[test]
930    fn test_unique_target_paths_archive_origin_relocation_unique() {
931        // Mirrors the archive-origin scenario: relocation_target_path
932        // is set, and two ops collide on the rewritten path.
933        let mut ops = vec![
934            op("/videos", "movie.srt", "/tmp/a.srt", true),
935            op("/videos", "movie.srt", "/tmp/b.srt", true),
936        ];
937        super::apply_unique_target_paths(&mut ops);
938        assert_eq!(ops[0].new_subtitle_name, "movie.srt");
939        assert_eq!(ops[1].new_subtitle_name, "movie.2.srt");
940    }
941
942    fn collected_with_origin(temp_root: &str, archive: &str) -> crate::core::input::CollectedFiles {
943        let mut origins = std::collections::HashMap::new();
944        origins.insert(PathBuf::from(temp_root), PathBuf::from(archive));
945        crate::core::input::CollectedFiles::with_archives(Vec::new(), Vec::new(), origins)
946    }
947
948    #[test]
949    fn test_archive_origin_relocation_forces_copy_into_video_dir() {
950        // Archive-extracted subtitle with no caller-chosen relocation: the
951        // rewrite targets the video's parent dir, flips requires_relocation,
952        // and pins Copy (the source would vanish with the TempDir).
953        let mut ops = vec![op(
954            "/videos",
955            "movie.srt",
956            "/tmp/subx-XXXX/movie.srt",
957            false,
958        )];
959        let collected = collected_with_origin("/tmp/subx-XXXX", "/data/subs.zip");
960        super::apply_archive_origin_relocation(&mut ops, &collected);
961        assert!(ops[0].requires_relocation);
962        assert_eq!(ops[0].relocation_mode, FileRelocationMode::Copy);
963        assert_eq!(
964            ops[0].relocation_target_path.as_ref().unwrap(),
965            &PathBuf::from("/videos/movie.srt")
966        );
967    }
968
969    #[test]
970    fn test_archive_origin_relocation_leaves_direct_subtitle_untouched() {
971        let mut ops = vec![op("/videos", "movie.srt", "/videos/movie.srt", false)];
972        let before = (
973            ops[0].requires_relocation,
974            ops[0].relocation_mode.clone(),
975            ops[0].relocation_target_path.clone(),
976        );
977        let collected = collected_with_origin("/tmp/subx-XXXX", "/data/subs.zip");
978        super::apply_archive_origin_relocation(&mut ops, &collected);
979        assert_eq!(
980            (
981                ops[0].requires_relocation,
982                ops[0].relocation_mode.clone(),
983                ops[0].relocation_target_path.clone()
984            ),
985            before
986        );
987    }
988
989    #[test]
990    fn test_archive_origin_relocation_respects_existing_relocation() {
991        // A caller-chosen --move already owns the relocation fields; the
992        // guard must not overwrite them with a Copy into the video dir.
993        let mut ops = vec![op("/videos", "movie.srt", "/tmp/subx-XXXX/movie.srt", true)];
994        ops[0].relocation_mode = FileRelocationMode::Move;
995        ops[0].relocation_target_path = Some(PathBuf::from("/elsewhere/movie.srt"));
996        let collected = collected_with_origin("/tmp/subx-XXXX", "/data/subs.zip");
997        super::apply_archive_origin_relocation(&mut ops, &collected);
998        assert_eq!(ops[0].relocation_mode, FileRelocationMode::Move);
999        assert_eq!(
1000            ops[0].relocation_target_path.as_ref().unwrap(),
1001            &PathBuf::from("/elsewhere/movie.srt")
1002        );
1003    }
1004
1005    #[test]
1006    fn test_archive_origin_relocation_leaves_parentless_video_untouched() {
1007        // A video path with no parent component cannot receive a
1008        // "<video dir>/<name>" target; the if let leaves all three fields
1009        // as they were.
1010        let mut archive_subtitle = op("/videos", "movie.srt", "/tmp/subx-XXXX/movie.srt", false);
1011        archive_subtitle.video_file.path = PathBuf::new();
1012        let mut ops = vec![archive_subtitle];
1013        let collected = collected_with_origin("/tmp/subx-XXXX", "/data/subs.zip");
1014        super::apply_archive_origin_relocation(&mut ops, &collected);
1015        assert!(!ops[0].requires_relocation);
1016        assert!(ops[0].relocation_target_path.is_none());
1017        assert_eq!(ops[0].relocation_mode, FileRelocationMode::None);
1018    }
1019
1020    #[test]
1021    fn test_archive_origin_relocation_before_allocator_yields_unique_targets() {
1022        // The documented pairing: two archive-extracted subtitles from the
1023        // SAME extraction dir matching videos in the same dir want the same
1024        // rewritten destination. Rewriting first, then allocating, is what
1025        // keeps the batch collision-free at final paths.
1026        let mut ops = vec![
1027            op("/videos", "movie.srt", "/tmp/subx-XXXX/one.srt", false),
1028            op("/videos", "movie.srt", "/tmp/subx-XXXX/two.srt", false),
1029        ];
1030        let collected = collected_with_origin("/tmp/subx-XXXX", "/data/subs.zip");
1031        super::apply_archive_origin_relocation(&mut ops, &collected);
1032        super::apply_unique_target_paths(&mut ops);
1033        assert_eq!(ops[0].new_subtitle_name, "movie.srt");
1034        assert_eq!(ops[1].new_subtitle_name, "movie.2.srt");
1035        assert_eq!(
1036            ops[1].relocation_target_path.as_ref().unwrap(),
1037            &PathBuf::from("/videos/movie.2.srt")
1038        );
1039    }
1040
1041    #[test]
1042    fn test_unique_target_paths_skip_existing_on_disk() {
1043        // Two ops want the same target. The output directory already
1044        // contains `movie.srt` and `movie.1.srt`. The allocator must
1045        // skip both pre-existing slots, otherwise downstream
1046        // `resolve_filename_conflict` re-probing can race and steal
1047        // a numerical slot that was already allocated to a peer op
1048        // (depending on the operations[] iteration order).
1049        let dir = tempfile::tempdir().unwrap();
1050        let dir_path = dir.path().to_path_buf();
1051        std::fs::write(dir_path.join("movie.srt"), b"existing").unwrap();
1052        std::fs::write(dir_path.join("movie.1.srt"), b"existing").unwrap();
1053        let dir_str = dir_path.to_string_lossy().to_string();
1054        let src1 = dir_path.parent().unwrap().join("sub1.srt");
1055        let src2 = dir_path.parent().unwrap().join("sub2.srt");
1056        let mut ops = vec![
1057            op(&dir_str, "movie.srt", src1.to_str().unwrap(), true),
1058            op(&dir_str, "movie.srt", src2.to_str().unwrap(), true),
1059        ];
1060        super::apply_unique_target_paths(&mut ops);
1061        let mut got: Vec<String> = ops.iter().map(|o| o.new_subtitle_name.clone()).collect();
1062        got.sort();
1063        assert_eq!(
1064            got,
1065            vec!["movie.2.srt".to_string(), "movie.3.srt".to_string()]
1066        );
1067    }
1068
1069    #[test]
1070    fn test_legacy_v1_cache_rejected() {
1071        // After the v1.0 → v2.0 bump, `check_file_list_cache` calls
1072        // `is_cache_version_current` on the loaded payload and bails out
1073        // with `Ok(None)` before considering operations, so stale
1074        // duplicate-name results from the legacy prompt cannot leak
1075        // through. Round-trip a real on-disk v1 cache through
1076        // `CacheData::load` (the same loader `check_file_list_cache`
1077        // uses) to prove the rejection path actually fires — going
1078        // through the engine method itself would require manipulating
1079        // `XDG_CONFIG_HOME`, which AGENTS.md forbids.
1080        use crate::core::matcher::cache::CacheData;
1081        use std::time::{SystemTime, UNIX_EPOCH};
1082        use tempfile::TempDir;
1083
1084        assert_eq!(CURRENT_CACHE_VERSION, "2.0");
1085        assert!(super::is_cache_version_current("2.0"));
1086        assert!(!super::is_cache_version_current("1.0"));
1087        assert!(!super::is_cache_version_current(""));
1088
1089        let temp = TempDir::new().unwrap();
1090        let cache_path = temp.path().join("legacy_v1_cache.json");
1091        let now = SystemTime::now()
1092            .duration_since(UNIX_EPOCH)
1093            .unwrap()
1094            .as_secs();
1095        let legacy = serde_json::json!({
1096            "cache_version": "1.0",
1097            "directory": "filelist_deadbeef",
1098            "file_snapshot": [],
1099            "match_operations": [],
1100            "created_at": now,
1101            "ai_model_used": "test-model",
1102            "config_hash": "0000000000000000",
1103            "original_relocation_mode": "None",
1104            "original_backup_enabled": false,
1105        });
1106        std::fs::write(&cache_path, serde_json::to_string(&legacy).unwrap()).unwrap();
1107
1108        let loaded = CacheData::load(&cache_path).expect("legacy cache should parse");
1109        assert_eq!(loaded.cache_version, "1.0");
1110        assert!(
1111            !super::is_cache_version_current(&loaded.cache_version),
1112            "loaded v1 cache must be rejected by the version gate"
1113        );
1114    }
1115
1116    #[tokio::test]
1117    async fn test_rename_file_displays_success_check_mark() {
1118        use std::fs;
1119        use tempfile::TempDir;
1120
1121        let temp_dir = TempDir::new().unwrap();
1122        let temp_path = temp_dir.path();
1123
1124        // Create a test file
1125        let original_file = temp_path.join("original.srt");
1126        fs::write(
1127            &original_file,
1128            "1\n00:00:01,000 --> 00:00:02,000\nTest subtitle",
1129        )
1130        .unwrap();
1131
1132        // Create a test MatchEngine
1133        let engine = MatchEngine::new(
1134            Box::new(DummyAI),
1135            MatchConfig {
1136                confidence_threshold: 0.0,
1137                max_sample_length: 0,
1138                enable_content_analysis: false,
1139                backup_enabled: false,
1140                relocation_mode: FileRelocationMode::None,
1141                conflict_resolution: ConflictResolution::Skip,
1142                ai_model: "test-model".to_string(),
1143                max_subtitle_bytes: 52_428_800,
1144            },
1145        );
1146
1147        // Create a MatchOperation
1148        let subtitle_file = MediaFile {
1149            id: "test_id".to_string(),
1150            relative_path: "original.srt".to_string(),
1151            path: original_file.clone(),
1152            file_type: MediaFileType::Subtitle,
1153            size: 40,
1154            name: "original".to_string(),
1155            extension: "srt".to_string(),
1156        };
1157
1158        let match_op = MatchOperation {
1159            video_file: MediaFile {
1160                id: "video_id".to_string(),
1161                relative_path: "test.mp4".to_string(),
1162                path: temp_path.join("test.mp4"),
1163                file_type: MediaFileType::Video,
1164                size: 1000,
1165                name: "test".to_string(),
1166                extension: "mp4".to_string(),
1167            },
1168            subtitle_file,
1169            new_subtitle_name: "renamed.srt".to_string(),
1170            confidence: 95.0,
1171            reasoning: vec!["Test match".to_string()],
1172            requires_relocation: false,
1173            relocation_target_path: None,
1174            relocation_mode: FileRelocationMode::None,
1175        };
1176
1177        // Execute the rename operation
1178        let result = engine.rename_file(&match_op).await;
1179
1180        // Verify the operation was successful
1181        assert!(result.is_ok());
1182
1183        // Verify the file has been renamed
1184        let renamed_file = temp_path.join("renamed.srt");
1185        assert!(renamed_file.exists(), "The renamed file should exist");
1186        assert!(
1187            !original_file.exists(),
1188            "The original file should have been renamed"
1189        );
1190
1191        // Verify the file content is correct
1192        let content = fs::read_to_string(&renamed_file).unwrap();
1193        assert!(content.contains("Test subtitle"));
1194    }
1195
1196    #[tokio::test]
1197    async fn test_rename_file_displays_error_cross_mark_when_file_not_exists() {
1198        use std::fs;
1199        use tempfile::TempDir;
1200
1201        let temp_dir = TempDir::new().unwrap();
1202        let temp_path = temp_dir.path();
1203
1204        // Create test file
1205        let original_file = temp_path.join("original.srt");
1206        fs::write(
1207            &original_file,
1208            "1\n00:00:01,000 --> 00:00:02,000\nTest subtitle",
1209        )
1210        .unwrap();
1211
1212        // Create a test MatchEngine
1213        let engine = MatchEngine::new(
1214            Box::new(DummyAI),
1215            MatchConfig {
1216                confidence_threshold: 0.0,
1217                max_sample_length: 0,
1218                enable_content_analysis: false,
1219                backup_enabled: false,
1220                relocation_mode: FileRelocationMode::None,
1221                conflict_resolution: ConflictResolution::Skip,
1222                ai_model: "test-model".to_string(),
1223                max_subtitle_bytes: 52_428_800,
1224            },
1225        );
1226
1227        // Create a MatchOperation
1228        let subtitle_file = MediaFile {
1229            id: "test_id".to_string(),
1230            relative_path: "original.srt".to_string(),
1231            path: original_file.clone(),
1232            file_type: MediaFileType::Subtitle,
1233            size: 40,
1234            name: "original".to_string(),
1235            extension: "srt".to_string(),
1236        };
1237
1238        let match_op = MatchOperation {
1239            video_file: MediaFile {
1240                id: "video_id".to_string(),
1241                relative_path: "test.mp4".to_string(),
1242                path: temp_path.join("test.mp4"),
1243                file_type: MediaFileType::Video,
1244                size: 1000,
1245                name: "test".to_string(),
1246                extension: "mp4".to_string(),
1247            },
1248            subtitle_file,
1249            new_subtitle_name: "renamed.srt".to_string(),
1250            confidence: 95.0,
1251            reasoning: vec!["Test match".to_string()],
1252            requires_relocation: false,
1253            relocation_target_path: None,
1254            relocation_mode: FileRelocationMode::None,
1255        };
1256
1257        // Simulate file not existing after operation
1258        // First, execute the rename operation normally
1259        let result = engine.rename_file(&match_op).await;
1260        assert!(result.is_ok());
1261
1262        // Manually delete the renamed file to simulate failure
1263        let renamed_file = temp_path.join("renamed.srt");
1264        if renamed_file.exists() {
1265            fs::remove_file(&renamed_file).unwrap();
1266        }
1267
1268        // Recreate the original file for the second test
1269        fs::write(
1270            &original_file,
1271            "1\n00:00:01,000 --> 00:00:02,000\nTest subtitle",
1272        )
1273        .unwrap();
1274
1275        // Create a rename operation that will fail, by overwriting the rename implementation
1276        // Since we cannot directly simulate std::fs::rename failure with file not existing,
1277        // we test the scenario where the file is manually removed after the operation completes
1278        let result = engine.rename_file(&match_op).await;
1279        assert!(result.is_ok());
1280
1281        // Manually delete the file again
1282        let renamed_file = temp_path.join("renamed.srt");
1283        if renamed_file.exists() {
1284            fs::remove_file(&renamed_file).unwrap();
1285        }
1286
1287        // This test mainly verifies the code structure is correct, the actual error message display needs to be validated through integration tests
1288        // Because we cannot easily simulate the scenario where the file system operation succeeds but the file does not exist
1289    }
1290
1291    #[test]
1292    fn test_file_operation_message_format() {
1293        // Test error message format
1294        let source_name = "test.srt";
1295        let target_name = "renamed.srt";
1296
1297        // Simulate success message format
1298        let success_msg = format!("  ✓ Renamed: {} -> {}", source_name, target_name);
1299        assert!(success_msg.contains("✓"));
1300        assert!(success_msg.contains("Renamed:"));
1301        assert!(success_msg.contains(source_name));
1302        assert!(success_msg.contains(target_name));
1303
1304        // Simulate failure message format
1305        let error_msg = format!(
1306            "  ✗ Rename failed: {} -> {} (target file does not exist after operation)",
1307            source_name, target_name
1308        );
1309        assert!(error_msg.contains("✗"));
1310        assert!(error_msg.contains("Rename failed:"));
1311        assert!(error_msg.contains("target file does not exist"));
1312        assert!(error_msg.contains(source_name));
1313        assert!(error_msg.contains(target_name));
1314    }
1315
1316    #[test]
1317    fn test_copy_operation_message_format() {
1318        // Test copy operation message format
1319        let source_name = "subtitle.srt";
1320        let target_name = "video.srt";
1321
1322        // Simulate success message format
1323        let success_msg = format!("  ✓ Copied: {} -> {}", source_name, target_name);
1324        assert!(success_msg.contains("✓"));
1325        assert!(success_msg.contains("Copied:"));
1326
1327        // Simulate failure message format
1328        let error_msg = format!(
1329            "  ✗ Copy failed: {} -> {} (target file does not exist after operation)",
1330            source_name, target_name
1331        );
1332        assert!(error_msg.contains("✗"));
1333        assert!(error_msg.contains("Copy failed:"));
1334        assert!(error_msg.contains("target file does not exist"));
1335    }
1336
1337    #[test]
1338    fn test_move_operation_message_format() {
1339        // Test move operation message format
1340        let source_name = "subtitle.srt";
1341        let target_name = "video.srt";
1342
1343        // Simulate success message format
1344        let success_msg = format!("  ✓ Moved: {} -> {}", source_name, target_name);
1345        assert!(success_msg.contains("✓"));
1346        assert!(success_msg.contains("Moved:"));
1347
1348        // Simulate failure message format
1349        let error_msg = format!(
1350            "  ✗ Move failed: {} -> {} (target file does not exist after operation)",
1351            source_name, target_name
1352        );
1353        assert!(error_msg.contains("✗"));
1354        assert!(error_msg.contains("Move failed:"));
1355        assert!(error_msg.contains("target file does not exist"));
1356    }
1357}
1358
1359/// Match operation result representing a single video-subtitle match.
1360///
1361/// Contains all information about a successful match between a video file
1362/// and a subtitle file, including confidence metrics and reasoning.
1363#[derive(Debug)]
1364pub struct MatchOperation {
1365    /// The matched video file
1366    pub video_file: MediaFile,
1367    /// The matched subtitle file
1368    pub subtitle_file: MediaFile,
1369    /// The new filename for the subtitle file
1370    pub new_subtitle_name: String,
1371    /// Confidence score of the match (0.0 to 1.0)
1372    pub confidence: f32,
1373    /// List of reasons supporting this match
1374    pub reasoning: Vec<String>,
1375    /// File relocation mode for this operation
1376    pub relocation_mode: FileRelocationMode,
1377    /// Target relocation path if operation is needed
1378    pub relocation_target_path: Option<std::path::PathBuf>,
1379    /// Whether relocation operation is needed (different folders)
1380    pub requires_relocation: bool,
1381}
1382
1383/// AI-suggested candidate that did not become a match operation.
1384///
1385/// Emitted by [`MatchEngine::match_file_list_with_audit`] so machine-readable
1386/// callers can surface AI suggestions that were rejected (sub-threshold or
1387/// referencing unknown file IDs).
1388#[derive(Debug, Clone)]
1389pub struct RejectedCandidate {
1390    /// Path of the candidate video file (empty string if unresolved).
1391    pub video_path: String,
1392    /// Path of the candidate subtitle file (empty string if unresolved).
1393    pub subtitle_path: String,
1394    /// AI-reported confidence score (0.0 to 1.0).
1395    pub confidence: f32,
1396    /// Stable reason code (`"below_threshold"` or `"id_not_found"`).
1397    pub reason: &'static str,
1398}
1399
1400/// Result of the auditable match planning pass: accepted operations plus
1401/// rejected candidates.
1402#[derive(Debug)]
1403pub struct MatchAudit {
1404    /// Operations that satisfied the confidence threshold and resolved to real files.
1405    pub operations: Vec<MatchOperation>,
1406    /// Candidates rejected by the planner.
1407    pub rejected: Vec<RejectedCandidate>,
1408}
1409
1410/// Per-operation outcome captured by [`MatchEngine::execute_operations_audit`].
1411///
1412/// Unlike [`MatchEngine::execute_operations`] which aborts on first failure,
1413/// the audit variant records each operation's outcome so callers can report
1414/// partial success/failure (used by JSON output mode).
1415#[derive(Debug)]
1416pub struct OperationOutcome {
1417    /// Whether the operation was applied to the filesystem.
1418    pub applied: bool,
1419    /// Set when the operation failed (mutually exclusive with `applied == true`).
1420    pub error: Option<OperationError>,
1421}
1422
1423/// Self-contained per-operation error metadata for machine-readable output.
1424#[derive(Debug, Clone)]
1425pub struct OperationError {
1426    /// Error category from [`SubXError::category`].
1427    pub category: &'static str,
1428    /// Stable machine code from [`SubXError::machine_code`].
1429    pub code: &'static str,
1430    /// Rendered message from [`SubXError`]'s `Display` impl.
1431    pub message: String,
1432}
1433
1434/// Convert a [`SubXError`] into a self-contained [`OperationError`] for
1435/// audit reporting.
1436///
1437/// The message is rendered through `Display` (`to_string()`), not the
1438/// binary's `SubXErrorExt::user_friendly_message` trait method, because core
1439/// must not depend on the presentation half of the error surface. This is
1440/// provably lossless today: both call sites in this file construct
1441/// `SubXError::FileOperationFailed` immediately before calling here, and for
1442/// that variant `Display` and `user_friendly_message` render identically
1443/// (`File operation failed: {msg}`) while `hint()` is `None` — so the
1444/// per-item `error.message` contract of the `machine-readable-output`
1445/// capability is preserved byte-for-byte. The equality is locked by
1446/// `file_operation_failed_display_equals_user_friendly_message` in the
1447/// `subx-cli` repository's `src/cli/error_ext.rs`. If this function ever
1448/// receives another variant, re-check that invariant before widening its
1449/// input set.
1450fn operation_error_from(err: &SubXError) -> OperationError {
1451    OperationError {
1452        category: err.category(),
1453        code: err.machine_code(),
1454        message: err.to_string(),
1455    }
1456}
1457
1458/// Engine for matching video and subtitle files using AI analysis.
1459pub struct MatchEngine {
1460    ai_client: Box<dyn AIProvider>,
1461    discovery: FileDiscovery,
1462    config: MatchConfig,
1463    reporter: std::sync::Arc<dyn crate::core::report::Reporter>,
1464}
1465
1466impl MatchEngine {
1467    /// Creates a new `MatchEngine` with the given AI provider and configuration.
1468    ///
1469    /// The engine reports through a [`crate::core::report::NoopReporter`]
1470    /// unless a reporter is attached with [`MatchEngine::with_reporter`].
1471    pub fn new(ai_client: Box<dyn AIProvider>, config: MatchConfig) -> Self {
1472        Self {
1473            ai_client,
1474            discovery: FileDiscovery::new(),
1475            config,
1476            reporter: crate::core::report::noop(),
1477        }
1478    }
1479
1480    /// Attach a reporting sink, consuming and returning the engine.
1481    ///
1482    /// # Arguments
1483    ///
1484    /// * `reporter` - Sink for diagnostics, warnings and progress; the CLI
1485    ///   attaches its `TerminalReporter` at command boundaries, library
1486    ///   consumers may pass any implementation.
1487    pub fn with_reporter(
1488        mut self,
1489        reporter: std::sync::Arc<dyn crate::core::report::Reporter>,
1490    ) -> Self {
1491        self.reporter = reporter;
1492        self
1493    }
1494
1495    /// Matches video and subtitle files from a specified list of files.
1496    ///
1497    /// This method processes a user-provided list of files, filtering them into
1498    /// video and subtitle files, then performing AI-powered matching analysis.
1499    /// This is useful when users specify exact files via -i parameters.
1500    ///
1501    /// # Arguments
1502    ///
1503    /// * `file_paths` - A slice of file paths to process for matching
1504    ///
1505    /// # Returns
1506    ///
1507    /// A list of `MatchOperation` entries that meet the confidence threshold.
1508    pub async fn match_file_list(&self, file_paths: &[PathBuf]) -> Result<Vec<MatchOperation>> {
1509        Ok(self
1510            .match_file_list_with_audit(file_paths)
1511            .await?
1512            .operations)
1513    }
1514
1515    /// Auditable variant of [`MatchEngine::match_file_list`] that also returns
1516    /// rejected candidates (sub-threshold or unresolvable AI suggestions).
1517    ///
1518    /// When operations are served from cache, `rejected` is returned empty
1519    /// because cache entries do not preserve rejection metadata.
1520    pub async fn match_file_list_with_audit(&self, file_paths: &[PathBuf]) -> Result<MatchAudit> {
1521        // 1. Process the file list to create MediaFile objects
1522        let files = self.discovery.scan_file_list(file_paths)?;
1523
1524        let videos: Vec<_> = files
1525            .iter()
1526            .filter(|f| matches!(f.file_type, MediaFileType::Video))
1527            .collect();
1528        let subtitles: Vec<_> = files
1529            .iter()
1530            .filter(|f| matches!(f.file_type, MediaFileType::Subtitle))
1531            .collect();
1532
1533        if videos.is_empty() || subtitles.is_empty() {
1534            return Ok(MatchAudit {
1535                operations: Vec::new(),
1536                rejected: Vec::new(),
1537            });
1538        }
1539
1540        // 2. Check if we can use cache for file list operations
1541        let cache_key = self.calculate_file_list_cache_key(file_paths)?;
1542        if let Some(ops) = self.check_file_list_cache(&cache_key).await? {
1543            return Ok(MatchAudit {
1544                operations: ops,
1545                rejected: Vec::new(),
1546            });
1547        }
1548
1549        // 3. Content sampling
1550        let content_samples = if self.config.enable_content_analysis {
1551            self.extract_content_samples(&subtitles).await?
1552        } else {
1553            Vec::new()
1554        };
1555
1556        // 4. AI analysis request
1557        let video_files: Vec<String> = videos
1558            .iter()
1559            .map(|v| format!("ID:{} | Name:{} | Path:{}", v.id, v.name, v.relative_path))
1560            .collect();
1561        let subtitle_files: Vec<String> = subtitles
1562            .iter()
1563            .map(|s| format!("ID:{} | Name:{} | Path:{}", s.id, s.name, s.relative_path))
1564            .collect();
1565
1566        let analysis_request = AnalysisRequest {
1567            video_files,
1568            subtitle_files,
1569            content_samples,
1570        };
1571
1572        // 5. Query AI service
1573        let match_result = self.ai_client.analyze_content(analysis_request).await?;
1574
1575        // Debug: Log AI analysis results as one atomic diagnostic block —
1576        // whether it reaches the terminal (and on which stream) is the
1577        // reporter's decision, not this engine's.
1578        let mut analysis_block = String::from("🔍 AI Analysis Results:");
1579        analysis_block.push_str(&format!(
1580            "\n   - Total matches: {}\n   - Confidence threshold: {:.2}",
1581            match_result.matches.len(),
1582            self.config.confidence_threshold
1583        ));
1584        for ai_match in &match_result.matches {
1585            analysis_block.push_str(&format!(
1586                "\n   - {} -> {} (confidence: {:.2})",
1587                ai_match.video_file_id, ai_match.subtitle_file_id, ai_match.confidence
1588            ));
1589        }
1590        self.reporter.diagnostic(&analysis_block);
1591
1592        // 6. Assemble match operation list
1593        let mut operations = Vec::new();
1594        let mut rejected = Vec::new();
1595
1596        for ai_match in match_result.matches {
1597            let video_match =
1598                Self::find_media_file_by_id_or_path(&videos, &ai_match.video_file_id, None);
1599            let subtitle_match =
1600                Self::find_media_file_by_id_or_path(&subtitles, &ai_match.subtitle_file_id, None);
1601
1602            if ai_match.confidence < self.config.confidence_threshold {
1603                rejected.push(RejectedCandidate {
1604                    video_path: video_match
1605                        .map(|v| v.path.display().to_string())
1606                        .unwrap_or_default(),
1607                    subtitle_path: subtitle_match
1608                        .map(|s| s.path.display().to_string())
1609                        .unwrap_or_default(),
1610                    confidence: ai_match.confidence,
1611                    reason: "below_threshold",
1612                });
1613                continue;
1614            }
1615
1616            match (video_match, subtitle_match) {
1617                (Some(video), Some(subtitle)) => {
1618                    let new_name = self.generate_subtitle_name(video, subtitle, &ai_match);
1619
1620                    let requires_relocation = self.config.relocation_mode
1621                        != FileRelocationMode::None
1622                        && subtitle.path.parent() != video.path.parent();
1623
1624                    let relocation_target_path = if requires_relocation {
1625                        let video_dir = video.path.parent().unwrap();
1626                        Some(video_dir.join(&new_name))
1627                    } else {
1628                        None
1629                    };
1630
1631                    operations.push(MatchOperation {
1632                        video_file: (*video).clone(),
1633                        subtitle_file: (*subtitle).clone(),
1634                        new_subtitle_name: new_name,
1635                        confidence: ai_match.confidence,
1636                        reasoning: ai_match.match_factors,
1637                        relocation_mode: self.config.relocation_mode.clone(),
1638                        relocation_target_path,
1639                        requires_relocation,
1640                    });
1641                }
1642                _ => {
1643                    self.reporter.warn(&format!(
1644                        "⚠️  Cannot find AI-suggested file pair:\n     Video ID: '{}'\n     Subtitle ID: '{}'",
1645                        ai_match.video_file_id, ai_match.subtitle_file_id
1646                    ));
1647                    rejected.push(RejectedCandidate {
1648                        video_path: video_match
1649                            .map(|v| v.path.display().to_string())
1650                            .unwrap_or_default(),
1651                        subtitle_path: subtitle_match
1652                            .map(|s| s.path.display().to_string())
1653                            .unwrap_or_default(),
1654                        confidence: ai_match.confidence,
1655                        reason: "id_not_found",
1656                    });
1657                }
1658            }
1659        }
1660
1661        // 7. Globally enforce unique target paths before caching so the
1662        //    cached operations are already conflict-free. The CLI-level
1663        //    `match_command.rs` runs a second pass after archive-origin
1664        //    relocation rewrites — `apply_unique_target_paths` is
1665        //    idempotent, so the two passes compose cleanly.
1666        apply_unique_target_paths(&mut operations);
1667
1668        // 8. Save to cache for future use
1669        self.save_file_list_cache(&cache_key, &operations).await?;
1670
1671        Ok(MatchAudit {
1672            operations,
1673            rejected,
1674        })
1675    }
1676
1677    async fn extract_content_samples(
1678        &self,
1679        subtitles: &[&MediaFile],
1680    ) -> Result<Vec<ContentSample>> {
1681        let mut samples = Vec::new();
1682
1683        for subtitle in subtitles {
1684            let path = subtitle.path.clone();
1685            crate::core::fs_util::check_file_size(
1686                &path,
1687                self.config.max_subtitle_bytes,
1688                "Subtitle",
1689            )
1690            .map_err(SubXError::Io)?;
1691            let content = tokio::task::spawn_blocking(move || std::fs::read_to_string(&path))
1692                .await
1693                .map_err(|e| SubXError::Io(std::io::Error::other(e.to_string())))??;
1694            let preview = self.create_content_preview(&content);
1695
1696            samples.push(ContentSample {
1697                filename: subtitle.name.clone(),
1698                subtitle_file_id: subtitle.id.clone(),
1699                content_preview: preview,
1700                file_size: subtitle.size,
1701            });
1702        }
1703
1704        Ok(samples)
1705    }
1706
1707    fn create_content_preview(&self, content: &str) -> String {
1708        let lines: Vec<&str> = content.lines().take(20).collect();
1709        let preview = lines.join("\n");
1710
1711        if preview.len() > self.config.max_sample_length {
1712            format!("{}...", &preview[..self.config.max_sample_length])
1713        } else {
1714            preview
1715        }
1716    }
1717
1718    fn generate_subtitle_name(
1719        &self,
1720        video: &MediaFile,
1721        subtitle: &MediaFile,
1722        ai_match: &crate::services::ai::FileMatch,
1723    ) -> String {
1724        let detector = LanguageDetector::new();
1725
1726        // Remove the extension from the video file name (if any)
1727        let video_base_name = if !video.extension.is_empty() {
1728            video
1729                .name
1730                .strip_suffix(&format!(".{}", video.extension))
1731                .unwrap_or(&video.name)
1732        } else {
1733            &video.name
1734        };
1735
1736        // Four-step precedence per the AI-driven naming spec:
1737        // 1. Sanitized + normalized AI `target_filename_suffix` wins.
1738        // 2. Sanitized + normalized AI `language` (with `und` → no tag).
1739        // 3. Filename-derived language detection.
1740        // 4. No language tag.
1741        let ai_tag = ai_match
1742            .target_filename_suffix
1743            .as_deref()
1744            .and_then(sanitize_suffix)
1745            .or_else(|| {
1746                ai_match
1747                    .language
1748                    .as_deref()
1749                    .and_then(|s| normalize_ai_language(&detector, s))
1750            });
1751
1752        let code = ai_tag.or_else(|| detector.get_primary_language(&subtitle.path));
1753
1754        if let Some(code) = code {
1755            format!("{}.{}.{}", video_base_name, code, subtitle.extension)
1756        } else {
1757            format!("{}.{}", video_base_name, subtitle.extension)
1758        }
1759    }
1760
1761    /// Execute match operations with dry-run mode support.
1762    ///
1763    /// When `dry_run` is false, a transactional journal is written to
1764    /// [`journal_path`] recording every successfully completed operation.
1765    /// The journal is saved atomically after each operation so that a
1766    /// crash mid-batch still leaves a consistent, resumable on-disk
1767    /// record. No journal is written in dry-run mode or when the batch
1768    /// performs zero operations.
1769    pub async fn execute_operations(
1770        &self,
1771        operations: &[MatchOperation],
1772        dry_run: bool,
1773    ) -> Result<()> {
1774        if dry_run {
1775            // Text-mode dry-run preview. JSON mode never reaches this path
1776            // (the match command takes the JSON branch via
1777            // `execute_operations_audit` before calling this method), but
1778            // gate defensively so a future caller cannot corrupt the
1779            // single-envelope contract.
1780            //
1781            // Decision 4b: these lines used to go to stdout via `println!`;
1782            // they now go through the reporter's diagnostic channel (stderr
1783            // on the terminal). This branch is unreachable from the CLI —
1784            // `src/commands/match_command.rs` calls `execute_operations`
1785            // only when `!args.dry_run` — so the stdout→stderr move is not
1786            // CLI-observable.
1787            for op in operations {
1788                self.reporter.diagnostic(&format!(
1789                    "Preview: {} -> {}",
1790                    op.subtitle_file.name, op.new_subtitle_name
1791                ));
1792                if op.requires_relocation {
1793                    if let Some(target_path) = &op.relocation_target_path {
1794                        let operation_verb = match op.relocation_mode {
1795                            FileRelocationMode::Copy => "Copy",
1796                            FileRelocationMode::Move => "Move",
1797                            _ => "",
1798                        };
1799                        self.reporter.diagnostic(&format!(
1800                            "Preview: {} {} to {}",
1801                            operation_verb,
1802                            op.subtitle_file.path.display(),
1803                            target_path.display()
1804                        ));
1805                    }
1806                }
1807            }
1808            return Ok(());
1809        }
1810
1811        // Prepare a fresh journal for this batch. The journal is saved
1812        // atomically after each successful operation so that the on-disk
1813        // record never diverges from the in-memory state by more than a
1814        // single completed entry.
1815        let created_at = std::time::SystemTime::now()
1816            .duration_since(std::time::UNIX_EPOCH)
1817            .map(|d| d.as_secs())
1818            .unwrap_or(0);
1819        let batch_id = {
1820            use std::collections::hash_map::DefaultHasher;
1821            use std::hash::{Hash, Hasher};
1822            let mut hasher = DefaultHasher::new();
1823            created_at.hash(&mut hasher);
1824            operations.len().hash(&mut hasher);
1825            for op in operations {
1826                op.subtitle_file.path.hash(&mut hasher);
1827                op.new_subtitle_name.hash(&mut hasher);
1828            }
1829            format!("{:016x}", hasher.finish())
1830        };
1831        let mut journal = JournalData {
1832            batch_id,
1833            created_at,
1834            entries: Vec::new(),
1835        };
1836        let journal_file = journal_path().ok();
1837
1838        // Structured progress stream for the execution loop (dry-run above
1839        // emits none). The non-audit loop aborts on the first failure, so it
1840        // polls no cancellation: `done` counts the operations that actually
1841        // completed, and the final `Finished` carries done < total whenever
1842        // the loop stopped early — the stream contract explicitly blesses a
1843        // `Finished` that reports an early stop. `item` is `None`: this loop
1844        // has no per-unit name worth surfacing to a progress line.
1845        let total = operations.len() as u64;
1846        self.reporter.progress(&ProgressEvent::Started { total });
1847        let mut completed: u64 = 0;
1848
1849        let mut first_error: Option<SubXError> = None;
1850
1851        for op in operations {
1852            // Build the task list the same way the previous implementation
1853            // did so relocation, backup and rename semantics are preserved.
1854            let mut backup_path: Option<PathBuf> = None;
1855
1856            if op.relocation_mode == FileRelocationMode::Move && self.config.backup_enabled {
1857                let backup_task =
1858                    self.create_backup_task(&op.subtitle_file.path, &op.subtitle_file.extension);
1859                if let ProcessingOperation::CreateBackup { backup, .. } = &backup_task.operation {
1860                    backup_path = Some(backup.clone());
1861                }
1862                if let TaskResult::Failed(err) = backup_task.execute().await {
1863                    first_error = Some(SubXError::FileOperationFailed(err));
1864                    break;
1865                }
1866            }
1867
1868            // Either a copy-with-rename task (Copy mode) or a rename task
1869            // (Move / None modes) produces the primary journal entry for
1870            // this operation.
1871            let primary_task = if op.relocation_mode == FileRelocationMode::Copy {
1872                self.create_copy_task(op)
1873            } else {
1874                self.create_rename_task(op)
1875            };
1876
1877            let (journal_source, journal_destination, journal_kind) = match &primary_task.operation
1878            {
1879                ProcessingOperation::CopyWithRename { source, target }
1880                | ProcessingOperation::CopyToVideoFolder { source, target } => {
1881                    (source.clone(), target.clone(), JournalOperationType::Copied)
1882                }
1883                ProcessingOperation::MoveToVideoFolder { source, target } => {
1884                    (source.clone(), target.clone(), JournalOperationType::Moved)
1885                }
1886                ProcessingOperation::RenameFile { source, target } => {
1887                    let kind = match op.relocation_mode {
1888                        FileRelocationMode::Move => JournalOperationType::Moved,
1889                        _ => JournalOperationType::Renamed,
1890                    };
1891                    (source.clone(), target.clone(), kind)
1892                }
1893                _ => (
1894                    op.subtitle_file.path.clone(),
1895                    op.relocation_target_path.clone().unwrap_or_else(|| {
1896                        op.subtitle_file.path.with_file_name(&op.new_subtitle_name)
1897                    }),
1898                    JournalOperationType::Renamed,
1899                ),
1900            };
1901
1902            // Capture source metadata before execution because move/rename
1903            // will invalidate the source path afterwards.
1904            let (pre_file_size, pre_file_mtime) = journal_source
1905                .metadata()
1906                .ok()
1907                .map(|m| {
1908                    let mtime = m
1909                        .modified()
1910                        .ok()
1911                        .and_then(|t| t.duration_since(std::time::UNIX_EPOCH).ok())
1912                        .map(|d| d.as_secs())
1913                        .unwrap_or(0);
1914                    (m.len(), mtime)
1915                })
1916                .unwrap_or((0, 0));
1917
1918            if let TaskResult::Failed(err) = primary_task.execute().await {
1919                first_error = Some(SubXError::FileOperationFailed(err));
1920                break;
1921            }
1922
1923            // Record destination metadata after execution so that rollback
1924            // integrity checks compare against the actual destination state.
1925            let (file_size, file_mtime) = journal_destination
1926                .metadata()
1927                .ok()
1928                .map(|m| {
1929                    let mtime = m
1930                        .modified()
1931                        .ok()
1932                        .and_then(|t| t.duration_since(std::time::UNIX_EPOCH).ok())
1933                        .map(|d| d.as_secs())
1934                        .unwrap_or(0);
1935                    (m.len(), mtime)
1936                })
1937                .unwrap_or((pre_file_size, pre_file_mtime));
1938
1939            journal.entries.push(JournalEntry {
1940                operation_type: journal_kind,
1941                source: journal_source,
1942                destination: journal_destination,
1943                backup_path: backup_path.clone(),
1944                status: JournalEntryStatus::Completed,
1945                file_size,
1946                file_mtime,
1947            });
1948
1949            if let Some(path) = journal_file.as_ref() {
1950                // Persist after every successful operation so interruption
1951                // leaves the on-disk journal consistent with the file system.
1952                if let Err(e) = journal.save(path).await {
1953                    // A persistence failure aborts the loop, but the stream
1954                    // contract is unconditional: once `Started` was emitted,
1955                    // a reporter must see exactly one `Finished`. Close the
1956                    // stream with the completed count before propagating.
1957                    self.reporter.progress(&ProgressEvent::Finished {
1958                        done: completed,
1959                        total,
1960                    });
1961                    return Err(e);
1962                }
1963            }
1964
1965            completed += 1;
1966            self.reporter.progress(&ProgressEvent::Advanced {
1967                done: completed,
1968                total,
1969                item: None,
1970            });
1971        }
1972
1973        self.reporter.progress(&ProgressEvent::Finished {
1974            done: completed,
1975            total,
1976        });
1977
1978        if let Some(err) = first_error {
1979            return Err(err);
1980        }
1981        Ok(())
1982    }
1983
1984    /// Auditable variant of [`MatchEngine::execute_operations`] that does NOT
1985    /// abort on first failure. Each operation produces an [`OperationOutcome`]
1986    /// describing whether it was applied or which error blocked it.
1987    ///
1988    /// This is the engine entry point used by JSON output mode to surface
1989    /// per-item statuses in the success envelope.
1990    pub async fn execute_operations_audit(
1991        &self,
1992        operations: &[MatchOperation],
1993        dry_run: bool,
1994    ) -> Result<Vec<OperationOutcome>> {
1995        if dry_run {
1996            return Ok(operations
1997                .iter()
1998                .map(|_| OperationOutcome {
1999                    applied: false,
2000                    error: None,
2001                })
2002                .collect());
2003        }
2004
2005        let created_at = std::time::SystemTime::now()
2006            .duration_since(std::time::UNIX_EPOCH)
2007            .map(|d| d.as_secs())
2008            .unwrap_or(0);
2009        let batch_id = {
2010            use std::collections::hash_map::DefaultHasher;
2011            use std::hash::{Hash, Hasher};
2012            let mut hasher = DefaultHasher::new();
2013            created_at.hash(&mut hasher);
2014            operations.len().hash(&mut hasher);
2015            for op in operations {
2016                op.subtitle_file.path.hash(&mut hasher);
2017                op.new_subtitle_name.hash(&mut hasher);
2018            }
2019            format!("{:016x}", hasher.finish())
2020        };
2021        let mut journal = JournalData {
2022            batch_id,
2023            created_at,
2024            entries: Vec::new(),
2025        };
2026        let journal_file = journal_path().ok();
2027
2028        let mut outcomes = Vec::with_capacity(operations.len());
2029
2030        // Structured progress stream for the audited execution loop (the
2031        // dry-run branch above emits none). One `Started`, then one
2032        // `Advanced` per produced outcome — applied or error-skipped, the
2033        // loop never silently drops work — then exactly one `Finished`.
2034        // `item` names the subtitle file whose unit just completed, the
2035        // name the batch was reported under in the pre-indicatif output.
2036        //
2037        // Cancellation is polled here (the JSON-mode path users actually
2038        // wait on): when `cancelled()` turns true the remaining operations
2039        // are NOT executed; their outcome slots are padded with
2040        // `{applied: false, error: None}` so the returned vector always
2041        // has one entry per operation, and `Finished` carries the `done`
2042        // count captured **before** padding (never done == total on a
2043        // cancel) with no `Advanced` for the padded slots. Cancellation
2044        // never surfaces as `Err` — the Ok return and the `applied: false`
2045        // entries are how it manifests.
2046        let total = operations.len() as u64;
2047        self.reporter.progress(&ProgressEvent::Started { total });
2048
2049        for op in operations {
2050            if self.reporter.cancelled() {
2051                let done = outcomes.len() as u64;
2052                outcomes.resize_with(operations.len(), || OperationOutcome {
2053                    applied: false,
2054                    error: None,
2055                });
2056                self.reporter
2057                    .progress(&ProgressEvent::Finished { done, total });
2058                return Ok(outcomes);
2059            }
2060
2061            let mut backup_path: Option<PathBuf> = None;
2062
2063            if op.relocation_mode == FileRelocationMode::Move && self.config.backup_enabled {
2064                let backup_task =
2065                    self.create_backup_task(&op.subtitle_file.path, &op.subtitle_file.extension);
2066                if let ProcessingOperation::CreateBackup { backup, .. } = &backup_task.operation {
2067                    backup_path = Some(backup.clone());
2068                }
2069                if let TaskResult::Failed(err) = backup_task.execute().await {
2070                    let err = SubXError::FileOperationFailed(err);
2071                    outcomes.push(OperationOutcome {
2072                        applied: false,
2073                        error: Some(operation_error_from(&err)),
2074                    });
2075                    self.reporter.progress(&ProgressEvent::Advanced {
2076                        done: outcomes.len() as u64,
2077                        total,
2078                        item: Some(&op.subtitle_file.name),
2079                    });
2080                    continue;
2081                }
2082            }
2083
2084            let primary_task = if op.relocation_mode == FileRelocationMode::Copy {
2085                self.create_copy_task(op)
2086            } else {
2087                self.create_rename_task(op)
2088            };
2089
2090            let (journal_source, journal_destination, journal_kind) = match &primary_task.operation
2091            {
2092                ProcessingOperation::CopyWithRename { source, target }
2093                | ProcessingOperation::CopyToVideoFolder { source, target } => {
2094                    (source.clone(), target.clone(), JournalOperationType::Copied)
2095                }
2096                ProcessingOperation::MoveToVideoFolder { source, target } => {
2097                    (source.clone(), target.clone(), JournalOperationType::Moved)
2098                }
2099                ProcessingOperation::RenameFile { source, target } => {
2100                    let kind = match op.relocation_mode {
2101                        FileRelocationMode::Move => JournalOperationType::Moved,
2102                        _ => JournalOperationType::Renamed,
2103                    };
2104                    (source.clone(), target.clone(), kind)
2105                }
2106                _ => (
2107                    op.subtitle_file.path.clone(),
2108                    op.relocation_target_path.clone().unwrap_or_else(|| {
2109                        op.subtitle_file.path.with_file_name(&op.new_subtitle_name)
2110                    }),
2111                    JournalOperationType::Renamed,
2112                ),
2113            };
2114
2115            let (pre_file_size, pre_file_mtime) = journal_source
2116                .metadata()
2117                .ok()
2118                .map(|m| {
2119                    let mtime = m
2120                        .modified()
2121                        .ok()
2122                        .and_then(|t| t.duration_since(std::time::UNIX_EPOCH).ok())
2123                        .map(|d| d.as_secs())
2124                        .unwrap_or(0);
2125                    (m.len(), mtime)
2126                })
2127                .unwrap_or((0, 0));
2128
2129            if let TaskResult::Failed(err) = primary_task.execute().await {
2130                let err = SubXError::FileOperationFailed(err);
2131                outcomes.push(OperationOutcome {
2132                    applied: false,
2133                    error: Some(operation_error_from(&err)),
2134                });
2135                self.reporter.progress(&ProgressEvent::Advanced {
2136                    done: outcomes.len() as u64,
2137                    total,
2138                    item: Some(&op.subtitle_file.name),
2139                });
2140                continue;
2141            }
2142
2143            let (file_size, file_mtime) = journal_destination
2144                .metadata()
2145                .ok()
2146                .map(|m| {
2147                    let mtime = m
2148                        .modified()
2149                        .ok()
2150                        .and_then(|t| t.duration_since(std::time::UNIX_EPOCH).ok())
2151                        .map(|d| d.as_secs())
2152                        .unwrap_or(0);
2153                    (m.len(), mtime)
2154                })
2155                .unwrap_or((pre_file_size, pre_file_mtime));
2156
2157            journal.entries.push(JournalEntry {
2158                operation_type: journal_kind,
2159                source: journal_source,
2160                destination: journal_destination,
2161                backup_path: backup_path.clone(),
2162                status: JournalEntryStatus::Completed,
2163                file_size,
2164                file_mtime,
2165            });
2166
2167            if let Some(path) = journal_file.as_ref() {
2168                if let Err(e) = journal.save(path).await {
2169                    // Same stream contract as `execute_operations`: close
2170                    // with `Finished` before propagating a persistence
2171                    // failure, so a reporter never sees a dangling `Started`.
2172                    self.reporter.progress(&ProgressEvent::Finished {
2173                        done: outcomes.len() as u64,
2174                        total,
2175                    });
2176                    return Err(e);
2177                }
2178            }
2179
2180            outcomes.push(OperationOutcome {
2181                applied: true,
2182                error: None,
2183            });
2184            self.reporter.progress(&ProgressEvent::Advanced {
2185                done: outcomes.len() as u64,
2186                total,
2187                item: Some(&op.subtitle_file.name),
2188            });
2189        }
2190
2191        self.reporter.progress(&ProgressEvent::Finished {
2192            done: outcomes.len() as u64,
2193            total,
2194        });
2195
2196        Ok(outcomes)
2197    }
2198
2199    /// Rename subtitle file by delegating to FileProcessingTask
2200    async fn rename_file(&self, op: &MatchOperation) -> Result<()> {
2201        let task = self.create_rename_task(op);
2202        match task.execute().await {
2203            TaskResult::Success(_) => Ok(()),
2204            TaskResult::Failed(err) => Err(SubXError::FileOperationFailed(err)),
2205            other => Err(SubXError::FileOperationFailed(format!(
2206                "Unexpected rename result: {:?}",
2207                other
2208            ))),
2209        }
2210    }
2211
2212    /// Resolve filename conflicts by adding numeric suffix
2213    fn resolve_filename_conflict(&self, target: std::path::PathBuf) -> Result<std::path::PathBuf> {
2214        if !target.exists() {
2215            return Ok(target);
2216        }
2217        match self.config.conflict_resolution {
2218            ConflictResolution::Skip => {
2219                self.reporter.warn(&format!(
2220                    "Warning: Skipping relocation due to existing file: {}",
2221                    target.display()
2222                ));
2223                Ok(target)
2224            }
2225            ConflictResolution::AutoRename => {
2226                let file_stem = target
2227                    .file_stem()
2228                    .and_then(|s| s.to_str())
2229                    .unwrap_or("file");
2230                let extension = target.extension().and_then(|s| s.to_str()).unwrap_or("");
2231                let parent = target.parent().unwrap_or_else(|| std::path::Path::new("."));
2232                // Try the base name first via atomic create; if it succeeds we drop
2233                // the handle immediately since the downstream FileProcessingTask
2234                // performs its own I/O against this path.
2235                match crate::core::fs_util::atomic_create_file(&target) {
2236                    Ok(_f) => {
2237                        // Remove the placeholder so the downstream task can create it.
2238                        let _ = std::fs::remove_file(&target);
2239                        return Ok(target);
2240                    }
2241                    Err(e) if e.kind() == std::io::ErrorKind::AlreadyExists => {}
2242                    Err(e) => return Err(SubXError::from(e)),
2243                }
2244                for i in 1..1000 {
2245                    let new_name = if extension.is_empty() {
2246                        format!("{}.{}", file_stem, i)
2247                    } else {
2248                        format!("{}.{}.{}", file_stem, i, extension)
2249                    };
2250                    let new_path = parent.join(new_name);
2251                    match crate::core::fs_util::atomic_create_file(&new_path) {
2252                        Ok(_f) => {
2253                            let _ = std::fs::remove_file(&new_path);
2254                            return Ok(new_path);
2255                        }
2256                        Err(e) if e.kind() == std::io::ErrorKind::AlreadyExists => continue,
2257                        Err(e) => return Err(SubXError::from(e)),
2258                    }
2259                }
2260                Err(SubXError::FileOperationFailed(
2261                    "Could not resolve filename conflict".to_string(),
2262                ))
2263            }
2264            ConflictResolution::Prompt => {
2265                self.reporter
2266                    .warn("Warning: Conflict resolution prompt not implemented, using auto-rename");
2267                self.resolve_filename_conflict(target)
2268            }
2269        }
2270    }
2271
2272    /// Create a task to copy (or rename) a file with new name
2273    fn create_copy_task(&self, op: &MatchOperation) -> FileProcessingTask {
2274        // In copy mode, always use the original subtitle file as source
2275        let source = op.subtitle_file.path.clone();
2276        let target_base = op.relocation_target_path.clone().unwrap();
2277        let final_target = self.resolve_filename_conflict(target_base).unwrap();
2278        FileProcessingTask::new(
2279            source.clone(),
2280            Some(final_target.clone()),
2281            ProcessingOperation::CopyWithRename {
2282                source,
2283                target: final_target,
2284            },
2285        )
2286    }
2287
2288    /// Create a task to backup a file
2289    fn create_backup_task(&self, source: &std::path::Path, ext: &str) -> FileProcessingTask {
2290        let backup_path = source.with_extension(format!("{}.backup", ext));
2291        FileProcessingTask::new(
2292            source.to_path_buf(),
2293            Some(backup_path.clone()),
2294            ProcessingOperation::CreateBackup {
2295                source: source.to_path_buf(),
2296                backup: backup_path,
2297            },
2298        )
2299    }
2300
2301    /// Create a task to rename (move) a file
2302    fn create_rename_task(&self, op: &MatchOperation) -> FileProcessingTask {
2303        let old = op.subtitle_file.path.clone();
2304        // If relocation is required, use the relocation target path
2305        let new_path = if op.requires_relocation && op.relocation_target_path.is_some() {
2306            let target_base = op.relocation_target_path.clone().unwrap();
2307            self.resolve_filename_conflict(target_base).unwrap()
2308        } else {
2309            old.with_file_name(&op.new_subtitle_name)
2310        };
2311
2312        FileProcessingTask::new(
2313            old.clone(),
2314            Some(new_path.clone()),
2315            ProcessingOperation::RenameFile {
2316                source: old,
2317                target: new_path,
2318            },
2319        )
2320    }
2321
2322    /// Calculate cache key for file list operations
2323    fn calculate_file_list_cache_key(&self, file_paths: &[PathBuf]) -> Result<String> {
2324        use std::collections::BTreeMap;
2325        use std::collections::hash_map::DefaultHasher;
2326        use std::hash::{Hash, Hasher};
2327
2328        // Sort paths to ensure consistent key generation
2329        let mut path_metadata = BTreeMap::new();
2330        for path in file_paths {
2331            if let Ok(metadata) = path.metadata() {
2332                let canonical = path.canonicalize().unwrap_or_else(|_| path.to_path_buf());
2333                path_metadata.insert(
2334                    canonical.to_string_lossy().to_string(),
2335                    (metadata.len(), metadata.modified().ok()),
2336                );
2337            }
2338        }
2339
2340        // Include config hash to invalidate cache when configuration changes
2341        let config_hash = self.calculate_config_hash()?;
2342
2343        let mut hasher = DefaultHasher::new();
2344        path_metadata.hash(&mut hasher);
2345        config_hash.hash(&mut hasher);
2346
2347        Ok(format!("filelist_{:016x}", hasher.finish()))
2348    }
2349
2350    /// Check cache for file list operations
2351    async fn check_file_list_cache(&self, cache_key: &str) -> Result<Option<Vec<MatchOperation>>> {
2352        let cache_file_path = self.get_cache_file_path()?;
2353        let cache_data = CacheData::load(&cache_file_path).ok();
2354
2355        if let Some(cache_data) = cache_data {
2356            if !is_cache_version_current(&cache_data.cache_version) {
2357                return Ok(None);
2358            }
2359            if cache_data.directory == cache_key {
2360                // Rebuild match operation list for file list cache
2361                let mut ops = Vec::new();
2362                let mut id_gen = Uuidv7Generator::new();
2363                for item in cache_data.match_operations {
2364                    // For file list operations, we reconstruct operations from cached data
2365                    let video_path = PathBuf::from(&item.video_file);
2366                    let subtitle_path = PathBuf::from(&item.subtitle_file);
2367
2368                    if video_path.exists() && subtitle_path.exists() {
2369                        // Create minimal MediaFile objects for the operation
2370                        let video_meta = video_path.metadata()?;
2371                        let subtitle_meta = subtitle_path.metadata()?;
2372
2373                        let video_file = MediaFile {
2374                            id: generate_file_id(&mut id_gen),
2375                            path: video_path.clone(),
2376                            file_type: MediaFileType::Video,
2377                            size: video_meta.len(),
2378                            name: video_path
2379                                .file_name()
2380                                .unwrap()
2381                                .to_string_lossy()
2382                                .to_string(),
2383                            extension: video_path
2384                                .extension()
2385                                .unwrap_or_default()
2386                                .to_string_lossy()
2387                                .to_lowercase(),
2388                            relative_path: video_path
2389                                .file_name()
2390                                .unwrap()
2391                                .to_string_lossy()
2392                                .to_string(),
2393                        };
2394
2395                        let subtitle_file = MediaFile {
2396                            id: generate_file_id(&mut id_gen),
2397                            path: subtitle_path.clone(),
2398                            file_type: MediaFileType::Subtitle,
2399                            size: subtitle_meta.len(),
2400                            name: subtitle_path
2401                                .file_name()
2402                                .unwrap()
2403                                .to_string_lossy()
2404                                .to_string(),
2405                            extension: subtitle_path
2406                                .extension()
2407                                .unwrap_or_default()
2408                                .to_string_lossy()
2409                                .to_lowercase(),
2410                            relative_path: subtitle_path
2411                                .file_name()
2412                                .unwrap()
2413                                .to_string_lossy()
2414                                .to_string(),
2415                        };
2416
2417                        // Recalculate relocation information based on current configuration
2418                        let requires_relocation = self.config.relocation_mode
2419                            != FileRelocationMode::None
2420                            && subtitle_file.path.parent() != video_file.path.parent();
2421
2422                        let relocation_target_path = if requires_relocation {
2423                            let video_dir = video_file.path.parent().unwrap();
2424                            Some(video_dir.join(&item.new_subtitle_name))
2425                        } else {
2426                            None
2427                        };
2428
2429                        ops.push(MatchOperation {
2430                            video_file,
2431                            subtitle_file,
2432                            new_subtitle_name: item.new_subtitle_name,
2433                            confidence: item.confidence,
2434                            reasoning: item.reasoning,
2435                            relocation_mode: self.config.relocation_mode.clone(),
2436                            relocation_target_path,
2437                            requires_relocation,
2438                        });
2439                    }
2440                }
2441                return Ok(Some(ops));
2442            }
2443        }
2444        Ok(None)
2445    }
2446
2447    /// Save cache for file list operations
2448    async fn save_file_list_cache(
2449        &self,
2450        cache_key: &str,
2451        operations: &[MatchOperation],
2452    ) -> Result<()> {
2453        let cache_file_path = self.get_cache_file_path()?;
2454        let config_hash = self.calculate_config_hash()?;
2455
2456        let mut cache_items = Vec::new();
2457        for op in operations {
2458            cache_items.push(OpItem {
2459                video_file: op.video_file.path.to_string_lossy().to_string(),
2460                subtitle_file: op.subtitle_file.path.to_string_lossy().to_string(),
2461                new_subtitle_name: op.new_subtitle_name.clone(),
2462                confidence: op.confidence,
2463                reasoning: op.reasoning.clone(),
2464            });
2465        }
2466
2467        // Build file snapshot with canonical paths, sizes, and mtimes
2468        let mut snapshot_items = Vec::new();
2469        let mut seen_paths = std::collections::HashSet::new();
2470        for op in operations {
2471            for path in [&op.video_file.path, &op.subtitle_file.path] {
2472                let canonical = path.canonicalize().unwrap_or_else(|_| path.clone());
2473                let key = canonical.to_string_lossy().to_string();
2474                if seen_paths.insert(key.clone()) {
2475                    if let Ok(meta) = std::fs::metadata(&canonical) {
2476                        let mtime = meta
2477                            .modified()
2478                            .ok()
2479                            .and_then(|t| t.duration_since(std::time::UNIX_EPOCH).ok())
2480                            .map(|d| d.as_secs())
2481                            .unwrap_or(0);
2482                        snapshot_items.push(SnapshotItem {
2483                            path: key,
2484                            name: canonical
2485                                .file_name()
2486                                .unwrap_or_default()
2487                                .to_string_lossy()
2488                                .to_string(),
2489                            size: meta.len(),
2490                            mtime,
2491                            file_type: if canonical.extension().is_some_and(|e| {
2492                                ["srt", "ass", "ssa", "vtt", "sub"]
2493                                    .contains(&e.to_string_lossy().to_lowercase().as_str())
2494                            }) {
2495                                "subtitle".to_string()
2496                            } else {
2497                                "video".to_string()
2498                            },
2499                        });
2500                    }
2501                }
2502            }
2503        }
2504
2505        let cache_data = CacheData {
2506            cache_version: CURRENT_CACHE_VERSION.to_string(),
2507            directory: cache_key.to_string(),
2508            file_snapshot: snapshot_items,
2509            match_operations: cache_items,
2510            created_at: std::time::SystemTime::now()
2511                .duration_since(std::time::UNIX_EPOCH)
2512                .unwrap()
2513                .as_secs(),
2514            ai_model_used: self.config.ai_model.clone(),
2515            config_hash,
2516            original_relocation_mode: format!("{:?}", self.config.relocation_mode),
2517            original_backup_enabled: self.config.backup_enabled,
2518        };
2519
2520        // Save cache data to file
2521        let cache_dir = cache_file_path.parent().unwrap().to_path_buf();
2522        let cache_json = serde_json::to_string_pretty(&cache_data)?;
2523        let cache_file_path_clone = cache_file_path.clone();
2524        tokio::task::spawn_blocking(move || -> std::io::Result<()> {
2525            std::fs::create_dir_all(&cache_dir)?;
2526            std::fs::write(&cache_file_path_clone, cache_json)?;
2527            Ok(())
2528        })
2529        .await
2530        .map_err(|e| SubXError::Io(std::io::Error::other(e.to_string())))??;
2531
2532        Ok(())
2533    }
2534
2535    /// Get cache file path
2536    fn get_cache_file_path(&self) -> Result<std::path::PathBuf> {
2537        // First check XDG_CONFIG_HOME environment variable (used for testing)
2538        let dir = if let Some(xdg_config) = std::env::var_os("XDG_CONFIG_HOME") {
2539            std::path::PathBuf::from(xdg_config)
2540        } else {
2541            dirs::config_dir()
2542                .ok_or_else(|| SubXError::config("Unable to determine cache directory"))?
2543        };
2544        Ok(dir.join("subx").join("match_cache.json"))
2545    }
2546
2547    /// Calculate current configuration hash for cache validation
2548    fn calculate_config_hash(&self) -> Result<String> {
2549        use std::collections::hash_map::DefaultHasher;
2550        use std::hash::{Hash, Hasher};
2551
2552        let mut hasher = DefaultHasher::new();
2553        // Add configuration items that affect cache validity to the hash
2554        format!("{:?}", self.config.relocation_mode).hash(&mut hasher);
2555        self.config.backup_enabled.hash(&mut hasher);
2556        // A short prompt-schema tag so future prompt rewrites invalidate
2557        // the on-disk match cache automatically.
2558        "prompt_v2".hash(&mut hasher);
2559        // Add other relevant configuration items
2560
2561        Ok(format!("{:016x}", hasher.finish()))
2562    }
2563
2564    /// Find a media file by ID, with an optional fallback to relative path or name.
2565    fn find_media_file_by_id_or_path<'a>(
2566        files: &'a [&MediaFile],
2567        file_id: &str,
2568        fallback_path: Option<&str>,
2569    ) -> Option<&'a MediaFile> {
2570        if let Some(file) = files.iter().find(|f| f.id == file_id) {
2571            return Some(*file);
2572        }
2573        if let Some(path) = fallback_path {
2574            if let Some(file) = files.iter().find(|f| f.relative_path == path) {
2575                return Some(*file);
2576            }
2577            files.iter().find(|f| f.name == path).copied()
2578        } else {
2579            None
2580        }
2581    }
2582
2583    /// Log available files to assist debugging when a match is not found.
2584    fn log_available_files(&self, files: &[&MediaFile], file_type: &str) {
2585        let mut block = format!("   Available {file_type} files:");
2586        for f in files {
2587            block.push_str(&format!(
2588                "\n     - ID: {} | Name: {} | Path: {}",
2589                f.id, f.name, f.relative_path
2590            ));
2591        }
2592        self.reporter.diagnostic(&block);
2593    }
2594
2595    /// Provide detailed information when no matches are found.
2596    fn log_no_matches_found(
2597        &self,
2598        match_result: &MatchResult,
2599        videos: &[MediaFile],
2600        subtitles: &[MediaFile],
2601    ) {
2602        // The leading '\n' preserves the blank line the old sequence of
2603        // eprintln! calls produced.
2604        let mut block = String::from(
2605            "\n❌ No matching files found that meet the criteria\n🔍 AI analysis results:",
2606        );
2607        block.push_str(&format!(
2608            "\n   - Total matches: {}\n   - Confidence threshold: {:.2}\n   - Matches meeting threshold: {}",
2609            match_result.matches.len(),
2610            self.config.confidence_threshold,
2611            match_result
2612                .matches
2613                .iter()
2614                .filter(|m| m.confidence >= self.config.confidence_threshold)
2615                .count()
2616        ));
2617        block.push_str(&format!(
2618            "\n\n📂 Scanned files:\n   Video files ({} files):",
2619            videos.len()
2620        ));
2621        for v in videos {
2622            block.push_str(&format!("\n     - ID: {} | {}", v.id, v.relative_path));
2623        }
2624        block.push_str(&format!("\n   Subtitle files ({} files):", subtitles.len()));
2625        for s in subtitles {
2626            block.push_str(&format!("\n     - ID: {} | {}", s.id, s.relative_path));
2627        }
2628        self.reporter.diagnostic(&block);
2629    }
2630}
2631
2632/// Replay a frozen set of cached match operations.
2633///
2634/// This helper powers the `cache apply` command. It reconstructs
2635/// [`MatchOperation`] values from the paths recorded in `cache`, without
2636/// performing any additional AI analysis or validation, and then feeds
2637/// them through the standard [`MatchEngine::execute_operations`] pipeline
2638/// so the same journal and file-system guarantees apply.
2639///
2640/// The provided `config` determines runtime behaviour such as relocation
2641/// mode, backup handling and conflict resolution. Callers are expected to
2642/// synchronise the config with the original cache's recorded values when
2643/// strict replay fidelity is required.
2644///
2645/// # Errors
2646///
2647/// Returns an error if any cached source path cannot be read or if the
2648/// underlying execution pipeline fails.
2649pub async fn apply_cached_operations_with_reporter(
2650    cache: &CacheData,
2651    config: &MatchConfig,
2652    reporter: std::sync::Arc<dyn crate::core::report::Reporter>,
2653) -> Result<()> {
2654    let operations = reconstruct_operations_from_cache(cache, config)?;
2655    let engine = MatchEngine::new(Box::new(NoOpAIProvider), config.clone()).with_reporter(reporter);
2656    engine.execute_operations(&operations, false).await
2657}
2658
2659/// Replay a frozen cache plan reporting through a no-op sink.
2660///
2661/// Convenience wrapper over [`apply_cached_operations_with_reporter`] for
2662/// callers with no reporting sink to attach.
2663pub async fn apply_cached_operations(cache: &CacheData, config: &MatchConfig) -> Result<()> {
2664    apply_cached_operations_with_reporter(cache, config, crate::core::report::noop()).await
2665}
2666
2667/// Rebuild [`MatchOperation`] values from a [`CacheData`] payload.
2668///
2669/// Silently skips entries whose source files no longer exist so the replay
2670/// is resilient to partial state (e.g., an earlier apply completed some
2671/// operations but was interrupted).
2672fn reconstruct_operations_from_cache(
2673    cache: &CacheData,
2674    config: &MatchConfig,
2675) -> Result<Vec<MatchOperation>> {
2676    let mut ops = Vec::new();
2677    let mut id_gen = Uuidv7Generator::new();
2678    for item in &cache.match_operations {
2679        let video_path = PathBuf::from(&item.video_file);
2680        let subtitle_path = PathBuf::from(&item.subtitle_file);
2681
2682        if !video_path.exists() || !subtitle_path.exists() {
2683            continue;
2684        }
2685
2686        let video_meta = video_path.metadata()?;
2687        let subtitle_meta = subtitle_path.metadata()?;
2688
2689        let video_file = MediaFile {
2690            id: generate_file_id(&mut id_gen),
2691            path: video_path.clone(),
2692            file_type: MediaFileType::Video,
2693            size: video_meta.len(),
2694            name: video_path
2695                .file_name()
2696                .unwrap_or_default()
2697                .to_string_lossy()
2698                .to_string(),
2699            extension: video_path
2700                .extension()
2701                .unwrap_or_default()
2702                .to_string_lossy()
2703                .to_lowercase(),
2704            relative_path: video_path
2705                .file_name()
2706                .unwrap_or_default()
2707                .to_string_lossy()
2708                .to_string(),
2709        };
2710
2711        let subtitle_file = MediaFile {
2712            id: generate_file_id(&mut id_gen),
2713            path: subtitle_path.clone(),
2714            file_type: MediaFileType::Subtitle,
2715            size: subtitle_meta.len(),
2716            name: subtitle_path
2717                .file_name()
2718                .unwrap_or_default()
2719                .to_string_lossy()
2720                .to_string(),
2721            extension: subtitle_path
2722                .extension()
2723                .unwrap_or_default()
2724                .to_string_lossy()
2725                .to_lowercase(),
2726            relative_path: subtitle_path
2727                .file_name()
2728                .unwrap_or_default()
2729                .to_string_lossy()
2730                .to_string(),
2731        };
2732
2733        let requires_relocation = config.relocation_mode != FileRelocationMode::None
2734            && subtitle_file.path.parent() != video_file.path.parent();
2735        let relocation_target_path = if requires_relocation {
2736            video_file
2737                .path
2738                .parent()
2739                .map(|p| p.join(&item.new_subtitle_name))
2740        } else {
2741            None
2742        };
2743
2744        ops.push(MatchOperation {
2745            video_file,
2746            subtitle_file,
2747            new_subtitle_name: item.new_subtitle_name.clone(),
2748            confidence: item.confidence,
2749            reasoning: item.reasoning.clone(),
2750            relocation_mode: config.relocation_mode.clone(),
2751            relocation_target_path,
2752            requires_relocation,
2753        });
2754    }
2755    Ok(ops)
2756}
2757
2758/// AI provider stub used by [`apply_cached_operations`].
2759///
2760/// `execute_operations` never calls the AI service, so replaying a cached
2761/// plan does not require a real provider; this stub panics defensively if
2762/// accidentally invoked.
2763struct NoOpAIProvider;
2764
2765#[async_trait::async_trait]
2766impl AIProvider for NoOpAIProvider {
2767    async fn analyze_content(&self, _request: AnalysisRequest) -> crate::Result<MatchResult> {
2768        Err(SubXError::config(
2769            "AI analysis is not available while replaying cached operations",
2770        ))
2771    }
2772
2773    async fn verify_match(
2774        &self,
2775        _verification: crate::services::ai::VerificationRequest,
2776    ) -> crate::Result<crate::services::ai::ConfidenceScore> {
2777        Err(SubXError::config(
2778            "AI verification is not available while replaying cached operations",
2779        ))
2780    }
2781}