1use 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
32pub(crate) const UNDO_SNAPSHOT_PREFIX: &str = "undo-";
34
35pub fn undo_cache_dir(repo_root: &Path) -> PathBuf {
37 repo_root.join(".ralph").join("cache").join("undo")
38}
39
40pub 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
90pub 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
130pub 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}