1use crate::config::CheckpointsConfig;
2use crate::errors::{RalphError, Result};
3use crate::providers::Message;
4use crate::session::Session;
5use chrono::{DateTime, Utc};
6use serde::{Deserialize, Serialize};
7use std::path::{Path, PathBuf};
8
9#[derive(Debug, Clone, Serialize, Deserialize)]
10pub struct CheckpointMeta {
11 pub id: String,
12 pub name: String,
13 pub turn: u32,
14 pub created_at: DateTime<Utc>,
15 pub modified_files: Vec<String>,
16 #[serde(default)]
18 pub git_sha: Option<String>,
19}
20
21pub struct Checkpoint {
22 pub meta: CheckpointMeta,
23 pub messages: Vec<Message>,
24 pub dir: PathBuf,
25}
26
27impl Checkpoint {
28 pub async fn create(session: &Session, name: &str, config: &CheckpointsConfig) -> Result<Self> {
30 let seq = next_seq(session);
31 let id = format!("{:03}_{}", seq, sanitize_name(name));
32 let dir = session.checkpoints_dir().join(&id);
33 std::fs::create_dir_all(&dir)?;
34
35 let files_dir = dir.join("files");
37 std::fs::create_dir_all(&files_dir)?;
38
39 let mut snapshotted_files = Vec::new();
40 for file_path in &session.modified_files {
41 if !file_path.exists() {
42 continue;
43 }
44 let rel = file_path
45 .strip_prefix(&session.meta.workspace)
46 .unwrap_or(file_path);
47 let dest = files_dir.join(rel);
48 if let Some(parent) = dest.parent() {
49 std::fs::create_dir_all(parent)?;
50 }
51 std::fs::copy(file_path, &dest)?;
52 snapshotted_files.push(rel.display().to_string());
53 }
54
55 let messages_json = serde_json::to_string_pretty(&session.messages)?;
57 std::fs::write(dir.join("messages.json"), messages_json)?;
58
59 let git_sha = if config.git_commit && snapshotted_files.len() > config.git_commit_threshold
61 {
62 let workspace = PathBuf::from(&session.meta.workspace);
63 try_git_commit(
64 &workspace,
65 &snapshotted_files,
66 name,
67 session.meta.turn_count,
68 )
69 .await
70 } else {
71 None
72 };
73
74 let mut meta = CheckpointMeta {
75 id: id.clone(),
76 name: name.to_string(),
77 turn: session.meta.turn_count,
78 created_at: Utc::now(),
79 modified_files: snapshotted_files,
80 git_sha: None,
81 };
82 meta.git_sha = git_sha;
83
84 let meta_json = serde_json::to_string_pretty(&meta)?;
85 std::fs::write(dir.join("checkpoint.json"), meta_json)?;
86
87 if let Some(ref sha) = meta.git_sha {
88 println!(
89 "[checkpoint] Created '{}' at turn {} ({}) — git commit {}",
90 name,
91 session.meta.turn_count,
92 meta.created_at.format("%H:%M:%S"),
93 &sha[..8.min(sha.len())]
94 );
95 } else {
96 println!(
97 "[checkpoint] Created '{}' at turn {} ({})",
98 name,
99 session.meta.turn_count,
100 meta.created_at.format("%H:%M:%S")
101 );
102 }
103
104 let messages = session.messages.clone();
105 Ok(Self {
106 meta,
107 messages,
108 dir,
109 })
110 }
111
112 pub fn list(session: &Session) -> Vec<CheckpointMeta> {
114 let cp_dir = session.checkpoints_dir();
115 if !cp_dir.exists() {
116 return Vec::new();
117 }
118
119 let mut checkpoints: Vec<CheckpointMeta> = std::fs::read_dir(&cp_dir)
120 .unwrap_or_else(|_| std::fs::read_dir(".").unwrap())
121 .flatten()
122 .filter(|e| e.path().is_dir())
123 .filter_map(|e| {
124 let meta_path = e.path().join("checkpoint.json");
125 let content = std::fs::read_to_string(&meta_path).ok()?;
126 serde_json::from_str(&content).ok()
127 })
128 .collect();
129
130 checkpoints.sort_by_key(|c| c.id.clone());
131 checkpoints
132 }
133
134 pub fn find(session: &Session, name_or_num: &str) -> Result<Self> {
136 let all = Self::list(session);
137
138 let meta = if let Ok(num) = name_or_num.parse::<usize>() {
139 all.into_iter().nth(num - 1)
140 } else {
141 all.into_iter()
142 .find(|c| c.name == name_or_num || c.id == name_or_num)
143 }
144 .ok_or_else(|| RalphError::CheckpointNotFound(name_or_num.to_string()))?;
145
146 let dir = session.checkpoints_dir().join(&meta.id);
147 let messages_json =
148 std::fs::read_to_string(dir.join("messages.json")).unwrap_or_else(|_| "[]".to_string());
149 let messages: Vec<Message> = serde_json::from_str(&messages_json).unwrap_or_default();
150
151 Ok(Self {
152 meta,
153 messages,
154 dir,
155 })
156 }
157
158 pub async fn revert_into(
162 &self,
163 session: &mut Session,
164 files_only: bool,
165 printer: &crate::output::Printer,
166 ) -> Result<()> {
167 let workspace = PathBuf::from(&session.meta.workspace);
168
169 self.show_revert_preview(session, files_only, printer);
171
172 let confirm = printer.confirm(
174 &format!(
175 "Reverting to checkpoint '{}' (turn {}). This cannot be skipped.",
176 self.meta.name, self.meta.turn
177 ),
178 true,
179 false,
180 );
181 if confirm != crate::output::ConfirmResult::Yes {
182 return Err(RalphError::UserAborted);
183 }
184
185 let safety_name = format!("before-revert-{}", Utc::now().format("%H%M%S"));
187 let _ = Checkpoint::create(session, &safety_name, &CheckpointsConfig::default()).await;
188
189 let cp_files: std::collections::HashSet<String> =
191 self.meta.modified_files.iter().cloned().collect();
192
193 for file_path in &session.modified_files {
195 let rel = file_path
196 .strip_prefix(&workspace)
197 .unwrap_or(file_path)
198 .to_string_lossy()
199 .to_string();
200 if !cp_files.contains(&rel) && file_path.exists() {
201 std::fs::remove_file(file_path)
202 .map_err(|e| RalphError::CheckpointRevertFailed(e.to_string()))?;
203 }
204 }
205
206 let files_dir = self.dir.join("files");
208 if files_dir.exists() {
209 for entry in walkdir_flat(&files_dir) {
210 let rel = entry.strip_prefix(&files_dir).unwrap_or(&entry);
211 let dest = workspace.join(rel);
212 if let Some(parent) = dest.parent() {
213 std::fs::create_dir_all(parent)?;
214 }
215 std::fs::copy(&entry, &dest)
216 .map_err(|e| RalphError::CheckpointRevertFailed(e.to_string()))?;
217 }
218 }
219
220 if !files_only {
221 session.messages = self.messages.clone();
222 session.meta.turn_count = self.meta.turn;
223 session.flush()?;
224 }
225
226 println!(
227 "[checkpoint] Reverted to '{}'. Safety checkpoint '{}' was created.",
228 self.meta.name, safety_name
229 );
230 Ok(())
231 }
232
233 fn show_revert_preview(
234 &self,
235 session: &Session,
236 files_only: bool,
237 printer: &crate::output::Printer,
238 ) {
239 use colored::Colorize;
240 let workspace = PathBuf::from(&session.meta.workspace);
241
242 println!(
243 "\n[checkpoint] Reverting to '{}' (turn {}, {})\n",
244 self.meta.name,
245 self.meta.turn,
246 self.meta.created_at.format("%Y-%m-%d %H:%M:%S")
247 );
248
249 println!(" Files to restore:");
251 for f in &self.meta.modified_files {
252 let dest = workspace.join(f);
253 if dest.exists() {
254 println!(" {} {}", "~".yellow(), f);
255 } else {
256 println!(" {} {} (will be created)", "+".green(), f);
257 }
258 }
259
260 let cp_files: std::collections::HashSet<&str> = self
262 .meta
263 .modified_files
264 .iter()
265 .map(|s| s.as_str())
266 .collect();
267 for file_path in &session.modified_files {
268 let rel = file_path
269 .strip_prefix(&workspace)
270 .unwrap_or(file_path)
271 .to_string_lossy()
272 .to_string();
273 if !cp_files.contains(rel.as_str()) && file_path.exists() {
274 println!(
275 " {} {} (delete — did not exist at checkpoint)",
276 "-".red(),
277 rel
278 );
279 }
280 }
281
282 if !files_only {
283 let turns_to_drop = session.meta.turn_count.saturating_sub(self.meta.turn);
284 println!(
285 "\n Conversation: {} turn(s) will be discarded",
286 turns_to_drop
287 );
288 } else {
289 println!("\n Conversation history: unchanged (--files-only)");
290 }
291 println!();
292 }
293}
294
295async fn try_git_commit(
299 workspace: &Path,
300 files: &[String],
301 name: &str,
302 turn: u32,
303) -> Option<String> {
304 if !workspace.join(".git").exists() {
305 return None;
306 }
307
308 let mut add = tokio::process::Command::new("git");
310 add.arg("add").arg("--").current_dir(workspace);
311 for f in files {
312 add.arg(f);
313 }
314 let add_ok = add
315 .output()
316 .await
317 .map(|o| o.status.success())
318 .unwrap_or(false);
319 if !add_ok {
320 eprintln!("[checkpoint] Warning: git add failed — checkpoint saved without git commit.");
321 return None;
322 }
323
324 let clean = tokio::process::Command::new("git")
326 .args(["diff", "--cached", "--quiet"])
327 .current_dir(workspace)
328 .output()
329 .await
330 .ok()?;
331 if clean.status.success() {
332 return None; }
334
335 let msg = format!("ralph checkpoint: {} (turn {})", name, turn);
337 let commit = tokio::process::Command::new("git")
338 .args(["commit", "-m", &msg])
339 .current_dir(workspace)
340 .output()
341 .await
342 .ok()?;
343 if !commit.status.success() {
344 let stderr = String::from_utf8_lossy(&commit.stderr);
345 eprintln!(
346 "[checkpoint] Warning: git commit failed: {} — checkpoint saved without git commit.",
347 stderr.trim()
348 );
349 return None;
350 }
351
352 let sha_out = tokio::process::Command::new("git")
354 .args(["rev-parse", "HEAD"])
355 .current_dir(workspace)
356 .output()
357 .await
358 .ok()?;
359 let sha = String::from_utf8_lossy(&sha_out.stdout).trim().to_string();
360 if sha.is_empty() {
361 None
362 } else {
363 Some(sha)
364 }
365}
366
367fn next_seq(session: &Session) -> usize {
368 Checkpoint::list(session).len() + 1
369}
370
371fn sanitize_name(name: &str) -> String {
372 name.chars()
373 .map(|c| {
374 if c.is_alphanumeric() || c == '-' {
375 c
376 } else {
377 '_'
378 }
379 })
380 .collect()
381}
382
383fn walkdir_flat(dir: &Path) -> Vec<PathBuf> {
384 let mut files = Vec::new();
385 if let Ok(entries) = std::fs::read_dir(dir) {
386 for entry in entries.flatten() {
387 let path = entry.path();
388 if path.is_dir() {
389 files.extend(walkdir_flat(&path));
390 } else {
391 files.push(path);
392 }
393 }
394 }
395 files
396}