Skip to main content

ralph/cli/
undo.rs

1//! CLI handler for `ralph undo` command.
2//!
3//! Responsibilities:
4//! - Handle `ralph undo` to restore the most recent snapshot.
5//! - Handle `ralph undo --list` to show available snapshots.
6//! - Handle `ralph undo --dry-run` to preview restores.
7//! - Handle `ralph undo --id <id>` to restore a specific snapshot.
8//!
9//! Not handled here:
10//! - Core undo logic (see `crate::undo`).
11//! - Queue lock management (delegated to queue module).
12
13use crate::config;
14use crate::queue;
15use crate::undo;
16use anyhow::Result;
17use clap::Args;
18
19#[derive(Args, Debug)]
20pub struct UndoArgs {
21    /// Snapshot ID to restore (defaults to most recent).
22    #[arg(long, short)]
23    pub id: Option<String>,
24
25    /// List available snapshots instead of restoring.
26    #[arg(long)]
27    pub list: bool,
28
29    /// Preview restore without modifying files.
30    #[arg(long)]
31    pub dry_run: bool,
32
33    /// Show verbose output.
34    #[arg(long, short)]
35    pub verbose: bool,
36}
37
38/// Handle the `ralph undo` command.
39pub fn handle(args: UndoArgs, force: bool) -> Result<()> {
40    let resolved = config::resolve_from_cwd()?;
41
42    if args.list {
43        return handle_list(&resolved);
44    }
45
46    let _lock = queue::acquire_queue_lock(&resolved.repo_root, "undo", force)?;
47
48    let result = undo::restore_from_snapshot(&resolved, args.id.as_deref(), args.dry_run)?;
49
50    if args.dry_run {
51        println!("Dry run - would restore from snapshot:");
52    } else {
53        println!("Restored from snapshot:");
54    }
55
56    println!("  Operation: {}", result.operation);
57    println!("  Timestamp: {}", result.timestamp);
58    println!("  Tasks affected: {}", result.tasks_affected);
59
60    if args.verbose && !args.dry_run {
61        println!("\nRun `ralph queue list` to see the restored queue state.");
62    }
63
64    Ok(())
65}
66
67fn handle_list(resolved: &config::Resolved) -> Result<()> {
68    let list = undo::list_undo_snapshots(&resolved.repo_root)?;
69
70    if list.snapshots.is_empty() {
71        println!("No undo snapshots available.");
72        println!("\nSnapshots are created automatically before queue mutations such as:");
73        println!("  - ralph task done/reject");
74        println!("  - ralph queue archive");
75        println!("  - ralph queue prune");
76        println!("  - ralph task batch operations");
77        println!("  - ralph task edit");
78        return Ok(());
79    }
80
81    println!("Available undo snapshots (newest first):\n");
82
83    for (i, snap) in list.snapshots.iter().enumerate() {
84        let num = i + 1;
85        println!("  {}. {} [{}]", num, snap.operation, snap.timestamp);
86        println!("     ID: {}", snap.id);
87    }
88
89    println!("\nTo restore the most recent: ralph undo");
90    println!("To restore a specific one:  ralph undo --id <ID>");
91    println!("To preview without applying: ralph undo --dry-run");
92
93    Ok(())
94}
95
96#[cfg(test)]
97mod tests {
98    use super::*;
99    use crate::contracts::{QueueFile, Task, TaskStatus};
100    use std::collections::HashMap;
101    use tempfile::TempDir;
102
103    fn create_test_resolved(temp_dir: &TempDir) -> config::Resolved {
104        let repo_root = temp_dir.path();
105        let ralph_dir = repo_root.join(".ralph");
106        std::fs::create_dir_all(&ralph_dir).unwrap();
107
108        let queue_path = ralph_dir.join("queue.json");
109        let done_path = ralph_dir.join("done.json");
110
111        // Create initial queue with one task
112        let queue = QueueFile {
113            version: 1,
114            tasks: vec![Task {
115                id: "RQ-0001".to_string(),
116                title: "Test task".to_string(),
117                status: TaskStatus::Todo,
118                description: None,
119                priority: Default::default(),
120                tags: vec!["test".to_string()],
121                scope: vec!["crates/ralph".to_string()],
122                evidence: vec!["observed".to_string()],
123                plan: vec!["do thing".to_string()],
124                notes: vec![],
125                request: Some("test request".to_string()),
126                agent: None,
127                created_at: Some("2026-01-18T00:00:00Z".to_string()),
128                updated_at: Some("2026-01-18T00:00:00Z".to_string()),
129                completed_at: None,
130                started_at: None,
131                scheduled_start: None,
132                estimated_minutes: None,
133                actual_minutes: None,
134                depends_on: vec![],
135                blocks: vec![],
136                relates_to: vec![],
137                duplicates: None,
138                custom_fields: HashMap::new(),
139                parent_id: None,
140            }],
141        };
142
143        queue::save_queue(&queue_path, &queue).unwrap();
144
145        config::Resolved {
146            config: crate::contracts::Config::default(),
147            repo_root: repo_root.to_path_buf(),
148            queue_path,
149            done_path,
150            id_prefix: "RQ".to_string(),
151            id_width: 4,
152            global_config_path: None,
153            project_config_path: None,
154        }
155    }
156
157    #[test]
158    fn handle_list_shows_snapshots() {
159        let temp = TempDir::new().unwrap();
160        let resolved = create_test_resolved(&temp);
161
162        // Create a snapshot
163        undo::create_undo_snapshot(&resolved, "test operation").unwrap();
164
165        // Test handle_list
166        let result = handle_list(&resolved);
167        assert!(result.is_ok());
168    }
169
170    #[test]
171    fn handle_list_empty_shows_helpful_message() {
172        let temp = TempDir::new().unwrap();
173        let resolved = create_test_resolved(&temp);
174
175        // Test handle_list with no snapshots
176        let result = handle_list(&resolved);
177        assert!(result.is_ok());
178    }
179}