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        Err(err) => {
77            log::warn!("failed to prune undo snapshots: {:#}", err);
78        }
79    }
80
81    log::debug!(
82        "created undo snapshot for '{}' at {}",
83        operation,
84        snapshot_path.display()
85    );
86
87    Ok(snapshot_path)
88}
89
90/// List available undo snapshots, newest first.
91pub fn list_undo_snapshots(repo_root: &Path) -> Result<SnapshotList> {
92    let undo_dir = undo_cache_dir(repo_root);
93
94    if !undo_dir.exists() {
95        return Ok(SnapshotList {
96            snapshots: Vec::new(),
97        });
98    }
99
100    let mut snapshots = Vec::new();
101
102    for entry in std::fs::read_dir(&undo_dir)
103        .with_context(|| format!("read undo directory {}", undo_dir.display()))?
104    {
105        let entry = entry?;
106        let path = entry.path();
107
108        if !path.extension().map(|ext| ext == "json").unwrap_or(false) {
109            continue;
110        }
111
112        let filename = path.file_name().unwrap().to_string_lossy();
113        if !filename.starts_with(UNDO_SNAPSHOT_PREFIX) {
114            continue;
115        }
116
117        match extract_snapshot_meta(&path) {
118            Ok(meta) => snapshots.push(meta),
119            Err(err) => {
120                log::warn!("failed to read snapshot {}: {:#}", path.display(), err);
121            }
122        }
123    }
124
125    snapshots.sort_by(|a, b| b.timestamp.cmp(&a.timestamp));
126
127    Ok(SnapshotList { snapshots })
128}
129
130/// Load a full snapshot by ID.
131pub fn load_undo_snapshot(repo_root: &Path, snapshot_id: &str) -> Result<UndoSnapshot> {
132    let undo_dir = undo_cache_dir(repo_root);
133    let snapshot_filename = format!("{}{}.json", UNDO_SNAPSHOT_PREFIX, snapshot_id);
134    let snapshot_path = undo_dir.join(snapshot_filename);
135
136    if !snapshot_path.exists() {
137        bail!("Snapshot not found: {}", snapshot_id);
138    }
139
140    let content = std::fs::read_to_string(&snapshot_path)?;
141    let snapshot: UndoSnapshot = serde_json::from_str(&content)?;
142    Ok(snapshot)
143}
144
145fn extract_snapshot_meta(path: &Path) -> Result<UndoSnapshotMeta> {
146    let content = std::fs::read_to_string(path)?;
147    let value: serde_json::Value = serde_json::from_str(&content)?;
148
149    let id = path
150        .file_stem()
151        .and_then(|stem| stem.to_str())
152        .map(str::to_string)
153        .filter(|stem| !stem.is_empty())
154        .ok_or_else(|| anyhow!("invalid snapshot filename: {}", path.display()))?
155        .strip_prefix(UNDO_SNAPSHOT_PREFIX)
156        .map(str::to_string)
157        .ok_or_else(|| anyhow!("invalid snapshot filename prefix: {}", path.display()))?;
158
159    let operation = value
160        .get("operation")
161        .and_then(|raw| raw.as_str())
162        .unwrap_or("unknown")
163        .to_string();
164    let timestamp = value
165        .get("timestamp")
166        .and_then(|raw| raw.as_str())
167        .unwrap_or("")
168        .to_string();
169
170    Ok(UndoSnapshotMeta {
171        id,
172        operation,
173        timestamp,
174    })
175}