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