Skip to main content

ralph/undo/
storage.rs

1//! Purpose: Create, list, and load undo snapshot files.
2//!
3//! Responsibilities:
4//! - Resolve the undo cache directory.
5//! - Persist queue/done snapshots atomically.
6//! - Enumerate and load stored undo snapshots.
7//!
8//! Scope:
9//! - Snapshot storage only; restore execution and retention policy live in
10//!   sibling modules.
11//!
12//! Usage:
13//! - Called by queue-mutation paths before writes and by restore flows when
14//!   locating snapshots.
15//!
16//! Invariants/Assumptions:
17//! - Snapshots capture both queue and done files together.
18//! - Snapshot files use the `undo-<timestamp>.json` naming contract.
19
20use std::path::{Path, PathBuf};
21
22use anyhow::{Context, Result, anyhow, bail};
23
24use crate::config::Resolved;
25use crate::constants::limits::MAX_UNDO_SNAPSHOTS;
26use crate::fsutil;
27use crate::queue::load_queue_or_default;
28
29use super::model::{SnapshotList, UndoSnapshot, UndoSnapshotMeta};
30use super::prune::prune_old_undo_snapshots;
31
32/// Snapshot filename prefix.
33pub(crate) const UNDO_SNAPSHOT_PREFIX: &str = "undo-";
34
35/// Get the undo cache directory path.
36pub fn undo_cache_dir(repo_root: &Path) -> PathBuf {
37    repo_root.join(".ralph").join("cache").join("undo")
38}
39
40/// Create a snapshot before a mutation operation.
41///
42/// This should be called AFTER acquiring the queue lock but BEFORE
43/// performing any modifications. The snapshot captures both queue.json
44/// and done.json atomically.
45pub fn create_undo_snapshot(resolved: &Resolved, operation: &str) -> Result<PathBuf> {
46    let undo_dir = undo_cache_dir(&resolved.repo_root);
47    std::fs::create_dir_all(&undo_dir)
48        .with_context(|| format!("create undo directory {}", undo_dir.display()))?;
49
50    let timestamp = crate::timeutil::now_utc_rfc3339()
51        .context("failed to generate timestamp for undo snapshot")?;
52    let snapshot_id = timestamp.replace([':', '.', '-'], "");
53    let snapshot_filename = format!("{}{}.json", UNDO_SNAPSHOT_PREFIX, snapshot_id);
54    let snapshot_path = undo_dir.join(snapshot_filename);
55
56    let queue_json = load_queue_or_default(&resolved.queue_path)?;
57    let done_json = load_queue_or_default(&resolved.done_path)?;
58
59    let snapshot = UndoSnapshot {
60        version: 1,
61        operation: operation.to_string(),
62        timestamp: timestamp.clone(),
63        queue_json,
64        done_json,
65    };
66
67    let content = serde_json::to_string_pretty(&snapshot)?;
68    fsutil::write_atomic(&snapshot_path, content.as_bytes())
69        .with_context(|| format!("write undo snapshot to {}", snapshot_path.display()))?;
70
71    match prune_old_undo_snapshots(&undo_dir, MAX_UNDO_SNAPSHOTS) {
72        Ok(pruned) if pruned > 0 => {
73            log::debug!("pruned {} old undo snapshot(s)", pruned);
74        }
75        Ok(_) => {
76            // The snapshot count was already within the retention limit, so nothing changed.
77        }
78        Err(err) => {
79            log::warn!("failed to prune undo snapshots: {:#}", err);
80        }
81    }
82
83    log::debug!(
84        "created undo snapshot for '{}' at {}",
85        operation,
86        snapshot_path.display()
87    );
88
89    Ok(snapshot_path)
90}
91
92/// List available undo snapshots, newest first.
93pub fn list_undo_snapshots(repo_root: &Path) -> Result<SnapshotList> {
94    let undo_dir = undo_cache_dir(repo_root);
95
96    if !undo_dir.exists() {
97        return Ok(SnapshotList {
98            snapshots: Vec::new(),
99        });
100    }
101
102    let mut snapshots = Vec::new();
103
104    for entry in std::fs::read_dir(&undo_dir)
105        .with_context(|| format!("read undo directory {}", undo_dir.display()))?
106    {
107        let entry = entry?;
108        let path = entry.path();
109
110        if !path.extension().map(|ext| ext == "json").unwrap_or(false) {
111            continue;
112        }
113
114        let filename = path.file_name().unwrap().to_string_lossy();
115        if !filename.starts_with(UNDO_SNAPSHOT_PREFIX) {
116            continue;
117        }
118
119        match extract_snapshot_meta(&path) {
120            Ok(meta) => snapshots.push(meta),
121            Err(err) => {
122                log::warn!("failed to read snapshot {}: {:#}", path.display(), err);
123            }
124        }
125    }
126
127    snapshots.sort_by(|a, b| b.timestamp.cmp(&a.timestamp));
128
129    Ok(SnapshotList { snapshots })
130}
131
132/// Load a full snapshot by ID.
133pub fn load_undo_snapshot(repo_root: &Path, snapshot_id: &str) -> Result<UndoSnapshot> {
134    let undo_dir = undo_cache_dir(repo_root);
135    let snapshot_filename = format!("{}{}.json", UNDO_SNAPSHOT_PREFIX, snapshot_id);
136    let snapshot_path = undo_dir.join(snapshot_filename);
137
138    if !snapshot_path.exists() {
139        bail!("Snapshot not found: {}", snapshot_id);
140    }
141
142    let content = std::fs::read_to_string(&snapshot_path)?;
143    let snapshot: UndoSnapshot = serde_json::from_str(&content)?;
144    Ok(snapshot)
145}
146
147fn extract_snapshot_meta(path: &Path) -> Result<UndoSnapshotMeta> {
148    let content = std::fs::read_to_string(path)?;
149    let value: serde_json::Value = serde_json::from_str(&content)?;
150
151    let id = path
152        .file_stem()
153        .and_then(|stem| stem.to_str())
154        .map(str::to_string)
155        .filter(|stem| !stem.is_empty())
156        .ok_or_else(|| anyhow!("invalid snapshot filename: {}", path.display()))?
157        .strip_prefix(UNDO_SNAPSHOT_PREFIX)
158        .map(str::to_string)
159        .ok_or_else(|| anyhow!("invalid snapshot filename prefix: {}", path.display()))?;
160
161    let operation = value
162        .get("operation")
163        .and_then(|raw| raw.as_str())
164        .unwrap_or("unknown")
165        .to_string();
166    let timestamp = value
167        .get("timestamp")
168        .and_then(|raw| raw.as_str())
169        .unwrap_or("")
170        .to_string();
171
172    Ok(UndoSnapshotMeta {
173        id,
174        operation,
175        timestamp,
176    })
177}