1use crate::Result;
24use crate::cli::{ApplyArgs, CacheArgs, ClearArgs, ClearType, RollbackArgs, StatusArgs};
25use crate::config::ConfigService;
26use crate::core::lock::acquire_subx_lock;
27use crate::core::matcher::cache::CacheData;
28use crate::core::matcher::engine::{FileRelocationMode, MatchConfig, apply_cached_operations};
29use crate::core::matcher::journal::{
30 JournalData, JournalEntry, JournalEntryStatus, JournalOperationType,
31};
32use crate::error::SubXError;
33use serde_json::json;
34use std::io::IsTerminal;
35use std::path::{Path, PathBuf};
36use std::time::{SystemTime, UNIX_EPOCH};
37
38fn get_config_dir() -> Result<PathBuf> {
44 if let Some(xdg_config) = std::env::var_os("XDG_CONFIG_HOME") {
45 Ok(PathBuf::from(xdg_config))
46 } else {
47 dirs::config_dir().ok_or_else(|| SubXError::config("Unable to determine config directory"))
48 }
49}
50
51fn cache_path() -> Result<PathBuf> {
53 Ok(get_config_dir()?.join("subx").join("match_cache.json"))
54}
55
56fn journal_path() -> Result<PathBuf> {
58 Ok(get_config_dir()?.join("subx").join("match_journal.json"))
59}
60
61fn clear_file(path: &Path, label: &str) -> Result<bool> {
66 if path.exists() {
67 std::fs::remove_file(path)?;
68 println!("{} cleared: {}", label, path.display());
69 Ok(true)
70 } else {
71 println!("{} not found: {}", label, path.display());
72 Ok(false)
73 }
74}
75
76async fn execute_clear(args: &ClearArgs) -> Result<()> {
78 let _lock = acquire_subx_lock().await?;
79 let config_dir = get_config_dir()?;
80 let cache_file = config_dir.join("subx").join("match_cache.json");
81 let journal_file = config_dir.join("subx").join("match_journal.json");
82
83 let mut cleared_any = false;
84
85 match args.r#type {
86 ClearType::Cache => {
87 cleared_any |= clear_file(&cache_file, "Cache")?;
88 }
89 ClearType::Journal => {
90 cleared_any |= clear_file(&journal_file, "Journal")?;
91 }
92 ClearType::All => {
93 cleared_any |= clear_file(&cache_file, "Cache")?;
94 cleared_any |= clear_file(&journal_file, "Journal")?;
95 }
96 }
97
98 if !cleared_any {
99 println!("No cache files found to clear.");
100 }
101 Ok(())
102}
103
104fn compute_config_hash(relocation_mode_debug: &str, backup_enabled: bool) -> String {
111 use std::collections::hash_map::DefaultHasher;
112 use std::hash::{Hash, Hasher};
113 let mut hasher = DefaultHasher::new();
114 relocation_mode_debug.hash(&mut hasher);
115 backup_enabled.hash(&mut hasher);
116 format!("{:016x}", hasher.finish())
117}
118
119fn current_config_hash(config_service: &dyn ConfigService) -> Result<String> {
123 let config = config_service.get_config()?;
124 Ok(compute_config_hash("None", config.general.backup_enabled))
125}
126
127fn format_size(bytes: u64) -> String {
129 const KB: f64 = 1024.0;
130 const MB: f64 = KB * 1024.0;
131 const GB: f64 = MB * 1024.0;
132 let b = bytes as f64;
133 if b >= GB {
134 format!("{:.1} GB", b / GB)
135 } else if b >= MB {
136 format!("{:.1} MB", b / MB)
137 } else if b >= KB {
138 format!("{:.1} KB", b / KB)
139 } else {
140 format!("{} B", bytes)
141 }
142}
143
144fn format_age(age_secs: u64) -> String {
146 const MIN: u64 = 60;
147 const HOUR: u64 = 60 * MIN;
148 const DAY: u64 = 24 * HOUR;
149 if age_secs < MIN {
150 format!("{} seconds ago", age_secs)
151 } else if age_secs < HOUR {
152 format!("{} minutes ago", age_secs / MIN)
153 } else if age_secs < DAY {
154 format!("{} hours ago", age_secs / HOUR)
155 } else {
156 format!("{} days ago", age_secs / DAY)
157 }
158}
159
160fn describe_snapshot(cache: &CacheData) -> (String, &'static str) {
166 if cache.has_empty_snapshot() {
167 ("Empty (legacy cache)".to_string(), "empty")
168 } else {
169 let stale = cache.validate_snapshot();
170 if stale.is_empty() {
171 ("Valid".to_string(), "valid")
172 } else {
173 (format!("Stale ({} files changed)", stale.len()), "stale")
174 }
175 }
176}
177
178pub async fn execute_status(args: &StatusArgs, config_service: &dyn ConfigService) -> Result<()> {
194 let cache_file = cache_path()?;
195 let journal_file = journal_path()?;
196
197 if !cache_file.exists() {
198 if args.json {
199 let payload = json!({
200 "path": cache_file.to_string_lossy(),
201 "exists": false,
202 "journal_present": journal_file.exists(),
203 });
204 println!("{}", serde_json::to_string_pretty(&payload)?);
205 } else {
206 println!("No cache found at {}", cache_file.display());
207 }
208 return Ok(());
209 }
210
211 let cache = CacheData::load(&cache_file).map_err(|e| {
212 SubXError::config(format!(
213 "Failed to load cache at {}: {}",
214 cache_file.display(),
215 e
216 ))
217 })?;
218
219 let metadata = std::fs::metadata(&cache_file)?;
220 let size_bytes = metadata.len();
221
222 let now_secs = SystemTime::now()
223 .duration_since(UNIX_EPOCH)
224 .map(|d| d.as_secs())
225 .unwrap_or(0);
226 let age_secs = now_secs.saturating_sub(cache.created_at);
227
228 let current_hash = current_config_hash(config_service)?;
229 let hash_match = current_hash == cache.config_hash;
230
231 let (snapshot_label, snapshot_status) = describe_snapshot(&cache);
232 let stale_entries = if snapshot_status == "stale" {
233 cache.validate_snapshot()
234 } else {
235 Vec::new()
236 };
237 let journal_present = journal_file.exists();
238
239 if args.json {
240 let stale_files: Vec<serde_json::Value> = stale_entries
241 .iter()
242 .map(|s| json!({ "path": s.path, "reason": s.reason }))
243 .collect();
244 let payload = json!({
245 "path": cache_file.to_string_lossy(),
246 "exists": true,
247 "size_bytes": size_bytes,
248 "created_at": cache.created_at,
249 "age_seconds": age_secs,
250 "cache_version": cache.cache_version,
251 "ai_model": cache.ai_model_used,
252 "operation_count": cache.match_operations.len(),
253 "config_hash": cache.config_hash,
254 "config_hash_match": hash_match,
255 "current_config_hash": current_hash,
256 "snapshot_status": snapshot_status,
257 "stale_files": stale_files,
258 "journal_present": journal_present,
259 });
260 println!("{}", serde_json::to_string_pretty(&payload)?);
261 } else {
262 let config_line = if hash_match {
263 "✓ (matches current)".to_string()
264 } else {
265 format!("✗ (differs from current: {})", current_hash)
266 };
267 let journal_line = if journal_present {
268 "Present"
269 } else {
270 "Not found"
271 };
272
273 println!("Cache Status");
274 println!("============");
275 println!("Path: {}", cache_file.display());
276 println!("Size: {}", format_size(size_bytes));
277 println!("Age: {}", format_age(age_secs));
278 println!("Cache version: {}", cache.cache_version);
279 println!("AI model: {}", cache.ai_model_used);
280 println!("Operations: {}", cache.match_operations.len());
281 println!("Config hash: {}", cache.config_hash);
282 println!("Config match: {}", config_line);
283 println!("Snapshot: {}", snapshot_label);
284 println!("Journal: {}", journal_line);
285 }
286
287 Ok(())
288}
289
290pub async fn execute_apply(args: &ApplyArgs, config_service: &dyn ConfigService) -> Result<()> {
304 let _lock = acquire_subx_lock().await?;
305
306 let cache_file = cache_path()?;
307 if !cache_file.exists() {
308 println!(
309 "No cache found at {}. Run a dry-run match first.",
310 cache_file.display()
311 );
312 return Ok(());
313 }
314
315 let mut cache = CacheData::load(&cache_file).map_err(|e| {
316 SubXError::config(format!(
317 "Failed to load cache at {}: {}",
318 cache_file.display(),
319 e
320 ))
321 })?;
322
323 let config = config_service.get_config()?;
325 let apply_hash = compute_config_hash(
326 &cache.original_relocation_mode,
327 config.general.backup_enabled,
328 );
329 if apply_hash != cache.config_hash && !args.force {
330 return Err(SubXError::config(format!(
331 "Configuration has changed since the cache was created.\n\
332 Cache hash: {}\n\
333 Current hash: {}\n\
334 Use --force to bypass this check.",
335 cache.config_hash, apply_hash
336 )));
337 }
338
339 if cache.has_empty_snapshot() && !args.force {
341 return Err(SubXError::config(
342 "Cache was created without file snapshot data (legacy format).\n\
343 Cannot verify file integrity. Use --force to apply anyway."
344 .to_string(),
345 ));
346 }
347
348 if !args.force && !cache.has_empty_snapshot() {
350 let stale = cache.validate_snapshot();
351 if !stale.is_empty() {
352 let mut msg = format!(
353 "{} source file(s) have changed since the cache was created:\n",
354 stale.len()
355 );
356 for s in &stale {
357 msg.push_str(&format!(" - {} ({})\n", s.path, s.reason));
358 }
359 msg.push_str("Use --force to apply anyway.");
360 return Err(SubXError::config(msg));
361 }
362 }
363
364 if !args.force {
366 let conflicts = cache.validate_target_paths();
367 if !conflicts.is_empty() {
368 let mut msg = format!("{} target path(s) already exist:\n", conflicts.len());
369 for p in &conflicts {
370 msg.push_str(&format!(" - {}\n", p.display()));
371 }
372 msg.push_str("Use --force to apply anyway.");
373 return Err(SubXError::config(msg));
374 }
375 }
376
377 if let Some(min_conf) = args.confidence {
379 let threshold = f32::from(min_conf) / 100.0;
380 let before = cache.match_operations.len();
381 cache
382 .match_operations
383 .retain(|op| op.confidence >= threshold);
384 let after = cache.match_operations.len();
385 if before != after {
386 println!(
387 "Filtered {} operation(s) below {}% confidence.",
388 before - after,
389 min_conf
390 );
391 }
392 }
393
394 if cache.match_operations.is_empty() {
395 println!("No operations to apply.");
396 return Ok(());
397 }
398
399 println!("Cache Apply Summary");
401 println!("===================");
402 println!("Operations: {}", cache.match_operations.len());
403 println!("AI model: {}", cache.ai_model_used);
404 println!("Relocation mode: {}", cache.original_relocation_mode);
405 println!();
406 for (i, op) in cache.match_operations.iter().enumerate() {
407 println!(
408 " {}. {} → {} (confidence: {:.0}%)",
409 i + 1,
410 op.subtitle_file,
411 op.new_subtitle_name,
412 op.confidence * 100.0
413 );
414 }
415 println!();
416
417 if !args.yes {
419 if !std::io::stdin().is_terminal() {
420 return Err(SubXError::config(
421 "Non-interactive terminal detected. Use --yes to skip confirmation.".to_string(),
422 ));
423 }
424 print!("Proceed with apply? [y/N] ");
425 use std::io::Write;
426 std::io::stdout().flush()?;
427 let mut input = String::new();
428 std::io::stdin().read_line(&mut input)?;
429 if !input.trim().eq_ignore_ascii_case("y") {
430 println!("Apply cancelled.");
431 return Ok(());
432 }
433 }
434
435 let config = config_service.get_config()?;
437 let relocation_mode = parse_relocation_mode(&cache.original_relocation_mode);
438 let match_config = MatchConfig {
439 confidence_threshold: 0.0,
440 max_sample_length: 2000,
441 enable_content_analysis: true,
442 backup_enabled: cache.original_backup_enabled,
443 relocation_mode,
444 conflict_resolution: crate::core::matcher::engine::ConflictResolution::Skip,
445 ai_model: cache.ai_model_used.clone(),
446 max_subtitle_bytes: config.general.max_subtitle_bytes,
447 };
448
449 apply_cached_operations(&cache, &match_config).await?;
450 println!("Apply complete.");
451 Ok(())
452}
453
454fn parse_relocation_mode(s: &str) -> FileRelocationMode {
456 match s {
457 "Copy" => FileRelocationMode::Copy,
458 "Move" => FileRelocationMode::Move,
459 _ => FileRelocationMode::None,
460 }
461}
462
463fn verify_destination_integrity(entry: &JournalEntry) -> Result<()> {
471 let metadata = match std::fs::metadata(&entry.destination) {
472 Ok(m) => m,
473 Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
474 return Err(SubXError::config(format!(
475 "Destination file {} no longer exists. Use --force to override.",
476 entry.destination.display()
477 )));
478 }
479 Err(e) => return Err(SubXError::Io(e)),
480 };
481
482 if metadata.len() != entry.file_size {
483 return Err(SubXError::config(format!(
484 "Destination file {} has been modified since the operation (size differs). \
485 Use --force to override.",
486 entry.destination.display()
487 )));
488 }
489
490 let mtime_secs = metadata
491 .modified()
492 .ok()
493 .and_then(|m| m.duration_since(UNIX_EPOCH).ok())
494 .map(|d| d.as_secs());
495
496 if let Some(actual) = mtime_secs {
497 if actual != entry.file_mtime {
498 return Err(SubXError::config(format!(
499 "Destination file {} has been modified since the operation (mtime differs). \
500 Use --force to override.",
501 entry.destination.display()
502 )));
503 }
504 }
505
506 Ok(())
507}
508
509fn rollback_entry(entry: &JournalEntry, force: bool) -> Result<()> {
523 match entry.operation_type {
524 JournalOperationType::Copied => {
525 std::fs::remove_file(&entry.destination)?;
526 println!("Removed copy: {}", entry.destination.display());
527 }
528 JournalOperationType::Moved | JournalOperationType::Renamed => {
529 if entry.source.exists() && !force {
530 return Err(SubXError::config(format!(
531 "Original source path {} already exists. \
532 Rollback would overwrite it. Use --force to override.",
533 entry.source.display()
534 )));
535 }
536 if let Some(parent) = entry.source.parent() {
537 if !parent.as_os_str().is_empty() {
538 std::fs::create_dir_all(parent)?;
539 }
540 }
541 std::fs::rename(&entry.destination, &entry.source)?;
542 println!(
543 "Rolled back: {} \u{2190} {}",
544 entry.source.display(),
545 entry.destination.display()
546 );
547 }
548 }
549
550 if let Some(backup) = &entry.backup_path {
551 if backup.exists() {
552 std::fs::remove_file(backup)?;
553 println!("Removed backup: {}", backup.display());
554 }
555 }
556
557 Ok(())
558}
559
560pub async fn execute_rollback(args: &RollbackArgs) -> Result<()> {
572 let _lock = acquire_subx_lock().await?;
573
574 let journal_file = journal_path()?;
575 if !journal_file.exists() {
576 println!("No operation journal found. Nothing to rollback.");
577 return Ok(());
578 }
579
580 let journal = JournalData::load(&journal_file).await?;
581
582 let reversed: Vec<&JournalEntry> = journal
583 .entries
584 .iter()
585 .filter(|e| e.status == JournalEntryStatus::Completed)
586 .rev()
587 .collect();
588
589 if reversed.is_empty() {
590 println!("Journal has no completed operations to rollback.");
591 return Ok(());
592 }
593
594 println!(
595 "Rolling back {} operations from batch {}...",
596 reversed.len(),
597 journal.batch_id
598 );
599
600 for entry in &reversed {
601 if !args.force {
602 verify_destination_integrity(entry)?;
603 }
604 rollback_entry(entry, args.force)?;
605 }
606
607 std::fs::remove_file(&journal_file)?;
608 println!("Rollback complete. Journal deleted.");
609 Ok(())
610}
611
612pub async fn execute(args: CacheArgs) -> Result<()> {
617 match args.action {
618 crate::cli::CacheAction::Clear(clear_args) => {
619 execute_clear(&clear_args).await?;
620 }
621 crate::cli::CacheAction::Status(status_args) => {
622 let config_service = crate::config::ProductionConfigService::new()?;
626 execute_status(&status_args, &config_service).await?;
627 }
628 crate::cli::CacheAction::Apply(ref apply_args) => {
629 let config_service = crate::config::ProductionConfigService::new()?;
630 execute_apply(apply_args, &config_service).await?;
631 }
632 crate::cli::CacheAction::Rollback(rollback_args) => {
633 execute_rollback(&rollback_args).await?;
634 }
635 }
636 Ok(())
637}
638
639pub async fn execute_with_config(
653 args: CacheArgs,
654 config_service: std::sync::Arc<dyn ConfigService>,
655) -> Result<()> {
656 match args.action {
657 crate::cli::CacheAction::Status(status_args) => {
658 execute_status(&status_args, config_service.as_ref()).await
659 }
660 crate::cli::CacheAction::Apply(apply_args) => {
661 execute_apply(&apply_args, config_service.as_ref()).await
662 }
663 other => execute(CacheArgs { action: other }).await,
664 }
665}