1use crate::services::ai::{AIProvider, AnalysisRequest, ContentSample, MatchResult};
16use std::path::PathBuf;
17
18use crate::Result;
19use crate::core::language::LanguageDetector;
20use crate::core::matcher::cache::{CacheData, OpItem, SnapshotItem};
21use crate::core::matcher::discovery::generate_file_id;
22use crate::core::matcher::journal::{
23 JournalData, JournalEntry, JournalEntryStatus, JournalOperationType, journal_path,
24};
25use crate::core::matcher::{FileDiscovery, MediaFile, MediaFileType};
26use crate::core::parallel::{FileProcessingTask, ProcessingOperation, Task, TaskResult};
27use crate::core::uuidv7::Uuidv7Generator;
28use crate::error::SubXError;
29use dirs;
30use serde_json;
31
32pub(crate) const CURRENT_CACHE_VERSION: &str = "2.0";
39
40pub(crate) fn is_cache_version_current(version: &str) -> bool {
45 version == CURRENT_CACHE_VERSION
46}
47
48pub(crate) fn sanitize_suffix(raw: &str) -> Option<String> {
66 if raw.is_empty() || raw.len() > 16 {
67 return None;
68 }
69 if raw
70 .chars()
71 .all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '-')
72 {
73 Some(raw.to_string())
74 } else {
75 None
76 }
77}
78
79pub(crate) fn normalize_ai_language(detector: &LanguageDetector, raw: &str) -> Option<String> {
100 let trimmed = raw.trim();
101 if trimmed.is_empty() || trimmed.eq_ignore_ascii_case("und") {
102 return None;
103 }
104 let lowered: String = trimmed
105 .chars()
106 .map(|c| {
107 if c.is_ascii_uppercase() {
108 c.to_ascii_lowercase()
109 } else {
110 c
111 }
112 })
113 .collect();
114 let resolved = detector.normalize(&lowered)?;
115 sanitize_suffix(&resolved)
116}
117
118pub fn apply_unique_target_paths(operations: &mut [MatchOperation]) {
135 use std::collections::HashSet;
136
137 fn final_target(op: &MatchOperation) -> PathBuf {
138 if let Some(p) = &op.relocation_target_path {
139 p.clone()
140 } else {
141 let parent = op
142 .subtitle_file
143 .path
144 .parent()
145 .unwrap_or_else(|| std::path::Path::new("."));
146 parent.join(&op.new_subtitle_name)
147 }
148 }
149
150 fn split_filename(name: &str) -> (String, String) {
151 if let Some(idx) = name.rfind('.') {
153 if idx > 0 {
154 return (name[..idx].to_string(), name[idx..].to_string());
155 }
156 }
157 (name.to_string(), String::new())
158 }
159
160 fn split_numeric_tail(stem: &str) -> (String, Option<u32>) {
165 if let Some(idx) = stem.rfind('.') {
166 let (head, tail) = (&stem[..idx], &stem[idx + 1..]);
167 if !tail.is_empty() && tail.chars().all(|c| c.is_ascii_digit()) {
168 if let Ok(n) = tail.parse::<u32>() {
169 return (head.to_string(), Some(n));
170 }
171 }
172 }
173 (stem.to_string(), None)
174 }
175
176 let mut indices: Vec<usize> = (0..operations.len()).collect();
179 indices.sort_by(|&a, &b| {
180 let pa = final_target(&operations[a]);
181 let pb = final_target(&operations[b]);
182 let dir_a = pa.parent().map(|p| p.to_path_buf()).unwrap_or_default();
183 let dir_b = pb.parent().map(|p| p.to_path_buf()).unwrap_or_default();
184 dir_a.cmp(&dir_b).then_with(|| {
185 operations[a]
186 .subtitle_file
187 .relative_path
188 .cmp(&operations[b].subtitle_file.relative_path)
189 })
190 });
191
192 let reserved: HashSet<PathBuf> = (0..operations.len())
199 .map(|i| final_target(&operations[i]))
200 .collect();
201
202 let mut claimed: HashSet<PathBuf> = HashSet::new();
203
204 for idx in indices {
205 let candidate = final_target(&operations[idx]);
206 let parent = candidate
207 .parent()
208 .map(|p| p.to_path_buf())
209 .unwrap_or_default();
210 let filename = candidate
211 .file_name()
212 .map(|f| f.to_string_lossy().to_string())
213 .unwrap_or_default();
214 let (stem, ext) = split_filename(&filename);
215 let (base_stem, existing_counter) = split_numeric_tail(&stem);
216 let source_path = operations[idx].subtitle_file.path.clone();
217
218 let is_taken = |p: &PathBuf| -> bool {
226 claimed.contains(p)
227 || (p != &candidate && reserved.contains(p))
228 || (p != &source_path && p.exists())
229 };
230
231 let mut resolved = candidate.clone();
232 let mut resolved_name = filename.clone();
233 if is_taken(&resolved) {
234 let mut counter = existing_counter.map(|n| n + 1).unwrap_or(2).max(2);
235 loop {
236 let new_name = format!("{}.{}{}", base_stem, counter, ext);
237 let probe = parent.join(&new_name);
238 if !is_taken(&probe) {
239 resolved = probe;
240 resolved_name = new_name;
241 break;
242 }
243 counter = counter.saturating_add(1);
244 if counter > 9999 {
245 break;
246 }
247 }
248 }
249
250 let op = &mut operations[idx];
251 op.new_subtitle_name = resolved_name;
252 if op.relocation_target_path.is_some() {
253 op.relocation_target_path = Some(resolved.clone());
254 }
255 claimed.insert(resolved);
256 }
257}
258
259#[derive(Debug, Clone, PartialEq)]
261pub enum FileRelocationMode {
262 None,
264 Copy,
266 Move,
268}
269
270#[derive(Debug, Clone)]
272pub enum ConflictResolution {
273 Skip,
275 AutoRename,
277 Prompt,
279}
280
281#[derive(Debug, Clone)]
286pub struct MatchConfig {
287 pub confidence_threshold: f32,
289 pub max_sample_length: usize,
291 pub enable_content_analysis: bool,
293 pub backup_enabled: bool,
295 pub relocation_mode: FileRelocationMode,
297 pub conflict_resolution: ConflictResolution,
299 pub ai_model: String,
301 pub max_subtitle_bytes: u64,
304}
305
306#[cfg(test)]
307mod language_name_tests {
308 use super::*;
309 use crate::core::matcher::discovery::{MediaFile, MediaFileType};
310 use crate::services::ai::{
311 AIProvider, AnalysisRequest, ConfidenceScore, FileMatch, MatchResult, VerificationRequest,
312 };
313 use async_trait::async_trait;
314 use std::path::PathBuf;
315
316 fn legacy_match() -> FileMatch {
317 FileMatch {
318 video_file_id: "v".into(),
319 subtitle_file_id: "s".into(),
320 confidence: 1.0,
321 match_factors: vec![],
322 language: None,
323 target_filename_suffix: None,
324 }
325 }
326
327 fn match_with_language(language: Option<&str>, suffix: Option<&str>) -> FileMatch {
328 FileMatch {
329 video_file_id: "v".into(),
330 subtitle_file_id: "s".into(),
331 confidence: 1.0,
332 match_factors: vec![],
333 language: language.map(|s| s.to_string()),
334 target_filename_suffix: suffix.map(|s| s.to_string()),
335 }
336 }
337
338 struct DummyAI;
339 #[async_trait]
340 impl AIProvider for DummyAI {
341 async fn analyze_content(&self, _req: AnalysisRequest) -> crate::Result<MatchResult> {
342 unimplemented!()
343 }
344 async fn verify_match(&self, _req: VerificationRequest) -> crate::Result<ConfidenceScore> {
345 unimplemented!()
346 }
347 }
348
349 #[test]
350 fn test_generate_subtitle_name_with_directory_language() {
351 let engine = MatchEngine::new(
352 Box::new(DummyAI),
353 MatchConfig {
354 confidence_threshold: 0.0,
355 max_sample_length: 0,
356 enable_content_analysis: false,
357 backup_enabled: false,
358 relocation_mode: FileRelocationMode::None,
359 conflict_resolution: ConflictResolution::Skip,
360 ai_model: "test-model".to_string(),
361 max_subtitle_bytes: 52_428_800,
362 },
363 );
364 let video = MediaFile {
365 id: "".to_string(),
366 relative_path: "".to_string(),
367 path: PathBuf::from("movie01.mp4"),
368 file_type: MediaFileType::Video,
369 size: 0,
370 name: "movie01".to_string(),
371 extension: "mp4".to_string(),
372 };
373 let subtitle = MediaFile {
374 id: "".to_string(),
375 relative_path: "".to_string(),
376 path: PathBuf::from("tc/subtitle01.ass"),
377 file_type: MediaFileType::Subtitle,
378 size: 0,
379 name: "subtitle01".to_string(),
380 extension: "ass".to_string(),
381 };
382 let new_name = engine.generate_subtitle_name(&video, &subtitle, &legacy_match());
383 assert_eq!(new_name, "movie01.tc.ass");
384 }
385
386 #[test]
387 fn test_generate_subtitle_name_with_filename_language() {
388 let engine = MatchEngine::new(
389 Box::new(DummyAI),
390 MatchConfig {
391 confidence_threshold: 0.0,
392 max_sample_length: 0,
393 enable_content_analysis: false,
394 backup_enabled: false,
395 relocation_mode: FileRelocationMode::None,
396 conflict_resolution: ConflictResolution::Skip,
397 ai_model: "test-model".to_string(),
398 max_subtitle_bytes: 52_428_800,
399 },
400 );
401 let video = MediaFile {
402 id: "".to_string(),
403 relative_path: "".to_string(),
404 path: PathBuf::from("movie02.mp4"),
405 file_type: MediaFileType::Video,
406 size: 0,
407 name: "movie02".to_string(),
408 extension: "mp4".to_string(),
409 };
410 let subtitle = MediaFile {
411 id: "".to_string(),
412 relative_path: "".to_string(),
413 path: PathBuf::from("subtitle02.sc.ass"),
414 file_type: MediaFileType::Subtitle,
415 size: 0,
416 name: "subtitle02".to_string(),
417 extension: "ass".to_string(),
418 };
419 let new_name = engine.generate_subtitle_name(&video, &subtitle, &legacy_match());
420 assert_eq!(new_name, "movie02.sc.ass");
421 }
422
423 #[test]
424 fn test_generate_subtitle_name_without_language() {
425 let engine = MatchEngine::new(
426 Box::new(DummyAI),
427 MatchConfig {
428 confidence_threshold: 0.0,
429 max_sample_length: 0,
430 enable_content_analysis: false,
431 backup_enabled: false,
432 relocation_mode: FileRelocationMode::None,
433 conflict_resolution: ConflictResolution::Skip,
434 ai_model: "test-model".to_string(),
435 max_subtitle_bytes: 52_428_800,
436 },
437 );
438 let video = MediaFile {
439 id: "".to_string(),
440 relative_path: "".to_string(),
441 path: PathBuf::from("movie03.mp4"),
442 file_type: MediaFileType::Video,
443 size: 0,
444 name: "movie03".to_string(),
445 extension: "mp4".to_string(),
446 };
447 let subtitle = MediaFile {
448 id: "".to_string(),
449 relative_path: "".to_string(),
450 path: PathBuf::from("subtitle03.ass"),
451 file_type: MediaFileType::Subtitle,
452 size: 0,
453 name: "subtitle03".to_string(),
454 extension: "ass".to_string(),
455 };
456 let new_name = engine.generate_subtitle_name(&video, &subtitle, &legacy_match());
457 assert_eq!(new_name, "movie03.ass");
458 }
459 #[test]
460 fn test_generate_subtitle_name_removes_video_extension() {
461 let engine = MatchEngine::new(
462 Box::new(DummyAI),
463 MatchConfig {
464 confidence_threshold: 0.0,
465 max_sample_length: 0,
466 enable_content_analysis: false,
467 backup_enabled: false,
468 relocation_mode: FileRelocationMode::None,
469 conflict_resolution: ConflictResolution::Skip,
470 ai_model: "test-model".to_string(),
471 max_subtitle_bytes: 52_428_800,
472 },
473 );
474 let video = MediaFile {
475 id: "".to_string(),
476 relative_path: "".to_string(),
477 path: PathBuf::from("movie.mkv"),
478 file_type: MediaFileType::Video,
479 size: 0,
480 name: "movie.mkv".to_string(),
481 extension: "mkv".to_string(),
482 };
483 let subtitle = MediaFile {
484 id: "".to_string(),
485 relative_path: "".to_string(),
486 path: PathBuf::from("subtitle.srt"),
487 file_type: MediaFileType::Subtitle,
488 size: 0,
489 name: "subtitle".to_string(),
490 extension: "srt".to_string(),
491 };
492 let new_name = engine.generate_subtitle_name(&video, &subtitle, &legacy_match());
493 assert_eq!(new_name, "movie.srt");
494 }
495
496 #[test]
497 fn test_generate_subtitle_name_with_language_removes_video_extension() {
498 let engine = MatchEngine::new(
499 Box::new(DummyAI),
500 MatchConfig {
501 confidence_threshold: 0.0,
502 max_sample_length: 0,
503 enable_content_analysis: false,
504 backup_enabled: false,
505 relocation_mode: FileRelocationMode::None,
506 conflict_resolution: ConflictResolution::Skip,
507 ai_model: "test-model".to_string(),
508 max_subtitle_bytes: 52_428_800,
509 },
510 );
511 let video = MediaFile {
512 id: "".to_string(),
513 relative_path: "".to_string(),
514 path: PathBuf::from("movie.mkv"),
515 file_type: MediaFileType::Video,
516 size: 0,
517 name: "movie.mkv".to_string(),
518 extension: "mkv".to_string(),
519 };
520 let subtitle = MediaFile {
521 id: "".to_string(),
522 relative_path: "".to_string(),
523 path: PathBuf::from("tc/subtitle.srt"),
524 file_type: MediaFileType::Subtitle,
525 size: 0,
526 name: "subtitle".to_string(),
527 extension: "srt".to_string(),
528 };
529 let new_name = engine.generate_subtitle_name(&video, &subtitle, &legacy_match());
530 assert_eq!(new_name, "movie.tc.srt");
531 }
532
533 #[test]
534 fn test_generate_subtitle_name_edge_cases() {
535 let engine = MatchEngine::new(
536 Box::new(DummyAI),
537 MatchConfig {
538 confidence_threshold: 0.0,
539 max_sample_length: 0,
540 enable_content_analysis: false,
541 backup_enabled: false,
542 relocation_mode: FileRelocationMode::None,
543 conflict_resolution: ConflictResolution::Skip,
544 ai_model: "test-model".to_string(),
545 max_subtitle_bytes: 52_428_800,
546 },
547 );
548 let video = MediaFile {
550 id: "".to_string(),
551 relative_path: "".to_string(),
552 path: PathBuf::from("a.b.c"),
553 file_type: MediaFileType::Video,
554 size: 0,
555 name: "a.b.c".to_string(),
556 extension: "".to_string(),
557 };
558 let subtitle = MediaFile {
559 id: "".to_string(),
560 relative_path: "".to_string(),
561 path: PathBuf::from("sub.srt"),
562 file_type: MediaFileType::Subtitle,
563 size: 0,
564 name: "sub".to_string(),
565 extension: "srt".to_string(),
566 };
567 let new_name = engine.generate_subtitle_name(&video, &subtitle, &legacy_match());
568 assert_eq!(new_name, "a.b.c.srt");
569 }
570
571 fn make_engine() -> MatchEngine {
572 MatchEngine::new(
573 Box::new(DummyAI),
574 MatchConfig {
575 confidence_threshold: 0.0,
576 max_sample_length: 0,
577 enable_content_analysis: false,
578 backup_enabled: false,
579 relocation_mode: FileRelocationMode::None,
580 conflict_resolution: ConflictResolution::Skip,
581 ai_model: "test-model".to_string(),
582 max_subtitle_bytes: 52_428_800,
583 },
584 )
585 }
586
587 fn media(path: &str, name: &str, ext: &str, ty: MediaFileType) -> MediaFile {
588 MediaFile {
589 id: "".into(),
590 relative_path: path.into(),
591 path: PathBuf::from(path),
592 file_type: ty,
593 size: 0,
594 name: name.into(),
595 extension: ext.into(),
596 }
597 }
598
599 #[test]
600 fn test_generate_subtitle_name_ai_suffix_wins() {
601 let engine = make_engine();
602 let video = media("movie.mkv", "movie.mkv", "mkv", MediaFileType::Video);
603 let subtitle = media("tc/subs.srt", "subs", "srt", MediaFileType::Subtitle);
604 let m = match_with_language(Some("en"), Some("tc"));
605 assert_eq!(
606 engine.generate_subtitle_name(&video, &subtitle, &m),
607 "movie.tc.srt"
608 );
609 }
610
611 #[test]
612 fn test_generate_subtitle_name_ai_language_used() {
613 let engine = make_engine();
614 let video = media("movie.mkv", "movie.mkv", "mkv", MediaFileType::Video);
615 let subtitle = media("subs/movie.srt", "movie", "srt", MediaFileType::Subtitle);
616 let m = match_with_language(Some("ja"), None);
617 assert_eq!(
618 engine.generate_subtitle_name(&video, &subtitle, &m),
619 "movie.ja.srt"
620 );
621 }
622
623 #[test]
624 fn test_generate_subtitle_name_language_synonym_normalized() {
625 let engine = make_engine();
626 let video = media("movie.mkv", "movie.mkv", "mkv", MediaFileType::Video);
627 let subtitle = media("subs/movie.srt", "movie", "srt", MediaFileType::Subtitle);
628 for variant in &["english", "eng", "EN"] {
629 let m = match_with_language(Some(variant), None);
630 assert_eq!(
631 engine.generate_subtitle_name(&video, &subtitle, &m),
632 "movie.en.srt",
633 "variant {variant} should normalize to en"
634 );
635 }
636 }
637
638 #[test]
639 fn test_generate_subtitle_name_und_collapses_to_no_tag() {
640 let engine = make_engine();
641 let video = media("movie.mkv", "movie.mkv", "mkv", MediaFileType::Video);
642 let subtitle = media("subs/movie.srt", "movie", "srt", MediaFileType::Subtitle);
643 let m = match_with_language(Some("und"), None);
644 assert_eq!(
645 engine.generate_subtitle_name(&video, &subtitle, &m),
646 "movie.srt"
647 );
648 }
649
650 #[test]
651 fn test_generate_subtitle_name_sanitization_drops_path_traversal() {
652 let engine = make_engine();
653 let video = media("movie.mkv", "movie.mkv", "mkv", MediaFileType::Video);
654 let subtitle = media("subs/movie.srt", "movie", "srt", MediaFileType::Subtitle);
655 let m = match_with_language(None, Some("../etc"));
659 assert_eq!(
660 engine.generate_subtitle_name(&video, &subtitle, &m),
661 "movie.srt"
662 );
663 }
664
665 #[test]
666 fn test_sanitize_suffix_helper() {
667 assert_eq!(super::sanitize_suffix(""), None);
668 assert_eq!(super::sanitize_suffix("../"), None);
669 assert_eq!(super::sanitize_suffix("../etc"), None);
671 assert_eq!(super::sanitize_suffix("a-b_c"), Some("a-b_c".into()));
672 assert_eq!(super::sanitize_suffix("繁中"), None);
673 assert_eq!(super::sanitize_suffix("0123456789abcdefGHIJKL"), None);
676 assert_eq!(
677 super::sanitize_suffix("0123456789abcdef"),
678 Some("0123456789abcdef".into())
679 );
680 }
681
682 #[test]
683 fn test_normalize_ai_language_helper() {
684 let det = LanguageDetector::new();
685 assert_eq!(
686 super::normalize_ai_language(&det, "english"),
687 Some("en".into())
688 );
689 assert_eq!(super::normalize_ai_language(&det, "ENG"), Some("en".into()));
690 assert_eq!(super::normalize_ai_language(&det, "EN"), Some("en".into()));
691 assert_eq!(super::normalize_ai_language(&det, "und"), None);
692 assert_eq!(super::normalize_ai_language(&det, "UND"), None);
693 assert_eq!(super::normalize_ai_language(&det, ""), None);
694 assert_eq!(super::normalize_ai_language(&det, "cht"), Some("tc".into()));
695 assert_eq!(super::normalize_ai_language(&det, "chs"), Some("sc".into()));
696 assert_eq!(
698 super::normalize_ai_language(&det, "繁中"),
699 Some("tc".into())
700 );
701 assert_eq!(
702 super::normalize_ai_language(&det, "简中"),
703 Some("sc".into())
704 );
705 assert_eq!(
707 super::normalize_ai_language(&det, "traditional-chinese"),
708 Some("tc".into())
709 );
710 assert_eq!(
711 super::normalize_ai_language(&det, "Traditional_Chinese"),
712 Some("tc".into())
713 );
714 assert_eq!(
715 super::normalize_ai_language(&det, "zh-Hant"),
716 Some("tc".into())
717 );
718 assert_eq!(
719 super::normalize_ai_language(&det, "zh_hans"),
720 Some("sc".into())
721 );
722 assert_eq!(super::normalize_ai_language(&det, "vi"), Some("vi".into()));
724 assert_eq!(super::normalize_ai_language(&det, "ID"), Some("id".into()));
725 }
726
727 fn op(parent: &str, name: &str, sub_relpath: &str, relocate: bool) -> MatchOperation {
728 let video_path = PathBuf::from(parent).join("movie.mkv");
729 let subtitle_path = PathBuf::from(sub_relpath);
730 let relocation_target_path = if relocate {
731 Some(PathBuf::from(parent).join(name))
732 } else {
733 None
734 };
735 MatchOperation {
736 video_file: MediaFile {
737 id: "v".into(),
738 relative_path: video_path.to_string_lossy().to_string(),
739 path: video_path,
740 file_type: MediaFileType::Video,
741 size: 0,
742 name: "movie.mkv".into(),
743 extension: "mkv".into(),
744 },
745 subtitle_file: MediaFile {
746 id: "s".into(),
747 relative_path: sub_relpath.into(),
748 path: subtitle_path,
749 file_type: MediaFileType::Subtitle,
750 size: 0,
751 name: name.into(),
752 extension: "srt".into(),
753 },
754 new_subtitle_name: name.into(),
755 confidence: 1.0,
756 reasoning: vec![],
757 relocation_mode: if relocate {
758 FileRelocationMode::Copy
759 } else {
760 FileRelocationMode::None
761 },
762 relocation_target_path,
763 requires_relocation: relocate,
764 }
765 }
766
767 #[test]
768 fn test_unique_target_paths_two_duplicates_same_dir() {
769 let mut ops = vec![
770 op("/d", "movie.srt", "/d/a.srt", false),
771 op("/d", "movie.srt", "/d/b.srt", false),
772 ];
773 super::apply_unique_target_paths(&mut ops);
774 assert_eq!(ops[0].new_subtitle_name, "movie.srt");
775 assert_eq!(ops[1].new_subtitle_name, "movie.2.srt");
776 }
777
778 #[test]
779 fn test_unique_target_paths_three_way_with_existing_two() {
780 let mut ops = vec![
787 op("/d", "movie.srt", "/d/a.srt", false),
788 op("/d", "movie.srt", "/d/b.srt", false),
789 op("/d", "movie.2.srt", "/d/c.srt", false),
790 ];
791 super::apply_unique_target_paths(&mut ops);
792 assert_eq!(ops[0].new_subtitle_name, "movie.srt");
793 assert_eq!(ops[1].new_subtitle_name, "movie.3.srt");
794 assert_eq!(ops[2].new_subtitle_name, "movie.2.srt");
795 }
796
797 #[test]
798 fn test_unique_target_paths_idempotent() {
799 let mut ops = vec![
803 op("/d", "movie.srt", "/d/a.srt", false),
804 op("/d", "movie.srt", "/d/b.srt", false),
805 op("/d", "movie.2.srt", "/d/c.srt", false),
806 ];
807 super::apply_unique_target_paths(&mut ops);
808 let snapshot: Vec<String> = ops.iter().map(|o| o.new_subtitle_name.clone()).collect();
809 super::apply_unique_target_paths(&mut ops);
810 let after: Vec<String> = ops.iter().map(|o| o.new_subtitle_name.clone()).collect();
811 assert_eq!(snapshot, after);
812 }
813
814 #[test]
815 fn test_unique_target_paths_two_languages_preserved() {
816 let mut ops = vec![
817 op("/d", "movie.tc.srt", "/d/a.srt", false),
818 op("/d", "movie.sc.srt", "/d/b.srt", false),
819 ];
820 super::apply_unique_target_paths(&mut ops);
821 assert_eq!(ops[0].new_subtitle_name, "movie.tc.srt");
822 assert_eq!(ops[1].new_subtitle_name, "movie.sc.srt");
823 }
824
825 #[test]
826 fn test_unique_target_paths_cross_video_collision_under_copy() {
827 let mut ops = vec![
830 op("/shared", "subs.srt", "/src1/subs.srt", true),
831 op("/shared", "subs.srt", "/src2/subs.srt", true),
832 ];
833 super::apply_unique_target_paths(&mut ops);
834 assert_eq!(ops[0].new_subtitle_name, "subs.srt");
835 assert_eq!(ops[1].new_subtitle_name, "subs.2.srt");
836 assert_eq!(
838 ops[0].relocation_target_path.as_ref().unwrap(),
839 &PathBuf::from("/shared/subs.srt")
840 );
841 assert_eq!(
842 ops[1].relocation_target_path.as_ref().unwrap(),
843 &PathBuf::from("/shared/subs.2.srt")
844 );
845 }
846
847 #[test]
848 fn test_unique_target_paths_archive_origin_relocation_unique() {
849 let mut ops = vec![
852 op("/videos", "movie.srt", "/tmp/a.srt", true),
853 op("/videos", "movie.srt", "/tmp/b.srt", true),
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_skip_existing_on_disk() {
862 let dir = tempfile::tempdir().unwrap();
869 let dir_path = dir.path().to_path_buf();
870 std::fs::write(dir_path.join("movie.srt"), b"existing").unwrap();
871 std::fs::write(dir_path.join("movie.1.srt"), b"existing").unwrap();
872 let dir_str = dir_path.to_string_lossy().to_string();
873 let src1 = dir_path.parent().unwrap().join("sub1.srt");
874 let src2 = dir_path.parent().unwrap().join("sub2.srt");
875 let mut ops = vec![
876 op(&dir_str, "movie.srt", src1.to_str().unwrap(), true),
877 op(&dir_str, "movie.srt", src2.to_str().unwrap(), true),
878 ];
879 super::apply_unique_target_paths(&mut ops);
880 let mut got: Vec<String> = ops.iter().map(|o| o.new_subtitle_name.clone()).collect();
881 got.sort();
882 assert_eq!(
883 got,
884 vec!["movie.2.srt".to_string(), "movie.3.srt".to_string()]
885 );
886 }
887
888 #[test]
889 fn test_legacy_v1_cache_rejected() {
890 use crate::core::matcher::cache::CacheData;
900 use std::time::{SystemTime, UNIX_EPOCH};
901 use tempfile::TempDir;
902
903 assert_eq!(CURRENT_CACHE_VERSION, "2.0");
904 assert!(super::is_cache_version_current("2.0"));
905 assert!(!super::is_cache_version_current("1.0"));
906 assert!(!super::is_cache_version_current(""));
907
908 let temp = TempDir::new().unwrap();
909 let cache_path = temp.path().join("legacy_v1_cache.json");
910 let now = SystemTime::now()
911 .duration_since(UNIX_EPOCH)
912 .unwrap()
913 .as_secs();
914 let legacy = serde_json::json!({
915 "cache_version": "1.0",
916 "directory": "filelist_deadbeef",
917 "file_snapshot": [],
918 "match_operations": [],
919 "created_at": now,
920 "ai_model_used": "test-model",
921 "config_hash": "0000000000000000",
922 "original_relocation_mode": "None",
923 "original_backup_enabled": false,
924 });
925 std::fs::write(&cache_path, serde_json::to_string(&legacy).unwrap()).unwrap();
926
927 let loaded = CacheData::load(&cache_path).expect("legacy cache should parse");
928 assert_eq!(loaded.cache_version, "1.0");
929 assert!(
930 !super::is_cache_version_current(&loaded.cache_version),
931 "loaded v1 cache must be rejected by the version gate"
932 );
933 }
934
935 #[tokio::test]
936 async fn test_rename_file_displays_success_check_mark() {
937 use std::fs;
938 use tempfile::TempDir;
939
940 let temp_dir = TempDir::new().unwrap();
941 let temp_path = temp_dir.path();
942
943 let original_file = temp_path.join("original.srt");
945 fs::write(
946 &original_file,
947 "1\n00:00:01,000 --> 00:00:02,000\nTest subtitle",
948 )
949 .unwrap();
950
951 let engine = MatchEngine::new(
953 Box::new(DummyAI),
954 MatchConfig {
955 confidence_threshold: 0.0,
956 max_sample_length: 0,
957 enable_content_analysis: false,
958 backup_enabled: false,
959 relocation_mode: FileRelocationMode::None,
960 conflict_resolution: ConflictResolution::Skip,
961 ai_model: "test-model".to_string(),
962 max_subtitle_bytes: 52_428_800,
963 },
964 );
965
966 let subtitle_file = MediaFile {
968 id: "test_id".to_string(),
969 relative_path: "original.srt".to_string(),
970 path: original_file.clone(),
971 file_type: MediaFileType::Subtitle,
972 size: 40,
973 name: "original".to_string(),
974 extension: "srt".to_string(),
975 };
976
977 let match_op = MatchOperation {
978 video_file: MediaFile {
979 id: "video_id".to_string(),
980 relative_path: "test.mp4".to_string(),
981 path: temp_path.join("test.mp4"),
982 file_type: MediaFileType::Video,
983 size: 1000,
984 name: "test".to_string(),
985 extension: "mp4".to_string(),
986 },
987 subtitle_file,
988 new_subtitle_name: "renamed.srt".to_string(),
989 confidence: 95.0,
990 reasoning: vec!["Test match".to_string()],
991 requires_relocation: false,
992 relocation_target_path: None,
993 relocation_mode: FileRelocationMode::None,
994 };
995
996 let result = engine.rename_file(&match_op).await;
998
999 assert!(result.is_ok());
1001
1002 let renamed_file = temp_path.join("renamed.srt");
1004 assert!(renamed_file.exists(), "The renamed file should exist");
1005 assert!(
1006 !original_file.exists(),
1007 "The original file should have been renamed"
1008 );
1009
1010 let content = fs::read_to_string(&renamed_file).unwrap();
1012 assert!(content.contains("Test subtitle"));
1013 }
1014
1015 #[tokio::test]
1016 async fn test_rename_file_displays_error_cross_mark_when_file_not_exists() {
1017 use std::fs;
1018 use tempfile::TempDir;
1019
1020 let temp_dir = TempDir::new().unwrap();
1021 let temp_path = temp_dir.path();
1022
1023 let original_file = temp_path.join("original.srt");
1025 fs::write(
1026 &original_file,
1027 "1\n00:00:01,000 --> 00:00:02,000\nTest subtitle",
1028 )
1029 .unwrap();
1030
1031 let engine = MatchEngine::new(
1033 Box::new(DummyAI),
1034 MatchConfig {
1035 confidence_threshold: 0.0,
1036 max_sample_length: 0,
1037 enable_content_analysis: false,
1038 backup_enabled: false,
1039 relocation_mode: FileRelocationMode::None,
1040 conflict_resolution: ConflictResolution::Skip,
1041 ai_model: "test-model".to_string(),
1042 max_subtitle_bytes: 52_428_800,
1043 },
1044 );
1045
1046 let subtitle_file = MediaFile {
1048 id: "test_id".to_string(),
1049 relative_path: "original.srt".to_string(),
1050 path: original_file.clone(),
1051 file_type: MediaFileType::Subtitle,
1052 size: 40,
1053 name: "original".to_string(),
1054 extension: "srt".to_string(),
1055 };
1056
1057 let match_op = MatchOperation {
1058 video_file: MediaFile {
1059 id: "video_id".to_string(),
1060 relative_path: "test.mp4".to_string(),
1061 path: temp_path.join("test.mp4"),
1062 file_type: MediaFileType::Video,
1063 size: 1000,
1064 name: "test".to_string(),
1065 extension: "mp4".to_string(),
1066 },
1067 subtitle_file,
1068 new_subtitle_name: "renamed.srt".to_string(),
1069 confidence: 95.0,
1070 reasoning: vec!["Test match".to_string()],
1071 requires_relocation: false,
1072 relocation_target_path: None,
1073 relocation_mode: FileRelocationMode::None,
1074 };
1075
1076 let result = engine.rename_file(&match_op).await;
1079 assert!(result.is_ok());
1080
1081 let renamed_file = temp_path.join("renamed.srt");
1083 if renamed_file.exists() {
1084 fs::remove_file(&renamed_file).unwrap();
1085 }
1086
1087 fs::write(
1089 &original_file,
1090 "1\n00:00:01,000 --> 00:00:02,000\nTest subtitle",
1091 )
1092 .unwrap();
1093
1094 let result = engine.rename_file(&match_op).await;
1098 assert!(result.is_ok());
1099
1100 let renamed_file = temp_path.join("renamed.srt");
1102 if renamed_file.exists() {
1103 fs::remove_file(&renamed_file).unwrap();
1104 }
1105
1106 }
1109
1110 #[test]
1111 fn test_file_operation_message_format() {
1112 let source_name = "test.srt";
1114 let target_name = "renamed.srt";
1115
1116 let success_msg = format!(" ✓ Renamed: {} -> {}", source_name, target_name);
1118 assert!(success_msg.contains("✓"));
1119 assert!(success_msg.contains("Renamed:"));
1120 assert!(success_msg.contains(source_name));
1121 assert!(success_msg.contains(target_name));
1122
1123 let error_msg = format!(
1125 " ✗ Rename failed: {} -> {} (target file does not exist after operation)",
1126 source_name, target_name
1127 );
1128 assert!(error_msg.contains("✗"));
1129 assert!(error_msg.contains("Rename failed:"));
1130 assert!(error_msg.contains("target file does not exist"));
1131 assert!(error_msg.contains(source_name));
1132 assert!(error_msg.contains(target_name));
1133 }
1134
1135 #[test]
1136 fn test_copy_operation_message_format() {
1137 let source_name = "subtitle.srt";
1139 let target_name = "video.srt";
1140
1141 let success_msg = format!(" ✓ Copied: {} -> {}", source_name, target_name);
1143 assert!(success_msg.contains("✓"));
1144 assert!(success_msg.contains("Copied:"));
1145
1146 let error_msg = format!(
1148 " ✗ Copy failed: {} -> {} (target file does not exist after operation)",
1149 source_name, target_name
1150 );
1151 assert!(error_msg.contains("✗"));
1152 assert!(error_msg.contains("Copy failed:"));
1153 assert!(error_msg.contains("target file does not exist"));
1154 }
1155
1156 #[test]
1157 fn test_move_operation_message_format() {
1158 let source_name = "subtitle.srt";
1160 let target_name = "video.srt";
1161
1162 let success_msg = format!(" ✓ Moved: {} -> {}", source_name, target_name);
1164 assert!(success_msg.contains("✓"));
1165 assert!(success_msg.contains("Moved:"));
1166
1167 let error_msg = format!(
1169 " ✗ Move failed: {} -> {} (target file does not exist after operation)",
1170 source_name, target_name
1171 );
1172 assert!(error_msg.contains("✗"));
1173 assert!(error_msg.contains("Move failed:"));
1174 assert!(error_msg.contains("target file does not exist"));
1175 }
1176}
1177
1178#[derive(Debug)]
1183pub struct MatchOperation {
1184 pub video_file: MediaFile,
1186 pub subtitle_file: MediaFile,
1188 pub new_subtitle_name: String,
1190 pub confidence: f32,
1192 pub reasoning: Vec<String>,
1194 pub relocation_mode: FileRelocationMode,
1196 pub relocation_target_path: Option<std::path::PathBuf>,
1198 pub requires_relocation: bool,
1200}
1201
1202#[derive(Debug, Clone)]
1208pub struct RejectedCandidate {
1209 pub video_path: String,
1211 pub subtitle_path: String,
1213 pub confidence: f32,
1215 pub reason: &'static str,
1217}
1218
1219#[derive(Debug)]
1222pub struct MatchAudit {
1223 pub operations: Vec<MatchOperation>,
1225 pub rejected: Vec<RejectedCandidate>,
1227}
1228
1229#[derive(Debug)]
1235pub struct OperationOutcome {
1236 pub applied: bool,
1238 pub error: Option<OperationError>,
1240}
1241
1242#[derive(Debug, Clone)]
1244pub struct OperationError {
1245 pub category: &'static str,
1247 pub code: &'static str,
1249 pub message: String,
1251}
1252
1253fn operation_error_from(err: &SubXError) -> OperationError {
1256 OperationError {
1257 category: err.category(),
1258 code: err.machine_code(),
1259 message: err.user_friendly_message(),
1260 }
1261}
1262
1263pub struct MatchEngine {
1265 ai_client: Box<dyn AIProvider>,
1266 discovery: FileDiscovery,
1267 config: MatchConfig,
1268}
1269
1270impl MatchEngine {
1271 pub fn new(ai_client: Box<dyn AIProvider>, config: MatchConfig) -> Self {
1273 Self {
1274 ai_client,
1275 discovery: FileDiscovery::new(),
1276 config,
1277 }
1278 }
1279
1280 pub async fn match_file_list(&self, file_paths: &[PathBuf]) -> Result<Vec<MatchOperation>> {
1294 Ok(self
1295 .match_file_list_with_audit(file_paths)
1296 .await?
1297 .operations)
1298 }
1299
1300 pub async fn match_file_list_with_audit(&self, file_paths: &[PathBuf]) -> Result<MatchAudit> {
1306 let json_mode = crate::cli::output::active_mode().is_json();
1307 let files = self.discovery.scan_file_list(file_paths)?;
1309
1310 let videos: Vec<_> = files
1311 .iter()
1312 .filter(|f| matches!(f.file_type, MediaFileType::Video))
1313 .collect();
1314 let subtitles: Vec<_> = files
1315 .iter()
1316 .filter(|f| matches!(f.file_type, MediaFileType::Subtitle))
1317 .collect();
1318
1319 if videos.is_empty() || subtitles.is_empty() {
1320 return Ok(MatchAudit {
1321 operations: Vec::new(),
1322 rejected: Vec::new(),
1323 });
1324 }
1325
1326 let cache_key = self.calculate_file_list_cache_key(file_paths)?;
1328 if let Some(ops) = self.check_file_list_cache(&cache_key).await? {
1329 return Ok(MatchAudit {
1330 operations: ops,
1331 rejected: Vec::new(),
1332 });
1333 }
1334
1335 let content_samples = if self.config.enable_content_analysis {
1337 self.extract_content_samples(&subtitles).await?
1338 } else {
1339 Vec::new()
1340 };
1341
1342 let video_files: Vec<String> = videos
1344 .iter()
1345 .map(|v| format!("ID:{} | Name:{} | Path:{}", v.id, v.name, v.relative_path))
1346 .collect();
1347 let subtitle_files: Vec<String> = subtitles
1348 .iter()
1349 .map(|s| format!("ID:{} | Name:{} | Path:{}", s.id, s.name, s.relative_path))
1350 .collect();
1351
1352 let analysis_request = AnalysisRequest {
1353 video_files,
1354 subtitle_files,
1355 content_samples,
1356 };
1357
1358 let match_result = self.ai_client.analyze_content(analysis_request).await?;
1360
1361 if !json_mode {
1364 eprintln!("🔍 AI Analysis Results:");
1365 eprintln!(" - Total matches: {}", match_result.matches.len());
1366 eprintln!(
1367 " - Confidence threshold: {:.2}",
1368 self.config.confidence_threshold
1369 );
1370 for ai_match in &match_result.matches {
1371 eprintln!(
1372 " - {} -> {} (confidence: {:.2})",
1373 ai_match.video_file_id, ai_match.subtitle_file_id, ai_match.confidence
1374 );
1375 }
1376 }
1377
1378 let mut operations = Vec::new();
1380 let mut rejected = Vec::new();
1381
1382 for ai_match in match_result.matches {
1383 let video_match =
1384 Self::find_media_file_by_id_or_path(&videos, &ai_match.video_file_id, None);
1385 let subtitle_match =
1386 Self::find_media_file_by_id_or_path(&subtitles, &ai_match.subtitle_file_id, None);
1387
1388 if ai_match.confidence < self.config.confidence_threshold {
1389 rejected.push(RejectedCandidate {
1390 video_path: video_match
1391 .map(|v| v.path.display().to_string())
1392 .unwrap_or_default(),
1393 subtitle_path: subtitle_match
1394 .map(|s| s.path.display().to_string())
1395 .unwrap_or_default(),
1396 confidence: ai_match.confidence,
1397 reason: "below_threshold",
1398 });
1399 continue;
1400 }
1401
1402 match (video_match, subtitle_match) {
1403 (Some(video), Some(subtitle)) => {
1404 let new_name = self.generate_subtitle_name(video, subtitle, &ai_match);
1405
1406 let requires_relocation = self.config.relocation_mode
1407 != FileRelocationMode::None
1408 && subtitle.path.parent() != video.path.parent();
1409
1410 let relocation_target_path = if requires_relocation {
1411 let video_dir = video.path.parent().unwrap();
1412 Some(video_dir.join(&new_name))
1413 } else {
1414 None
1415 };
1416
1417 operations.push(MatchOperation {
1418 video_file: (*video).clone(),
1419 subtitle_file: (*subtitle).clone(),
1420 new_subtitle_name: new_name,
1421 confidence: ai_match.confidence,
1422 reasoning: ai_match.match_factors,
1423 relocation_mode: self.config.relocation_mode.clone(),
1424 relocation_target_path,
1425 requires_relocation,
1426 });
1427 }
1428 _ => {
1429 if !json_mode {
1430 eprintln!(
1431 "⚠️ Cannot find AI-suggested file pair:\n Video ID: '{}'\n Subtitle ID: '{}'",
1432 ai_match.video_file_id, ai_match.subtitle_file_id
1433 );
1434 }
1435 rejected.push(RejectedCandidate {
1436 video_path: video_match
1437 .map(|v| v.path.display().to_string())
1438 .unwrap_or_default(),
1439 subtitle_path: subtitle_match
1440 .map(|s| s.path.display().to_string())
1441 .unwrap_or_default(),
1442 confidence: ai_match.confidence,
1443 reason: "id_not_found",
1444 });
1445 }
1446 }
1447 }
1448
1449 apply_unique_target_paths(&mut operations);
1455
1456 self.save_file_list_cache(&cache_key, &operations).await?;
1458
1459 Ok(MatchAudit {
1460 operations,
1461 rejected,
1462 })
1463 }
1464
1465 async fn extract_content_samples(
1466 &self,
1467 subtitles: &[&MediaFile],
1468 ) -> Result<Vec<ContentSample>> {
1469 let mut samples = Vec::new();
1470
1471 for subtitle in subtitles {
1472 let path = subtitle.path.clone();
1473 crate::core::fs_util::check_file_size(
1474 &path,
1475 self.config.max_subtitle_bytes,
1476 "Subtitle",
1477 )
1478 .map_err(SubXError::Io)?;
1479 let content = tokio::task::spawn_blocking(move || std::fs::read_to_string(&path))
1480 .await
1481 .map_err(|e| SubXError::Io(std::io::Error::other(e.to_string())))??;
1482 let preview = self.create_content_preview(&content);
1483
1484 samples.push(ContentSample {
1485 filename: subtitle.name.clone(),
1486 subtitle_file_id: subtitle.id.clone(),
1487 content_preview: preview,
1488 file_size: subtitle.size,
1489 });
1490 }
1491
1492 Ok(samples)
1493 }
1494
1495 fn create_content_preview(&self, content: &str) -> String {
1496 let lines: Vec<&str> = content.lines().take(20).collect();
1497 let preview = lines.join("\n");
1498
1499 if preview.len() > self.config.max_sample_length {
1500 format!("{}...", &preview[..self.config.max_sample_length])
1501 } else {
1502 preview
1503 }
1504 }
1505
1506 fn generate_subtitle_name(
1507 &self,
1508 video: &MediaFile,
1509 subtitle: &MediaFile,
1510 ai_match: &crate::services::ai::FileMatch,
1511 ) -> String {
1512 let detector = LanguageDetector::new();
1513
1514 let video_base_name = if !video.extension.is_empty() {
1516 video
1517 .name
1518 .strip_suffix(&format!(".{}", video.extension))
1519 .unwrap_or(&video.name)
1520 } else {
1521 &video.name
1522 };
1523
1524 let ai_tag = ai_match
1530 .target_filename_suffix
1531 .as_deref()
1532 .and_then(sanitize_suffix)
1533 .or_else(|| {
1534 ai_match
1535 .language
1536 .as_deref()
1537 .and_then(|s| normalize_ai_language(&detector, s))
1538 });
1539
1540 let code = ai_tag.or_else(|| detector.get_primary_language(&subtitle.path));
1541
1542 if let Some(code) = code {
1543 format!("{}.{}.{}", video_base_name, code, subtitle.extension)
1544 } else {
1545 format!("{}.{}", video_base_name, subtitle.extension)
1546 }
1547 }
1548
1549 pub async fn execute_operations(
1558 &self,
1559 operations: &[MatchOperation],
1560 dry_run: bool,
1561 ) -> Result<()> {
1562 if dry_run {
1563 let json_mode = crate::cli::output::active_mode().is_json();
1569 for op in operations {
1570 if !json_mode {
1571 println!(
1572 "Preview: {} -> {}",
1573 op.subtitle_file.name, op.new_subtitle_name
1574 );
1575 }
1576 if op.requires_relocation {
1577 if let Some(target_path) = &op.relocation_target_path {
1578 let operation_verb = match op.relocation_mode {
1579 FileRelocationMode::Copy => "Copy",
1580 FileRelocationMode::Move => "Move",
1581 _ => "",
1582 };
1583 if !json_mode {
1584 println!(
1585 "Preview: {} {} to {}",
1586 operation_verb,
1587 op.subtitle_file.path.display(),
1588 target_path.display()
1589 );
1590 }
1591 }
1592 }
1593 }
1594 return Ok(());
1595 }
1596
1597 let created_at = std::time::SystemTime::now()
1602 .duration_since(std::time::UNIX_EPOCH)
1603 .map(|d| d.as_secs())
1604 .unwrap_or(0);
1605 let batch_id = {
1606 use std::collections::hash_map::DefaultHasher;
1607 use std::hash::{Hash, Hasher};
1608 let mut hasher = DefaultHasher::new();
1609 created_at.hash(&mut hasher);
1610 operations.len().hash(&mut hasher);
1611 for op in operations {
1612 op.subtitle_file.path.hash(&mut hasher);
1613 op.new_subtitle_name.hash(&mut hasher);
1614 }
1615 format!("{:016x}", hasher.finish())
1616 };
1617 let mut journal = JournalData {
1618 batch_id,
1619 created_at,
1620 entries: Vec::new(),
1621 };
1622 let journal_file = journal_path().ok();
1623
1624 let mut first_error: Option<SubXError> = None;
1625
1626 for op in operations {
1627 let mut backup_path: Option<PathBuf> = None;
1630
1631 if op.relocation_mode == FileRelocationMode::Move && self.config.backup_enabled {
1632 let backup_task =
1633 self.create_backup_task(&op.subtitle_file.path, &op.subtitle_file.extension);
1634 if let ProcessingOperation::CreateBackup { backup, .. } = &backup_task.operation {
1635 backup_path = Some(backup.clone());
1636 }
1637 if let TaskResult::Failed(err) = backup_task.execute().await {
1638 first_error = Some(SubXError::FileOperationFailed(err));
1639 break;
1640 }
1641 }
1642
1643 let primary_task = if op.relocation_mode == FileRelocationMode::Copy {
1647 self.create_copy_task(op)
1648 } else {
1649 self.create_rename_task(op)
1650 };
1651
1652 let (journal_source, journal_destination, journal_kind) = match &primary_task.operation
1653 {
1654 ProcessingOperation::CopyWithRename { source, target }
1655 | ProcessingOperation::CopyToVideoFolder { source, target } => {
1656 (source.clone(), target.clone(), JournalOperationType::Copied)
1657 }
1658 ProcessingOperation::MoveToVideoFolder { source, target } => {
1659 (source.clone(), target.clone(), JournalOperationType::Moved)
1660 }
1661 ProcessingOperation::RenameFile { source, target } => {
1662 let kind = match op.relocation_mode {
1663 FileRelocationMode::Move => JournalOperationType::Moved,
1664 _ => JournalOperationType::Renamed,
1665 };
1666 (source.clone(), target.clone(), kind)
1667 }
1668 _ => (
1669 op.subtitle_file.path.clone(),
1670 op.relocation_target_path.clone().unwrap_or_else(|| {
1671 op.subtitle_file.path.with_file_name(&op.new_subtitle_name)
1672 }),
1673 JournalOperationType::Renamed,
1674 ),
1675 };
1676
1677 let (pre_file_size, pre_file_mtime) = journal_source
1680 .metadata()
1681 .ok()
1682 .map(|m| {
1683 let mtime = m
1684 .modified()
1685 .ok()
1686 .and_then(|t| t.duration_since(std::time::UNIX_EPOCH).ok())
1687 .map(|d| d.as_secs())
1688 .unwrap_or(0);
1689 (m.len(), mtime)
1690 })
1691 .unwrap_or((0, 0));
1692
1693 if let TaskResult::Failed(err) = primary_task.execute().await {
1694 first_error = Some(SubXError::FileOperationFailed(err));
1695 break;
1696 }
1697
1698 let (file_size, file_mtime) = journal_destination
1701 .metadata()
1702 .ok()
1703 .map(|m| {
1704 let mtime = m
1705 .modified()
1706 .ok()
1707 .and_then(|t| t.duration_since(std::time::UNIX_EPOCH).ok())
1708 .map(|d| d.as_secs())
1709 .unwrap_or(0);
1710 (m.len(), mtime)
1711 })
1712 .unwrap_or((pre_file_size, pre_file_mtime));
1713
1714 journal.entries.push(JournalEntry {
1715 operation_type: journal_kind,
1716 source: journal_source,
1717 destination: journal_destination,
1718 backup_path: backup_path.clone(),
1719 status: JournalEntryStatus::Completed,
1720 file_size,
1721 file_mtime,
1722 });
1723
1724 if let Some(path) = journal_file.as_ref() {
1725 journal.save(path).await?;
1728 }
1729 }
1730
1731 if let Some(err) = first_error {
1732 return Err(err);
1733 }
1734 Ok(())
1735 }
1736
1737 pub async fn execute_operations_audit(
1744 &self,
1745 operations: &[MatchOperation],
1746 dry_run: bool,
1747 ) -> Result<Vec<OperationOutcome>> {
1748 if dry_run {
1749 return Ok(operations
1750 .iter()
1751 .map(|_| OperationOutcome {
1752 applied: false,
1753 error: None,
1754 })
1755 .collect());
1756 }
1757
1758 let created_at = std::time::SystemTime::now()
1759 .duration_since(std::time::UNIX_EPOCH)
1760 .map(|d| d.as_secs())
1761 .unwrap_or(0);
1762 let batch_id = {
1763 use std::collections::hash_map::DefaultHasher;
1764 use std::hash::{Hash, Hasher};
1765 let mut hasher = DefaultHasher::new();
1766 created_at.hash(&mut hasher);
1767 operations.len().hash(&mut hasher);
1768 for op in operations {
1769 op.subtitle_file.path.hash(&mut hasher);
1770 op.new_subtitle_name.hash(&mut hasher);
1771 }
1772 format!("{:016x}", hasher.finish())
1773 };
1774 let mut journal = JournalData {
1775 batch_id,
1776 created_at,
1777 entries: Vec::new(),
1778 };
1779 let journal_file = journal_path().ok();
1780
1781 let mut outcomes = Vec::with_capacity(operations.len());
1782
1783 for op in operations {
1784 let mut backup_path: Option<PathBuf> = None;
1785
1786 if op.relocation_mode == FileRelocationMode::Move && self.config.backup_enabled {
1787 let backup_task =
1788 self.create_backup_task(&op.subtitle_file.path, &op.subtitle_file.extension);
1789 if let ProcessingOperation::CreateBackup { backup, .. } = &backup_task.operation {
1790 backup_path = Some(backup.clone());
1791 }
1792 if let TaskResult::Failed(err) = backup_task.execute().await {
1793 let err = SubXError::FileOperationFailed(err);
1794 outcomes.push(OperationOutcome {
1795 applied: false,
1796 error: Some(operation_error_from(&err)),
1797 });
1798 continue;
1799 }
1800 }
1801
1802 let primary_task = if op.relocation_mode == FileRelocationMode::Copy {
1803 self.create_copy_task(op)
1804 } else {
1805 self.create_rename_task(op)
1806 };
1807
1808 let (journal_source, journal_destination, journal_kind) = match &primary_task.operation
1809 {
1810 ProcessingOperation::CopyWithRename { source, target }
1811 | ProcessingOperation::CopyToVideoFolder { source, target } => {
1812 (source.clone(), target.clone(), JournalOperationType::Copied)
1813 }
1814 ProcessingOperation::MoveToVideoFolder { source, target } => {
1815 (source.clone(), target.clone(), JournalOperationType::Moved)
1816 }
1817 ProcessingOperation::RenameFile { source, target } => {
1818 let kind = match op.relocation_mode {
1819 FileRelocationMode::Move => JournalOperationType::Moved,
1820 _ => JournalOperationType::Renamed,
1821 };
1822 (source.clone(), target.clone(), kind)
1823 }
1824 _ => (
1825 op.subtitle_file.path.clone(),
1826 op.relocation_target_path.clone().unwrap_or_else(|| {
1827 op.subtitle_file.path.with_file_name(&op.new_subtitle_name)
1828 }),
1829 JournalOperationType::Renamed,
1830 ),
1831 };
1832
1833 let (pre_file_size, pre_file_mtime) = journal_source
1834 .metadata()
1835 .ok()
1836 .map(|m| {
1837 let mtime = m
1838 .modified()
1839 .ok()
1840 .and_then(|t| t.duration_since(std::time::UNIX_EPOCH).ok())
1841 .map(|d| d.as_secs())
1842 .unwrap_or(0);
1843 (m.len(), mtime)
1844 })
1845 .unwrap_or((0, 0));
1846
1847 if let TaskResult::Failed(err) = primary_task.execute().await {
1848 let err = SubXError::FileOperationFailed(err);
1849 outcomes.push(OperationOutcome {
1850 applied: false,
1851 error: Some(operation_error_from(&err)),
1852 });
1853 continue;
1854 }
1855
1856 let (file_size, file_mtime) = journal_destination
1857 .metadata()
1858 .ok()
1859 .map(|m| {
1860 let mtime = m
1861 .modified()
1862 .ok()
1863 .and_then(|t| t.duration_since(std::time::UNIX_EPOCH).ok())
1864 .map(|d| d.as_secs())
1865 .unwrap_or(0);
1866 (m.len(), mtime)
1867 })
1868 .unwrap_or((pre_file_size, pre_file_mtime));
1869
1870 journal.entries.push(JournalEntry {
1871 operation_type: journal_kind,
1872 source: journal_source,
1873 destination: journal_destination,
1874 backup_path: backup_path.clone(),
1875 status: JournalEntryStatus::Completed,
1876 file_size,
1877 file_mtime,
1878 });
1879
1880 if let Some(path) = journal_file.as_ref() {
1881 journal.save(path).await?;
1882 }
1883
1884 outcomes.push(OperationOutcome {
1885 applied: true,
1886 error: None,
1887 });
1888 }
1889
1890 Ok(outcomes)
1891 }
1892
1893 async fn rename_file(&self, op: &MatchOperation) -> Result<()> {
1895 let task = self.create_rename_task(op);
1896 match task.execute().await {
1897 TaskResult::Success(_) => Ok(()),
1898 TaskResult::Failed(err) => Err(SubXError::FileOperationFailed(err)),
1899 other => Err(SubXError::FileOperationFailed(format!(
1900 "Unexpected rename result: {:?}",
1901 other
1902 ))),
1903 }
1904 }
1905
1906 fn resolve_filename_conflict(&self, target: std::path::PathBuf) -> Result<std::path::PathBuf> {
1908 if !target.exists() {
1909 return Ok(target);
1910 }
1911 let json_mode = crate::cli::output::active_mode().is_json();
1912 match self.config.conflict_resolution {
1913 ConflictResolution::Skip => {
1914 if !json_mode {
1915 eprintln!(
1916 "Warning: Skipping relocation due to existing file: {}",
1917 target.display()
1918 );
1919 }
1920 Ok(target)
1921 }
1922 ConflictResolution::AutoRename => {
1923 let file_stem = target
1924 .file_stem()
1925 .and_then(|s| s.to_str())
1926 .unwrap_or("file");
1927 let extension = target.extension().and_then(|s| s.to_str()).unwrap_or("");
1928 let parent = target.parent().unwrap_or_else(|| std::path::Path::new("."));
1929 match crate::core::fs_util::atomic_create_file(&target) {
1933 Ok(_f) => {
1934 let _ = std::fs::remove_file(&target);
1936 return Ok(target);
1937 }
1938 Err(e) if e.kind() == std::io::ErrorKind::AlreadyExists => {}
1939 Err(e) => return Err(SubXError::from(e)),
1940 }
1941 for i in 1..1000 {
1942 let new_name = if extension.is_empty() {
1943 format!("{}.{}", file_stem, i)
1944 } else {
1945 format!("{}.{}.{}", file_stem, i, extension)
1946 };
1947 let new_path = parent.join(new_name);
1948 match crate::core::fs_util::atomic_create_file(&new_path) {
1949 Ok(_f) => {
1950 let _ = std::fs::remove_file(&new_path);
1951 return Ok(new_path);
1952 }
1953 Err(e) if e.kind() == std::io::ErrorKind::AlreadyExists => continue,
1954 Err(e) => return Err(SubXError::from(e)),
1955 }
1956 }
1957 Err(SubXError::FileOperationFailed(
1958 "Could not resolve filename conflict".to_string(),
1959 ))
1960 }
1961 ConflictResolution::Prompt => {
1962 if !json_mode {
1963 eprintln!(
1964 "Warning: Conflict resolution prompt not implemented, using auto-rename"
1965 );
1966 }
1967 self.resolve_filename_conflict(target)
1968 }
1969 }
1970 }
1971
1972 fn create_copy_task(&self, op: &MatchOperation) -> FileProcessingTask {
1974 let source = op.subtitle_file.path.clone();
1976 let target_base = op.relocation_target_path.clone().unwrap();
1977 let final_target = self.resolve_filename_conflict(target_base).unwrap();
1978 FileProcessingTask::new(
1979 source.clone(),
1980 Some(final_target.clone()),
1981 ProcessingOperation::CopyWithRename {
1982 source,
1983 target: final_target,
1984 },
1985 )
1986 }
1987
1988 fn create_backup_task(&self, source: &std::path::Path, ext: &str) -> FileProcessingTask {
1990 let backup_path = source.with_extension(format!("{}.backup", ext));
1991 FileProcessingTask::new(
1992 source.to_path_buf(),
1993 Some(backup_path.clone()),
1994 ProcessingOperation::CreateBackup {
1995 source: source.to_path_buf(),
1996 backup: backup_path,
1997 },
1998 )
1999 }
2000
2001 fn create_rename_task(&self, op: &MatchOperation) -> FileProcessingTask {
2003 let old = op.subtitle_file.path.clone();
2004 let new_path = if op.requires_relocation && op.relocation_target_path.is_some() {
2006 let target_base = op.relocation_target_path.clone().unwrap();
2007 self.resolve_filename_conflict(target_base).unwrap()
2008 } else {
2009 old.with_file_name(&op.new_subtitle_name)
2010 };
2011
2012 FileProcessingTask::new(
2013 old.clone(),
2014 Some(new_path.clone()),
2015 ProcessingOperation::RenameFile {
2016 source: old,
2017 target: new_path,
2018 },
2019 )
2020 }
2021
2022 fn calculate_file_list_cache_key(&self, file_paths: &[PathBuf]) -> Result<String> {
2024 use std::collections::BTreeMap;
2025 use std::collections::hash_map::DefaultHasher;
2026 use std::hash::{Hash, Hasher};
2027
2028 let mut path_metadata = BTreeMap::new();
2030 for path in file_paths {
2031 if let Ok(metadata) = path.metadata() {
2032 let canonical = path.canonicalize().unwrap_or_else(|_| path.to_path_buf());
2033 path_metadata.insert(
2034 canonical.to_string_lossy().to_string(),
2035 (metadata.len(), metadata.modified().ok()),
2036 );
2037 }
2038 }
2039
2040 let config_hash = self.calculate_config_hash()?;
2042
2043 let mut hasher = DefaultHasher::new();
2044 path_metadata.hash(&mut hasher);
2045 config_hash.hash(&mut hasher);
2046
2047 Ok(format!("filelist_{:016x}", hasher.finish()))
2048 }
2049
2050 async fn check_file_list_cache(&self, cache_key: &str) -> Result<Option<Vec<MatchOperation>>> {
2052 let cache_file_path = self.get_cache_file_path()?;
2053 let cache_data = CacheData::load(&cache_file_path).ok();
2054
2055 if let Some(cache_data) = cache_data {
2056 if !is_cache_version_current(&cache_data.cache_version) {
2057 return Ok(None);
2058 }
2059 if cache_data.directory == cache_key {
2060 let mut ops = Vec::new();
2062 let mut id_gen = Uuidv7Generator::new();
2063 for item in cache_data.match_operations {
2064 let video_path = PathBuf::from(&item.video_file);
2066 let subtitle_path = PathBuf::from(&item.subtitle_file);
2067
2068 if video_path.exists() && subtitle_path.exists() {
2069 let video_meta = video_path.metadata()?;
2071 let subtitle_meta = subtitle_path.metadata()?;
2072
2073 let video_file = MediaFile {
2074 id: generate_file_id(&mut id_gen),
2075 path: video_path.clone(),
2076 file_type: MediaFileType::Video,
2077 size: video_meta.len(),
2078 name: video_path
2079 .file_name()
2080 .unwrap()
2081 .to_string_lossy()
2082 .to_string(),
2083 extension: video_path
2084 .extension()
2085 .unwrap_or_default()
2086 .to_string_lossy()
2087 .to_lowercase(),
2088 relative_path: video_path
2089 .file_name()
2090 .unwrap()
2091 .to_string_lossy()
2092 .to_string(),
2093 };
2094
2095 let subtitle_file = MediaFile {
2096 id: generate_file_id(&mut id_gen),
2097 path: subtitle_path.clone(),
2098 file_type: MediaFileType::Subtitle,
2099 size: subtitle_meta.len(),
2100 name: subtitle_path
2101 .file_name()
2102 .unwrap()
2103 .to_string_lossy()
2104 .to_string(),
2105 extension: subtitle_path
2106 .extension()
2107 .unwrap_or_default()
2108 .to_string_lossy()
2109 .to_lowercase(),
2110 relative_path: subtitle_path
2111 .file_name()
2112 .unwrap()
2113 .to_string_lossy()
2114 .to_string(),
2115 };
2116
2117 let requires_relocation = self.config.relocation_mode
2119 != FileRelocationMode::None
2120 && subtitle_file.path.parent() != video_file.path.parent();
2121
2122 let relocation_target_path = if requires_relocation {
2123 let video_dir = video_file.path.parent().unwrap();
2124 Some(video_dir.join(&item.new_subtitle_name))
2125 } else {
2126 None
2127 };
2128
2129 ops.push(MatchOperation {
2130 video_file,
2131 subtitle_file,
2132 new_subtitle_name: item.new_subtitle_name,
2133 confidence: item.confidence,
2134 reasoning: item.reasoning,
2135 relocation_mode: self.config.relocation_mode.clone(),
2136 relocation_target_path,
2137 requires_relocation,
2138 });
2139 }
2140 }
2141 return Ok(Some(ops));
2142 }
2143 }
2144 Ok(None)
2145 }
2146
2147 async fn save_file_list_cache(
2149 &self,
2150 cache_key: &str,
2151 operations: &[MatchOperation],
2152 ) -> Result<()> {
2153 let cache_file_path = self.get_cache_file_path()?;
2154 let config_hash = self.calculate_config_hash()?;
2155
2156 let mut cache_items = Vec::new();
2157 for op in operations {
2158 cache_items.push(OpItem {
2159 video_file: op.video_file.path.to_string_lossy().to_string(),
2160 subtitle_file: op.subtitle_file.path.to_string_lossy().to_string(),
2161 new_subtitle_name: op.new_subtitle_name.clone(),
2162 confidence: op.confidence,
2163 reasoning: op.reasoning.clone(),
2164 });
2165 }
2166
2167 let mut snapshot_items = Vec::new();
2169 let mut seen_paths = std::collections::HashSet::new();
2170 for op in operations {
2171 for path in [&op.video_file.path, &op.subtitle_file.path] {
2172 let canonical = path.canonicalize().unwrap_or_else(|_| path.clone());
2173 let key = canonical.to_string_lossy().to_string();
2174 if seen_paths.insert(key.clone()) {
2175 if let Ok(meta) = std::fs::metadata(&canonical) {
2176 let mtime = meta
2177 .modified()
2178 .ok()
2179 .and_then(|t| t.duration_since(std::time::UNIX_EPOCH).ok())
2180 .map(|d| d.as_secs())
2181 .unwrap_or(0);
2182 snapshot_items.push(SnapshotItem {
2183 path: key,
2184 name: canonical
2185 .file_name()
2186 .unwrap_or_default()
2187 .to_string_lossy()
2188 .to_string(),
2189 size: meta.len(),
2190 mtime,
2191 file_type: if canonical.extension().is_some_and(|e| {
2192 ["srt", "ass", "ssa", "vtt", "sub"]
2193 .contains(&e.to_string_lossy().to_lowercase().as_str())
2194 }) {
2195 "subtitle".to_string()
2196 } else {
2197 "video".to_string()
2198 },
2199 });
2200 }
2201 }
2202 }
2203 }
2204
2205 let cache_data = CacheData {
2206 cache_version: CURRENT_CACHE_VERSION.to_string(),
2207 directory: cache_key.to_string(),
2208 file_snapshot: snapshot_items,
2209 match_operations: cache_items,
2210 created_at: std::time::SystemTime::now()
2211 .duration_since(std::time::UNIX_EPOCH)
2212 .unwrap()
2213 .as_secs(),
2214 ai_model_used: self.config.ai_model.clone(),
2215 config_hash,
2216 original_relocation_mode: format!("{:?}", self.config.relocation_mode),
2217 original_backup_enabled: self.config.backup_enabled,
2218 };
2219
2220 let cache_dir = cache_file_path.parent().unwrap().to_path_buf();
2222 let cache_json = serde_json::to_string_pretty(&cache_data)?;
2223 let cache_file_path_clone = cache_file_path.clone();
2224 tokio::task::spawn_blocking(move || -> std::io::Result<()> {
2225 std::fs::create_dir_all(&cache_dir)?;
2226 std::fs::write(&cache_file_path_clone, cache_json)?;
2227 Ok(())
2228 })
2229 .await
2230 .map_err(|e| SubXError::Io(std::io::Error::other(e.to_string())))??;
2231
2232 Ok(())
2233 }
2234
2235 fn get_cache_file_path(&self) -> Result<std::path::PathBuf> {
2237 let dir = if let Some(xdg_config) = std::env::var_os("XDG_CONFIG_HOME") {
2239 std::path::PathBuf::from(xdg_config)
2240 } else {
2241 dirs::config_dir()
2242 .ok_or_else(|| SubXError::config("Unable to determine cache directory"))?
2243 };
2244 Ok(dir.join("subx").join("match_cache.json"))
2245 }
2246
2247 fn calculate_config_hash(&self) -> Result<String> {
2249 use std::collections::hash_map::DefaultHasher;
2250 use std::hash::{Hash, Hasher};
2251
2252 let mut hasher = DefaultHasher::new();
2253 format!("{:?}", self.config.relocation_mode).hash(&mut hasher);
2255 self.config.backup_enabled.hash(&mut hasher);
2256 "prompt_v2".hash(&mut hasher);
2259 Ok(format!("{:016x}", hasher.finish()))
2262 }
2263
2264 fn find_media_file_by_id_or_path<'a>(
2266 files: &'a [&MediaFile],
2267 file_id: &str,
2268 fallback_path: Option<&str>,
2269 ) -> Option<&'a MediaFile> {
2270 if let Some(file) = files.iter().find(|f| f.id == file_id) {
2271 return Some(*file);
2272 }
2273 if let Some(path) = fallback_path {
2274 if let Some(file) = files.iter().find(|f| f.relative_path == path) {
2275 return Some(*file);
2276 }
2277 files.iter().find(|f| f.name == path).copied()
2278 } else {
2279 None
2280 }
2281 }
2282
2283 fn log_available_files(&self, files: &[&MediaFile], file_type: &str) {
2285 if crate::cli::output::active_mode().is_json() {
2286 return;
2287 }
2288 eprintln!(" Available {} files:", file_type);
2289 for f in files {
2290 eprintln!(
2291 " - ID: {} | Name: {} | Path: {}",
2292 f.id, f.name, f.relative_path
2293 );
2294 }
2295 }
2296
2297 fn log_no_matches_found(
2299 &self,
2300 match_result: &MatchResult,
2301 videos: &[MediaFile],
2302 subtitles: &[MediaFile],
2303 ) {
2304 if crate::cli::output::active_mode().is_json() {
2305 return;
2306 }
2307 eprintln!("\n❌ No matching files found that meet the criteria");
2308 eprintln!("🔍 AI analysis results:");
2309 eprintln!(" - Total matches: {}", match_result.matches.len());
2310 eprintln!(
2311 " - Confidence threshold: {:.2}",
2312 self.config.confidence_threshold
2313 );
2314 eprintln!(
2315 " - Matches meeting threshold: {}",
2316 match_result
2317 .matches
2318 .iter()
2319 .filter(|m| m.confidence >= self.config.confidence_threshold)
2320 .count()
2321 );
2322 eprintln!("\n📂 Scanned files:");
2323 eprintln!(" Video files ({} files):", videos.len());
2324 for v in videos {
2325 eprintln!(" - ID: {} | {}", v.id, v.relative_path);
2326 }
2327 eprintln!(" Subtitle files ({} files):", subtitles.len());
2328 for s in subtitles {
2329 eprintln!(" - ID: {} | {}", s.id, s.relative_path);
2330 }
2331 }
2332}
2333
2334pub async fn apply_cached_operations(cache: &CacheData, config: &MatchConfig) -> Result<()> {
2352 let operations = reconstruct_operations_from_cache(cache, config)?;
2353 let engine = MatchEngine::new(Box::new(NoOpAIProvider), config.clone());
2354 engine.execute_operations(&operations, false).await
2355}
2356
2357fn reconstruct_operations_from_cache(
2363 cache: &CacheData,
2364 config: &MatchConfig,
2365) -> Result<Vec<MatchOperation>> {
2366 let mut ops = Vec::new();
2367 let mut id_gen = Uuidv7Generator::new();
2368 for item in &cache.match_operations {
2369 let video_path = PathBuf::from(&item.video_file);
2370 let subtitle_path = PathBuf::from(&item.subtitle_file);
2371
2372 if !video_path.exists() || !subtitle_path.exists() {
2373 continue;
2374 }
2375
2376 let video_meta = video_path.metadata()?;
2377 let subtitle_meta = subtitle_path.metadata()?;
2378
2379 let video_file = MediaFile {
2380 id: generate_file_id(&mut id_gen),
2381 path: video_path.clone(),
2382 file_type: MediaFileType::Video,
2383 size: video_meta.len(),
2384 name: video_path
2385 .file_name()
2386 .unwrap_or_default()
2387 .to_string_lossy()
2388 .to_string(),
2389 extension: video_path
2390 .extension()
2391 .unwrap_or_default()
2392 .to_string_lossy()
2393 .to_lowercase(),
2394 relative_path: video_path
2395 .file_name()
2396 .unwrap_or_default()
2397 .to_string_lossy()
2398 .to_string(),
2399 };
2400
2401 let subtitle_file = MediaFile {
2402 id: generate_file_id(&mut id_gen),
2403 path: subtitle_path.clone(),
2404 file_type: MediaFileType::Subtitle,
2405 size: subtitle_meta.len(),
2406 name: subtitle_path
2407 .file_name()
2408 .unwrap_or_default()
2409 .to_string_lossy()
2410 .to_string(),
2411 extension: subtitle_path
2412 .extension()
2413 .unwrap_or_default()
2414 .to_string_lossy()
2415 .to_lowercase(),
2416 relative_path: subtitle_path
2417 .file_name()
2418 .unwrap_or_default()
2419 .to_string_lossy()
2420 .to_string(),
2421 };
2422
2423 let requires_relocation = config.relocation_mode != FileRelocationMode::None
2424 && subtitle_file.path.parent() != video_file.path.parent();
2425 let relocation_target_path = if requires_relocation {
2426 video_file
2427 .path
2428 .parent()
2429 .map(|p| p.join(&item.new_subtitle_name))
2430 } else {
2431 None
2432 };
2433
2434 ops.push(MatchOperation {
2435 video_file,
2436 subtitle_file,
2437 new_subtitle_name: item.new_subtitle_name.clone(),
2438 confidence: item.confidence,
2439 reasoning: item.reasoning.clone(),
2440 relocation_mode: config.relocation_mode.clone(),
2441 relocation_target_path,
2442 requires_relocation,
2443 });
2444 }
2445 Ok(ops)
2446}
2447
2448struct NoOpAIProvider;
2454
2455#[async_trait::async_trait]
2456impl AIProvider for NoOpAIProvider {
2457 async fn analyze_content(&self, _request: AnalysisRequest) -> crate::Result<MatchResult> {
2458 Err(SubXError::config(
2459 "AI analysis is not available while replaying cached operations",
2460 ))
2461 }
2462
2463 async fn verify_match(
2464 &self,
2465 _verification: crate::services::ai::VerificationRequest,
2466 ) -> crate::Result<crate::services::ai::ConfidenceScore> {
2467 Err(SubXError::config(
2468 "AI verification is not available while replaying cached operations",
2469 ))
2470 }
2471}