Skip to main content

subx_cli/commands/
cache_command.rs

1//! Cache management command implementation.
2//!
3//! This module provides cache management functionality through the `cache`
4//! subcommand, enabling users to inspect, apply, rollback, and clear cached
5//! data from SubX operations.
6//!
7//! # Subcommands
8//!
9//! - **`cache status`** — display cache metadata (path, size, age, AI model,
10//!   operation count, config hash validity, snapshot freshness, journal presence).
11//!   Supports `--json` for machine-readable output.
12//! - **`cache apply`** — replay cached dry-run results without calling the AI
13//!   provider. Validates file snapshot and target paths, prompts for
14//!   confirmation, and writes a journal for rollback.
15//! - **`cache rollback`** — undo the most recent batch of file operations by
16//!   reading the journal and reversing entries in LIFO order.
17//! - **`cache clear`** — remove cached data. `--type cache` clears only the
18//!   match cache, `--type journal` clears only the journal, `--type all`
19//!   (default) clears both.
20//!
21//! All mutating operations acquire an exclusive file lock before proceeding.
22
23use 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// ─── JSON payload types (machine-readable-output / cache-management) ─────
40
41/// Per-item error embedded in cache JSON payloads.
42///
43/// Mirrors the top-level error envelope's `error` field minus
44/// `exit_code`, matching the per-file-isolation contract documented in
45/// `openspec/changes/add-machine-readable-output/specs/machine-readable-output/spec.md`.
46#[derive(Debug, Serialize)]
47pub struct CacheItemError {
48    /// Stable snake_case category from
49    /// [`crate::error::SubXError::category`].
50    pub category: String,
51    /// Stable upper-snake-case machine code from
52    /// [`crate::error::SubXError::machine_code`].
53    pub code: String,
54    /// Human-readable English message.
55    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/// Stale-file entry used inside [`CacheStatusPayload::stale_files`].
69#[derive(Debug, Serialize)]
70pub struct StaleFileInfo {
71    /// Absolute path of the file as recorded in the snapshot.
72    pub path: String,
73    /// Human-readable explanation of why the entry is stale.
74    pub reason: String,
75}
76
77/// `data` payload for `cache status` JSON envelope.
78///
79/// The required spec fields are `total`, `pending`, and `applied`
80/// (non-negative integer counters). All other fields are additive
81/// enrichments preserving information already exposed by the text path.
82#[derive(Debug, Serialize)]
83pub struct CacheStatusPayload {
84    /// Resolved path to the match cache file.
85    pub path: String,
86    /// Whether the cache file exists on disk.
87    pub exists: bool,
88    /// Whether the operation journal file exists on disk.
89    pub journal_present: bool,
90    /// Total number of cached match operations (`0` when no cache).
91    pub total: u64,
92    /// Number of journal entries still pending (`0` when no journal).
93    pub pending: u64,
94    /// Number of journal entries already applied (`0` when no journal).
95    pub applied: u64,
96    /// Cache file size in bytes (omitted when no cache).
97    #[serde(skip_serializing_if = "Option::is_none")]
98    pub size_bytes: Option<u64>,
99    /// Cache creation timestamp (Unix epoch seconds).
100    #[serde(skip_serializing_if = "Option::is_none")]
101    pub created_at: Option<u64>,
102    /// Cache age in seconds.
103    #[serde(skip_serializing_if = "Option::is_none")]
104    pub age_seconds: Option<u64>,
105    /// Cache schema version recorded in the file.
106    #[serde(skip_serializing_if = "Option::is_none")]
107    pub cache_version: Option<String>,
108    /// AI model name recorded in the cache.
109    #[serde(skip_serializing_if = "Option::is_none")]
110    pub ai_model: Option<String>,
111    /// Number of cached match operations (mirrors `total`).
112    #[serde(skip_serializing_if = "Option::is_none")]
113    pub operation_count: Option<usize>,
114    /// Configuration hash recorded inside the cache.
115    #[serde(skip_serializing_if = "Option::is_none")]
116    pub config_hash: Option<String>,
117    /// Configuration hash recomputed from the active config service.
118    #[serde(skip_serializing_if = "Option::is_none")]
119    pub current_config_hash: Option<String>,
120    /// Whether `config_hash` matches `current_config_hash`.
121    #[serde(skip_serializing_if = "Option::is_none")]
122    pub config_hash_match: Option<bool>,
123    /// `"valid"`, `"stale"`, or `"empty"`.
124    #[serde(skip_serializing_if = "Option::is_none")]
125    pub snapshot_status: Option<&'static str>,
126    /// Per-file staleness diagnostics (only when `snapshot_status == "stale"`).
127    #[serde(skip_serializing_if = "Option::is_none")]
128    pub stale_files: Option<Vec<StaleFileInfo>>,
129}
130
131/// `data` payload for `cache clear` JSON envelope.
132///
133/// Per the `cache-management` spec the only required field is
134/// `removed`; the additional fields are additive enrichments mirroring
135/// what the text path already exposes.
136#[derive(Debug, Serialize)]
137pub struct CacheClearPayload {
138    /// Number of cache files removed (0–2: cache and/or journal).
139    pub removed: u64,
140    /// Selector echoed from `--type` (`cache`, `journal`, or `all`).
141    pub kind: &'static str,
142    /// Resolved cache file path that was inspected for removal.
143    pub cache_path: String,
144    /// Whether the cache file was removed in this invocation.
145    pub cache_removed: bool,
146    /// Resolved journal file path that was inspected for removal.
147    pub journal_path: String,
148    /// Whether the journal file was removed in this invocation.
149    pub journal_removed: bool,
150}
151
152/// `data` payload for `cache rollback` JSON envelope.
153#[derive(Debug, Serialize)]
154pub struct CacheRollbackPayload {
155    /// Number of journal entries successfully rolled back.
156    pub rolled_back: u64,
157}
158
159/// Per-operation entry inside [`CacheApplyPayload::items`].
160#[derive(Debug, Serialize)]
161pub struct CacheApplyItem {
162    /// Stable identifier for the operation. Currently the cached
163    /// subtitle source path (the most stable handle the cache exposes).
164    pub id: String,
165    /// `"ok"` or `"error"`.
166    pub status: &'static str,
167    /// Populated only when `status == "error"`.
168    #[serde(skip_serializing_if = "Option::is_none")]
169    pub error: Option<CacheItemError>,
170}
171
172/// `data` payload for `cache apply` JSON envelope.
173///
174/// Per the `cache-management` spec, `applied + failed == items.len()` and
175/// each entry in `items` carries per-operation `status` (`"ok"` or
176/// `"error"`) plus an optional [`CacheItemError`] when the entry failed.
177#[derive(Debug, Serialize)]
178pub struct CacheApplyPayload {
179    /// Number of cached operations applied successfully.
180    pub applied: u64,
181    /// Number of cached operations that failed to apply.
182    pub failed: u64,
183    /// Per-item status for every entry processed.
184    pub items: Vec<CacheApplyItem>,
185}
186
187/// Compute `(pending, applied)` counters from the journal at `path`,
188/// returning `(0, 0)` when the journal is missing or unreadable.
189async 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
209/// Resolve the configuration directory, preferring `XDG_CONFIG_HOME` when set.
210///
211/// This mirrors the path resolution used by the journal module so that cache
212/// and journal files live under the same parent directory across commands and
213/// tests (which typically override `XDG_CONFIG_HOME`).
214fn 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
222/// Resolve the canonical path to the match cache file.
223fn cache_path() -> Result<PathBuf> {
224    Ok(get_config_dir()?.join("subx").join("match_cache.json"))
225}
226
227/// Resolve the canonical path to the match journal file.
228fn journal_path() -> Result<PathBuf> {
229    Ok(get_config_dir()?.join("subx").join("match_journal.json"))
230}
231
232/// Delete `path` if it exists.
233///
234/// Returns `Ok(true)` when a file was removed, `Ok(false)` when no file was
235/// present. In text mode a per-file confirmation is printed; in JSON mode
236/// stdout is silenced.
237fn 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
253/// Handle the `cache clear` subcommand, honoring the `--type` selector.
254///
255/// In JSON mode emits a single envelope of shape
256/// `{ "removed": N, ... }` per the `cache-management` spec; in text mode
257/// preserves the original confirmation messages.
258async 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
304/// Compute a config validity hash for a given relocation mode and backup setting.
305///
306/// This mirrors `MatchEngine::calculate_config_hash`. For `cache status`, pass
307/// the default relocation mode (`"None"`) since the CLI flag is unavailable.
308/// For `cache apply`, pass the cache's recorded `original_relocation_mode` to
309/// get a correct comparison.
310fn 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    // Mirror `MatchEngine::calculate_config_hash` so the prompt-schema
317    // tag is part of the hash here too. Without this, `cache status` /
318    // `cache apply` would compute a different hash than the engine and
319    // never recognise its own caches as current.
320    "prompt_v2".hash(&mut hasher);
321    format!("{:016x}", hasher.finish())
322}
323
324/// Compute the config hash assuming the default relocation mode.
325///
326/// Used by `cache status` where the CLI relocation flag is not available.
327fn 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
332/// Format a byte count as a short human-readable string (e.g. `2.4 KB`).
333fn 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
349/// Format an age (in seconds) as a short human-readable phrase.
350fn 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
365/// Describe the snapshot state of a cache for human-readable reporting.
366///
367/// Returns a tuple `(label, machine_status)` where `label` is a user-facing
368/// string and `machine_status` is the JSON-friendly status identifier
369/// (`"valid"`, `"stale"`, or `"empty"`).
370fn 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
383/// Handle the `cache status` subcommand.
384///
385/// Loads cache metadata from disk and prints a summary of its location,
386/// size, age, AI model, operation count, configuration fingerprint,
387/// snapshot freshness, and whether a journal exists.
388///
389/// # JSON output
390///
391/// JSON mode is activated when *either* the global `--output json` flag
392/// is set *or* the legacy subcommand-local `--json` flag is supplied
393/// (the latter is preserved as a backward-compatible alias per the
394/// `cache-management` spec). Both invocations route through
395/// [`emit_success`] with the same [`CacheStatusPayload`] type and emit
396/// byte-identical output.
397///
398/// When no cache file is present, a friendly message is printed (text
399/// mode) or a payload with `total = 0`, `pending = 0`, `applied = 0` is
400/// emitted (JSON mode) and the function returns `Ok(())` without error.
401///
402/// # Arguments
403///
404/// * `args` - Parsed `cache status` arguments controlling output format.
405/// * `config_service` - Active configuration service, used to recompute
406///   the configuration hash for comparison against the cached value.
407pub async fn execute_status(args: &StatusArgs, config_service: &dyn ConfigService) -> Result<()> {
408    let cache_file = cache_path()?;
409    let journal_file = journal_path()?;
410    // Legacy `--json` is a thin alias for the global `--output json`.
411    // When either is set, the same JSON envelope is emitted via the
412    // shared renderer so both invocations produce byte-identical output.
413    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
532/// Handle the `cache apply` subcommand.
533///
534/// Loads the cached dry-run results and replays the file operations without
535/// calling the AI provider. Validates the file snapshot and target paths
536/// before proceeding, prompts for confirmation unless `--yes` is supplied,
537/// and aborts on non-TTY stdin without `--yes`.
538///
539/// # JSON output
540///
541/// In JSON mode (`--output json`):
542/// - All informational `println!` chatter on stdout is suppressed so the
543///   final envelope is the only document on the stream.
544/// - The interactive confirmation prompt is skipped: callers SHALL pass
545///   `--yes`, otherwise an error envelope is emitted (prompting on
546///   stdout would corrupt the JSON document).
547/// - Each cache operation is applied individually so the per-file
548///   isolation contract from the `machine-readable-output` spec can be
549///   honored: failures are recorded as `items[i].status = "error"` with
550///   an [`CacheItemError`] populated from
551///   [`SubXError::category`]/[`SubXError::machine_code`]/[`SubXError::user_friendly_message`].
552///
553/// # Arguments
554///
555/// * `args` - Parsed `cache apply` arguments controlling validation bypass,
556///   confirmation, and confidence filtering.
557/// * `config_service` - Active configuration service for rebuilding the
558///   `MatchConfig` needed by the engine replay path.
559pub 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            // Empty cache → empty success envelope (`applied + failed == 0`).
567            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    // Config hash mismatch detection — use the cache's recorded relocation mode
594    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    // Legacy cache with empty snapshot requires --force
610    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    // Snapshot validation
619    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    // Target path conflict detection
635    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    // Apply confidence filter
648    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        // Display summary (text mode only)
683        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    // Non-TTY check and interactive confirmation. JSON mode forbids the
702    // interactive prompt because it would write to stdout and corrupt
703    // the single JSON document contract.
704    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    // Build MatchConfig from config service
729    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        // Per-item application: each cache entry is replayed individually
744        // so a single failure does not abort the whole batch and so each
745        // item's outcome can be reported independently.
746        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
808/// Parse a relocation mode string from cache metadata back into an enum value.
809fn parse_relocation_mode(s: &str) -> FileRelocationMode {
810    match s {
811        "Copy" => FileRelocationMode::Copy,
812        "Move" => FileRelocationMode::Move,
813        _ => FileRelocationMode::None,
814    }
815}
816
817/// Verify that a destination file still matches the metadata recorded in
818/// the journal entry at the time of the original operation.
819///
820/// The check compares file size and modification time (seconds since the
821/// Unix epoch). A mismatch or a missing destination aborts the rollback
822/// and returns a descriptive error so the user can investigate or opt in
823/// to force rollback via the `--force` flag.
824fn 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
863/// Reverse the effect of a single completed journal entry.
864///
865/// The reversal depends on the original operation:
866/// - `Copied`: the destination copy is deleted, leaving the source intact.
867/// - `Moved` / `Renamed`: the destination is moved back to the original
868///   source path via `std::fs::rename`.
869///
870/// If the entry recorded a backup file, that backup is deleted after the
871/// primary reversal succeeds.
872///
873/// For `Moved`/`Renamed` operations the function checks that the original
874/// source path is vacant before renaming back. If the source already exists
875/// and `force` is false, an error is returned.
876fn 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
921/// Handle the `cache rollback` subcommand.
922///
923/// Acquires the process-wide SubX lock, loads the journal, and replays
924/// completed entries in last-in-first-out order — undoing each file
925/// operation. When the rollback finishes successfully the journal file
926/// is removed so subsequent commands start from a clean state.
927///
928/// A missing journal is not an error; it yields an informational message
929/// and returns `Ok(())`. When `--force` is not supplied, the command
930/// aborts before touching any file if any destination's size or mtime no
931/// longer matches the journal record.
932pub 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
1003/// Dispatch the cache subcommand using the production configuration service.
1004///
1005/// For testable code paths, prefer [`execute_with_config`] which accepts an
1006/// injected [`ConfigService`].
1007pub 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            // Fall back to the production configuration service when no service
1014            // was injected by the caller. This keeps the legacy `execute` entry
1015            // point functional for users invoking it directly.
1016            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
1030/// Execute cache management command with injected configuration service.
1031///
1032/// This function provides the new dependency injection interface for the cache command,
1033/// accepting a configuration service instead of loading configuration globally.
1034///
1035/// # Arguments
1036///
1037/// * `args` - Cache command arguments
1038/// * `config_service` - Configuration service providing access to cache settings
1039///
1040/// # Returns
1041///
1042/// Returns `Ok(())` on successful completion, or an error if the operation fails.
1043pub 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    // -----------------------------------------------------------------------
1068    // Helpers
1069    // -----------------------------------------------------------------------
1070
1071    /// Redirect the config directory to an isolated temp directory and return
1072    /// both the `TempDir` guard (must stay alive) and the subx subdirectory.
1073    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    /// Build a minimal `JournalEntry` whose destination file already exists
1084    /// on disk so that integrity checks pass by default.
1085    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    /// Minimal valid `CacheData` with an empty snapshot (legacy-style).
1109    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    // -----------------------------------------------------------------------
1124    // format_size
1125    // -----------------------------------------------------------------------
1126
1127    #[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        // Just below 1 MB
1139        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        // Just below 1 GB
1149        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    // -----------------------------------------------------------------------
1161    // format_age
1162    // -----------------------------------------------------------------------
1163
1164    #[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    // -----------------------------------------------------------------------
1193    // compute_config_hash / current_config_hash
1194    // -----------------------------------------------------------------------
1195
1196    #[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    // -----------------------------------------------------------------------
1235    // parse_relocation_mode
1236    // -----------------------------------------------------------------------
1237
1238    #[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    // -----------------------------------------------------------------------
1271    // describe_snapshot
1272    // -----------------------------------------------------------------------
1273
1274    #[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    // -----------------------------------------------------------------------
1329    // clear_file
1330    // -----------------------------------------------------------------------
1331
1332    #[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    // -----------------------------------------------------------------------
1355    // get_config_dir / cache_path / journal_path (path resolution)
1356    // -----------------------------------------------------------------------
1357
1358    #[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    // -----------------------------------------------------------------------
1389    // verify_destination_integrity
1390    // -----------------------------------------------------------------------
1391
1392    #[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        // Do not create the file
1412
1413        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(); // 5 bytes
1436
1437        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, // deliberately wrong
1452            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, // deliberately wrong mtime
1478        };
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    // -----------------------------------------------------------------------
1489    // rollback_entry
1490    // -----------------------------------------------------------------------
1491
1492    #[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        // After a move, only the destination exists on disk.
1513        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        // Both source and destination exist (conflicting state).
1543        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        // backup file intentionally not created
1610
1611        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    // -----------------------------------------------------------------------
1633    // execute_status (unit-level path through private helpers)
1634    // -----------------------------------------------------------------------
1635
1636    #[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        // Should succeed even when hash doesn't match — status is informational only.
1761        execute_status(&args, &svc)
1762            .await
1763            .expect("status succeeds even with mismatched config hash");
1764    }
1765
1766    // -----------------------------------------------------------------------
1767    // execute_rollback edge cases
1768    // -----------------------------------------------------------------------
1769
1770    #[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        // When there are no completed entries to roll back, the journal is
1804        // left in place (nothing was reversed) and the destination is untouched.
1805        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        // Record wrong size so integrity check would normally fail.
1825        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,  // wrong size
1832            file_mtime: 9999, // wrong mtime
1833        };
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    // -----------------------------------------------------------------------
1852    // execute_clear via execute_with_config
1853    // -----------------------------------------------------------------------
1854
1855    #[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    // -----------------------------------------------------------------------
1879    // execute_apply — confidence filter
1880    // -----------------------------------------------------------------------
1881
1882    #[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        // Two operations: one at 90 %, one at 50 %.  Filter at 80 % should
1899        // leave only the first, reducing to 1 op — which then hits
1900        // "No operations to apply" because the filtered result has 1 entry
1901        // but the test uses force=true so it proceeds; with an empty cache
1902        // we'd get "No operations to apply".
1903        // Use --force to skip snapshot/hash checks.
1904        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        // confidence threshold = 80, filters out the 50% op → empty → "No operations"
1926        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}