1use std::collections::HashSet;
16use std::path::{Path, PathBuf};
17
18use crate::checkpoint::{CheckpointId, RestoreStats, ShadowRepo};
19use crate::checkpoint_log::{read_log, truncate_to_turn};
20use crate::error::{Error, Result};
21
22#[derive(Debug, Clone)]
24pub struct ConflictReport {
25 pub files: Vec<String>,
27}
28
29#[derive(Debug, Clone)]
31pub struct RewindPlan {
32 pub target: CheckpointId,
34 pub touched_paths: Vec<String>,
36 pub last_known_post: Option<CheckpointId>,
39 pub turns_to_drop: Vec<usize>,
42}
43
44pub fn plan_rewind(log_path: &Path, to_turn: usize) -> Result<RewindPlan> {
58 let recs = read_log(log_path)?;
59 if recs.is_empty() {
60 return Err(Error::Tool {
61 name: "rewind".into(),
62 call_id: None,
63 message: "no checkpoints recorded for this session".into(),
64 });
65 }
66
67 let target: CheckpointId = if to_turn == 0 {
71 recs.first()
76 .and_then(|r| r.pre.clone().or_else(|| Some(r.id.clone())))
77 .ok_or_else(|| Error::Tool {
78 name: "rewind".into(),
79 call_id: None,
80 message: "no checkpoint recorded before turn 0".into(),
81 })?
82 } else {
83 let prev = recs.iter().rev().find(|r| r.turn < to_turn);
85 match prev {
86 Some(r) => r.id.clone(),
87 None => {
88 let target_rec =
92 recs.iter()
93 .find(|r| r.turn == to_turn)
94 .ok_or_else(|| Error::Tool {
95 name: "rewind".into(),
96 call_id: None,
97 message: format!(
98 "turn {to_turn} is not in this session's checkpoint log"
99 ),
100 })?;
101 target_rec.pre.clone().ok_or_else(|| Error::Tool {
102 name: "rewind".into(),
103 call_id: None,
104 message: format!(
105 "turn {to_turn} has no pre-snapshot recorded; \
106 cannot rewind to its start"
107 ),
108 })?
109 }
110 }
111 };
112
113 let mut touched: HashSet<String> = HashSet::new();
114 let mut turns_to_drop = Vec::new();
115 for r in &recs {
116 if r.turn >= to_turn {
117 turns_to_drop.push(r.turn);
118 for p in &r.touched_files {
119 touched.insert(p.clone());
120 }
121 }
122 }
123 let mut touched_paths: Vec<String> = touched.into_iter().collect();
124 touched_paths.sort();
125
126 let last_known_post = recs.last().map(|r| r.id.clone());
127
128 Ok(RewindPlan {
129 target,
130 touched_paths,
131 last_known_post,
132 turns_to_drop,
133 })
134}
135
136pub fn detect_conflicts(repo: &ShadowRepo, plan: &RewindPlan) -> Result<Vec<String>> {
140 let last = match &plan.last_known_post {
141 Some(id) => id,
142 None => return Ok(vec![]),
143 };
144 let mut conflicts = Vec::new();
145 for path in &plan.touched_paths {
146 let abs = repo.workspace().join(path);
147 let current = std::fs::read(&abs).ok();
148 let expected = repo.read_file_at(last, path)?;
149 if current != expected {
150 conflicts.push(path.clone());
151 }
152 }
153 Ok(conflicts)
154}
155
156pub fn apply_rewind(
164 repo: &ShadowRepo,
165 log_path: &Path,
166 plan: &RewindPlan,
167 force: bool,
168) -> Result<RewindResult> {
169 if !force {
170 let conflicts = detect_conflicts(repo, plan)?;
171 if !conflicts.is_empty() {
172 return Err(Error::Tool {
173 name: "rewind".into(),
174 call_id: None,
175 message: format!(
176 "rewind blocked by {} conflicting file(s): {}\n\
177 Re-run with --force to overwrite.",
178 conflicts.len(),
179 conflicts.join(", ")
180 ),
181 });
182 }
183 }
184 let stats = repo.restore_paths(&plan.target, &plan.touched_paths)?;
185 truncate_to_turn(
186 log_path,
187 plan.turns_to_drop.iter().copied().min().unwrap_or(0),
188 )?;
189 Ok(RewindResult {
190 stats,
191 dropped_turns: plan.turns_to_drop.clone(),
192 })
193}
194
195#[derive(Debug, Clone)]
197pub struct RewindResult {
198 pub stats: RestoreStats,
199 pub dropped_turns: Vec<usize>,
200}
201
202pub fn checkpoint_log_path(workspace: &Path, workspace_slug: &str, session_id: &str) -> PathBuf {
206 workspace
207 .join(".recursive")
208 .join("sessions")
209 .join(workspace_slug)
210 .join(session_id)
211 .join("checkpoints.jsonl")
212}
213
214#[cfg(test)]
215mod tests {
216 use super::*;
217 use crate::checkpoint_log::{CheckpointLogWriter, CheckpointRecord, TouchedVia};
218 use std::fs;
219 use std::process::Command;
220 use tempfile::TempDir;
221
222 #[test]
223 fn checkpoint_log_path_has_correct_structure() {
224 use std::path::PathBuf;
226 let workspace = PathBuf::from("/home/user/project");
227 let path = checkpoint_log_path(&workspace, "my-slug", "sess-01");
228 assert_eq!(
229 path,
230 PathBuf::from(
231 "/home/user/project/.recursive/sessions/my-slug/sess-01/checkpoints.jsonl"
232 )
233 );
234 }
235
236 fn has_git() -> bool {
237 Command::new("git").arg("--version").output().is_ok()
238 }
239
240 struct ShadowWs {
245 workspace: TempDir,
246 shadow: TempDir,
247 }
248
249 impl ShadowWs {
250 fn path(&self) -> &Path {
251 self.workspace.path()
252 }
253 fn open_repo(&self) -> Result<ShadowRepo> {
254 ShadowRepo::open_at(self.path(), self.shadow.path().join("shadow-git"))
255 }
256 }
257
258 fn shadow_ws() -> ShadowWs {
259 ShadowWs {
260 workspace: tempfile::tempdir().expect("workspace tempdir"),
261 shadow: tempfile::tempdir().expect("shadow tempdir"),
262 }
263 }
264
265 fn write_log(path: &Path, records: &[CheckpointRecord]) {
266 let w = CheckpointLogWriter::open(path).unwrap();
267 for r in records {
268 w.append(r).unwrap();
269 }
270 }
271
272 fn rec(turn: usize, pre: Option<&str>, id: &str, touched: &[&str]) -> CheckpointRecord {
273 CheckpointRecord {
274 turn,
275 pre: pre.map(|s| CheckpointId(s.to_string())),
276 id: CheckpointId(id.to_string()),
277 message: None,
278 touched_files: touched.iter().map(|s| s.to_string()).collect(),
279 touched_via: TouchedVia::Structured,
280 started_at: 0,
281 finished_at: 0,
282 saved_at: 0,
283 }
284 }
285
286 #[test]
287 fn plan_rewind_collects_touched_files_across_dropped_turns() {
288 let dir = tempfile::tempdir().unwrap();
289 let log = dir.path().join("c.jsonl");
290 write_log(
291 &log,
292 &[
293 rec(0, Some("p0"), "q0", &["a.txt"]),
294 rec(1, Some("q0"), "q1", &["b.txt"]),
295 rec(2, Some("q1"), "q2", &["c.txt"]),
296 ],
297 );
298 let plan = plan_rewind(&log, 1).unwrap();
299 assert_eq!(plan.target.0, "q0");
300 assert_eq!(plan.touched_paths, vec!["b.txt", "c.txt"]);
301 assert_eq!(plan.turns_to_drop, vec![1, 2]);
302 assert_eq!(
303 plan.last_known_post.as_ref().map(|c| c.0.as_str()),
304 Some("q2")
305 );
306 }
307
308 #[test]
309 fn plan_rewind_to_zero_drops_all() {
310 let dir = tempfile::tempdir().unwrap();
311 let log = dir.path().join("c.jsonl");
312 write_log(
313 &log,
314 &[
315 rec(0, Some("p0"), "q0", &["a.txt"]),
316 rec(1, Some("q0"), "q1", &["b.txt"]),
317 ],
318 );
319 let plan = plan_rewind(&log, 0).unwrap();
320 assert_eq!(plan.turns_to_drop, vec![0, 1]);
321 }
322
323 #[test]
327 fn plan_rewind_to_zero_uses_first_id_when_no_pre() {
328 let dir = tempfile::tempdir().unwrap();
329 let log = dir.path().join("c.jsonl");
330 write_log(
331 &log,
332 &[
333 rec(0, None, "q0", &["a.txt"]),
334 rec(1, None, "q1", &["b.txt"]),
335 ],
336 );
337 let plan = plan_rewind(&log, 0).unwrap();
338 assert_eq!(plan.target.0, "q0");
339 assert_eq!(plan.turns_to_drop, vec![0, 1]);
340 }
341
342 #[test]
343 fn plan_rewind_uses_closest_previous_checkpoint() {
344 let dir = tempfile::tempdir().unwrap();
345 let log = dir.path().join("c.jsonl");
346 write_log(&log, &[rec(0, Some("p0"), "q0", &["a.txt"])]);
347 let plan = plan_rewind(&log, 5).unwrap();
349 assert_eq!(plan.target.0, "q0");
350 assert!(plan.turns_to_drop.is_empty());
351 assert!(plan.touched_paths.is_empty());
352 }
353
354 #[test]
355 fn detect_conflicts_flags_externally_modified_file() {
356 if !has_git() {
357 return;
358 }
359 let dir = shadow_ws();
360 fs::write(dir.path().join("a.txt"), "v0").unwrap();
361 let repo = dir.open_repo().unwrap();
362 let pre = repo.snapshot_for_session("s", "pre").unwrap();
363 fs::write(dir.path().join("a.txt"), "v1").unwrap();
364 let post = repo.snapshot_for_session("s", "post").unwrap();
365
366 fs::write(dir.path().join("a.txt"), "v2-from-someone-else").unwrap();
368
369 let plan = RewindPlan {
370 target: pre.clone(),
371 touched_paths: vec!["a.txt".into()],
372 last_known_post: Some(post),
373 turns_to_drop: vec![0],
374 };
375 let conflicts = detect_conflicts(&repo, &plan).unwrap();
376 assert_eq!(conflicts, vec!["a.txt".to_string()]);
377 }
378
379 #[test]
380 fn apply_rewind_restores_and_truncates_log() {
381 if !has_git() {
382 return;
383 }
384 let dir = shadow_ws();
385 let log = dir.path().join("c.jsonl");
386 let target_path = dir.path().join("file.txt");
387 fs::write(&target_path, "before").unwrap();
388 let repo = dir.open_repo().unwrap();
389 let pre = repo.snapshot_for_session("s", "t0 pre").unwrap();
390 fs::write(&target_path, "after").unwrap();
391 let post = repo.snapshot_for_session("s", "t0 post").unwrap();
392
393 write_log(
394 &log,
395 &[CheckpointRecord {
396 turn: 0,
397 pre: Some(pre.clone()),
398 id: post.clone(),
399 message: None,
400 touched_files: vec!["file.txt".into()],
401 touched_via: TouchedVia::Structured,
402 started_at: 0,
403 finished_at: 0,
404 saved_at: 0,
405 }],
406 );
407
408 let plan = plan_rewind(&log, 0).unwrap();
409 let result = apply_rewind(&repo, &log, &plan, false).unwrap();
410 assert_eq!(result.stats.restored, 1);
411 assert_eq!(fs::read_to_string(&target_path).unwrap(), "before");
412 assert!(read_log(&log).unwrap().is_empty());
413 }
414
415 #[test]
416 fn apply_rewind_blocks_on_conflict_without_force() {
417 if !has_git() {
418 return;
419 }
420 let dir = shadow_ws();
421 let log = dir.path().join("c.jsonl");
422 let f = dir.path().join("file.txt");
423 fs::write(&f, "v0").unwrap();
424 let repo = dir.open_repo().unwrap();
425 let pre = repo.snapshot_for_session("s", "pre").unwrap();
426 fs::write(&f, "v1").unwrap();
427 let post = repo.snapshot_for_session("s", "post").unwrap();
428
429 write_log(
430 &log,
431 &[CheckpointRecord {
432 turn: 0,
433 pre: Some(pre.clone()),
434 id: post.clone(),
435 message: None,
436 touched_files: vec!["file.txt".into()],
437 touched_via: TouchedVia::Structured,
438 started_at: 0,
439 finished_at: 0,
440 saved_at: 0,
441 }],
442 );
443
444 fs::write(&f, "external-edit").unwrap();
446
447 let plan = plan_rewind(&log, 0).unwrap();
448 let err = apply_rewind(&repo, &log, &plan, false).unwrap_err();
449 assert!(err.to_string().contains("conflict"));
450 }
451
452 #[test]
453 fn apply_rewind_force_overrides_conflict() {
454 if !has_git() {
455 return;
456 }
457 let dir = shadow_ws();
458 let log = dir.path().join("c.jsonl");
459 let f = dir.path().join("file.txt");
460 fs::write(&f, "v0").unwrap();
461 let repo = dir.open_repo().unwrap();
462 let pre = repo.snapshot_for_session("s", "pre").unwrap();
463 fs::write(&f, "v1").unwrap();
464 let post = repo.snapshot_for_session("s", "post").unwrap();
465
466 write_log(
467 &log,
468 &[CheckpointRecord {
469 turn: 0,
470 pre: Some(pre.clone()),
471 id: post.clone(),
472 message: None,
473 touched_files: vec!["file.txt".into()],
474 touched_via: TouchedVia::Structured,
475 started_at: 0,
476 finished_at: 0,
477 saved_at: 0,
478 }],
479 );
480
481 fs::write(&f, "external").unwrap();
482 let plan = plan_rewind(&log, 0).unwrap();
483 let _ = apply_rewind(&repo, &log, &plan, true).unwrap();
484 assert_eq!(fs::read_to_string(&f).unwrap(), "v0");
485 }
486
487 #[test]
488 fn rewind_does_not_touch_sibling_session_files() {
489 if !has_git() {
490 return;
491 }
492 let dir = shadow_ws();
493 let log_a = dir.path().join("a.jsonl");
494 let mine = dir.path().join("mine.txt");
495 let theirs = dir.path().join("theirs.txt");
496 fs::write(&mine, "mine-v0").unwrap();
497 fs::write(&theirs, "theirs-v0").unwrap();
498 let repo = dir.open_repo().unwrap();
499
500 let pre_a = repo.snapshot_for_session("a", "pre").unwrap();
501 fs::write(&mine, "mine-v1").unwrap();
503 let post_a = repo.snapshot_for_session("a", "post").unwrap();
504
505 fs::write(&theirs, "theirs-v1").unwrap();
507
508 write_log(
509 &log_a,
510 &[CheckpointRecord {
511 turn: 0,
512 pre: Some(pre_a.clone()),
513 id: post_a.clone(),
514 message: None,
515 touched_files: vec!["mine.txt".into()],
516 touched_via: TouchedVia::Structured,
517 started_at: 0,
518 finished_at: 0,
519 saved_at: 0,
520 }],
521 );
522
523 let plan = plan_rewind(&log_a, 0).unwrap();
524 let _ = apply_rewind(&repo, &log_a, &plan, false).unwrap();
525
526 assert_eq!(fs::read_to_string(&mine).unwrap(), "mine-v0");
527 assert_eq!(
528 fs::read_to_string(&theirs).unwrap(),
529 "theirs-v1",
530 "sibling session's file must not be touched"
531 );
532 }
533
534 #[test]
535 fn plan_rewind_no_prev_and_target_has_no_pre_returns_error() {
536 let dir = tempfile::tempdir().unwrap();
539 let log_path = dir.path().join("ck.jsonl");
540 write_log(&log_path, &[rec(3, None, "snap-3-id", &["x.txt"])]);
542 let err = plan_rewind(&log_path, 3);
543 assert!(
544 err.is_err(),
545 "turn with no pre-snapshot must return an error"
546 );
547 let msg = format!("{}", err.unwrap_err());
548 assert!(
549 msg.contains("no pre-snapshot") || msg.contains("pre"),
550 "error must mention missing pre-snapshot; got: {msg}"
551 );
552 }
553
554 #[test]
555 fn plan_rewind_returns_error_for_empty_log() {
556 let dir = tempfile::tempdir().unwrap();
558 let log_path = dir.path().join("empty.jsonl");
559 std::fs::write(&log_path, "").unwrap();
561 let err = plan_rewind(&log_path, 1);
562 assert!(err.is_err(), "empty log must return an error");
563 let msg = format!("{}", err.unwrap_err());
564 assert!(
565 msg.contains("no checkpoints"),
566 "error must mention 'no checkpoints', got: {msg}"
567 );
568 }
569
570 #[test]
571 fn plan_rewind_returns_error_when_turn_not_in_log_and_no_prev() {
572 let dir = tempfile::tempdir().unwrap();
576 let log_path = dir.path().join("ck.jsonl");
577 write_log(
579 &log_path,
580 &[rec(5, Some("snap-pre"), "snap-post", &["a.txt"])],
581 );
582 let err = plan_rewind(&log_path, 3);
583 assert!(err.is_err(), "non-existent turn must return an error");
584 let msg = format!("{}", err.unwrap_err());
585 assert!(
586 msg.contains("3"),
587 "error must mention the missing turn number, got: {msg}"
588 );
589 }
590
591 #[test]
592 fn checkpoint_log_path_omits_workspace_slug_duplication() {
593 let ws = std::path::Path::new("/home/user/myproject");
595 let path = checkpoint_log_path(ws, "myproject", "sess-abc");
596 let expected = ws
597 .join(".recursive")
598 .join("sessions")
599 .join("myproject")
600 .join("sess-abc")
601 .join("checkpoints.jsonl");
602 assert_eq!(path, expected);
603 }
604}