Skip to main content

ralph/
undo.rs

1//! Undo system for queue mutations.
2//!
3//! Responsibilities:
4//! - Create snapshots before queue-modifying operations.
5//! - List available snapshots for undo.
6//! - Restore queue state from snapshots.
7//! - Prune old snapshots to enforce retention limits.
8//!
9//! Not handled here:
10//! - CLI argument parsing (see `cli::undo`).
11//! - Queue lock acquisition (callers must hold lock).
12//!
13//! Invariants/assumptions:
14//! - Snapshots capture BOTH queue.json and done.json atomically.
15//! - Snapshots are written atomically via `fsutil::write_atomic`.
16//! - Callers hold queue locks during snapshot creation and restore.
17
18use 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
27/// Snapshot filename prefix.
28const UNDO_SNAPSHOT_PREFIX: &str = "undo-";
29
30/// Metadata about a single undo snapshot.
31#[derive(Debug, Clone, Serialize, Deserialize)]
32pub struct UndoSnapshotMeta {
33    /// Unique snapshot ID (timestamp-based).
34    pub id: String,
35    /// Human-readable operation description.
36    pub operation: String,
37    /// RFC3339 timestamp when snapshot was created.
38    pub timestamp: String,
39}
40
41/// Full snapshot content (stored in JSON file).
42#[derive(Debug, Clone, Serialize, Deserialize)]
43pub struct UndoSnapshot {
44    /// Schema version for future migrations.
45    pub version: u32,
46    /// Human-readable operation description.
47    pub operation: String,
48    /// RFC3339 timestamp when snapshot was created.
49    pub timestamp: String,
50    /// Full queue.json content at snapshot time.
51    pub queue_json: QueueFile,
52    /// Full done.json content at snapshot time.
53    pub done_json: QueueFile,
54}
55
56/// Result of listing snapshots.
57#[derive(Debug, Clone)]
58pub struct SnapshotList {
59    pub snapshots: Vec<UndoSnapshotMeta>,
60}
61
62/// Result of a restore operation.
63#[derive(Debug, Clone)]
64pub struct RestoreResult {
65    pub operation: String,
66    pub timestamp: String,
67    pub tasks_affected: usize,
68}
69
70/// Get the undo cache directory path.
71pub fn undo_cache_dir(repo_root: &Path) -> PathBuf {
72    repo_root.join(".ralph").join("cache").join("undo")
73}
74
75/// Create a snapshot before a mutation operation.
76///
77/// This should be called AFTER acquiring the queue lock but BEFORE
78/// performing any modifications. The snapshot captures both queue.json
79/// and done.json atomically.
80///
81/// # Arguments
82/// * `resolved` - Resolved configuration containing paths
83/// * `operation` - Human-readable description of the operation (e.g., "complete_task RQ-0001")
84///
85/// # Returns
86/// Path to the created snapshot file.
87pub 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    // Load current state - these should succeed since caller has lock
99    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    // Prune old snapshots
115    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
134/// List available undo snapshots, newest first.
135pub 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        // Read just the metadata without full content
162        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    // Sort by timestamp descending (newest first)
171    snapshots.sort_by(|a, b| b.timestamp.cmp(&a.timestamp));
172
173    Ok(SnapshotList { snapshots })
174}
175
176/// Extract metadata from a snapshot file without loading full content.
177fn 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
210/// Load a full snapshot by ID.
211pub 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
226/// Restore queue state from a snapshot.
227///
228/// This overwrites both queue.json and done.json with the snapshot content.
229/// Caller must hold the queue lock.
230///
231/// # Arguments
232/// * `resolved` - Resolved configuration containing paths
233/// * `snapshot_id` - ID of snapshot to restore (or None for most recent)
234/// * `dry_run` - If true, preview restore without modifying files
235///
236/// # Returns
237/// Information about the restored state.
238pub 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    // Perform the restore
266    save_queue(&resolved.done_path, &snapshot.done_json)?;
267    save_queue(&resolved.queue_path, &snapshot.queue_json)?;
268
269    // Remove the used snapshot (prevents redo cycles)
270    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
290/// Prune old snapshots to enforce retention limit.
291///
292/// Returns the number of snapshots removed.
293pub 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    // Sort by filename (which contains timestamp) ascending
319    // Oldest files have smallest timestamp
320    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        // Create initial queue with one task
365        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        // Add a task to done.json
427        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        // Get the actual snapshot ID from the created file (strip "undo-" prefix)
465        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 multiple snapshots with small delay
486        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        // Should be newest first
496        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        // Create initial snapshot and capture its ID
507        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        // Modify the queue - add a new task and change existing
517        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        // Restore from snapshot using the specific ID
549        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        // Verify queue is restored
555        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        // Create initial snapshot and capture its ID
567        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        // Modify the queue
577        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        // Restore with dry_run using specific ID
582        let result = restore_from_snapshot(&resolved, Some(&snapshot_id), true).unwrap();
583
584        assert_eq!(result.operation, "initial state");
585
586        // Verify queue is NOT restored
587        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        // Create more snapshots than the limit
597        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        // Should be limited to MAX_UNDO_SNAPSHOTS
604        assert_eq!(list.snapshots.len(), MAX_UNDO_SNAPSHOTS);
605
606        // Most recent should be preserved
607        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 first snapshot
617        create_undo_snapshot(&resolved, "first").unwrap();
618        std::thread::sleep(std::time::Duration::from_millis(10));
619
620        // Create second snapshot and capture its ID
621        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        // Modify queue
631        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        // Restore specific snapshot
636        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        // Create snapshot and capture its path and ID
646        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 using specific ID
656        restore_from_snapshot(&resolved, Some(&id), false).unwrap();
657
658        // Snapshot should be removed
659        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}