1use crate::config::Resolved;
19use crate::constants::limits::MAX_UNDO_SNAPSHOTS;
20use crate::contracts::QueueFile;
21use crate::fsutil;
22use crate::queue::{load_queue_or_default, save_queue};
23use anyhow::{Context, Result, bail};
24use serde::{Deserialize, Serialize};
25use std::path::{Path, PathBuf};
26
27const UNDO_SNAPSHOT_PREFIX: &str = "undo-";
29
30#[derive(Debug, Clone, Serialize, Deserialize)]
32pub struct UndoSnapshotMeta {
33 pub id: String,
35 pub operation: String,
37 pub timestamp: String,
39}
40
41#[derive(Debug, Clone, Serialize, Deserialize)]
43pub struct UndoSnapshot {
44 pub version: u32,
46 pub operation: String,
48 pub timestamp: String,
50 pub queue_json: QueueFile,
52 pub done_json: QueueFile,
54}
55
56#[derive(Debug, Clone)]
58pub struct SnapshotList {
59 pub snapshots: Vec<UndoSnapshotMeta>,
60}
61
62#[derive(Debug, Clone)]
64pub struct RestoreResult {
65 pub operation: String,
66 pub timestamp: String,
67 pub tasks_affected: usize,
68}
69
70pub fn undo_cache_dir(repo_root: &Path) -> PathBuf {
72 repo_root.join(".ralph").join("cache").join("undo")
73}
74
75pub fn create_undo_snapshot(resolved: &Resolved, operation: &str) -> Result<PathBuf> {
88 let undo_dir = undo_cache_dir(&resolved.repo_root);
89 std::fs::create_dir_all(&undo_dir)
90 .with_context(|| format!("create undo directory {}", undo_dir.display()))?;
91
92 let timestamp = crate::timeutil::now_utc_rfc3339()
93 .context("failed to generate timestamp for undo snapshot")?;
94 let snapshot_id = timestamp.replace([':', '.', '-'], "");
95 let snapshot_filename = format!("{}{}.json", UNDO_SNAPSHOT_PREFIX, snapshot_id);
96 let snapshot_path = undo_dir.join(snapshot_filename);
97
98 let queue_json = load_queue_or_default(&resolved.queue_path)?;
100 let done_json = load_queue_or_default(&resolved.done_path)?;
101
102 let snapshot = UndoSnapshot {
103 version: 1,
104 operation: operation.to_string(),
105 timestamp: timestamp.clone(),
106 queue_json,
107 done_json,
108 };
109
110 let content = serde_json::to_string_pretty(&snapshot)?;
111 fsutil::write_atomic(&snapshot_path, content.as_bytes())
112 .with_context(|| format!("write undo snapshot to {}", snapshot_path.display()))?;
113
114 match prune_old_undo_snapshots(&undo_dir, MAX_UNDO_SNAPSHOTS) {
116 Ok(pruned) if pruned > 0 => {
117 log::debug!("pruned {} old undo snapshot(s)", pruned);
118 }
119 Ok(_) => {}
120 Err(err) => {
121 log::warn!("failed to prune undo snapshots: {:#}", err);
122 }
123 }
124
125 log::debug!(
126 "created undo snapshot for '{}' at {}",
127 operation,
128 snapshot_path.display()
129 );
130
131 Ok(snapshot_path)
132}
133
134pub fn list_undo_snapshots(repo_root: &Path) -> Result<SnapshotList> {
136 let undo_dir = undo_cache_dir(repo_root);
137
138 if !undo_dir.exists() {
139 return Ok(SnapshotList {
140 snapshots: Vec::new(),
141 });
142 }
143
144 let mut snapshots = Vec::new();
145
146 for entry in std::fs::read_dir(&undo_dir)
147 .with_context(|| format!("read undo directory {}", undo_dir.display()))?
148 {
149 let entry = entry?;
150 let path = entry.path();
151
152 if !path.extension().map(|e| e == "json").unwrap_or(false) {
153 continue;
154 }
155
156 let filename = path.file_name().unwrap().to_string_lossy();
157 if !filename.starts_with(UNDO_SNAPSHOT_PREFIX) {
158 continue;
159 }
160
161 match extract_snapshot_meta(&path) {
163 Ok(meta) => snapshots.push(meta),
164 Err(err) => {
165 log::warn!("failed to read snapshot {}: {:#}", path.display(), err);
166 }
167 }
168 }
169
170 snapshots.sort_by(|a, b| b.timestamp.cmp(&a.timestamp));
172
173 Ok(SnapshotList { snapshots })
174}
175
176fn extract_snapshot_meta(path: &Path) -> Result<UndoSnapshotMeta> {
178 let content = std::fs::read_to_string(path)?;
179 let value: serde_json::Value = serde_json::from_str(&content)?;
180
181 let id = path
182 .file_stem()
183 .and_then(|s| s.to_str())
184 .map(|s| s.to_string())
185 .filter(|s| !s.is_empty())
186 .ok_or_else(|| anyhow::anyhow!("invalid snapshot filename: {}", path.display()))?
187 .strip_prefix(UNDO_SNAPSHOT_PREFIX)
188 .map(|s| s.to_string())
189 .ok_or_else(|| anyhow::anyhow!("invalid snapshot filename prefix: {}", path.display()))?;
190
191 let operation = value
192 .get("operation")
193 .and_then(|v| v.as_str())
194 .unwrap_or("unknown")
195 .to_string();
196
197 let timestamp = value
198 .get("timestamp")
199 .and_then(|v| v.as_str())
200 .unwrap_or("")
201 .to_string();
202
203 Ok(UndoSnapshotMeta {
204 id,
205 operation,
206 timestamp,
207 })
208}
209
210pub fn load_undo_snapshot(repo_root: &Path, snapshot_id: &str) -> Result<UndoSnapshot> {
212 let undo_dir = undo_cache_dir(repo_root);
213 let snapshot_filename = format!("{}{}.json", UNDO_SNAPSHOT_PREFIX, snapshot_id);
214 let snapshot_path = undo_dir.join(snapshot_filename);
215
216 if !snapshot_path.exists() {
217 bail!("Snapshot not found: {}", snapshot_id);
218 }
219
220 let content = std::fs::read_to_string(&snapshot_path)?;
221 let snapshot: UndoSnapshot = serde_json::from_str(&content)?;
222
223 Ok(snapshot)
224}
225
226pub fn restore_from_snapshot(
239 resolved: &Resolved,
240 snapshot_id: Option<&str>,
241 dry_run: bool,
242) -> Result<RestoreResult> {
243 let list = list_undo_snapshots(&resolved.repo_root)?;
244
245 if list.snapshots.is_empty() {
246 bail!("No undo snapshots available");
247 }
248
249 let target_id = snapshot_id
250 .map(|s| s.to_string())
251 .unwrap_or_else(|| list.snapshots[0].id.clone());
252
253 let snapshot = load_undo_snapshot(&resolved.repo_root, &target_id)?;
254
255 let tasks_affected = snapshot.queue_json.tasks.len() + snapshot.done_json.tasks.len();
256
257 if dry_run {
258 return Ok(RestoreResult {
259 operation: snapshot.operation,
260 timestamp: snapshot.timestamp,
261 tasks_affected,
262 });
263 }
264
265 save_queue(&resolved.done_path, &snapshot.done_json)?;
267 save_queue(&resolved.queue_path, &snapshot.queue_json)?;
268
269 let undo_dir = undo_cache_dir(&resolved.repo_root);
271 let snapshot_path = undo_dir.join(format!("{}{}.json", UNDO_SNAPSHOT_PREFIX, target_id));
272 if let Err(err) = std::fs::remove_file(&snapshot_path) {
273 log::warn!("failed to remove used snapshot: {:#}", err);
274 }
275
276 log::info!(
277 "restored queue state from snapshot '{}' (operation: {}, {} tasks affected)",
278 target_id,
279 snapshot.operation,
280 tasks_affected
281 );
282
283 Ok(RestoreResult {
284 operation: snapshot.operation,
285 timestamp: snapshot.timestamp,
286 tasks_affected,
287 })
288}
289
290pub fn prune_old_undo_snapshots(undo_dir: &Path, max_count: usize) -> Result<usize> {
294 if max_count == 0 || !undo_dir.exists() {
295 return Ok(0);
296 }
297
298 let mut snapshot_paths: Vec<PathBuf> = Vec::new();
299
300 for entry in std::fs::read_dir(undo_dir)? {
301 let entry = entry?;
302 let path = entry.path();
303
304 if !path.extension().map(|e| e == "json").unwrap_or(false) {
305 continue;
306 }
307
308 let filename = path.file_name().unwrap().to_string_lossy();
309 if filename.starts_with(UNDO_SNAPSHOT_PREFIX) {
310 snapshot_paths.push(path);
311 }
312 }
313
314 if snapshot_paths.len() <= max_count {
315 return Ok(0);
316 }
317
318 snapshot_paths.sort_by_key(|p| {
321 p.file_name()
322 .map(|n| n.to_string_lossy().into_owned())
323 .unwrap_or_default()
324 });
325
326 let to_remove = snapshot_paths.len() - max_count;
327 let mut removed = 0;
328
329 for path in snapshot_paths.into_iter().take(to_remove) {
330 match std::fs::remove_file(&path) {
331 Ok(_) => {
332 removed += 1;
333 log::debug!("pruned old undo snapshot: {}", path.display());
334 }
335 Err(err) => {
336 log::warn!(
337 "failed to remove old snapshot {}: {:#}",
338 path.display(),
339 err
340 )
341 }
342 }
343 }
344
345 Ok(removed)
346}
347
348#[cfg(test)]
349mod tests {
350 use super::*;
351 use crate::contracts::{Task, TaskStatus};
352 use crate::queue::load_queue;
353 use std::collections::HashMap;
354 use tempfile::TempDir;
355
356 fn create_test_resolved(temp_dir: &TempDir) -> Resolved {
357 let repo_root = temp_dir.path();
358 let ralph_dir = repo_root.join(".ralph");
359 std::fs::create_dir_all(&ralph_dir).unwrap();
360
361 let queue_path = ralph_dir.join("queue.json");
362 let done_path = ralph_dir.join("done.json");
363
364 let queue = QueueFile {
366 version: 1,
367 tasks: vec![Task {
368 id: "RQ-0001".to_string(),
369 title: "Test task".to_string(),
370 status: TaskStatus::Todo,
371 description: None,
372 priority: Default::default(),
373 tags: vec!["test".to_string()],
374 scope: vec!["crates/ralph".to_string()],
375 evidence: vec!["observed".to_string()],
376 plan: vec!["do thing".to_string()],
377 notes: vec![],
378 request: Some("test request".to_string()),
379 agent: None,
380 created_at: Some("2026-01-18T00:00:00Z".to_string()),
381 updated_at: Some("2026-01-18T00:00:00Z".to_string()),
382 completed_at: None,
383 started_at: None,
384 scheduled_start: None,
385 depends_on: vec![],
386 blocks: vec![],
387 relates_to: vec![],
388 duplicates: None,
389 custom_fields: HashMap::new(),
390 parent_id: None,
391 estimated_minutes: None,
392 actual_minutes: None,
393 }],
394 };
395
396 save_queue(&queue_path, &queue).unwrap();
397
398 Resolved {
399 config: crate::contracts::Config::default(),
400 repo_root: repo_root.to_path_buf(),
401 queue_path,
402 done_path,
403 id_prefix: "RQ".to_string(),
404 id_width: 4,
405 global_config_path: None,
406 project_config_path: None,
407 }
408 }
409
410 #[test]
411 fn create_undo_snapshot_creates_file() {
412 let temp = TempDir::new().unwrap();
413 let resolved = create_test_resolved(&temp);
414
415 let snapshot_path = create_undo_snapshot(&resolved, "test operation").unwrap();
416
417 assert!(snapshot_path.exists());
418 assert!(snapshot_path.to_string_lossy().contains("undo-"));
419 }
420
421 #[test]
422 fn snapshot_contains_both_queues() {
423 let temp = TempDir::new().unwrap();
424 let resolved = create_test_resolved(&temp);
425
426 let done = QueueFile {
428 version: 1,
429 tasks: vec![Task {
430 id: "RQ-0000".to_string(),
431 title: "Done task".to_string(),
432 status: TaskStatus::Done,
433 description: None,
434 priority: Default::default(),
435 tags: vec!["done".to_string()],
436 scope: vec!["crates/ralph".to_string()],
437 evidence: vec!["observed".to_string()],
438 plan: vec!["done thing".to_string()],
439 notes: vec![],
440 request: Some("test request".to_string()),
441 agent: None,
442 created_at: Some("2026-01-17T00:00:00Z".to_string()),
443 updated_at: Some("2026-01-17T00:00:00Z".to_string()),
444 completed_at: Some("2026-01-17T12:00:00Z".to_string()),
445 started_at: None,
446 scheduled_start: None,
447 depends_on: vec![],
448 blocks: vec![],
449 relates_to: vec![],
450 duplicates: None,
451 custom_fields: HashMap::new(),
452 parent_id: None,
453 estimated_minutes: None,
454 actual_minutes: None,
455 }],
456 };
457 save_queue(&resolved.done_path, &done).unwrap();
458
459 let snapshot_path = create_undo_snapshot(&resolved, "test operation").unwrap();
460
461 let list = list_undo_snapshots(&resolved.repo_root).unwrap();
462 assert_eq!(list.snapshots.len(), 1);
463
464 let actual_id = snapshot_path
466 .file_stem()
467 .unwrap()
468 .to_string_lossy()
469 .strip_prefix(UNDO_SNAPSHOT_PREFIX)
470 .unwrap()
471 .to_string();
472
473 let snapshot = load_undo_snapshot(&resolved.repo_root, &actual_id).unwrap();
474 assert_eq!(snapshot.queue_json.tasks.len(), 1);
475 assert_eq!(snapshot.queue_json.tasks[0].id, "RQ-0001");
476 assert_eq!(snapshot.done_json.tasks.len(), 1);
477 assert_eq!(snapshot.done_json.tasks[0].id, "RQ-0000");
478 }
479
480 #[test]
481 fn list_snapshots_returns_newest_first() {
482 let temp = TempDir::new().unwrap();
483 let resolved = create_test_resolved(&temp);
484
485 create_undo_snapshot(&resolved, "operation 1").unwrap();
487 std::thread::sleep(std::time::Duration::from_millis(10));
488 create_undo_snapshot(&resolved, "operation 2").unwrap();
489 std::thread::sleep(std::time::Duration::from_millis(10));
490 create_undo_snapshot(&resolved, "operation 3").unwrap();
491
492 let list = list_undo_snapshots(&resolved.repo_root).unwrap();
493 assert_eq!(list.snapshots.len(), 3);
494
495 assert_eq!(list.snapshots[0].operation, "operation 3");
497 assert_eq!(list.snapshots[1].operation, "operation 2");
498 assert_eq!(list.snapshots[2].operation, "operation 1");
499 }
500
501 #[test]
502 fn restore_from_snapshot_restores_both_files() {
503 let temp = TempDir::new().unwrap();
504 let resolved = create_test_resolved(&temp);
505
506 let snapshot_path = create_undo_snapshot(&resolved, "initial state").unwrap();
508 let snapshot_id = snapshot_path
509 .file_stem()
510 .unwrap()
511 .to_string_lossy()
512 .strip_prefix(UNDO_SNAPSHOT_PREFIX)
513 .unwrap()
514 .to_string();
515
516 let mut queue = load_queue(&resolved.queue_path).unwrap();
518 queue.tasks[0].status = TaskStatus::Doing;
519 queue.tasks.push(Task {
520 id: "RQ-0002".to_string(),
521 title: "New task".to_string(),
522 status: TaskStatus::Todo,
523 description: None,
524 priority: Default::default(),
525 tags: vec!["new".to_string()],
526 scope: vec!["crates/ralph".to_string()],
527 evidence: vec!["observed".to_string()],
528 plan: vec!["new thing".to_string()],
529 notes: vec![],
530 request: Some("test request".to_string()),
531 agent: None,
532 created_at: Some("2026-01-18T00:00:00Z".to_string()),
533 updated_at: Some("2026-01-18T00:00:00Z".to_string()),
534 completed_at: None,
535 started_at: None,
536 scheduled_start: None,
537 depends_on: vec![],
538 blocks: vec![],
539 relates_to: vec![],
540 duplicates: None,
541 custom_fields: HashMap::new(),
542 parent_id: None,
543 estimated_minutes: None,
544 actual_minutes: None,
545 });
546 save_queue(&resolved.queue_path, &queue).unwrap();
547
548 let result = restore_from_snapshot(&resolved, Some(&snapshot_id), false).unwrap();
550
551 assert_eq!(result.operation, "initial state");
552 assert_eq!(result.tasks_affected, 1);
553
554 let restored_queue = load_queue(&resolved.queue_path).unwrap();
556 assert_eq!(restored_queue.tasks.len(), 1);
557 assert_eq!(restored_queue.tasks[0].id, "RQ-0001");
558 assert_eq!(restored_queue.tasks[0].status, TaskStatus::Todo);
559 }
560
561 #[test]
562 fn dry_run_does_not_modify_files() {
563 let temp = TempDir::new().unwrap();
564 let resolved = create_test_resolved(&temp);
565
566 let snapshot_path = create_undo_snapshot(&resolved, "initial state").unwrap();
568 let snapshot_id = snapshot_path
569 .file_stem()
570 .unwrap()
571 .to_string_lossy()
572 .strip_prefix(UNDO_SNAPSHOT_PREFIX)
573 .unwrap()
574 .to_string();
575
576 let mut queue = load_queue(&resolved.queue_path).unwrap();
578 queue.tasks[0].status = TaskStatus::Doing;
579 save_queue(&resolved.queue_path, &queue).unwrap();
580
581 let result = restore_from_snapshot(&resolved, Some(&snapshot_id), true).unwrap();
583
584 assert_eq!(result.operation, "initial state");
585
586 let current_queue = load_queue(&resolved.queue_path).unwrap();
588 assert_eq!(current_queue.tasks[0].status, TaskStatus::Doing);
589 }
590
591 #[test]
592 fn prune_removes_oldest_snapshots() {
593 let temp = TempDir::new().unwrap();
594 let resolved = create_test_resolved(&temp);
595
596 for i in 0..(MAX_UNDO_SNAPSHOTS + 5) {
598 create_undo_snapshot(&resolved, &format!("operation {}", i)).unwrap();
599 std::thread::sleep(std::time::Duration::from_millis(5));
600 }
601
602 let list = list_undo_snapshots(&resolved.repo_root).unwrap();
603 assert_eq!(list.snapshots.len(), MAX_UNDO_SNAPSHOTS);
605
606 let most_recent = format!("operation {}", MAX_UNDO_SNAPSHOTS + 4);
608 assert!(list.snapshots.iter().any(|s| s.operation == most_recent));
609 }
610
611 #[test]
612 fn restore_with_specific_id() {
613 let temp = TempDir::new().unwrap();
614 let resolved = create_test_resolved(&temp);
615
616 create_undo_snapshot(&resolved, "first").unwrap();
618 std::thread::sleep(std::time::Duration::from_millis(10));
619
620 let second_path = create_undo_snapshot(&resolved, "second").unwrap();
622 let second_id = second_path
623 .file_stem()
624 .unwrap()
625 .to_string_lossy()
626 .strip_prefix(UNDO_SNAPSHOT_PREFIX)
627 .unwrap()
628 .to_string();
629
630 let mut queue = load_queue(&resolved.queue_path).unwrap();
632 queue.tasks[0].title = "Modified".to_string();
633 save_queue(&resolved.queue_path, &queue).unwrap();
634
635 let result = restore_from_snapshot(&resolved, Some(&second_id), false).unwrap();
637 assert_eq!(result.operation, "second");
638 }
639
640 #[test]
641 fn restore_removes_used_snapshot() {
642 let temp = TempDir::new().unwrap();
643 let resolved = create_test_resolved(&temp);
644
645 let path = create_undo_snapshot(&resolved, "test").unwrap();
647 let id = path
648 .file_stem()
649 .unwrap()
650 .to_string_lossy()
651 .strip_prefix(UNDO_SNAPSHOT_PREFIX)
652 .unwrap()
653 .to_string();
654
655 restore_from_snapshot(&resolved, Some(&id), false).unwrap();
657
658 let list = list_undo_snapshots(&resolved.repo_root).unwrap();
660 assert!(list.snapshots.is_empty());
661 assert!(!path.exists());
662 }
663
664 #[test]
665 fn restore_no_snapshots_error() {
666 let temp = TempDir::new().unwrap();
667 let resolved = create_test_resolved(&temp);
668
669 let result = restore_from_snapshot(&resolved, None, false);
670 assert!(result.is_err());
671 assert!(
672 result
673 .unwrap_err()
674 .to_string()
675 .contains("No undo snapshots")
676 );
677 }
678
679 #[test]
680 fn undo_cache_dir_creates_correct_path() {
681 let temp = TempDir::new().unwrap();
682 let repo_root = temp.path();
683
684 let dir = undo_cache_dir(repo_root);
685 assert!(dir.to_string_lossy().contains(".ralph/cache/undo"));
686 }
687}