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::error_ext::SubXErrorExt;
25use crate::cli::output::{OutputMode, active_mode, emit_success};
26use crate::cli::{ApplyArgs, CacheArgs, ClearArgs, ClearType, RollbackArgs, StatusArgs};
27use serde::Serialize;
28use std::io::IsTerminal;
29use std::path::{Path, PathBuf};
30use std::time::{SystemTime, UNIX_EPOCH};
31use subx_core::config::ConfigService;
32use subx_core::core::lock::acquire_subx_lock;
33use subx_core::core::matcher::cache::CacheData;
34use subx_core::core::matcher::engine::{
35    FileRelocationMode, MatchConfig, apply_cached_operations_with_reporter,
36};
37use subx_core::core::matcher::journal::{
38    JournalData, JournalEntry, JournalEntryStatus, JournalOperationType,
39};
40use subx_core::error::SubXError;
41
42// ─── JSON payload types (machine-readable-output / cache-management) ─────
43
44/// Per-item error embedded in cache JSON payloads.
45///
46/// Mirrors the top-level error envelope's `error` field minus
47/// `exit_code`, matching the per-file-isolation contract documented in
48/// `openspec/changes/add-machine-readable-output/specs/machine-readable-output/spec.md`.
49#[derive(Debug, Serialize)]
50pub struct CacheItemError {
51    /// Stable snake_case category from
52    /// [`subx_core::error::SubXError::category`].
53    pub category: String,
54    /// Stable upper-snake-case machine code from
55    /// [`subx_core::error::SubXError::machine_code`].
56    pub code: String,
57    /// Human-readable English message.
58    pub message: String,
59}
60
61impl CacheItemError {
62    fn from_error(err: &SubXError) -> Self {
63        Self {
64            category: err.category().to_string(),
65            code: err.machine_code().to_string(),
66            message: err.user_friendly_message(),
67        }
68    }
69}
70
71/// Stale-file entry used inside [`CacheStatusPayload::stale_files`].
72#[derive(Debug, Serialize)]
73pub struct StaleFileInfo {
74    /// Absolute path of the file as recorded in the snapshot.
75    pub path: String,
76    /// Human-readable explanation of why the entry is stale.
77    pub reason: String,
78}
79
80/// `data` payload for `cache status` JSON envelope.
81///
82/// The required spec fields are `total`, `pending`, and `applied`
83/// (non-negative integer counters). All other fields are additive
84/// enrichments preserving information already exposed by the text path.
85#[derive(Debug, Serialize)]
86pub struct CacheStatusPayload {
87    /// Resolved path to the match cache file.
88    pub path: String,
89    /// Whether the cache file exists on disk.
90    pub exists: bool,
91    /// Whether the operation journal file exists on disk.
92    pub journal_present: bool,
93    /// Total number of cached match operations (`0` when no cache).
94    pub total: u64,
95    /// Number of journal entries still pending (`0` when no journal).
96    pub pending: u64,
97    /// Number of journal entries already applied (`0` when no journal).
98    pub applied: u64,
99    /// Cache file size in bytes (omitted when no cache).
100    #[serde(skip_serializing_if = "Option::is_none")]
101    pub size_bytes: Option<u64>,
102    /// Cache creation timestamp (Unix epoch seconds).
103    #[serde(skip_serializing_if = "Option::is_none")]
104    pub created_at: Option<u64>,
105    /// Cache age in seconds.
106    #[serde(skip_serializing_if = "Option::is_none")]
107    pub age_seconds: Option<u64>,
108    /// Cache schema version recorded in the file.
109    #[serde(skip_serializing_if = "Option::is_none")]
110    pub cache_version: Option<String>,
111    /// AI model name recorded in the cache.
112    #[serde(skip_serializing_if = "Option::is_none")]
113    pub ai_model: Option<String>,
114    /// Number of cached match operations (mirrors `total`).
115    #[serde(skip_serializing_if = "Option::is_none")]
116    pub operation_count: Option<usize>,
117    /// Configuration hash recorded inside the cache.
118    #[serde(skip_serializing_if = "Option::is_none")]
119    pub config_hash: Option<String>,
120    /// Configuration hash recomputed from the active config service.
121    #[serde(skip_serializing_if = "Option::is_none")]
122    pub current_config_hash: Option<String>,
123    /// Whether `config_hash` matches `current_config_hash`.
124    #[serde(skip_serializing_if = "Option::is_none")]
125    pub config_hash_match: Option<bool>,
126    /// `"valid"`, `"stale"`, or `"empty"`.
127    #[serde(skip_serializing_if = "Option::is_none")]
128    pub snapshot_status: Option<&'static str>,
129    /// Per-file staleness diagnostics (only when `snapshot_status == "stale"`).
130    #[serde(skip_serializing_if = "Option::is_none")]
131    pub stale_files: Option<Vec<StaleFileInfo>>,
132}
133
134/// `data` payload for `cache clear` JSON envelope.
135///
136/// Per the `cache-management` spec the only required field is
137/// `removed`; the additional fields are additive enrichments mirroring
138/// what the text path already exposes.
139#[derive(Debug, Serialize)]
140pub struct CacheClearPayload {
141    /// Number of cache files removed (0–2: cache and/or journal).
142    pub removed: u64,
143    /// Selector echoed from `--type` (`cache`, `journal`, or `all`).
144    pub kind: &'static str,
145    /// Resolved cache file path that was inspected for removal.
146    pub cache_path: String,
147    /// Whether the cache file was removed in this invocation.
148    pub cache_removed: bool,
149    /// Resolved journal file path that was inspected for removal.
150    pub journal_path: String,
151    /// Whether the journal file was removed in this invocation.
152    pub journal_removed: bool,
153}
154
155/// `data` payload for `cache rollback` JSON envelope.
156#[derive(Debug, Serialize)]
157pub struct CacheRollbackPayload {
158    /// Number of journal entries successfully rolled back.
159    pub rolled_back: u64,
160}
161
162/// Per-operation entry inside [`CacheApplyPayload::items`].
163#[derive(Debug, Serialize)]
164pub struct CacheApplyItem {
165    /// Stable identifier for the operation. Currently the cached
166    /// subtitle source path (the most stable handle the cache exposes).
167    pub id: String,
168    /// `"ok"` or `"error"`.
169    pub status: &'static str,
170    /// Populated only when `status == "error"`.
171    #[serde(skip_serializing_if = "Option::is_none")]
172    pub error: Option<CacheItemError>,
173}
174
175/// `data` payload for `cache apply` JSON envelope.
176///
177/// Per the `cache-management` spec, `applied + failed == items.len()` and
178/// each entry in `items` carries per-operation `status` (`"ok"` or
179/// `"error"`) plus an optional [`CacheItemError`] when the entry failed.
180#[derive(Debug, Serialize)]
181pub struct CacheApplyPayload {
182    /// Number of cached operations applied successfully.
183    pub applied: u64,
184    /// Number of cached operations that failed to apply.
185    pub failed: u64,
186    /// Per-item status for every entry processed.
187    pub items: Vec<CacheApplyItem>,
188}
189
190/// Compute `(pending, applied)` counters from the journal at `path`,
191/// returning `(0, 0)` when the journal is missing or unreadable.
192async fn journal_counters(path: &Path) -> (u64, u64) {
193    if !path.exists() {
194        return (0, 0);
195    }
196    match JournalData::load(path).await {
197        Ok(j) => {
198            let mut pending = 0u64;
199            let mut applied = 0u64;
200            for entry in &j.entries {
201                match entry.status {
202                    JournalEntryStatus::Pending => pending += 1,
203                    JournalEntryStatus::Completed => applied += 1,
204                }
205            }
206            (pending, applied)
207        }
208        Err(_) => (0, 0),
209    }
210}
211
212/// Resolve the configuration directory, preferring `XDG_CONFIG_HOME` when set.
213///
214/// This mirrors the path resolution used by the journal module so that cache
215/// and journal files live under the same parent directory across commands and
216/// tests (which typically override `XDG_CONFIG_HOME`).
217fn get_config_dir() -> Result<PathBuf> {
218    if let Some(xdg_config) = std::env::var_os("XDG_CONFIG_HOME") {
219        Ok(PathBuf::from(xdg_config))
220    } else {
221        dirs::config_dir().ok_or_else(|| SubXError::config("Unable to determine config directory"))
222    }
223}
224
225/// Resolve the canonical path to the match cache file.
226fn cache_path() -> Result<PathBuf> {
227    Ok(get_config_dir()?.join("subx").join("match_cache.json"))
228}
229
230/// Resolve the canonical path to the match journal file.
231fn journal_path() -> Result<PathBuf> {
232    Ok(get_config_dir()?.join("subx").join("match_journal.json"))
233}
234
235/// Delete `path` if it exists.
236///
237/// Returns `Ok(true)` when a file was removed, `Ok(false)` when no file was
238/// present. In text mode a per-file confirmation is printed; in JSON mode
239/// stdout is silenced.
240fn clear_file(path: &Path, label: &str) -> Result<bool> {
241    let json_mode = active_mode().is_json();
242    if path.exists() {
243        std::fs::remove_file(path)?;
244        if !json_mode {
245            println!("{} cleared: {}", label, path.display());
246        }
247        Ok(true)
248    } else {
249        if !json_mode {
250            println!("{} not found: {}", label, path.display());
251        }
252        Ok(false)
253    }
254}
255
256/// Handle the `cache clear` subcommand, honoring the `--type` selector.
257///
258/// In JSON mode emits a single envelope of shape
259/// `{ "removed": N, ... }` per the `cache-management` spec; in text mode
260/// preserves the original confirmation messages.
261async fn execute_clear(args: &ClearArgs) -> Result<()> {
262    let _lock = acquire_subx_lock().await?;
263    let config_dir = get_config_dir()?;
264    let cache_file = config_dir.join("subx").join("match_cache.json");
265    let journal_file = config_dir.join("subx").join("match_journal.json");
266
267    let json_mode = active_mode().is_json();
268    let mut cache_removed = false;
269    let mut journal_removed = false;
270
271    match args.r#type {
272        ClearType::Cache => {
273            cache_removed = clear_file(&cache_file, "Cache")?;
274        }
275        ClearType::Journal => {
276            journal_removed = clear_file(&journal_file, "Journal")?;
277        }
278        ClearType::All => {
279            cache_removed = clear_file(&cache_file, "Cache")?;
280            journal_removed = clear_file(&journal_file, "Journal")?;
281        }
282    }
283
284    let removed = u64::from(cache_removed) + u64::from(journal_removed);
285
286    if json_mode {
287        let kind = match args.r#type {
288            ClearType::Cache => "cache",
289            ClearType::Journal => "journal",
290            ClearType::All => "all",
291        };
292        let payload = CacheClearPayload {
293            removed,
294            kind,
295            cache_path: cache_file.to_string_lossy().into_owned(),
296            cache_removed,
297            journal_path: journal_file.to_string_lossy().into_owned(),
298            journal_removed,
299        };
300        emit_success(OutputMode::Json, "cache", payload);
301    } else if removed == 0 {
302        println!("No cache files found to clear.");
303    }
304    Ok(())
305}
306
307/// Compute a config validity hash for a given relocation mode and backup setting.
308///
309/// This mirrors `MatchEngine::calculate_config_hash`. For `cache status`, pass
310/// the default relocation mode (`"None"`) since the CLI flag is unavailable.
311/// For `cache apply`, pass the cache's recorded `original_relocation_mode` to
312/// get a correct comparison.
313fn compute_config_hash(relocation_mode_debug: &str, backup_enabled: bool) -> String {
314    use std::collections::hash_map::DefaultHasher;
315    use std::hash::{Hash, Hasher};
316    let mut hasher = DefaultHasher::new();
317    relocation_mode_debug.hash(&mut hasher);
318    backup_enabled.hash(&mut hasher);
319    // Mirror `MatchEngine::calculate_config_hash` so the prompt-schema
320    // tag is part of the hash here too. Without this, `cache status` /
321    // `cache apply` would compute a different hash than the engine and
322    // never recognise its own caches as current.
323    "prompt_v2".hash(&mut hasher);
324    format!("{:016x}", hasher.finish())
325}
326
327/// Compute the config hash assuming the default relocation mode.
328///
329/// Used by `cache status` where the CLI relocation flag is not available.
330fn current_config_hash(config_service: &dyn ConfigService) -> Result<String> {
331    let config = config_service.get_config()?;
332    Ok(compute_config_hash("None", config.general.backup_enabled))
333}
334
335/// Format a byte count as a short human-readable string (e.g. `2.4 KB`).
336fn format_size(bytes: u64) -> String {
337    const KB: f64 = 1024.0;
338    const MB: f64 = KB * 1024.0;
339    const GB: f64 = MB * 1024.0;
340    let b = bytes as f64;
341    if b >= GB {
342        format!("{:.1} GB", b / GB)
343    } else if b >= MB {
344        format!("{:.1} MB", b / MB)
345    } else if b >= KB {
346        format!("{:.1} KB", b / KB)
347    } else {
348        format!("{} B", bytes)
349    }
350}
351
352/// Format an age (in seconds) as a short human-readable phrase.
353fn format_age(age_secs: u64) -> String {
354    const MIN: u64 = 60;
355    const HOUR: u64 = 60 * MIN;
356    const DAY: u64 = 24 * HOUR;
357    if age_secs < MIN {
358        format!("{} seconds ago", age_secs)
359    } else if age_secs < HOUR {
360        format!("{} minutes ago", age_secs / MIN)
361    } else if age_secs < DAY {
362        format!("{} hours ago", age_secs / HOUR)
363    } else {
364        format!("{} days ago", age_secs / DAY)
365    }
366}
367
368/// Describe the snapshot state of a cache for human-readable reporting.
369///
370/// Returns a tuple `(label, machine_status)` where `label` is a user-facing
371/// string and `machine_status` is the JSON-friendly status identifier
372/// (`"valid"`, `"stale"`, or `"empty"`).
373fn describe_snapshot(cache: &CacheData) -> (String, &'static str) {
374    if cache.has_empty_snapshot() {
375        ("Empty (legacy cache)".to_string(), "empty")
376    } else {
377        let stale = cache.validate_snapshot();
378        if stale.is_empty() {
379            ("Valid".to_string(), "valid")
380        } else {
381            (format!("Stale ({} files changed)", stale.len()), "stale")
382        }
383    }
384}
385
386/// Handle the `cache status` subcommand.
387///
388/// Loads cache metadata from disk and prints a summary of its location,
389/// size, age, AI model, operation count, configuration fingerprint,
390/// snapshot freshness, and whether a journal exists.
391///
392/// # JSON output
393///
394/// JSON mode is activated when *either* the global `--output json` flag
395/// is set *or* the legacy subcommand-local `--json` flag is supplied
396/// (the latter is preserved as a backward-compatible alias per the
397/// `cache-management` spec). Both invocations route through
398/// [`emit_success`] with the same [`CacheStatusPayload`] type and emit
399/// byte-identical output.
400///
401/// When no cache file is present, a friendly message is printed (text
402/// mode) or a payload with `total = 0`, `pending = 0`, `applied = 0` is
403/// emitted (JSON mode) and the function returns `Ok(())` without error.
404///
405/// # Arguments
406///
407/// * `args` - Parsed `cache status` arguments controlling output format.
408/// * `config_service` - Active configuration service, used to recompute
409///   the configuration hash for comparison against the cached value.
410pub async fn execute_status(args: &StatusArgs, config_service: &dyn ConfigService) -> Result<()> {
411    let cache_file = cache_path()?;
412    let journal_file = journal_path()?;
413    // Legacy `--json` is a thin alias for the global `--output json`.
414    // When either is set, the same JSON envelope is emitted via the
415    // shared renderer so both invocations produce byte-identical output.
416    let json_mode = active_mode().is_json() || args.json;
417
418    if !cache_file.exists() {
419        let journal_present = journal_file.exists();
420        let (pending, applied) = journal_counters(&journal_file).await;
421        if json_mode {
422            let payload = CacheStatusPayload {
423                path: cache_file.to_string_lossy().into_owned(),
424                exists: false,
425                journal_present,
426                total: 0,
427                pending,
428                applied,
429                size_bytes: None,
430                created_at: None,
431                age_seconds: None,
432                cache_version: None,
433                ai_model: None,
434                operation_count: None,
435                config_hash: None,
436                current_config_hash: None,
437                config_hash_match: None,
438                snapshot_status: None,
439                stale_files: None,
440            };
441            emit_success(OutputMode::Json, "cache", payload);
442        } else {
443            println!("No cache found at {}", cache_file.display());
444        }
445        return Ok(());
446    }
447
448    let cache = CacheData::load(&cache_file).map_err(|e| {
449        SubXError::config(format!(
450            "Failed to load cache at {}: {}",
451            cache_file.display(),
452            e
453        ))
454    })?;
455
456    let metadata = std::fs::metadata(&cache_file)?;
457    let size_bytes = metadata.len();
458
459    let now_secs = SystemTime::now()
460        .duration_since(UNIX_EPOCH)
461        .map(|d| d.as_secs())
462        .unwrap_or(0);
463    let age_secs = now_secs.saturating_sub(cache.created_at);
464
465    let current_hash = current_config_hash(config_service)?;
466    let hash_match = current_hash == cache.config_hash;
467
468    let (snapshot_label, snapshot_status) = describe_snapshot(&cache);
469    let stale_entries = if snapshot_status == "stale" {
470        cache.validate_snapshot()
471    } else {
472        Vec::new()
473    };
474    let journal_present = journal_file.exists();
475    let (pending, applied) = journal_counters(&journal_file).await;
476    let total = cache.match_operations.len() as u64;
477
478    if json_mode {
479        let stale_files: Vec<StaleFileInfo> = stale_entries
480            .iter()
481            .map(|s| StaleFileInfo {
482                path: s.path.clone(),
483                reason: s.reason.clone(),
484            })
485            .collect();
486        let payload = CacheStatusPayload {
487            path: cache_file.to_string_lossy().into_owned(),
488            exists: true,
489            journal_present,
490            total,
491            pending,
492            applied,
493            size_bytes: Some(size_bytes),
494            created_at: Some(cache.created_at),
495            age_seconds: Some(age_secs),
496            cache_version: Some(cache.cache_version.clone()),
497            ai_model: Some(cache.ai_model_used.clone()),
498            operation_count: Some(cache.match_operations.len()),
499            config_hash: Some(cache.config_hash.clone()),
500            current_config_hash: Some(current_hash),
501            config_hash_match: Some(hash_match),
502            snapshot_status: Some(snapshot_status),
503            stale_files: Some(stale_files),
504        };
505        emit_success(OutputMode::Json, "cache", payload);
506    } else {
507        let config_line = if hash_match {
508            "✓ (matches current)".to_string()
509        } else {
510            format!("✗ (differs from current: {})", current_hash)
511        };
512        let journal_line = if journal_present {
513            "Present"
514        } else {
515            "Not found"
516        };
517
518        println!("Cache Status");
519        println!("============");
520        println!("Path:             {}", cache_file.display());
521        println!("Size:             {}", format_size(size_bytes));
522        println!("Age:              {}", format_age(age_secs));
523        println!("Cache version:    {}", cache.cache_version);
524        println!("AI model:         {}", cache.ai_model_used);
525        println!("Operations:       {}", cache.match_operations.len());
526        println!("Config hash:      {}", cache.config_hash);
527        println!("Config match:     {}", config_line);
528        println!("Snapshot:         {}", snapshot_label);
529        println!("Journal:          {}", journal_line);
530    }
531
532    Ok(())
533}
534
535/// Handle the `cache apply` subcommand.
536///
537/// Loads the cached dry-run results and replays the file operations without
538/// calling the AI provider. Validates the file snapshot and target paths
539/// before proceeding, prompts for confirmation unless `--yes` is supplied,
540/// and aborts on non-TTY stdin without `--yes`.
541///
542/// # JSON output
543///
544/// In JSON mode (`--output json`):
545/// - All informational `println!` chatter on stdout is suppressed so the
546///   final envelope is the only document on the stream.
547/// - The interactive confirmation prompt is skipped: callers SHALL pass
548///   `--yes`, otherwise an error envelope is emitted (prompting on
549///   stdout would corrupt the JSON document).
550/// - Each cache operation is applied individually so the per-file
551///   isolation contract from the `machine-readable-output` spec can be
552///   honored: failures are recorded as `items[i].status = "error"` with
553///   an [`CacheItemError`] populated from
554///   [`SubXError::category`]/[`SubXError::machine_code`]/[`crate::cli::error_ext::SubXErrorExt::user_friendly_message`].
555///
556/// # Arguments
557///
558/// * `args` - Parsed `cache apply` arguments controlling validation bypass,
559///   confirmation, and confidence filtering.
560/// * `config_service` - Active configuration service for rebuilding the
561///   `MatchConfig` needed by the engine replay path.
562pub async fn execute_apply(args: &ApplyArgs, config_service: &dyn ConfigService) -> Result<()> {
563    let _lock = acquire_subx_lock().await?;
564    let json_mode = active_mode().is_json();
565
566    let cache_file = cache_path()?;
567    if !cache_file.exists() {
568        if json_mode {
569            // Empty cache → empty success envelope (`applied + failed == 0`).
570            emit_success(
571                OutputMode::Json,
572                "cache",
573                CacheApplyPayload {
574                    applied: 0,
575                    failed: 0,
576                    items: Vec::new(),
577                },
578            );
579        } else {
580            println!(
581                "No cache found at {}. Run a dry-run match first.",
582                cache_file.display()
583            );
584        }
585        return Ok(());
586    }
587
588    let mut cache = CacheData::load(&cache_file).map_err(|e| {
589        SubXError::config(format!(
590            "Failed to load cache at {}: {}",
591            cache_file.display(),
592            e
593        ))
594    })?;
595
596    // Config hash mismatch detection — use the cache's recorded relocation mode
597    let config = config_service.get_config()?;
598    let apply_hash = compute_config_hash(
599        &cache.original_relocation_mode,
600        config.general.backup_enabled,
601    );
602    if apply_hash != cache.config_hash && !args.force {
603        return Err(SubXError::config(format!(
604            "Configuration has changed since the cache was created.\n\
605             Cache hash:   {}\n\
606             Current hash: {}\n\
607             Use --force to bypass this check.",
608            cache.config_hash, apply_hash
609        )));
610    }
611
612    // Legacy cache with empty snapshot requires --force
613    if cache.has_empty_snapshot() && !args.force {
614        return Err(SubXError::config(
615            "Cache was created without file snapshot data (legacy format).\n\
616             Cannot verify file integrity. Use --force to apply anyway."
617                .to_string(),
618        ));
619    }
620
621    // Snapshot validation
622    if !args.force && !cache.has_empty_snapshot() {
623        let stale = cache.validate_snapshot();
624        if !stale.is_empty() {
625            let mut msg = format!(
626                "{} source file(s) have changed since the cache was created:\n",
627                stale.len()
628            );
629            for s in &stale {
630                msg.push_str(&format!("  - {} ({})\n", s.path, s.reason));
631            }
632            msg.push_str("Use --force to apply anyway.");
633            return Err(SubXError::config(msg));
634        }
635    }
636
637    // Target path conflict detection
638    if !args.force {
639        let conflicts = cache.validate_target_paths();
640        if !conflicts.is_empty() {
641            let mut msg = format!("{} target path(s) already exist:\n", conflicts.len());
642            for p in &conflicts {
643                msg.push_str(&format!("  - {}\n", p.display()));
644            }
645            msg.push_str("Use --force to apply anyway.");
646            return Err(SubXError::config(msg));
647        }
648    }
649
650    // Apply confidence filter
651    if let Some(min_conf) = args.confidence {
652        let threshold = f32::from(min_conf) / 100.0;
653        let before = cache.match_operations.len();
654        cache
655            .match_operations
656            .retain(|op| op.confidence >= threshold);
657        let after = cache.match_operations.len();
658        if before != after && !json_mode {
659            println!(
660                "Filtered {} operation(s) below {}% confidence.",
661                before - after,
662                min_conf
663            );
664        }
665    }
666
667    if cache.match_operations.is_empty() {
668        if json_mode {
669            emit_success(
670                OutputMode::Json,
671                "cache",
672                CacheApplyPayload {
673                    applied: 0,
674                    failed: 0,
675                    items: Vec::new(),
676                },
677            );
678        } else {
679            println!("No operations to apply.");
680        }
681        return Ok(());
682    }
683
684    if !json_mode {
685        // Display summary (text mode only)
686        println!("Cache Apply Summary");
687        println!("===================");
688        println!("Operations:       {}", cache.match_operations.len());
689        println!("AI model:         {}", cache.ai_model_used);
690        println!("Relocation mode:  {}", cache.original_relocation_mode);
691        println!();
692        for (i, op) in cache.match_operations.iter().enumerate() {
693            println!(
694                "  {}. {} → {} (confidence: {:.0}%)",
695                i + 1,
696                op.subtitle_file,
697                op.new_subtitle_name,
698                op.confidence * 100.0
699            );
700        }
701        println!();
702    }
703
704    // Non-TTY check and interactive confirmation. JSON mode forbids the
705    // interactive prompt because it would write to stdout and corrupt
706    // the single JSON document contract.
707    if !args.yes {
708        if json_mode {
709            return Err(SubXError::CommandExecution(
710                "cache apply in JSON output mode requires --yes (interactive confirmation \
711                 would write to stdout and corrupt the JSON envelope)."
712                    .to_string(),
713            ));
714        }
715        if !std::io::stdin().is_terminal() {
716            return Err(SubXError::config(
717                "Non-interactive terminal detected. Use --yes to skip confirmation.".to_string(),
718            ));
719        }
720        print!("Proceed with apply? [y/N] ");
721        use std::io::Write;
722        std::io::stdout().flush()?;
723        let mut input = String::new();
724        std::io::stdin().read_line(&mut input)?;
725        if !input.trim().eq_ignore_ascii_case("y") {
726            println!("Apply cancelled.");
727            return Ok(());
728        }
729    }
730
731    // Build MatchConfig from config service
732    let config = config_service.get_config()?;
733    let relocation_mode = parse_relocation_mode(&cache.original_relocation_mode);
734    let match_config = MatchConfig {
735        confidence_threshold: 0.0,
736        max_sample_length: 2000,
737        enable_content_analysis: true,
738        backup_enabled: cache.original_backup_enabled,
739        relocation_mode,
740        conflict_resolution: subx_core::core::matcher::engine::ConflictResolution::Skip,
741        ai_model: cache.ai_model_used.clone(),
742        max_subtitle_bytes: config.general.max_subtitle_bytes,
743    };
744
745    if json_mode {
746        // Per-item application: each cache entry is replayed individually
747        // so a single failure does not abort the whole batch and so each
748        // item's outcome can be reported independently.
749        let mut items: Vec<CacheApplyItem> = Vec::with_capacity(cache.match_operations.len());
750        let mut applied = 0u64;
751        let mut failed = 0u64;
752
753        for op in &cache.match_operations {
754            let id = op.subtitle_file.clone();
755            let video_exists = std::path::Path::new(&op.video_file).exists();
756            let sub_exists = std::path::Path::new(&op.subtitle_file).exists();
757            if !video_exists || !sub_exists {
758                let missing = if !sub_exists {
759                    op.subtitle_file.clone()
760                } else {
761                    op.video_file.clone()
762                };
763                let err = SubXError::FileNotFound(missing);
764                items.push(CacheApplyItem {
765                    id,
766                    status: "error",
767                    error: Some(CacheItemError::from_error(&err)),
768                });
769                failed += 1;
770                continue;
771            }
772
773            let mut single = cache.clone();
774            single.match_operations = vec![op.clone()];
775            match apply_cached_operations_with_reporter(
776                &single,
777                &match_config,
778                crate::cli::terminal_reporter_with_progress_bar(config.general.enable_progress_bar),
779            )
780            .await
781            {
782                Ok(()) => {
783                    applied += 1;
784                    items.push(CacheApplyItem {
785                        id,
786                        status: "ok",
787                        error: None,
788                    });
789                }
790                Err(e) => {
791                    failed += 1;
792                    items.push(CacheApplyItem {
793                        id,
794                        status: "error",
795                        error: Some(CacheItemError::from_error(&e)),
796                    });
797                }
798            }
799        }
800
801        emit_success(
802            OutputMode::Json,
803            "cache",
804            CacheApplyPayload {
805                applied,
806                failed,
807                items,
808            },
809        );
810    } else {
811        apply_cached_operations_with_reporter(
812            &cache,
813            &match_config,
814            crate::cli::terminal_reporter_with_progress_bar(config.general.enable_progress_bar),
815        )
816        .await?;
817        println!("Apply complete.");
818    }
819    Ok(())
820}
821
822/// Parse a relocation mode string from cache metadata back into an enum value.
823fn parse_relocation_mode(s: &str) -> FileRelocationMode {
824    match s {
825        "Copy" => FileRelocationMode::Copy,
826        "Move" => FileRelocationMode::Move,
827        _ => FileRelocationMode::None,
828    }
829}
830
831/// Verify that a destination file still matches the metadata recorded in
832/// the journal entry at the time of the original operation.
833///
834/// The check compares file size and modification time (seconds since the
835/// Unix epoch). A mismatch or a missing destination aborts the rollback
836/// and returns a descriptive error so the user can investigate or opt in
837/// to force rollback via the `--force` flag.
838fn verify_destination_integrity(entry: &JournalEntry) -> Result<()> {
839    let metadata = match std::fs::metadata(&entry.destination) {
840        Ok(m) => m,
841        Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
842            return Err(SubXError::config(format!(
843                "Destination file {} no longer exists. Use --force to override.",
844                entry.destination.display()
845            )));
846        }
847        Err(e) => return Err(SubXError::Io(e)),
848    };
849
850    if metadata.len() != entry.file_size {
851        return Err(SubXError::config(format!(
852            "Destination file {} has been modified since the operation (size differs). \
853             Use --force to override.",
854            entry.destination.display()
855        )));
856    }
857
858    let mtime_secs = metadata
859        .modified()
860        .ok()
861        .and_then(|m| m.duration_since(UNIX_EPOCH).ok())
862        .map(|d| d.as_secs());
863
864    if let Some(actual) = mtime_secs {
865        if actual != entry.file_mtime {
866            return Err(SubXError::config(format!(
867                "Destination file {} has been modified since the operation (mtime differs). \
868                 Use --force to override.",
869                entry.destination.display()
870            )));
871        }
872    }
873
874    Ok(())
875}
876
877/// Reverse the effect of a single completed journal entry.
878///
879/// The reversal depends on the original operation:
880/// - `Copied`: the destination copy is deleted, leaving the source intact.
881/// - `Moved` / `Renamed`: the destination is moved back to the original
882///   source path via `std::fs::rename`.
883///
884/// If the entry recorded a backup file, that backup is deleted after the
885/// primary reversal succeeds.
886///
887/// For `Moved`/`Renamed` operations the function checks that the original
888/// source path is vacant before renaming back. If the source already exists
889/// and `force` is false, an error is returned.
890fn rollback_entry(entry: &JournalEntry, force: bool) -> Result<()> {
891    let json_mode = active_mode().is_json();
892    match entry.operation_type {
893        JournalOperationType::Copied => {
894            std::fs::remove_file(&entry.destination)?;
895            if !json_mode {
896                println!("Removed copy: {}", entry.destination.display());
897            }
898        }
899        JournalOperationType::Moved | JournalOperationType::Renamed => {
900            if entry.source.exists() && !force {
901                return Err(SubXError::config(format!(
902                    "Original source path {} already exists. \
903                     Rollback would overwrite it. Use --force to override.",
904                    entry.source.display()
905                )));
906            }
907            if let Some(parent) = entry.source.parent() {
908                if !parent.as_os_str().is_empty() {
909                    std::fs::create_dir_all(parent)?;
910                }
911            }
912            std::fs::rename(&entry.destination, &entry.source)?;
913            if !json_mode {
914                println!(
915                    "Rolled back: {} \u{2190} {}",
916                    entry.source.display(),
917                    entry.destination.display()
918                );
919            }
920        }
921    }
922
923    if let Some(backup) = &entry.backup_path {
924        if backup.exists() {
925            std::fs::remove_file(backup)?;
926            if !json_mode {
927                println!("Removed backup: {}", backup.display());
928            }
929        }
930    }
931
932    Ok(())
933}
934
935/// Handle the `cache rollback` subcommand.
936///
937/// Acquires the process-wide SubX lock, loads the journal, and replays
938/// completed entries in last-in-first-out order — undoing each file
939/// operation. When the rollback finishes successfully the journal file
940/// is removed so subsequent commands start from a clean state.
941///
942/// A missing journal is not an error; it yields an informational message
943/// and returns `Ok(())`. When `--force` is not supplied, the command
944/// aborts before touching any file if any destination's size or mtime no
945/// longer matches the journal record.
946pub async fn execute_rollback(args: &RollbackArgs) -> Result<()> {
947    let _lock = acquire_subx_lock().await?;
948    let json_mode = active_mode().is_json();
949
950    let journal_file = journal_path()?;
951    if !journal_file.exists() {
952        if json_mode {
953            emit_success(
954                OutputMode::Json,
955                "cache",
956                CacheRollbackPayload { rolled_back: 0 },
957            );
958        } else {
959            println!("No operation journal found. Nothing to rollback.");
960        }
961        return Ok(());
962    }
963
964    let journal = JournalData::load(&journal_file).await?;
965
966    let reversed: Vec<&JournalEntry> = journal
967        .entries
968        .iter()
969        .filter(|e| e.status == JournalEntryStatus::Completed)
970        .rev()
971        .collect();
972
973    if reversed.is_empty() {
974        if json_mode {
975            emit_success(
976                OutputMode::Json,
977                "cache",
978                CacheRollbackPayload { rolled_back: 0 },
979            );
980        } else {
981            println!("Journal has no completed operations to rollback.");
982        }
983        return Ok(());
984    }
985
986    if !json_mode {
987        println!(
988            "Rolling back {} operations from batch {}...",
989            reversed.len(),
990            journal.batch_id
991        );
992    }
993
994    let mut rolled_back: u64 = 0;
995    for entry in &reversed {
996        if !args.force {
997            verify_destination_integrity(entry)?;
998        }
999        rollback_entry(entry, args.force)?;
1000        rolled_back += 1;
1001    }
1002
1003    std::fs::remove_file(&journal_file)?;
1004
1005    if json_mode {
1006        emit_success(
1007            OutputMode::Json,
1008            "cache",
1009            CacheRollbackPayload { rolled_back },
1010        );
1011    } else {
1012        println!("Rollback complete. Journal deleted.");
1013    }
1014    Ok(())
1015}
1016
1017/// Dispatch the cache subcommand using the production configuration service.
1018///
1019/// For testable code paths, prefer [`execute_with_config`] which accepts an
1020/// injected [`ConfigService`].
1021pub async fn execute(args: CacheArgs) -> Result<()> {
1022    match args.action {
1023        crate::cli::CacheAction::Clear(clear_args) => {
1024            execute_clear(&clear_args).await?;
1025        }
1026        crate::cli::CacheAction::Status(status_args) => {
1027            // Fall back to the production configuration service when no service
1028            // was injected by the caller. This keeps the legacy `execute` entry
1029            // point functional for users invoking it directly.
1030            let config_service = subx_core::config::ProductionConfigService::new()?;
1031            execute_status(&status_args, &config_service).await?;
1032        }
1033        crate::cli::CacheAction::Apply(ref apply_args) => {
1034            let config_service = subx_core::config::ProductionConfigService::new()?;
1035            execute_apply(apply_args, &config_service).await?;
1036        }
1037        crate::cli::CacheAction::Rollback(rollback_args) => {
1038            execute_rollback(&rollback_args).await?;
1039        }
1040    }
1041    Ok(())
1042}
1043
1044/// Execute cache management command with injected configuration service.
1045///
1046/// This function provides the new dependency injection interface for the cache command,
1047/// accepting a configuration service instead of loading configuration globally.
1048///
1049/// # Arguments
1050///
1051/// * `args` - Cache command arguments
1052/// * `config_service` - Configuration service providing access to cache settings
1053///
1054/// # Returns
1055///
1056/// Returns `Ok(())` on successful completion, or an error if the operation fails.
1057pub async fn execute_with_config(
1058    args: CacheArgs,
1059    config_service: std::sync::Arc<dyn ConfigService>,
1060) -> Result<()> {
1061    match args.action {
1062        crate::cli::CacheAction::Status(status_args) => {
1063            execute_status(&status_args, config_service.as_ref()).await
1064        }
1065        crate::cli::CacheAction::Apply(apply_args) => {
1066            execute_apply(&apply_args, config_service.as_ref()).await
1067        }
1068        other => execute(CacheArgs { action: other }).await,
1069    }
1070}
1071
1072#[cfg(test)]
1073mod tests {
1074    use super::*;
1075    use std::path::PathBuf;
1076    use subx_core::config::TestConfigService;
1077    use subx_core::core::matcher::cache::{CacheData, SnapshotItem};
1078    use subx_core::core::matcher::journal::{
1079        JournalEntry, JournalEntryStatus, JournalOperationType,
1080    };
1081    use tempfile::TempDir;
1082
1083    // -----------------------------------------------------------------------
1084    // Helpers
1085    // -----------------------------------------------------------------------
1086
1087    /// Redirect the config directory to an isolated temp directory and return
1088    /// both the `TempDir` guard (must stay alive) and the subx subdirectory.
1089    fn isolated_config_dir() -> (TempDir, PathBuf) {
1090        let tmp = TempDir::new().expect("tempdir");
1091        unsafe {
1092            std::env::set_var("XDG_CONFIG_HOME", tmp.path());
1093        }
1094        let subx_dir = tmp.path().join("subx");
1095        std::fs::create_dir_all(&subx_dir).expect("create subx dir");
1096        (tmp, subx_dir)
1097    }
1098
1099    /// Build a minimal `JournalEntry` whose destination file already exists
1100    /// on disk so that integrity checks pass by default.
1101    fn make_journal_entry(
1102        op_type: JournalOperationType,
1103        source: PathBuf,
1104        destination: PathBuf,
1105    ) -> JournalEntry {
1106        let meta = std::fs::metadata(&destination).expect("destination must exist");
1107        let mtime = meta
1108            .modified()
1109            .unwrap()
1110            .duration_since(std::time::UNIX_EPOCH)
1111            .unwrap()
1112            .as_secs();
1113        JournalEntry {
1114            operation_type: op_type,
1115            source,
1116            destination,
1117            backup_path: None,
1118            status: JournalEntryStatus::Completed,
1119            file_size: meta.len(),
1120            file_mtime: mtime,
1121        }
1122    }
1123
1124    /// Minimal valid `CacheData` with an empty snapshot (legacy-style).
1125    fn empty_snapshot_cache() -> CacheData {
1126        CacheData {
1127            cache_version: "1.0".into(),
1128            directory: "/tmp".into(),
1129            file_snapshot: vec![],
1130            match_operations: vec![],
1131            created_at: 0,
1132            ai_model_used: "test-model".into(),
1133            config_hash: "abc123".into(),
1134            original_relocation_mode: "None".into(),
1135            original_backup_enabled: false,
1136        }
1137    }
1138
1139    // -----------------------------------------------------------------------
1140    // format_size
1141    // -----------------------------------------------------------------------
1142
1143    #[test]
1144    fn format_size_bytes() {
1145        assert_eq!(format_size(0), "0 B");
1146        assert_eq!(format_size(512), "512 B");
1147        assert_eq!(format_size(1023), "1023 B");
1148    }
1149
1150    #[test]
1151    fn format_size_kilobytes() {
1152        assert_eq!(format_size(1024), "1.0 KB");
1153        assert_eq!(format_size(2048), "2.0 KB");
1154        // Just below 1 MB
1155        let just_below_mb = (1024.0 * 1024.0 - 1.0) as u64;
1156        let result = format_size(just_below_mb);
1157        assert!(result.ends_with("KB"), "expected KB, got {result}");
1158    }
1159
1160    #[test]
1161    fn format_size_megabytes() {
1162        assert_eq!(format_size(1024 * 1024), "1.0 MB");
1163        assert_eq!(format_size(5 * 1024 * 1024), "5.0 MB");
1164        // Just below 1 GB
1165        let just_below_gb = (1024.0 * 1024.0 * 1024.0 - 1.0) as u64;
1166        let result = format_size(just_below_gb);
1167        assert!(result.ends_with("MB"), "expected MB, got {result}");
1168    }
1169
1170    #[test]
1171    fn format_size_gigabytes() {
1172        assert_eq!(format_size(1024 * 1024 * 1024), "1.0 GB");
1173        assert_eq!(format_size(2 * 1024 * 1024 * 1024), "2.0 GB");
1174    }
1175
1176    // -----------------------------------------------------------------------
1177    // format_age
1178    // -----------------------------------------------------------------------
1179
1180    #[test]
1181    fn format_age_seconds() {
1182        assert_eq!(format_age(0), "0 seconds ago");
1183        assert_eq!(format_age(30), "30 seconds ago");
1184        assert_eq!(format_age(59), "59 seconds ago");
1185    }
1186
1187    #[test]
1188    fn format_age_minutes() {
1189        assert_eq!(format_age(60), "1 minutes ago");
1190        assert_eq!(format_age(90), "1 minutes ago");
1191        assert_eq!(format_age(3599), "59 minutes ago");
1192    }
1193
1194    #[test]
1195    fn format_age_hours() {
1196        assert_eq!(format_age(3600), "1 hours ago");
1197        assert_eq!(format_age(7200), "2 hours ago");
1198        assert_eq!(format_age(86399), "23 hours ago");
1199    }
1200
1201    #[test]
1202    fn format_age_days() {
1203        assert_eq!(format_age(86400), "1 days ago");
1204        assert_eq!(format_age(172800), "2 days ago");
1205        assert_eq!(format_age(604800), "7 days ago");
1206    }
1207
1208    // -----------------------------------------------------------------------
1209    // compute_config_hash / current_config_hash
1210    // -----------------------------------------------------------------------
1211
1212    #[test]
1213    fn compute_config_hash_is_deterministic() {
1214        let h1 = compute_config_hash("None", false);
1215        let h2 = compute_config_hash("None", false);
1216        assert_eq!(h1, h2);
1217    }
1218
1219    #[test]
1220    fn compute_config_hash_differs_for_different_modes() {
1221        let h_none = compute_config_hash("None", false);
1222        let h_copy = compute_config_hash("Copy", false);
1223        let h_move = compute_config_hash("Move", false);
1224        assert_ne!(h_none, h_copy);
1225        assert_ne!(h_none, h_move);
1226        assert_ne!(h_copy, h_move);
1227    }
1228
1229    #[test]
1230    fn compute_config_hash_differs_for_backup_flag() {
1231        let h_off = compute_config_hash("None", false);
1232        let h_on = compute_config_hash("None", true);
1233        assert_ne!(h_off, h_on);
1234    }
1235
1236    #[test]
1237    fn compute_config_hash_is_16_hex_chars() {
1238        let h = compute_config_hash("None", false);
1239        assert_eq!(h.len(), 16);
1240        assert!(h.chars().all(|c| c.is_ascii_hexdigit()));
1241    }
1242
1243    #[test]
1244    fn current_config_hash_returns_string() {
1245        let svc = TestConfigService::with_defaults();
1246        let h = current_config_hash(&svc).expect("should succeed");
1247        assert_eq!(h.len(), 16);
1248    }
1249
1250    // -----------------------------------------------------------------------
1251    // parse_relocation_mode
1252    // -----------------------------------------------------------------------
1253
1254    #[test]
1255    fn parse_relocation_mode_copy() {
1256        assert!(matches!(
1257            parse_relocation_mode("Copy"),
1258            FileRelocationMode::Copy
1259        ));
1260    }
1261
1262    #[test]
1263    fn parse_relocation_mode_move() {
1264        assert!(matches!(
1265            parse_relocation_mode("Move"),
1266            FileRelocationMode::Move
1267        ));
1268    }
1269
1270    #[test]
1271    fn parse_relocation_mode_none_keyword() {
1272        assert!(matches!(
1273            parse_relocation_mode("None"),
1274            FileRelocationMode::None
1275        ));
1276    }
1277
1278    #[test]
1279    fn parse_relocation_mode_unknown_falls_back_to_none() {
1280        assert!(matches!(
1281            parse_relocation_mode("UnknownVariant"),
1282            FileRelocationMode::None
1283        ));
1284    }
1285
1286    // -----------------------------------------------------------------------
1287    // describe_snapshot
1288    // -----------------------------------------------------------------------
1289
1290    #[test]
1291    fn describe_snapshot_empty_is_reported_as_legacy() {
1292        let cache = empty_snapshot_cache();
1293        let (label, status) = describe_snapshot(&cache);
1294        assert_eq!(status, "empty");
1295        assert!(label.contains("legacy"), "label: {label}");
1296    }
1297
1298    #[test]
1299    fn describe_snapshot_valid_when_files_match_on_disk() {
1300        let tmp = TempDir::new().unwrap();
1301        let file = tmp.path().join("video.srt");
1302        std::fs::write(&file, "content").unwrap();
1303        let meta = std::fs::metadata(&file).unwrap();
1304        let mtime = meta
1305            .modified()
1306            .unwrap()
1307            .duration_since(std::time::UNIX_EPOCH)
1308            .unwrap()
1309            .as_secs();
1310
1311        let mut cache = empty_snapshot_cache();
1312        cache.file_snapshot = vec![SnapshotItem {
1313            path: file.to_string_lossy().into_owned(),
1314            name: "video.srt".into(),
1315            size: meta.len(),
1316            mtime,
1317            file_type: "subtitle".into(),
1318        }];
1319
1320        let (label, status) = describe_snapshot(&cache);
1321        assert_eq!(status, "valid", "label: {label}");
1322        assert_eq!(label, "Valid");
1323    }
1324
1325    #[test]
1326    fn describe_snapshot_stale_when_file_missing() {
1327        let tmp = TempDir::new().unwrap();
1328        let missing = tmp.path().join("gone.srt");
1329
1330        let mut cache = empty_snapshot_cache();
1331        cache.file_snapshot = vec![SnapshotItem {
1332            path: missing.to_string_lossy().into_owned(),
1333            name: "gone.srt".into(),
1334            size: 100,
1335            mtime: 999,
1336            file_type: "subtitle".into(),
1337        }];
1338
1339        let (label, status) = describe_snapshot(&cache);
1340        assert_eq!(status, "stale", "label: {label}");
1341        assert!(label.starts_with("Stale"), "label: {label}");
1342    }
1343
1344    // -----------------------------------------------------------------------
1345    // clear_file
1346    // -----------------------------------------------------------------------
1347
1348    #[test]
1349    fn clear_file_returns_true_and_removes_existing_file() {
1350        let tmp = TempDir::new().unwrap();
1351        let target = tmp.path().join("to_delete.txt");
1352        std::fs::write(&target, "data").unwrap();
1353        assert!(target.exists());
1354
1355        let result = clear_file(&target, "Cache").expect("should succeed");
1356        assert!(result, "should return true when file existed");
1357        assert!(!target.exists(), "file should be removed");
1358    }
1359
1360    #[test]
1361    fn clear_file_returns_false_when_file_absent() {
1362        let tmp = TempDir::new().unwrap();
1363        let missing = tmp.path().join("nonexistent.txt");
1364        assert!(!missing.exists());
1365
1366        let result = clear_file(&missing, "Cache").expect("should succeed");
1367        assert!(!result, "should return false when file was absent");
1368    }
1369
1370    // -----------------------------------------------------------------------
1371    // get_config_dir / cache_path / journal_path (path resolution)
1372    // -----------------------------------------------------------------------
1373
1374    #[test]
1375    fn get_config_dir_uses_xdg_config_home_when_set() {
1376        let tmp = TempDir::new().unwrap();
1377        unsafe {
1378            std::env::set_var("XDG_CONFIG_HOME", tmp.path());
1379        }
1380        let dir = get_config_dir().expect("should succeed");
1381        assert_eq!(dir, tmp.path());
1382    }
1383
1384    #[test]
1385    fn cache_path_ends_with_expected_components() {
1386        let tmp = TempDir::new().unwrap();
1387        unsafe {
1388            std::env::set_var("XDG_CONFIG_HOME", tmp.path());
1389        }
1390        let p = cache_path().expect("should succeed");
1391        assert!(p.ends_with("subx/match_cache.json"));
1392    }
1393
1394    #[test]
1395    fn journal_path_ends_with_expected_components() {
1396        let tmp = TempDir::new().unwrap();
1397        unsafe {
1398            std::env::set_var("XDG_CONFIG_HOME", tmp.path());
1399        }
1400        let p = journal_path().expect("should succeed");
1401        assert!(p.ends_with("subx/match_journal.json"));
1402    }
1403
1404    // -----------------------------------------------------------------------
1405    // verify_destination_integrity
1406    // -----------------------------------------------------------------------
1407
1408    #[test]
1409    fn verify_destination_integrity_ok_when_metadata_matches() {
1410        let tmp = TempDir::new().unwrap();
1411        let dst = tmp.path().join("dest.srt");
1412        std::fs::write(&dst, "hello").unwrap();
1413
1414        let entry = make_journal_entry(
1415            JournalOperationType::Copied,
1416            tmp.path().join("src.srt"),
1417            dst,
1418        );
1419
1420        verify_destination_integrity(&entry).expect("should pass integrity check");
1421    }
1422
1423    #[test]
1424    fn verify_destination_integrity_errors_when_file_missing() {
1425        let tmp = TempDir::new().unwrap();
1426        let dst = tmp.path().join("missing.srt");
1427        // Do not create the file
1428
1429        let entry = JournalEntry {
1430            operation_type: JournalOperationType::Copied,
1431            source: tmp.path().join("src.srt"),
1432            destination: dst,
1433            backup_path: None,
1434            status: JournalEntryStatus::Completed,
1435            file_size: 5,
1436            file_mtime: 1_700_000_000,
1437        };
1438
1439        let err = verify_destination_integrity(&entry).expect_err("should fail");
1440        let msg = format!("{err}");
1441        assert!(
1442            msg.contains("no longer exists"),
1443            "error should mention missing file: {msg}"
1444        );
1445    }
1446
1447    #[test]
1448    fn verify_destination_integrity_errors_on_size_mismatch() {
1449        let tmp = TempDir::new().unwrap();
1450        let dst = tmp.path().join("sized.srt");
1451        std::fs::write(&dst, "hello").unwrap(); // 5 bytes
1452
1453        let meta = std::fs::metadata(&dst).unwrap();
1454        let mtime = meta
1455            .modified()
1456            .unwrap()
1457            .duration_since(std::time::UNIX_EPOCH)
1458            .unwrap()
1459            .as_secs();
1460
1461        let entry = JournalEntry {
1462            operation_type: JournalOperationType::Copied,
1463            source: tmp.path().join("src.srt"),
1464            destination: dst,
1465            backup_path: None,
1466            status: JournalEntryStatus::Completed,
1467            file_size: 999, // deliberately wrong
1468            file_mtime: mtime,
1469        };
1470
1471        let err = verify_destination_integrity(&entry).expect_err("should fail on size mismatch");
1472        let msg = format!("{err}");
1473        assert!(
1474            msg.contains("size differs"),
1475            "error should mention size: {msg}"
1476        );
1477    }
1478
1479    #[test]
1480    fn verify_destination_integrity_errors_on_mtime_mismatch() {
1481        let tmp = TempDir::new().unwrap();
1482        let dst = tmp.path().join("mtimed.srt");
1483        std::fs::write(&dst, "hello").unwrap();
1484        let meta = std::fs::metadata(&dst).unwrap();
1485
1486        let entry = JournalEntry {
1487            operation_type: JournalOperationType::Copied,
1488            source: tmp.path().join("src.srt"),
1489            destination: dst,
1490            backup_path: None,
1491            status: JournalEntryStatus::Completed,
1492            file_size: meta.len(),
1493            file_mtime: 1, // deliberately wrong mtime
1494        };
1495
1496        let err = verify_destination_integrity(&entry).expect_err("should fail on mtime mismatch");
1497        let msg = format!("{err}");
1498        assert!(
1499            msg.contains("mtime differs"),
1500            "error should mention mtime: {msg}"
1501        );
1502    }
1503
1504    // -----------------------------------------------------------------------
1505    // rollback_entry
1506    // -----------------------------------------------------------------------
1507
1508    #[test]
1509    fn rollback_entry_copied_removes_destination() {
1510        let tmp = TempDir::new().unwrap();
1511        let src = tmp.path().join("src.srt");
1512        let dst = tmp.path().join("dst.srt");
1513        std::fs::write(&src, "original").unwrap();
1514        std::fs::write(&dst, "copy").unwrap();
1515
1516        let entry = make_journal_entry(JournalOperationType::Copied, src.clone(), dst.clone());
1517        rollback_entry(&entry, false).expect("rollback copy");
1518
1519        assert!(!dst.exists(), "copy destination must be removed");
1520        assert!(src.exists(), "source must remain");
1521    }
1522
1523    #[test]
1524    fn rollback_entry_moved_restores_source() {
1525        let tmp = TempDir::new().unwrap();
1526        let src = tmp.path().join("original.srt");
1527        let dst = tmp.path().join("moved.srt");
1528        // After a move, only the destination exists on disk.
1529        std::fs::write(&dst, "payload").unwrap();
1530
1531        let entry = make_journal_entry(JournalOperationType::Moved, src.clone(), dst.clone());
1532        rollback_entry(&entry, false).expect("rollback move");
1533
1534        assert!(src.exists(), "source must be restored");
1535        assert!(!dst.exists(), "destination must be removed");
1536        assert_eq!(std::fs::read_to_string(&src).unwrap(), "payload");
1537    }
1538
1539    #[test]
1540    fn rollback_entry_renamed_restores_source() {
1541        let tmp = TempDir::new().unwrap();
1542        let src = tmp.path().join("old_name.srt");
1543        let dst = tmp.path().join("new_name.srt");
1544        std::fs::write(&dst, "content").unwrap();
1545
1546        let entry = make_journal_entry(JournalOperationType::Renamed, src.clone(), dst.clone());
1547        rollback_entry(&entry, false).expect("rollback rename");
1548
1549        assert!(src.exists(), "original name must be restored");
1550        assert!(!dst.exists(), "new name must be gone");
1551    }
1552
1553    #[test]
1554    fn rollback_entry_moved_errors_when_source_exists_without_force() {
1555        let tmp = TempDir::new().unwrap();
1556        let src = tmp.path().join("exists.srt");
1557        let dst = tmp.path().join("dest.srt");
1558        // Both source and destination exist (conflicting state).
1559        std::fs::write(&src, "already here").unwrap();
1560        std::fs::write(&dst, "moved here").unwrap();
1561
1562        let entry = make_journal_entry(JournalOperationType::Moved, src.clone(), dst.clone());
1563        let err = rollback_entry(&entry, false).expect_err("should abort when source exists");
1564        let msg = format!("{err}");
1565        assert!(
1566            msg.contains("already exists"),
1567            "error should mention conflict: {msg}"
1568        );
1569    }
1570
1571    #[test]
1572    fn rollback_entry_moved_with_force_overwrites_existing_source() {
1573        let tmp = TempDir::new().unwrap();
1574        let src = tmp.path().join("src_force.srt");
1575        let dst = tmp.path().join("dst_force.srt");
1576        std::fs::write(&src, "old").unwrap();
1577        std::fs::write(&dst, "new content").unwrap();
1578
1579        let entry = make_journal_entry(JournalOperationType::Moved, src.clone(), dst.clone());
1580        rollback_entry(&entry, true).expect("force rollback should succeed");
1581
1582        assert!(src.exists(), "source must exist after force rollback");
1583        assert!(!dst.exists(), "destination must be gone");
1584        assert_eq!(std::fs::read_to_string(&src).unwrap(), "new content");
1585    }
1586
1587    #[test]
1588    fn rollback_entry_removes_existing_backup() {
1589        let tmp = TempDir::new().unwrap();
1590        let src = tmp.path().join("src_bak.srt");
1591        let dst = tmp.path().join("dst_bak.srt");
1592        let backup = tmp.path().join("src_bak.srt.bak");
1593        std::fs::write(&dst, "copy").unwrap();
1594        std::fs::write(&backup, "backup content").unwrap();
1595
1596        let meta = std::fs::metadata(&dst).unwrap();
1597        let mtime = meta
1598            .modified()
1599            .unwrap()
1600            .duration_since(std::time::UNIX_EPOCH)
1601            .unwrap()
1602            .as_secs();
1603        let entry = JournalEntry {
1604            operation_type: JournalOperationType::Copied,
1605            source: src,
1606            destination: dst.clone(),
1607            backup_path: Some(backup.clone()),
1608            status: JournalEntryStatus::Completed,
1609            file_size: meta.len(),
1610            file_mtime: mtime,
1611        };
1612
1613        rollback_entry(&entry, false).expect("rollback with backup");
1614        assert!(!dst.exists(), "copy destination must be removed");
1615        assert!(!backup.exists(), "backup must be deleted");
1616    }
1617
1618    #[test]
1619    fn rollback_entry_tolerates_missing_backup_file() {
1620        let tmp = TempDir::new().unwrap();
1621        let src = tmp.path().join("src_nobak.srt");
1622        let dst = tmp.path().join("dst_nobak.srt");
1623        let backup = tmp.path().join("missing_backup.srt.bak");
1624        std::fs::write(&dst, "copy").unwrap();
1625        // backup file intentionally not created
1626
1627        let meta = std::fs::metadata(&dst).unwrap();
1628        let mtime = meta
1629            .modified()
1630            .unwrap()
1631            .duration_since(std::time::UNIX_EPOCH)
1632            .unwrap()
1633            .as_secs();
1634        let entry = JournalEntry {
1635            operation_type: JournalOperationType::Copied,
1636            source: src,
1637            destination: dst.clone(),
1638            backup_path: Some(backup),
1639            status: JournalEntryStatus::Completed,
1640            file_size: meta.len(),
1641            file_mtime: mtime,
1642        };
1643
1644        rollback_entry(&entry, false).expect("missing backup should not cause error");
1645        assert!(!dst.exists());
1646    }
1647
1648    // -----------------------------------------------------------------------
1649    // execute_status (unit-level path through private helpers)
1650    // -----------------------------------------------------------------------
1651
1652    #[tokio::test]
1653    async fn execute_status_no_cache_json_output_contains_exists_false() {
1654        let (_tmp, subx_dir) = isolated_config_dir();
1655        let cache_file = subx_dir.join("match_cache.json");
1656        assert!(!cache_file.exists());
1657
1658        let svc = TestConfigService::with_defaults();
1659        let args = crate::cli::StatusArgs { json: true };
1660        execute_status(&args, &svc)
1661            .await
1662            .expect("status must succeed without cache");
1663    }
1664
1665    #[tokio::test]
1666    async fn execute_status_no_cache_plain_output_is_ok() {
1667        let (_tmp, subx_dir) = isolated_config_dir();
1668        let cache_file = subx_dir.join("match_cache.json");
1669        assert!(!cache_file.exists());
1670
1671        let svc = TestConfigService::with_defaults();
1672        let args = crate::cli::StatusArgs { json: false };
1673        execute_status(&args, &svc)
1674            .await
1675            .expect("status must succeed without cache (plain)");
1676    }
1677
1678    #[tokio::test]
1679    async fn execute_status_valid_cache_plain_succeeds() {
1680        let (_tmp, subx_dir) = isolated_config_dir();
1681        let cache_file = subx_dir.join("match_cache.json");
1682
1683        let svc = TestConfigService::with_defaults();
1684        let config = svc.get_config().unwrap();
1685        let hash = compute_config_hash("None", config.general.backup_enabled);
1686
1687        let now = std::time::SystemTime::now()
1688            .duration_since(std::time::UNIX_EPOCH)
1689            .unwrap()
1690            .as_secs();
1691        let cache = serde_json::json!({
1692            "cache_version": "1.0",
1693            "directory": "/some/dir",
1694            "file_snapshot": [],
1695            "match_operations": [
1696                {
1697                    "video_file": "/some/video.mkv",
1698                    "subtitle_file": "/some/sub.srt",
1699                    "new_subtitle_name": "video.srt",
1700                    "confidence": 0.95,
1701                    "reasoning": []
1702                }
1703            ],
1704            "created_at": now,
1705            "ai_model_used": "gpt-4",
1706            "config_hash": hash,
1707            "original_relocation_mode": "None",
1708            "original_backup_enabled": false,
1709        });
1710        std::fs::write(&cache_file, serde_json::to_string(&cache).unwrap()).unwrap();
1711
1712        let args = crate::cli::StatusArgs { json: false };
1713        execute_status(&args, &svc)
1714            .await
1715            .expect("status with matching hash must succeed");
1716    }
1717
1718    #[tokio::test]
1719    async fn execute_status_valid_cache_json_mode_succeeds() {
1720        let (_tmp, subx_dir) = isolated_config_dir();
1721        let cache_file = subx_dir.join("match_cache.json");
1722        let journal_file = subx_dir.join("match_journal.json");
1723
1724        let svc = TestConfigService::with_defaults();
1725        let config = svc.get_config().unwrap();
1726        let hash = compute_config_hash("None", config.general.backup_enabled);
1727
1728        let now = std::time::SystemTime::now()
1729            .duration_since(std::time::UNIX_EPOCH)
1730            .unwrap()
1731            .as_secs();
1732        let cache = serde_json::json!({
1733            "cache_version": "1.0",
1734            "directory": "/some/dir",
1735            "file_snapshot": [],
1736            "match_operations": [],
1737            "created_at": now,
1738            "ai_model_used": "gpt-4",
1739            "config_hash": hash,
1740            "original_relocation_mode": "None",
1741            "original_backup_enabled": false,
1742        });
1743        std::fs::write(&cache_file, serde_json::to_string(&cache).unwrap()).unwrap();
1744        std::fs::write(&journal_file, "{}").unwrap();
1745
1746        let args = crate::cli::StatusArgs { json: true };
1747        execute_status(&args, &svc)
1748            .await
1749            .expect("JSON status must succeed with matching hash");
1750    }
1751
1752    #[tokio::test]
1753    async fn execute_status_mismatched_hash_shows_in_plain_output() {
1754        let (_tmp, subx_dir) = isolated_config_dir();
1755        let cache_file = subx_dir.join("match_cache.json");
1756
1757        let now = std::time::SystemTime::now()
1758            .duration_since(std::time::UNIX_EPOCH)
1759            .unwrap()
1760            .as_secs();
1761        let cache = serde_json::json!({
1762            "cache_version": "1.0",
1763            "directory": "/some/dir",
1764            "file_snapshot": [],
1765            "match_operations": [],
1766            "created_at": now,
1767            "ai_model_used": "gpt-4",
1768            "config_hash": "00000000deadbeef",
1769            "original_relocation_mode": "None",
1770            "original_backup_enabled": false,
1771        });
1772        std::fs::write(&cache_file, serde_json::to_string(&cache).unwrap()).unwrap();
1773
1774        let svc = TestConfigService::with_defaults();
1775        let args = crate::cli::StatusArgs { json: false };
1776        // Should succeed even when hash doesn't match — status is informational only.
1777        execute_status(&args, &svc)
1778            .await
1779            .expect("status succeeds even with mismatched config hash");
1780    }
1781
1782    // -----------------------------------------------------------------------
1783    // execute_rollback edge cases
1784    // -----------------------------------------------------------------------
1785
1786    #[tokio::test]
1787    async fn execute_rollback_journal_with_only_pending_entries_is_noop() {
1788        use subx_core::core::matcher::journal::JournalData;
1789
1790        let (_tmp, subx_dir) = isolated_config_dir();
1791        let journal_file = subx_dir.join("match_journal.json");
1792
1793        let tmp2 = TempDir::new().unwrap();
1794        let dst = tmp2.path().join("file.srt");
1795        std::fs::write(&dst, "data").unwrap();
1796
1797        let pending_entry = JournalEntry {
1798            operation_type: JournalOperationType::Copied,
1799            source: tmp2.path().join("src.srt"),
1800            destination: dst.clone(),
1801            backup_path: None,
1802            status: JournalEntryStatus::Pending,
1803            file_size: 4,
1804            file_mtime: 0,
1805        };
1806
1807        let journal = JournalData {
1808            batch_id: "pending-only".into(),
1809            created_at: 0,
1810            entries: vec![pending_entry],
1811        };
1812        journal.save(&journal_file).await.expect("save journal");
1813
1814        let args = RollbackArgs { force: false };
1815        execute_rollback(&args)
1816            .await
1817            .expect("should succeed with only pending entries");
1818
1819        // When there are no completed entries to roll back, the journal is
1820        // left in place (nothing was reversed) and the destination is untouched.
1821        assert!(
1822            journal_file.exists(),
1823            "journal kept when nothing was rolled back"
1824        );
1825        assert!(dst.exists(), "pending entry destination must be untouched");
1826    }
1827
1828    #[tokio::test]
1829    async fn execute_rollback_force_skips_integrity_check() {
1830        use subx_core::core::matcher::journal::JournalData;
1831
1832        let (_tmp, subx_dir) = isolated_config_dir();
1833        let journal_file = subx_dir.join("match_journal.json");
1834
1835        let tmp2 = TempDir::new().unwrap();
1836        let src = tmp2.path().join("orig.srt");
1837        let dst = tmp2.path().join("copy.srt");
1838        std::fs::write(&dst, "data").unwrap();
1839
1840        // Record wrong size so integrity check would normally fail.
1841        let entry = JournalEntry {
1842            operation_type: JournalOperationType::Copied,
1843            source: src.clone(),
1844            destination: dst.clone(),
1845            backup_path: None,
1846            status: JournalEntryStatus::Completed,
1847            file_size: 9999,  // wrong size
1848            file_mtime: 9999, // wrong mtime
1849        };
1850
1851        let journal = JournalData {
1852            batch_id: "force-batch".into(),
1853            created_at: 0,
1854            entries: vec![entry],
1855        };
1856        journal.save(&journal_file).await.expect("save journal");
1857
1858        let args = RollbackArgs { force: true };
1859        execute_rollback(&args)
1860            .await
1861            .expect("force rollback should succeed despite integrity mismatch");
1862
1863        assert!(!dst.exists(), "copy destination must be removed");
1864        assert!(!journal_file.exists(), "journal must be deleted");
1865    }
1866
1867    // -----------------------------------------------------------------------
1868    // execute_clear via execute_with_config
1869    // -----------------------------------------------------------------------
1870
1871    #[tokio::test]
1872    async fn execute_with_config_clear_journal_type_works() {
1873        use std::sync::Arc;
1874        let (_tmp, subx_dir) = isolated_config_dir();
1875        let journal_file = subx_dir.join("match_journal.json");
1876        let cache_file = subx_dir.join("match_cache.json");
1877        std::fs::write(&journal_file, "{}").unwrap();
1878        std::fs::write(&cache_file, "{}").unwrap();
1879
1880        let svc = Arc::new(TestConfigService::with_defaults());
1881        let args = CacheArgs {
1882            action: crate::cli::CacheAction::Clear(crate::cli::ClearArgs {
1883                r#type: crate::cli::ClearType::Journal,
1884            }),
1885        };
1886        execute_with_config(args, svc)
1887            .await
1888            .expect("clear journal via execute_with_config");
1889
1890        assert!(!journal_file.exists(), "journal should be removed");
1891        assert!(cache_file.exists(), "cache should remain");
1892    }
1893
1894    // -----------------------------------------------------------------------
1895    // execute_apply — confidence filter
1896    // -----------------------------------------------------------------------
1897
1898    #[tokio::test]
1899    async fn execute_apply_confidence_filter_removes_low_confidence_ops() {
1900        use crate::cli::ApplyArgs;
1901
1902        let (_tmp, subx_dir) = isolated_config_dir();
1903        let cache_file = subx_dir.join("match_cache.json");
1904
1905        let svc = TestConfigService::with_defaults();
1906        let config = svc.get_config().unwrap();
1907        let hash = compute_config_hash("None", config.general.backup_enabled);
1908
1909        let now = std::time::SystemTime::now()
1910            .duration_since(std::time::UNIX_EPOCH)
1911            .unwrap()
1912            .as_secs();
1913
1914        // Two operations: one at 90 %, one at 50 %.  Filter at 80 % should
1915        // leave only the first, reducing to 1 op — which then hits
1916        // "No operations to apply" because the filtered result has 1 entry
1917        // but the test uses force=true so it proceeds; with an empty cache
1918        // we'd get "No operations to apply".
1919        // Use --force to skip snapshot/hash checks.
1920        let cache = serde_json::json!({
1921            "cache_version": "1.0",
1922            "directory": "/dir",
1923            "file_snapshot": [],
1924            "match_operations": [
1925                {
1926                    "video_file": "/dir/v1.mkv",
1927                    "subtitle_file": "/dir/s1.srt",
1928                    "new_subtitle_name": "v1.srt",
1929                    "confidence": 0.5,
1930                    "reasoning": []
1931                }
1932            ],
1933            "created_at": now,
1934            "ai_model_used": "gpt-4",
1935            "config_hash": hash,
1936            "original_relocation_mode": "None",
1937            "original_backup_enabled": false,
1938        });
1939        std::fs::write(&cache_file, serde_json::to_string(&cache).unwrap()).unwrap();
1940
1941        // confidence threshold = 80, filters out the 50% op → empty → "No operations"
1942        let result = execute_apply(
1943            &ApplyArgs {
1944                yes: true,
1945                force: true,
1946                confidence: Some(80),
1947            },
1948            &svc,
1949        )
1950        .await;
1951        assert!(
1952            result.is_ok(),
1953            "confidence filter to empty ops should be Ok: {result:?}"
1954        );
1955    }
1956}