1use crate::Result;
24use crate::cli::output::{OutputMode, active_mode, emit_success};
25use crate::cli::{ApplyArgs, CacheArgs, ClearArgs, ClearType, RollbackArgs, StatusArgs};
26use crate::config::ConfigService;
27use crate::core::lock::acquire_subx_lock;
28use crate::core::matcher::cache::CacheData;
29use crate::core::matcher::engine::{FileRelocationMode, MatchConfig, apply_cached_operations};
30use crate::core::matcher::journal::{
31 JournalData, JournalEntry, JournalEntryStatus, JournalOperationType,
32};
33use crate::error::SubXError;
34use serde::Serialize;
35use std::io::IsTerminal;
36use std::path::{Path, PathBuf};
37use std::time::{SystemTime, UNIX_EPOCH};
38
39#[derive(Debug, Serialize)]
47pub struct CacheItemError {
48 pub category: String,
51 pub code: String,
54 pub message: String,
56}
57
58impl CacheItemError {
59 fn from_error(err: &SubXError) -> Self {
60 Self {
61 category: err.category().to_string(),
62 code: err.machine_code().to_string(),
63 message: err.user_friendly_message(),
64 }
65 }
66}
67
68#[derive(Debug, Serialize)]
70pub struct StaleFileInfo {
71 pub path: String,
73 pub reason: String,
75}
76
77#[derive(Debug, Serialize)]
83pub struct CacheStatusPayload {
84 pub path: String,
86 pub exists: bool,
88 pub journal_present: bool,
90 pub total: u64,
92 pub pending: u64,
94 pub applied: u64,
96 #[serde(skip_serializing_if = "Option::is_none")]
98 pub size_bytes: Option<u64>,
99 #[serde(skip_serializing_if = "Option::is_none")]
101 pub created_at: Option<u64>,
102 #[serde(skip_serializing_if = "Option::is_none")]
104 pub age_seconds: Option<u64>,
105 #[serde(skip_serializing_if = "Option::is_none")]
107 pub cache_version: Option<String>,
108 #[serde(skip_serializing_if = "Option::is_none")]
110 pub ai_model: Option<String>,
111 #[serde(skip_serializing_if = "Option::is_none")]
113 pub operation_count: Option<usize>,
114 #[serde(skip_serializing_if = "Option::is_none")]
116 pub config_hash: Option<String>,
117 #[serde(skip_serializing_if = "Option::is_none")]
119 pub current_config_hash: Option<String>,
120 #[serde(skip_serializing_if = "Option::is_none")]
122 pub config_hash_match: Option<bool>,
123 #[serde(skip_serializing_if = "Option::is_none")]
125 pub snapshot_status: Option<&'static str>,
126 #[serde(skip_serializing_if = "Option::is_none")]
128 pub stale_files: Option<Vec<StaleFileInfo>>,
129}
130
131#[derive(Debug, Serialize)]
137pub struct CacheClearPayload {
138 pub removed: u64,
140 pub kind: &'static str,
142 pub cache_path: String,
144 pub cache_removed: bool,
146 pub journal_path: String,
148 pub journal_removed: bool,
150}
151
152#[derive(Debug, Serialize)]
154pub struct CacheRollbackPayload {
155 pub rolled_back: u64,
157}
158
159#[derive(Debug, Serialize)]
161pub struct CacheApplyItem {
162 pub id: String,
165 pub status: &'static str,
167 #[serde(skip_serializing_if = "Option::is_none")]
169 pub error: Option<CacheItemError>,
170}
171
172#[derive(Debug, Serialize)]
178pub struct CacheApplyPayload {
179 pub applied: u64,
181 pub failed: u64,
183 pub items: Vec<CacheApplyItem>,
185}
186
187async fn journal_counters(path: &Path) -> (u64, u64) {
190 if !path.exists() {
191 return (0, 0);
192 }
193 match JournalData::load(path).await {
194 Ok(j) => {
195 let mut pending = 0u64;
196 let mut applied = 0u64;
197 for entry in &j.entries {
198 match entry.status {
199 JournalEntryStatus::Pending => pending += 1,
200 JournalEntryStatus::Completed => applied += 1,
201 }
202 }
203 (pending, applied)
204 }
205 Err(_) => (0, 0),
206 }
207}
208
209fn get_config_dir() -> Result<PathBuf> {
215 if let Some(xdg_config) = std::env::var_os("XDG_CONFIG_HOME") {
216 Ok(PathBuf::from(xdg_config))
217 } else {
218 dirs::config_dir().ok_or_else(|| SubXError::config("Unable to determine config directory"))
219 }
220}
221
222fn cache_path() -> Result<PathBuf> {
224 Ok(get_config_dir()?.join("subx").join("match_cache.json"))
225}
226
227fn journal_path() -> Result<PathBuf> {
229 Ok(get_config_dir()?.join("subx").join("match_journal.json"))
230}
231
232fn clear_file(path: &Path, label: &str) -> Result<bool> {
238 let json_mode = active_mode().is_json();
239 if path.exists() {
240 std::fs::remove_file(path)?;
241 if !json_mode {
242 println!("{} cleared: {}", label, path.display());
243 }
244 Ok(true)
245 } else {
246 if !json_mode {
247 println!("{} not found: {}", label, path.display());
248 }
249 Ok(false)
250 }
251}
252
253async fn execute_clear(args: &ClearArgs) -> Result<()> {
259 let _lock = acquire_subx_lock().await?;
260 let config_dir = get_config_dir()?;
261 let cache_file = config_dir.join("subx").join("match_cache.json");
262 let journal_file = config_dir.join("subx").join("match_journal.json");
263
264 let json_mode = active_mode().is_json();
265 let mut cache_removed = false;
266 let mut journal_removed = false;
267
268 match args.r#type {
269 ClearType::Cache => {
270 cache_removed = clear_file(&cache_file, "Cache")?;
271 }
272 ClearType::Journal => {
273 journal_removed = clear_file(&journal_file, "Journal")?;
274 }
275 ClearType::All => {
276 cache_removed = clear_file(&cache_file, "Cache")?;
277 journal_removed = clear_file(&journal_file, "Journal")?;
278 }
279 }
280
281 let removed = u64::from(cache_removed) + u64::from(journal_removed);
282
283 if json_mode {
284 let kind = match args.r#type {
285 ClearType::Cache => "cache",
286 ClearType::Journal => "journal",
287 ClearType::All => "all",
288 };
289 let payload = CacheClearPayload {
290 removed,
291 kind,
292 cache_path: cache_file.to_string_lossy().into_owned(),
293 cache_removed,
294 journal_path: journal_file.to_string_lossy().into_owned(),
295 journal_removed,
296 };
297 emit_success(OutputMode::Json, "cache", payload);
298 } else if removed == 0 {
299 println!("No cache files found to clear.");
300 }
301 Ok(())
302}
303
304fn compute_config_hash(relocation_mode_debug: &str, backup_enabled: bool) -> String {
311 use std::collections::hash_map::DefaultHasher;
312 use std::hash::{Hash, Hasher};
313 let mut hasher = DefaultHasher::new();
314 relocation_mode_debug.hash(&mut hasher);
315 backup_enabled.hash(&mut hasher);
316 "prompt_v2".hash(&mut hasher);
321 format!("{:016x}", hasher.finish())
322}
323
324fn current_config_hash(config_service: &dyn ConfigService) -> Result<String> {
328 let config = config_service.get_config()?;
329 Ok(compute_config_hash("None", config.general.backup_enabled))
330}
331
332fn format_size(bytes: u64) -> String {
334 const KB: f64 = 1024.0;
335 const MB: f64 = KB * 1024.0;
336 const GB: f64 = MB * 1024.0;
337 let b = bytes as f64;
338 if b >= GB {
339 format!("{:.1} GB", b / GB)
340 } else if b >= MB {
341 format!("{:.1} MB", b / MB)
342 } else if b >= KB {
343 format!("{:.1} KB", b / KB)
344 } else {
345 format!("{} B", bytes)
346 }
347}
348
349fn format_age(age_secs: u64) -> String {
351 const MIN: u64 = 60;
352 const HOUR: u64 = 60 * MIN;
353 const DAY: u64 = 24 * HOUR;
354 if age_secs < MIN {
355 format!("{} seconds ago", age_secs)
356 } else if age_secs < HOUR {
357 format!("{} minutes ago", age_secs / MIN)
358 } else if age_secs < DAY {
359 format!("{} hours ago", age_secs / HOUR)
360 } else {
361 format!("{} days ago", age_secs / DAY)
362 }
363}
364
365fn describe_snapshot(cache: &CacheData) -> (String, &'static str) {
371 if cache.has_empty_snapshot() {
372 ("Empty (legacy cache)".to_string(), "empty")
373 } else {
374 let stale = cache.validate_snapshot();
375 if stale.is_empty() {
376 ("Valid".to_string(), "valid")
377 } else {
378 (format!("Stale ({} files changed)", stale.len()), "stale")
379 }
380 }
381}
382
383pub async fn execute_status(args: &StatusArgs, config_service: &dyn ConfigService) -> Result<()> {
408 let cache_file = cache_path()?;
409 let journal_file = journal_path()?;
410 let json_mode = active_mode().is_json() || args.json;
414
415 if !cache_file.exists() {
416 let journal_present = journal_file.exists();
417 let (pending, applied) = journal_counters(&journal_file).await;
418 if json_mode {
419 let payload = CacheStatusPayload {
420 path: cache_file.to_string_lossy().into_owned(),
421 exists: false,
422 journal_present,
423 total: 0,
424 pending,
425 applied,
426 size_bytes: None,
427 created_at: None,
428 age_seconds: None,
429 cache_version: None,
430 ai_model: None,
431 operation_count: None,
432 config_hash: None,
433 current_config_hash: None,
434 config_hash_match: None,
435 snapshot_status: None,
436 stale_files: None,
437 };
438 emit_success(OutputMode::Json, "cache", payload);
439 } else {
440 println!("No cache found at {}", cache_file.display());
441 }
442 return Ok(());
443 }
444
445 let cache = CacheData::load(&cache_file).map_err(|e| {
446 SubXError::config(format!(
447 "Failed to load cache at {}: {}",
448 cache_file.display(),
449 e
450 ))
451 })?;
452
453 let metadata = std::fs::metadata(&cache_file)?;
454 let size_bytes = metadata.len();
455
456 let now_secs = SystemTime::now()
457 .duration_since(UNIX_EPOCH)
458 .map(|d| d.as_secs())
459 .unwrap_or(0);
460 let age_secs = now_secs.saturating_sub(cache.created_at);
461
462 let current_hash = current_config_hash(config_service)?;
463 let hash_match = current_hash == cache.config_hash;
464
465 let (snapshot_label, snapshot_status) = describe_snapshot(&cache);
466 let stale_entries = if snapshot_status == "stale" {
467 cache.validate_snapshot()
468 } else {
469 Vec::new()
470 };
471 let journal_present = journal_file.exists();
472 let (pending, applied) = journal_counters(&journal_file).await;
473 let total = cache.match_operations.len() as u64;
474
475 if json_mode {
476 let stale_files: Vec<StaleFileInfo> = stale_entries
477 .iter()
478 .map(|s| StaleFileInfo {
479 path: s.path.clone(),
480 reason: s.reason.clone(),
481 })
482 .collect();
483 let payload = CacheStatusPayload {
484 path: cache_file.to_string_lossy().into_owned(),
485 exists: true,
486 journal_present,
487 total,
488 pending,
489 applied,
490 size_bytes: Some(size_bytes),
491 created_at: Some(cache.created_at),
492 age_seconds: Some(age_secs),
493 cache_version: Some(cache.cache_version.clone()),
494 ai_model: Some(cache.ai_model_used.clone()),
495 operation_count: Some(cache.match_operations.len()),
496 config_hash: Some(cache.config_hash.clone()),
497 current_config_hash: Some(current_hash),
498 config_hash_match: Some(hash_match),
499 snapshot_status: Some(snapshot_status),
500 stale_files: Some(stale_files),
501 };
502 emit_success(OutputMode::Json, "cache", payload);
503 } else {
504 let config_line = if hash_match {
505 "✓ (matches current)".to_string()
506 } else {
507 format!("✗ (differs from current: {})", current_hash)
508 };
509 let journal_line = if journal_present {
510 "Present"
511 } else {
512 "Not found"
513 };
514
515 println!("Cache Status");
516 println!("============");
517 println!("Path: {}", cache_file.display());
518 println!("Size: {}", format_size(size_bytes));
519 println!("Age: {}", format_age(age_secs));
520 println!("Cache version: {}", cache.cache_version);
521 println!("AI model: {}", cache.ai_model_used);
522 println!("Operations: {}", cache.match_operations.len());
523 println!("Config hash: {}", cache.config_hash);
524 println!("Config match: {}", config_line);
525 println!("Snapshot: {}", snapshot_label);
526 println!("Journal: {}", journal_line);
527 }
528
529 Ok(())
530}
531
532pub async fn execute_apply(args: &ApplyArgs, config_service: &dyn ConfigService) -> Result<()> {
560 let _lock = acquire_subx_lock().await?;
561 let json_mode = active_mode().is_json();
562
563 let cache_file = cache_path()?;
564 if !cache_file.exists() {
565 if json_mode {
566 emit_success(
568 OutputMode::Json,
569 "cache",
570 CacheApplyPayload {
571 applied: 0,
572 failed: 0,
573 items: Vec::new(),
574 },
575 );
576 } else {
577 println!(
578 "No cache found at {}. Run a dry-run match first.",
579 cache_file.display()
580 );
581 }
582 return Ok(());
583 }
584
585 let mut cache = CacheData::load(&cache_file).map_err(|e| {
586 SubXError::config(format!(
587 "Failed to load cache at {}: {}",
588 cache_file.display(),
589 e
590 ))
591 })?;
592
593 let config = config_service.get_config()?;
595 let apply_hash = compute_config_hash(
596 &cache.original_relocation_mode,
597 config.general.backup_enabled,
598 );
599 if apply_hash != cache.config_hash && !args.force {
600 return Err(SubXError::config(format!(
601 "Configuration has changed since the cache was created.\n\
602 Cache hash: {}\n\
603 Current hash: {}\n\
604 Use --force to bypass this check.",
605 cache.config_hash, apply_hash
606 )));
607 }
608
609 if cache.has_empty_snapshot() && !args.force {
611 return Err(SubXError::config(
612 "Cache was created without file snapshot data (legacy format).\n\
613 Cannot verify file integrity. Use --force to apply anyway."
614 .to_string(),
615 ));
616 }
617
618 if !args.force && !cache.has_empty_snapshot() {
620 let stale = cache.validate_snapshot();
621 if !stale.is_empty() {
622 let mut msg = format!(
623 "{} source file(s) have changed since the cache was created:\n",
624 stale.len()
625 );
626 for s in &stale {
627 msg.push_str(&format!(" - {} ({})\n", s.path, s.reason));
628 }
629 msg.push_str("Use --force to apply anyway.");
630 return Err(SubXError::config(msg));
631 }
632 }
633
634 if !args.force {
636 let conflicts = cache.validate_target_paths();
637 if !conflicts.is_empty() {
638 let mut msg = format!("{} target path(s) already exist:\n", conflicts.len());
639 for p in &conflicts {
640 msg.push_str(&format!(" - {}\n", p.display()));
641 }
642 msg.push_str("Use --force to apply anyway.");
643 return Err(SubXError::config(msg));
644 }
645 }
646
647 if let Some(min_conf) = args.confidence {
649 let threshold = f32::from(min_conf) / 100.0;
650 let before = cache.match_operations.len();
651 cache
652 .match_operations
653 .retain(|op| op.confidence >= threshold);
654 let after = cache.match_operations.len();
655 if before != after && !json_mode {
656 println!(
657 "Filtered {} operation(s) below {}% confidence.",
658 before - after,
659 min_conf
660 );
661 }
662 }
663
664 if cache.match_operations.is_empty() {
665 if json_mode {
666 emit_success(
667 OutputMode::Json,
668 "cache",
669 CacheApplyPayload {
670 applied: 0,
671 failed: 0,
672 items: Vec::new(),
673 },
674 );
675 } else {
676 println!("No operations to apply.");
677 }
678 return Ok(());
679 }
680
681 if !json_mode {
682 println!("Cache Apply Summary");
684 println!("===================");
685 println!("Operations: {}", cache.match_operations.len());
686 println!("AI model: {}", cache.ai_model_used);
687 println!("Relocation mode: {}", cache.original_relocation_mode);
688 println!();
689 for (i, op) in cache.match_operations.iter().enumerate() {
690 println!(
691 " {}. {} → {} (confidence: {:.0}%)",
692 i + 1,
693 op.subtitle_file,
694 op.new_subtitle_name,
695 op.confidence * 100.0
696 );
697 }
698 println!();
699 }
700
701 if !args.yes {
705 if json_mode {
706 return Err(SubXError::CommandExecution(
707 "cache apply in JSON output mode requires --yes (interactive confirmation \
708 would write to stdout and corrupt the JSON envelope)."
709 .to_string(),
710 ));
711 }
712 if !std::io::stdin().is_terminal() {
713 return Err(SubXError::config(
714 "Non-interactive terminal detected. Use --yes to skip confirmation.".to_string(),
715 ));
716 }
717 print!("Proceed with apply? [y/N] ");
718 use std::io::Write;
719 std::io::stdout().flush()?;
720 let mut input = String::new();
721 std::io::stdin().read_line(&mut input)?;
722 if !input.trim().eq_ignore_ascii_case("y") {
723 println!("Apply cancelled.");
724 return Ok(());
725 }
726 }
727
728 let config = config_service.get_config()?;
730 let relocation_mode = parse_relocation_mode(&cache.original_relocation_mode);
731 let match_config = MatchConfig {
732 confidence_threshold: 0.0,
733 max_sample_length: 2000,
734 enable_content_analysis: true,
735 backup_enabled: cache.original_backup_enabled,
736 relocation_mode,
737 conflict_resolution: crate::core::matcher::engine::ConflictResolution::Skip,
738 ai_model: cache.ai_model_used.clone(),
739 max_subtitle_bytes: config.general.max_subtitle_bytes,
740 };
741
742 if json_mode {
743 let mut items: Vec<CacheApplyItem> = Vec::with_capacity(cache.match_operations.len());
747 let mut applied = 0u64;
748 let mut failed = 0u64;
749
750 for op in &cache.match_operations {
751 let id = op.subtitle_file.clone();
752 let video_exists = std::path::Path::new(&op.video_file).exists();
753 let sub_exists = std::path::Path::new(&op.subtitle_file).exists();
754 if !video_exists || !sub_exists {
755 let missing = if !sub_exists {
756 op.subtitle_file.clone()
757 } else {
758 op.video_file.clone()
759 };
760 let err = SubXError::FileNotFound(missing);
761 items.push(CacheApplyItem {
762 id,
763 status: "error",
764 error: Some(CacheItemError::from_error(&err)),
765 });
766 failed += 1;
767 continue;
768 }
769
770 let mut single = cache.clone();
771 single.match_operations = vec![op.clone()];
772 match apply_cached_operations(&single, &match_config).await {
773 Ok(()) => {
774 applied += 1;
775 items.push(CacheApplyItem {
776 id,
777 status: "ok",
778 error: None,
779 });
780 }
781 Err(e) => {
782 failed += 1;
783 items.push(CacheApplyItem {
784 id,
785 status: "error",
786 error: Some(CacheItemError::from_error(&e)),
787 });
788 }
789 }
790 }
791
792 emit_success(
793 OutputMode::Json,
794 "cache",
795 CacheApplyPayload {
796 applied,
797 failed,
798 items,
799 },
800 );
801 } else {
802 apply_cached_operations(&cache, &match_config).await?;
803 println!("Apply complete.");
804 }
805 Ok(())
806}
807
808fn parse_relocation_mode(s: &str) -> FileRelocationMode {
810 match s {
811 "Copy" => FileRelocationMode::Copy,
812 "Move" => FileRelocationMode::Move,
813 _ => FileRelocationMode::None,
814 }
815}
816
817fn verify_destination_integrity(entry: &JournalEntry) -> Result<()> {
825 let metadata = match std::fs::metadata(&entry.destination) {
826 Ok(m) => m,
827 Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
828 return Err(SubXError::config(format!(
829 "Destination file {} no longer exists. Use --force to override.",
830 entry.destination.display()
831 )));
832 }
833 Err(e) => return Err(SubXError::Io(e)),
834 };
835
836 if metadata.len() != entry.file_size {
837 return Err(SubXError::config(format!(
838 "Destination file {} has been modified since the operation (size differs). \
839 Use --force to override.",
840 entry.destination.display()
841 )));
842 }
843
844 let mtime_secs = metadata
845 .modified()
846 .ok()
847 .and_then(|m| m.duration_since(UNIX_EPOCH).ok())
848 .map(|d| d.as_secs());
849
850 if let Some(actual) = mtime_secs {
851 if actual != entry.file_mtime {
852 return Err(SubXError::config(format!(
853 "Destination file {} has been modified since the operation (mtime differs). \
854 Use --force to override.",
855 entry.destination.display()
856 )));
857 }
858 }
859
860 Ok(())
861}
862
863fn rollback_entry(entry: &JournalEntry, force: bool) -> Result<()> {
877 let json_mode = active_mode().is_json();
878 match entry.operation_type {
879 JournalOperationType::Copied => {
880 std::fs::remove_file(&entry.destination)?;
881 if !json_mode {
882 println!("Removed copy: {}", entry.destination.display());
883 }
884 }
885 JournalOperationType::Moved | JournalOperationType::Renamed => {
886 if entry.source.exists() && !force {
887 return Err(SubXError::config(format!(
888 "Original source path {} already exists. \
889 Rollback would overwrite it. Use --force to override.",
890 entry.source.display()
891 )));
892 }
893 if let Some(parent) = entry.source.parent() {
894 if !parent.as_os_str().is_empty() {
895 std::fs::create_dir_all(parent)?;
896 }
897 }
898 std::fs::rename(&entry.destination, &entry.source)?;
899 if !json_mode {
900 println!(
901 "Rolled back: {} \u{2190} {}",
902 entry.source.display(),
903 entry.destination.display()
904 );
905 }
906 }
907 }
908
909 if let Some(backup) = &entry.backup_path {
910 if backup.exists() {
911 std::fs::remove_file(backup)?;
912 if !json_mode {
913 println!("Removed backup: {}", backup.display());
914 }
915 }
916 }
917
918 Ok(())
919}
920
921pub async fn execute_rollback(args: &RollbackArgs) -> Result<()> {
933 let _lock = acquire_subx_lock().await?;
934 let json_mode = active_mode().is_json();
935
936 let journal_file = journal_path()?;
937 if !journal_file.exists() {
938 if json_mode {
939 emit_success(
940 OutputMode::Json,
941 "cache",
942 CacheRollbackPayload { rolled_back: 0 },
943 );
944 } else {
945 println!("No operation journal found. Nothing to rollback.");
946 }
947 return Ok(());
948 }
949
950 let journal = JournalData::load(&journal_file).await?;
951
952 let reversed: Vec<&JournalEntry> = journal
953 .entries
954 .iter()
955 .filter(|e| e.status == JournalEntryStatus::Completed)
956 .rev()
957 .collect();
958
959 if reversed.is_empty() {
960 if json_mode {
961 emit_success(
962 OutputMode::Json,
963 "cache",
964 CacheRollbackPayload { rolled_back: 0 },
965 );
966 } else {
967 println!("Journal has no completed operations to rollback.");
968 }
969 return Ok(());
970 }
971
972 if !json_mode {
973 println!(
974 "Rolling back {} operations from batch {}...",
975 reversed.len(),
976 journal.batch_id
977 );
978 }
979
980 let mut rolled_back: u64 = 0;
981 for entry in &reversed {
982 if !args.force {
983 verify_destination_integrity(entry)?;
984 }
985 rollback_entry(entry, args.force)?;
986 rolled_back += 1;
987 }
988
989 std::fs::remove_file(&journal_file)?;
990
991 if json_mode {
992 emit_success(
993 OutputMode::Json,
994 "cache",
995 CacheRollbackPayload { rolled_back },
996 );
997 } else {
998 println!("Rollback complete. Journal deleted.");
999 }
1000 Ok(())
1001}
1002
1003pub async fn execute(args: CacheArgs) -> Result<()> {
1008 match args.action {
1009 crate::cli::CacheAction::Clear(clear_args) => {
1010 execute_clear(&clear_args).await?;
1011 }
1012 crate::cli::CacheAction::Status(status_args) => {
1013 let config_service = crate::config::ProductionConfigService::new()?;
1017 execute_status(&status_args, &config_service).await?;
1018 }
1019 crate::cli::CacheAction::Apply(ref apply_args) => {
1020 let config_service = crate::config::ProductionConfigService::new()?;
1021 execute_apply(apply_args, &config_service).await?;
1022 }
1023 crate::cli::CacheAction::Rollback(rollback_args) => {
1024 execute_rollback(&rollback_args).await?;
1025 }
1026 }
1027 Ok(())
1028}
1029
1030pub async fn execute_with_config(
1044 args: CacheArgs,
1045 config_service: std::sync::Arc<dyn ConfigService>,
1046) -> Result<()> {
1047 match args.action {
1048 crate::cli::CacheAction::Status(status_args) => {
1049 execute_status(&status_args, config_service.as_ref()).await
1050 }
1051 crate::cli::CacheAction::Apply(apply_args) => {
1052 execute_apply(&apply_args, config_service.as_ref()).await
1053 }
1054 other => execute(CacheArgs { action: other }).await,
1055 }
1056}
1057
1058#[cfg(test)]
1059mod tests {
1060 use super::*;
1061 use crate::config::TestConfigService;
1062 use crate::core::matcher::cache::{CacheData, SnapshotItem};
1063 use crate::core::matcher::journal::{JournalEntry, JournalEntryStatus, JournalOperationType};
1064 use std::path::PathBuf;
1065 use tempfile::TempDir;
1066
1067 fn isolated_config_dir() -> (TempDir, PathBuf) {
1074 let tmp = TempDir::new().expect("tempdir");
1075 unsafe {
1076 std::env::set_var("XDG_CONFIG_HOME", tmp.path());
1077 }
1078 let subx_dir = tmp.path().join("subx");
1079 std::fs::create_dir_all(&subx_dir).expect("create subx dir");
1080 (tmp, subx_dir)
1081 }
1082
1083 fn make_journal_entry(
1086 op_type: JournalOperationType,
1087 source: PathBuf,
1088 destination: PathBuf,
1089 ) -> JournalEntry {
1090 let meta = std::fs::metadata(&destination).expect("destination must exist");
1091 let mtime = meta
1092 .modified()
1093 .unwrap()
1094 .duration_since(std::time::UNIX_EPOCH)
1095 .unwrap()
1096 .as_secs();
1097 JournalEntry {
1098 operation_type: op_type,
1099 source,
1100 destination,
1101 backup_path: None,
1102 status: JournalEntryStatus::Completed,
1103 file_size: meta.len(),
1104 file_mtime: mtime,
1105 }
1106 }
1107
1108 fn empty_snapshot_cache() -> CacheData {
1110 CacheData {
1111 cache_version: "1.0".into(),
1112 directory: "/tmp".into(),
1113 file_snapshot: vec![],
1114 match_operations: vec![],
1115 created_at: 0,
1116 ai_model_used: "test-model".into(),
1117 config_hash: "abc123".into(),
1118 original_relocation_mode: "None".into(),
1119 original_backup_enabled: false,
1120 }
1121 }
1122
1123 #[test]
1128 fn format_size_bytes() {
1129 assert_eq!(format_size(0), "0 B");
1130 assert_eq!(format_size(512), "512 B");
1131 assert_eq!(format_size(1023), "1023 B");
1132 }
1133
1134 #[test]
1135 fn format_size_kilobytes() {
1136 assert_eq!(format_size(1024), "1.0 KB");
1137 assert_eq!(format_size(2048), "2.0 KB");
1138 let just_below_mb = (1024.0 * 1024.0 - 1.0) as u64;
1140 let result = format_size(just_below_mb);
1141 assert!(result.ends_with("KB"), "expected KB, got {result}");
1142 }
1143
1144 #[test]
1145 fn format_size_megabytes() {
1146 assert_eq!(format_size(1024 * 1024), "1.0 MB");
1147 assert_eq!(format_size(5 * 1024 * 1024), "5.0 MB");
1148 let just_below_gb = (1024.0 * 1024.0 * 1024.0 - 1.0) as u64;
1150 let result = format_size(just_below_gb);
1151 assert!(result.ends_with("MB"), "expected MB, got {result}");
1152 }
1153
1154 #[test]
1155 fn format_size_gigabytes() {
1156 assert_eq!(format_size(1024 * 1024 * 1024), "1.0 GB");
1157 assert_eq!(format_size(2 * 1024 * 1024 * 1024), "2.0 GB");
1158 }
1159
1160 #[test]
1165 fn format_age_seconds() {
1166 assert_eq!(format_age(0), "0 seconds ago");
1167 assert_eq!(format_age(30), "30 seconds ago");
1168 assert_eq!(format_age(59), "59 seconds ago");
1169 }
1170
1171 #[test]
1172 fn format_age_minutes() {
1173 assert_eq!(format_age(60), "1 minutes ago");
1174 assert_eq!(format_age(90), "1 minutes ago");
1175 assert_eq!(format_age(3599), "59 minutes ago");
1176 }
1177
1178 #[test]
1179 fn format_age_hours() {
1180 assert_eq!(format_age(3600), "1 hours ago");
1181 assert_eq!(format_age(7200), "2 hours ago");
1182 assert_eq!(format_age(86399), "23 hours ago");
1183 }
1184
1185 #[test]
1186 fn format_age_days() {
1187 assert_eq!(format_age(86400), "1 days ago");
1188 assert_eq!(format_age(172800), "2 days ago");
1189 assert_eq!(format_age(604800), "7 days ago");
1190 }
1191
1192 #[test]
1197 fn compute_config_hash_is_deterministic() {
1198 let h1 = compute_config_hash("None", false);
1199 let h2 = compute_config_hash("None", false);
1200 assert_eq!(h1, h2);
1201 }
1202
1203 #[test]
1204 fn compute_config_hash_differs_for_different_modes() {
1205 let h_none = compute_config_hash("None", false);
1206 let h_copy = compute_config_hash("Copy", false);
1207 let h_move = compute_config_hash("Move", false);
1208 assert_ne!(h_none, h_copy);
1209 assert_ne!(h_none, h_move);
1210 assert_ne!(h_copy, h_move);
1211 }
1212
1213 #[test]
1214 fn compute_config_hash_differs_for_backup_flag() {
1215 let h_off = compute_config_hash("None", false);
1216 let h_on = compute_config_hash("None", true);
1217 assert_ne!(h_off, h_on);
1218 }
1219
1220 #[test]
1221 fn compute_config_hash_is_16_hex_chars() {
1222 let h = compute_config_hash("None", false);
1223 assert_eq!(h.len(), 16);
1224 assert!(h.chars().all(|c| c.is_ascii_hexdigit()));
1225 }
1226
1227 #[test]
1228 fn current_config_hash_returns_string() {
1229 let svc = TestConfigService::with_defaults();
1230 let h = current_config_hash(&svc).expect("should succeed");
1231 assert_eq!(h.len(), 16);
1232 }
1233
1234 #[test]
1239 fn parse_relocation_mode_copy() {
1240 assert!(matches!(
1241 parse_relocation_mode("Copy"),
1242 FileRelocationMode::Copy
1243 ));
1244 }
1245
1246 #[test]
1247 fn parse_relocation_mode_move() {
1248 assert!(matches!(
1249 parse_relocation_mode("Move"),
1250 FileRelocationMode::Move
1251 ));
1252 }
1253
1254 #[test]
1255 fn parse_relocation_mode_none_keyword() {
1256 assert!(matches!(
1257 parse_relocation_mode("None"),
1258 FileRelocationMode::None
1259 ));
1260 }
1261
1262 #[test]
1263 fn parse_relocation_mode_unknown_falls_back_to_none() {
1264 assert!(matches!(
1265 parse_relocation_mode("UnknownVariant"),
1266 FileRelocationMode::None
1267 ));
1268 }
1269
1270 #[test]
1275 fn describe_snapshot_empty_is_reported_as_legacy() {
1276 let cache = empty_snapshot_cache();
1277 let (label, status) = describe_snapshot(&cache);
1278 assert_eq!(status, "empty");
1279 assert!(label.contains("legacy"), "label: {label}");
1280 }
1281
1282 #[test]
1283 fn describe_snapshot_valid_when_files_match_on_disk() {
1284 let tmp = TempDir::new().unwrap();
1285 let file = tmp.path().join("video.srt");
1286 std::fs::write(&file, "content").unwrap();
1287 let meta = std::fs::metadata(&file).unwrap();
1288 let mtime = meta
1289 .modified()
1290 .unwrap()
1291 .duration_since(std::time::UNIX_EPOCH)
1292 .unwrap()
1293 .as_secs();
1294
1295 let mut cache = empty_snapshot_cache();
1296 cache.file_snapshot = vec![SnapshotItem {
1297 path: file.to_string_lossy().into_owned(),
1298 name: "video.srt".into(),
1299 size: meta.len(),
1300 mtime,
1301 file_type: "subtitle".into(),
1302 }];
1303
1304 let (label, status) = describe_snapshot(&cache);
1305 assert_eq!(status, "valid", "label: {label}");
1306 assert_eq!(label, "Valid");
1307 }
1308
1309 #[test]
1310 fn describe_snapshot_stale_when_file_missing() {
1311 let tmp = TempDir::new().unwrap();
1312 let missing = tmp.path().join("gone.srt");
1313
1314 let mut cache = empty_snapshot_cache();
1315 cache.file_snapshot = vec![SnapshotItem {
1316 path: missing.to_string_lossy().into_owned(),
1317 name: "gone.srt".into(),
1318 size: 100,
1319 mtime: 999,
1320 file_type: "subtitle".into(),
1321 }];
1322
1323 let (label, status) = describe_snapshot(&cache);
1324 assert_eq!(status, "stale", "label: {label}");
1325 assert!(label.starts_with("Stale"), "label: {label}");
1326 }
1327
1328 #[test]
1333 fn clear_file_returns_true_and_removes_existing_file() {
1334 let tmp = TempDir::new().unwrap();
1335 let target = tmp.path().join("to_delete.txt");
1336 std::fs::write(&target, "data").unwrap();
1337 assert!(target.exists());
1338
1339 let result = clear_file(&target, "Cache").expect("should succeed");
1340 assert!(result, "should return true when file existed");
1341 assert!(!target.exists(), "file should be removed");
1342 }
1343
1344 #[test]
1345 fn clear_file_returns_false_when_file_absent() {
1346 let tmp = TempDir::new().unwrap();
1347 let missing = tmp.path().join("nonexistent.txt");
1348 assert!(!missing.exists());
1349
1350 let result = clear_file(&missing, "Cache").expect("should succeed");
1351 assert!(!result, "should return false when file was absent");
1352 }
1353
1354 #[test]
1359 fn get_config_dir_uses_xdg_config_home_when_set() {
1360 let tmp = TempDir::new().unwrap();
1361 unsafe {
1362 std::env::set_var("XDG_CONFIG_HOME", tmp.path());
1363 }
1364 let dir = get_config_dir().expect("should succeed");
1365 assert_eq!(dir, tmp.path());
1366 }
1367
1368 #[test]
1369 fn cache_path_ends_with_expected_components() {
1370 let tmp = TempDir::new().unwrap();
1371 unsafe {
1372 std::env::set_var("XDG_CONFIG_HOME", tmp.path());
1373 }
1374 let p = cache_path().expect("should succeed");
1375 assert!(p.ends_with("subx/match_cache.json"));
1376 }
1377
1378 #[test]
1379 fn journal_path_ends_with_expected_components() {
1380 let tmp = TempDir::new().unwrap();
1381 unsafe {
1382 std::env::set_var("XDG_CONFIG_HOME", tmp.path());
1383 }
1384 let p = journal_path().expect("should succeed");
1385 assert!(p.ends_with("subx/match_journal.json"));
1386 }
1387
1388 #[test]
1393 fn verify_destination_integrity_ok_when_metadata_matches() {
1394 let tmp = TempDir::new().unwrap();
1395 let dst = tmp.path().join("dest.srt");
1396 std::fs::write(&dst, "hello").unwrap();
1397
1398 let entry = make_journal_entry(
1399 JournalOperationType::Copied,
1400 tmp.path().join("src.srt"),
1401 dst,
1402 );
1403
1404 verify_destination_integrity(&entry).expect("should pass integrity check");
1405 }
1406
1407 #[test]
1408 fn verify_destination_integrity_errors_when_file_missing() {
1409 let tmp = TempDir::new().unwrap();
1410 let dst = tmp.path().join("missing.srt");
1411 let entry = JournalEntry {
1414 operation_type: JournalOperationType::Copied,
1415 source: tmp.path().join("src.srt"),
1416 destination: dst,
1417 backup_path: None,
1418 status: JournalEntryStatus::Completed,
1419 file_size: 5,
1420 file_mtime: 1_700_000_000,
1421 };
1422
1423 let err = verify_destination_integrity(&entry).expect_err("should fail");
1424 let msg = format!("{err}");
1425 assert!(
1426 msg.contains("no longer exists"),
1427 "error should mention missing file: {msg}"
1428 );
1429 }
1430
1431 #[test]
1432 fn verify_destination_integrity_errors_on_size_mismatch() {
1433 let tmp = TempDir::new().unwrap();
1434 let dst = tmp.path().join("sized.srt");
1435 std::fs::write(&dst, "hello").unwrap(); let meta = std::fs::metadata(&dst).unwrap();
1438 let mtime = meta
1439 .modified()
1440 .unwrap()
1441 .duration_since(std::time::UNIX_EPOCH)
1442 .unwrap()
1443 .as_secs();
1444
1445 let entry = JournalEntry {
1446 operation_type: JournalOperationType::Copied,
1447 source: tmp.path().join("src.srt"),
1448 destination: dst,
1449 backup_path: None,
1450 status: JournalEntryStatus::Completed,
1451 file_size: 999, file_mtime: mtime,
1453 };
1454
1455 let err = verify_destination_integrity(&entry).expect_err("should fail on size mismatch");
1456 let msg = format!("{err}");
1457 assert!(
1458 msg.contains("size differs"),
1459 "error should mention size: {msg}"
1460 );
1461 }
1462
1463 #[test]
1464 fn verify_destination_integrity_errors_on_mtime_mismatch() {
1465 let tmp = TempDir::new().unwrap();
1466 let dst = tmp.path().join("mtimed.srt");
1467 std::fs::write(&dst, "hello").unwrap();
1468 let meta = std::fs::metadata(&dst).unwrap();
1469
1470 let entry = JournalEntry {
1471 operation_type: JournalOperationType::Copied,
1472 source: tmp.path().join("src.srt"),
1473 destination: dst,
1474 backup_path: None,
1475 status: JournalEntryStatus::Completed,
1476 file_size: meta.len(),
1477 file_mtime: 1, };
1479
1480 let err = verify_destination_integrity(&entry).expect_err("should fail on mtime mismatch");
1481 let msg = format!("{err}");
1482 assert!(
1483 msg.contains("mtime differs"),
1484 "error should mention mtime: {msg}"
1485 );
1486 }
1487
1488 #[test]
1493 fn rollback_entry_copied_removes_destination() {
1494 let tmp = TempDir::new().unwrap();
1495 let src = tmp.path().join("src.srt");
1496 let dst = tmp.path().join("dst.srt");
1497 std::fs::write(&src, "original").unwrap();
1498 std::fs::write(&dst, "copy").unwrap();
1499
1500 let entry = make_journal_entry(JournalOperationType::Copied, src.clone(), dst.clone());
1501 rollback_entry(&entry, false).expect("rollback copy");
1502
1503 assert!(!dst.exists(), "copy destination must be removed");
1504 assert!(src.exists(), "source must remain");
1505 }
1506
1507 #[test]
1508 fn rollback_entry_moved_restores_source() {
1509 let tmp = TempDir::new().unwrap();
1510 let src = tmp.path().join("original.srt");
1511 let dst = tmp.path().join("moved.srt");
1512 std::fs::write(&dst, "payload").unwrap();
1514
1515 let entry = make_journal_entry(JournalOperationType::Moved, src.clone(), dst.clone());
1516 rollback_entry(&entry, false).expect("rollback move");
1517
1518 assert!(src.exists(), "source must be restored");
1519 assert!(!dst.exists(), "destination must be removed");
1520 assert_eq!(std::fs::read_to_string(&src).unwrap(), "payload");
1521 }
1522
1523 #[test]
1524 fn rollback_entry_renamed_restores_source() {
1525 let tmp = TempDir::new().unwrap();
1526 let src = tmp.path().join("old_name.srt");
1527 let dst = tmp.path().join("new_name.srt");
1528 std::fs::write(&dst, "content").unwrap();
1529
1530 let entry = make_journal_entry(JournalOperationType::Renamed, src.clone(), dst.clone());
1531 rollback_entry(&entry, false).expect("rollback rename");
1532
1533 assert!(src.exists(), "original name must be restored");
1534 assert!(!dst.exists(), "new name must be gone");
1535 }
1536
1537 #[test]
1538 fn rollback_entry_moved_errors_when_source_exists_without_force() {
1539 let tmp = TempDir::new().unwrap();
1540 let src = tmp.path().join("exists.srt");
1541 let dst = tmp.path().join("dest.srt");
1542 std::fs::write(&src, "already here").unwrap();
1544 std::fs::write(&dst, "moved here").unwrap();
1545
1546 let entry = make_journal_entry(JournalOperationType::Moved, src.clone(), dst.clone());
1547 let err = rollback_entry(&entry, false).expect_err("should abort when source exists");
1548 let msg = format!("{err}");
1549 assert!(
1550 msg.contains("already exists"),
1551 "error should mention conflict: {msg}"
1552 );
1553 }
1554
1555 #[test]
1556 fn rollback_entry_moved_with_force_overwrites_existing_source() {
1557 let tmp = TempDir::new().unwrap();
1558 let src = tmp.path().join("src_force.srt");
1559 let dst = tmp.path().join("dst_force.srt");
1560 std::fs::write(&src, "old").unwrap();
1561 std::fs::write(&dst, "new content").unwrap();
1562
1563 let entry = make_journal_entry(JournalOperationType::Moved, src.clone(), dst.clone());
1564 rollback_entry(&entry, true).expect("force rollback should succeed");
1565
1566 assert!(src.exists(), "source must exist after force rollback");
1567 assert!(!dst.exists(), "destination must be gone");
1568 assert_eq!(std::fs::read_to_string(&src).unwrap(), "new content");
1569 }
1570
1571 #[test]
1572 fn rollback_entry_removes_existing_backup() {
1573 let tmp = TempDir::new().unwrap();
1574 let src = tmp.path().join("src_bak.srt");
1575 let dst = tmp.path().join("dst_bak.srt");
1576 let backup = tmp.path().join("src_bak.srt.bak");
1577 std::fs::write(&dst, "copy").unwrap();
1578 std::fs::write(&backup, "backup content").unwrap();
1579
1580 let meta = std::fs::metadata(&dst).unwrap();
1581 let mtime = meta
1582 .modified()
1583 .unwrap()
1584 .duration_since(std::time::UNIX_EPOCH)
1585 .unwrap()
1586 .as_secs();
1587 let entry = JournalEntry {
1588 operation_type: JournalOperationType::Copied,
1589 source: src,
1590 destination: dst.clone(),
1591 backup_path: Some(backup.clone()),
1592 status: JournalEntryStatus::Completed,
1593 file_size: meta.len(),
1594 file_mtime: mtime,
1595 };
1596
1597 rollback_entry(&entry, false).expect("rollback with backup");
1598 assert!(!dst.exists(), "copy destination must be removed");
1599 assert!(!backup.exists(), "backup must be deleted");
1600 }
1601
1602 #[test]
1603 fn rollback_entry_tolerates_missing_backup_file() {
1604 let tmp = TempDir::new().unwrap();
1605 let src = tmp.path().join("src_nobak.srt");
1606 let dst = tmp.path().join("dst_nobak.srt");
1607 let backup = tmp.path().join("missing_backup.srt.bak");
1608 std::fs::write(&dst, "copy").unwrap();
1609 let meta = std::fs::metadata(&dst).unwrap();
1612 let mtime = meta
1613 .modified()
1614 .unwrap()
1615 .duration_since(std::time::UNIX_EPOCH)
1616 .unwrap()
1617 .as_secs();
1618 let entry = JournalEntry {
1619 operation_type: JournalOperationType::Copied,
1620 source: src,
1621 destination: dst.clone(),
1622 backup_path: Some(backup),
1623 status: JournalEntryStatus::Completed,
1624 file_size: meta.len(),
1625 file_mtime: mtime,
1626 };
1627
1628 rollback_entry(&entry, false).expect("missing backup should not cause error");
1629 assert!(!dst.exists());
1630 }
1631
1632 #[tokio::test]
1637 async fn execute_status_no_cache_json_output_contains_exists_false() {
1638 let (_tmp, subx_dir) = isolated_config_dir();
1639 let cache_file = subx_dir.join("match_cache.json");
1640 assert!(!cache_file.exists());
1641
1642 let svc = TestConfigService::with_defaults();
1643 let args = crate::cli::StatusArgs { json: true };
1644 execute_status(&args, &svc)
1645 .await
1646 .expect("status must succeed without cache");
1647 }
1648
1649 #[tokio::test]
1650 async fn execute_status_no_cache_plain_output_is_ok() {
1651 let (_tmp, subx_dir) = isolated_config_dir();
1652 let cache_file = subx_dir.join("match_cache.json");
1653 assert!(!cache_file.exists());
1654
1655 let svc = TestConfigService::with_defaults();
1656 let args = crate::cli::StatusArgs { json: false };
1657 execute_status(&args, &svc)
1658 .await
1659 .expect("status must succeed without cache (plain)");
1660 }
1661
1662 #[tokio::test]
1663 async fn execute_status_valid_cache_plain_succeeds() {
1664 let (_tmp, subx_dir) = isolated_config_dir();
1665 let cache_file = subx_dir.join("match_cache.json");
1666
1667 let svc = TestConfigService::with_defaults();
1668 let config = svc.get_config().unwrap();
1669 let hash = compute_config_hash("None", config.general.backup_enabled);
1670
1671 let now = std::time::SystemTime::now()
1672 .duration_since(std::time::UNIX_EPOCH)
1673 .unwrap()
1674 .as_secs();
1675 let cache = serde_json::json!({
1676 "cache_version": "1.0",
1677 "directory": "/some/dir",
1678 "file_snapshot": [],
1679 "match_operations": [
1680 {
1681 "video_file": "/some/video.mkv",
1682 "subtitle_file": "/some/sub.srt",
1683 "new_subtitle_name": "video.srt",
1684 "confidence": 0.95,
1685 "reasoning": []
1686 }
1687 ],
1688 "created_at": now,
1689 "ai_model_used": "gpt-4",
1690 "config_hash": hash,
1691 "original_relocation_mode": "None",
1692 "original_backup_enabled": false,
1693 });
1694 std::fs::write(&cache_file, serde_json::to_string(&cache).unwrap()).unwrap();
1695
1696 let args = crate::cli::StatusArgs { json: false };
1697 execute_status(&args, &svc)
1698 .await
1699 .expect("status with matching hash must succeed");
1700 }
1701
1702 #[tokio::test]
1703 async fn execute_status_valid_cache_json_mode_succeeds() {
1704 let (_tmp, subx_dir) = isolated_config_dir();
1705 let cache_file = subx_dir.join("match_cache.json");
1706 let journal_file = subx_dir.join("match_journal.json");
1707
1708 let svc = TestConfigService::with_defaults();
1709 let config = svc.get_config().unwrap();
1710 let hash = compute_config_hash("None", config.general.backup_enabled);
1711
1712 let now = std::time::SystemTime::now()
1713 .duration_since(std::time::UNIX_EPOCH)
1714 .unwrap()
1715 .as_secs();
1716 let cache = serde_json::json!({
1717 "cache_version": "1.0",
1718 "directory": "/some/dir",
1719 "file_snapshot": [],
1720 "match_operations": [],
1721 "created_at": now,
1722 "ai_model_used": "gpt-4",
1723 "config_hash": hash,
1724 "original_relocation_mode": "None",
1725 "original_backup_enabled": false,
1726 });
1727 std::fs::write(&cache_file, serde_json::to_string(&cache).unwrap()).unwrap();
1728 std::fs::write(&journal_file, "{}").unwrap();
1729
1730 let args = crate::cli::StatusArgs { json: true };
1731 execute_status(&args, &svc)
1732 .await
1733 .expect("JSON status must succeed with matching hash");
1734 }
1735
1736 #[tokio::test]
1737 async fn execute_status_mismatched_hash_shows_in_plain_output() {
1738 let (_tmp, subx_dir) = isolated_config_dir();
1739 let cache_file = subx_dir.join("match_cache.json");
1740
1741 let now = std::time::SystemTime::now()
1742 .duration_since(std::time::UNIX_EPOCH)
1743 .unwrap()
1744 .as_secs();
1745 let cache = serde_json::json!({
1746 "cache_version": "1.0",
1747 "directory": "/some/dir",
1748 "file_snapshot": [],
1749 "match_operations": [],
1750 "created_at": now,
1751 "ai_model_used": "gpt-4",
1752 "config_hash": "00000000deadbeef",
1753 "original_relocation_mode": "None",
1754 "original_backup_enabled": false,
1755 });
1756 std::fs::write(&cache_file, serde_json::to_string(&cache).unwrap()).unwrap();
1757
1758 let svc = TestConfigService::with_defaults();
1759 let args = crate::cli::StatusArgs { json: false };
1760 execute_status(&args, &svc)
1762 .await
1763 .expect("status succeeds even with mismatched config hash");
1764 }
1765
1766 #[tokio::test]
1771 async fn execute_rollback_journal_with_only_pending_entries_is_noop() {
1772 use crate::core::matcher::journal::JournalData;
1773
1774 let (_tmp, subx_dir) = isolated_config_dir();
1775 let journal_file = subx_dir.join("match_journal.json");
1776
1777 let tmp2 = TempDir::new().unwrap();
1778 let dst = tmp2.path().join("file.srt");
1779 std::fs::write(&dst, "data").unwrap();
1780
1781 let pending_entry = JournalEntry {
1782 operation_type: JournalOperationType::Copied,
1783 source: tmp2.path().join("src.srt"),
1784 destination: dst.clone(),
1785 backup_path: None,
1786 status: JournalEntryStatus::Pending,
1787 file_size: 4,
1788 file_mtime: 0,
1789 };
1790
1791 let journal = JournalData {
1792 batch_id: "pending-only".into(),
1793 created_at: 0,
1794 entries: vec![pending_entry],
1795 };
1796 journal.save(&journal_file).await.expect("save journal");
1797
1798 let args = RollbackArgs { force: false };
1799 execute_rollback(&args)
1800 .await
1801 .expect("should succeed with only pending entries");
1802
1803 assert!(
1806 journal_file.exists(),
1807 "journal kept when nothing was rolled back"
1808 );
1809 assert!(dst.exists(), "pending entry destination must be untouched");
1810 }
1811
1812 #[tokio::test]
1813 async fn execute_rollback_force_skips_integrity_check() {
1814 use crate::core::matcher::journal::JournalData;
1815
1816 let (_tmp, subx_dir) = isolated_config_dir();
1817 let journal_file = subx_dir.join("match_journal.json");
1818
1819 let tmp2 = TempDir::new().unwrap();
1820 let src = tmp2.path().join("orig.srt");
1821 let dst = tmp2.path().join("copy.srt");
1822 std::fs::write(&dst, "data").unwrap();
1823
1824 let entry = JournalEntry {
1826 operation_type: JournalOperationType::Copied,
1827 source: src.clone(),
1828 destination: dst.clone(),
1829 backup_path: None,
1830 status: JournalEntryStatus::Completed,
1831 file_size: 9999, file_mtime: 9999, };
1834
1835 let journal = JournalData {
1836 batch_id: "force-batch".into(),
1837 created_at: 0,
1838 entries: vec![entry],
1839 };
1840 journal.save(&journal_file).await.expect("save journal");
1841
1842 let args = RollbackArgs { force: true };
1843 execute_rollback(&args)
1844 .await
1845 .expect("force rollback should succeed despite integrity mismatch");
1846
1847 assert!(!dst.exists(), "copy destination must be removed");
1848 assert!(!journal_file.exists(), "journal must be deleted");
1849 }
1850
1851 #[tokio::test]
1856 async fn execute_with_config_clear_journal_type_works() {
1857 use std::sync::Arc;
1858 let (_tmp, subx_dir) = isolated_config_dir();
1859 let journal_file = subx_dir.join("match_journal.json");
1860 let cache_file = subx_dir.join("match_cache.json");
1861 std::fs::write(&journal_file, "{}").unwrap();
1862 std::fs::write(&cache_file, "{}").unwrap();
1863
1864 let svc = Arc::new(TestConfigService::with_defaults());
1865 let args = CacheArgs {
1866 action: crate::cli::CacheAction::Clear(crate::cli::ClearArgs {
1867 r#type: crate::cli::ClearType::Journal,
1868 }),
1869 };
1870 execute_with_config(args, svc)
1871 .await
1872 .expect("clear journal via execute_with_config");
1873
1874 assert!(!journal_file.exists(), "journal should be removed");
1875 assert!(cache_file.exists(), "cache should remain");
1876 }
1877
1878 #[tokio::test]
1883 async fn execute_apply_confidence_filter_removes_low_confidence_ops() {
1884 use crate::cli::ApplyArgs;
1885
1886 let (_tmp, subx_dir) = isolated_config_dir();
1887 let cache_file = subx_dir.join("match_cache.json");
1888
1889 let svc = TestConfigService::with_defaults();
1890 let config = svc.get_config().unwrap();
1891 let hash = compute_config_hash("None", config.general.backup_enabled);
1892
1893 let now = std::time::SystemTime::now()
1894 .duration_since(std::time::UNIX_EPOCH)
1895 .unwrap()
1896 .as_secs();
1897
1898 let cache = serde_json::json!({
1905 "cache_version": "1.0",
1906 "directory": "/dir",
1907 "file_snapshot": [],
1908 "match_operations": [
1909 {
1910 "video_file": "/dir/v1.mkv",
1911 "subtitle_file": "/dir/s1.srt",
1912 "new_subtitle_name": "v1.srt",
1913 "confidence": 0.5,
1914 "reasoning": []
1915 }
1916 ],
1917 "created_at": now,
1918 "ai_model_used": "gpt-4",
1919 "config_hash": hash,
1920 "original_relocation_mode": "None",
1921 "original_backup_enabled": false,
1922 });
1923 std::fs::write(&cache_file, serde_json::to_string(&cache).unwrap()).unwrap();
1924
1925 let result = execute_apply(
1927 &ApplyArgs {
1928 yes: true,
1929 force: true,
1930 confidence: Some(80),
1931 },
1932 &svc,
1933 )
1934 .await;
1935 assert!(
1936 result.is_ok(),
1937 "confidence filter to empty ops should be Ok: {result:?}"
1938 );
1939 }
1940}