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 }
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
92pub 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
132pub 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}