Skip to main content

ralph/cli/
undo.rs

1//! CLI handler for `ralph undo` command.
2//!
3//! Responsibilities:
4//! - List, preview, or restore continuation checkpoints.
5//! - Keep undo visible as a normal queue continuation workflow.
6//! - Align wording with the canonical blocked/waiting/stalled vocabulary.
7//!
8//! Not handled here:
9//! - Core undo logic (see `crate::undo`).
10//! - Queue lock management details beyond invoking the shared machine builders.
11
12use crate::cli::machine::MachineQueueUndoArgs;
13use crate::config;
14use anyhow::Result;
15use clap::Args;
16
17#[derive(Args, Debug)]
18pub struct UndoArgs {
19    /// Snapshot ID to restore (defaults to most recent).
20    #[arg(long, short)]
21    pub id: Option<String>,
22
23    /// List available snapshots instead of restoring.
24    #[arg(long)]
25    pub list: bool,
26
27    /// Preview restore without modifying files.
28    #[arg(long)]
29    pub dry_run: bool,
30
31    /// Show verbose output.
32    #[arg(long, short)]
33    pub verbose: bool,
34}
35
36/// Handle the `ralph undo` command.
37pub fn handle(args: UndoArgs, force: bool) -> Result<()> {
38    let resolved = config::resolve_from_cwd()?;
39    let document = crate::cli::machine::build_queue_undo_document(
40        &resolved,
41        force,
42        &MachineQueueUndoArgs {
43            id: args.id.clone(),
44            list: args.list,
45            dry_run: args.dry_run,
46        },
47    )?;
48
49    println!("{}", document.continuation.headline);
50    println!("{}", document.continuation.detail);
51
52    if let Some(blocking) = document
53        .blocking
54        .as_ref()
55        .or(document.continuation.blocking.as_ref())
56    {
57        println!();
58        println!(
59            "Operator state: {}",
60            format!("{:?}", blocking.status).to_lowercase()
61        );
62        println!("{}", blocking.message);
63        if !blocking.detail.is_empty() {
64            println!("{}", blocking.detail);
65        }
66    }
67
68    if let Some(result) = &document.result {
69        println!();
70        if args.list {
71            let snapshots =
72                serde_json::from_value::<Vec<crate::undo::UndoSnapshotMeta>>(result.clone())?;
73            if snapshots.is_empty() {
74                println!(
75                    "Ralph will create new checkpoints automatically before future queue writes."
76                );
77            } else {
78                println!("Available continuation checkpoints (newest first):");
79                println!();
80                for (index, snapshot) in snapshots.iter().enumerate() {
81                    println!(
82                        "  {}. {} [{}]",
83                        index + 1,
84                        snapshot.operation,
85                        snapshot.timestamp
86                    );
87                    println!("     ID: {}", snapshot.id);
88                }
89            }
90        } else {
91            let restore = serde_json::from_value::<crate::undo::RestoreResult>(result.clone())?;
92            println!("Checkpoint: {}", restore.snapshot_id);
93            println!("Operation: {}", restore.operation);
94            println!("Timestamp: {}", restore.timestamp);
95            println!("Tasks affected: {}", restore.tasks_affected);
96            if args.verbose && !args.dry_run {
97                println!();
98                println!("Run `ralph queue list` to inspect the restored queue state in detail.");
99            }
100        }
101    }
102
103    if !document.continuation.next_steps.is_empty() {
104        println!();
105        println!("Next:");
106        for (index, step) in document.continuation.next_steps.iter().enumerate() {
107            println!("  {}. {} — {}", index + 1, step.command, step.detail);
108        }
109    }
110
111    Ok(())
112}
113
114#[cfg(test)]
115mod tests {
116    use super::*;
117    use crate::contracts::{QueueFile, Task, TaskStatus};
118    use std::collections::HashMap;
119    use tempfile::TempDir;
120
121    fn create_test_resolved(temp_dir: &TempDir) -> config::Resolved {
122        let repo_root = temp_dir.path();
123        let ralph_dir = repo_root.join(".ralph");
124        std::fs::create_dir_all(&ralph_dir).unwrap();
125
126        let queue_path = ralph_dir.join("queue.json");
127        let done_path = ralph_dir.join("done.json");
128
129        let queue = QueueFile {
130            version: 1,
131            tasks: vec![Task {
132                id: "RQ-0001".to_string(),
133                title: "Test task".to_string(),
134                status: TaskStatus::Todo,
135                description: None,
136                priority: Default::default(),
137                tags: vec!["test".to_string()],
138                scope: vec!["crates/ralph".to_string()],
139                evidence: vec!["observed".to_string()],
140                plan: vec!["do thing".to_string()],
141                notes: vec![],
142                request: Some("test request".to_string()),
143                agent: None,
144                created_at: Some("2026-01-18T00:00:00Z".to_string()),
145                updated_at: Some("2026-01-18T00:00:00Z".to_string()),
146                completed_at: None,
147                started_at: None,
148                scheduled_start: None,
149                estimated_minutes: None,
150                actual_minutes: None,
151                depends_on: vec![],
152                blocks: vec![],
153                relates_to: vec![],
154                duplicates: None,
155                custom_fields: HashMap::new(),
156                parent_id: None,
157            }],
158        };
159
160        crate::queue::save_queue(&queue_path, &queue).unwrap();
161
162        config::Resolved {
163            config: crate::contracts::Config::default(),
164            repo_root: repo_root.to_path_buf(),
165            queue_path,
166            done_path,
167            id_prefix: "RQ".to_string(),
168            id_width: 4,
169            global_config_path: None,
170            project_config_path: None,
171        }
172    }
173
174    #[test]
175    fn build_undo_list_document_shows_snapshots() {
176        let temp = TempDir::new().unwrap();
177        let resolved = create_test_resolved(&temp);
178
179        crate::undo::create_undo_snapshot(&resolved, "test operation").unwrap();
180
181        let document = crate::cli::machine::build_queue_undo_document(
182            &resolved,
183            false,
184            &MachineQueueUndoArgs {
185                id: None,
186                list: true,
187                dry_run: false,
188            },
189        )
190        .expect("undo list document");
191        assert!(document.result.is_some());
192    }
193
194    #[test]
195    fn build_undo_list_document_handles_empty_snapshots() {
196        let temp = TempDir::new().unwrap();
197        let resolved = create_test_resolved(&temp);
198
199        let document = crate::cli::machine::build_queue_undo_document(
200            &resolved,
201            false,
202            &MachineQueueUndoArgs {
203                id: None,
204                list: true,
205                dry_run: false,
206            },
207        )
208        .expect("undo list document");
209        assert!(document.result.is_some());
210    }
211}