1use anyhow::Result;
9use serde::{Deserialize, Serialize};
10use std::path::{Path, PathBuf};
11
12#[derive(Debug, Clone, PartialEq, Eq)]
14pub struct Checkpoint {
15 pub role: String,
16 pub task_id: u32,
17 pub task_title: String,
18 pub task_description: String,
19 pub branch: Option<String>,
20 pub last_commit: Option<String>,
21 pub test_summary: Option<String>,
22 pub timestamp: String,
23}
24
25#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
26pub struct RestartContext {
27 pub role: String,
28 pub task_id: u32,
29 pub task_title: String,
30 pub task_description: String,
31 pub branch: Option<String>,
32 pub worktree_path: Option<String>,
33 pub restart_count: u32,
34 pub reason: String,
35 #[serde(default)]
36 pub output_bytes: Option<u64>,
37 #[serde(default)]
38 pub last_commit: Option<String>,
39 #[serde(default)]
40 pub created_at_epoch_secs: Option<u64>,
41 #[serde(default)]
42 pub handoff_consumed: bool,
43}
44
45pub fn progress_dir(project_root: &Path) -> PathBuf {
47 project_root.join(".batty").join("progress")
48}
49
50pub fn checkpoint_path(project_root: &Path, role: &str) -> PathBuf {
52 progress_dir(project_root).join(format!("{role}.md"))
53}
54
55pub fn restart_context_path(worktree_dir: &Path) -> PathBuf {
56 worktree_dir.join("restart_context.json")
57}
58
59pub fn write_checkpoint(project_root: &Path, checkpoint: &Checkpoint) -> Result<()> {
63 let dir = progress_dir(project_root);
64 std::fs::create_dir_all(&dir)?;
65 let path = dir.join(format!("{}.md", checkpoint.role));
66 let content = format_checkpoint(checkpoint);
67 std::fs::write(&path, content)?;
68 Ok(())
69}
70
71pub fn read_checkpoint(project_root: &Path, role: &str) -> Option<String> {
73 let path = checkpoint_path(project_root, role);
74 std::fs::read_to_string(&path).ok()
75}
76
77pub fn remove_checkpoint(project_root: &Path, role: &str) {
79 let path = checkpoint_path(project_root, role);
80 let _ = std::fs::remove_file(&path);
81}
82
83pub fn write_restart_context(worktree_dir: &Path, context: &RestartContext) -> Result<()> {
84 std::fs::create_dir_all(worktree_dir)?;
85 let path = restart_context_path(worktree_dir);
86 let content = serde_json::to_vec_pretty(context)?;
87 std::fs::write(path, content)?;
88 Ok(())
89}
90
91pub fn read_restart_context(worktree_dir: &Path) -> Option<RestartContext> {
92 let path = restart_context_path(worktree_dir);
93 let content = std::fs::read(path).ok()?;
94 serde_json::from_slice(&content).ok()
95}
96
97pub fn remove_restart_context(worktree_dir: &Path) {
98 let path = restart_context_path(worktree_dir);
99 let _ = std::fs::remove_file(path);
100}
101
102pub fn gather_checkpoint(project_root: &Path, role: &str, task: &crate::task::Task) -> Checkpoint {
104 let worktree_dir = project_root.join(".batty").join("worktrees").join(role);
105
106 let branch = task
107 .branch
108 .clone()
109 .or_else(|| git_current_branch(&worktree_dir));
110
111 let last_commit = git_last_commit(&worktree_dir);
112 let test_summary = last_test_output(&worktree_dir);
113
114 let timestamp = chrono_timestamp();
115
116 Checkpoint {
117 role: role.to_string(),
118 task_id: task.id,
119 task_title: task.title.clone(),
120 task_description: task.description.clone(),
121 branch,
122 last_commit,
123 test_summary,
124 timestamp,
125 }
126}
127
128fn format_checkpoint(cp: &Checkpoint) -> String {
130 let mut out = String::new();
131 out.push_str(&format!("# Progress Checkpoint: {}\n\n", cp.role));
132 out.push_str(&format!(
133 "**Task:** #{} — {}\n\n",
134 cp.task_id, cp.task_title
135 ));
136 out.push_str(&format!("**Timestamp:** {}\n\n", cp.timestamp));
137
138 if let Some(branch) = &cp.branch {
139 out.push_str(&format!("**Branch:** {branch}\n\n"));
140 }
141
142 if let Some(commit) = &cp.last_commit {
143 out.push_str(&format!("**Last commit:** {commit}\n\n"));
144 }
145
146 out.push_str("## Task Description\n\n");
147 out.push_str(&cp.task_description);
148 out.push('\n');
149
150 if let Some(tests) = &cp.test_summary {
151 out.push_str("\n## Last Test Output\n\n");
152 out.push_str(tests);
153 out.push('\n');
154 }
155
156 out
157}
158
159fn git_current_branch(worktree_dir: &Path) -> Option<String> {
161 if !worktree_dir.exists() {
162 return None;
163 }
164 let output = std::process::Command::new("git")
165 .args(["rev-parse", "--abbrev-ref", "HEAD"])
166 .current_dir(worktree_dir)
167 .output()
168 .ok()?;
169 if output.status.success() {
170 let branch = String::from_utf8_lossy(&output.stdout).trim().to_string();
171 if branch.is_empty() || branch == "HEAD" {
172 None
173 } else {
174 Some(branch)
175 }
176 } else {
177 None
178 }
179}
180
181fn git_last_commit(worktree_dir: &Path) -> Option<String> {
183 if !worktree_dir.exists() {
184 return None;
185 }
186 let output = std::process::Command::new("git")
187 .args(["log", "-1", "--oneline"])
188 .current_dir(worktree_dir)
189 .output()
190 .ok()?;
191 if output.status.success() {
192 let line = String::from_utf8_lossy(&output.stdout).trim().to_string();
193 if line.is_empty() { None } else { Some(line) }
194 } else {
195 None
196 }
197}
198
199fn last_test_output(worktree_dir: &Path) -> Option<String> {
202 let test_output_path = worktree_dir.join(".batty_test_output");
204 if test_output_path.exists() {
205 if let Ok(content) = std::fs::read_to_string(&test_output_path) {
206 if !content.is_empty() {
207 let lines: Vec<&str> = content.lines().collect();
209 let start = lines.len().saturating_sub(50);
210 return Some(lines[start..].join("\n"));
211 }
212 }
213 }
214 None
215}
216
217fn chrono_timestamp() -> String {
219 use std::time::SystemTime;
220 let now = SystemTime::now()
221 .duration_since(SystemTime::UNIX_EPOCH)
222 .unwrap_or_default();
223 let secs = now.as_secs();
225 let hours = (secs / 3600) % 24;
226 let minutes = (secs / 60) % 60;
227 let seconds = secs % 60;
228 let days_since_epoch = secs / 86400;
229 let (year, month, day) = epoch_days_to_date(days_since_epoch);
231 format!("{year:04}-{month:02}-{day:02}T{hours:02}:{minutes:02}:{seconds:02}Z")
232}
233
234fn epoch_days_to_date(days: u64) -> (u64, u64, u64) {
236 let z = days + 719468;
238 let era = z / 146097;
239 let doe = z - era * 146097;
240 let yoe = (doe - doe / 1460 + doe / 36524 - doe / 146096) / 365;
241 let y = yoe + era * 400;
242 let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
243 let mp = (5 * doy + 2) / 153;
244 let d = doy - (153 * mp + 2) / 5 + 1;
245 let m = if mp < 10 { mp + 3 } else { mp - 9 };
246 let y = if m <= 2 { y + 1 } else { y };
247 (y, m, d)
248}
249
250#[cfg(test)]
251mod tests {
252 use super::*;
253 use std::fs;
254
255 fn make_task(id: u32, title: &str, description: &str) -> crate::task::Task {
256 crate::task::Task {
257 id,
258 title: title.to_string(),
259 status: "in-progress".to_string(),
260 priority: "high".to_string(),
261 claimed_by: None,
262 claimed_at: None,
263 claim_ttl_secs: None,
264 claim_expires_at: None,
265 last_progress_at: None,
266 claim_warning_sent_at: None,
267 claim_extensions: None,
268 last_output_bytes: None,
269 blocked: None,
270 tags: vec![],
271 depends_on: vec![],
272 review_owner: None,
273 blocked_on: None,
274 worktree_path: None,
275 branch: Some("eng-1-2/42".to_string()),
276 commit: None,
277 artifacts: vec![],
278 next_action: None,
279 scheduled_for: None,
280 cron_schedule: None,
281 cron_last_run: None,
282 completed: None,
283 description: description.to_string(),
284 batty_config: None,
285 source_path: PathBuf::from("/tmp/fake.md"),
286 }
287 }
288
289 #[test]
290 fn write_and_read_checkpoint() {
291 let tmp = tempfile::tempdir().unwrap();
292 let root = tmp.path();
293 let cp = Checkpoint {
294 role: "eng-1-1".to_string(),
295 task_id: 42,
296 task_title: "Fix the widget".to_string(),
297 task_description: "Widget is broken, needs fixing.".to_string(),
298 branch: Some("eng-1-1/42".to_string()),
299 last_commit: Some("abc1234 fix widget rendering".to_string()),
300 test_summary: Some("test result: ok. 10 passed".to_string()),
301 timestamp: "2026-03-22T10:00:00Z".to_string(),
302 };
303
304 write_checkpoint(root, &cp).unwrap();
305
306 let content = read_checkpoint(root, "eng-1-1").unwrap();
307 assert!(content.contains("# Progress Checkpoint: eng-1-1"));
308 assert!(content.contains("**Task:** #42 — Fix the widget"));
309 assert!(content.contains("**Branch:** eng-1-1/42"));
310 assert!(content.contains("**Last commit:** abc1234 fix widget rendering"));
311 assert!(content.contains("Widget is broken, needs fixing."));
312 assert!(content.contains("test result: ok. 10 passed"));
313 assert!(content.contains("**Timestamp:** 2026-03-22T10:00:00Z"));
314 }
315
316 #[test]
317 fn read_checkpoint_returns_none_when_missing() {
318 let tmp = tempfile::tempdir().unwrap();
319 assert!(read_checkpoint(tmp.path(), "eng-nonexistent").is_none());
320 }
321
322 #[test]
323 fn remove_checkpoint_deletes_file() {
324 let tmp = tempfile::tempdir().unwrap();
325 let root = tmp.path();
326 let cp = Checkpoint {
327 role: "eng-1-1".to_string(),
328 task_id: 1,
329 task_title: "t".to_string(),
330 task_description: "d".to_string(),
331 branch: None,
332 last_commit: None,
333 test_summary: None,
334 timestamp: "2026-01-01T00:00:00Z".to_string(),
335 };
336 write_checkpoint(root, &cp).unwrap();
337 assert!(checkpoint_path(root, "eng-1-1").exists());
338
339 remove_checkpoint(root, "eng-1-1");
340 assert!(!checkpoint_path(root, "eng-1-1").exists());
341 }
342
343 #[test]
344 fn remove_checkpoint_noop_when_missing() {
345 let tmp = tempfile::tempdir().unwrap();
346 remove_checkpoint(tmp.path(), "eng-nonexistent");
348 }
349
350 #[test]
351 fn checkpoint_creates_progress_directory() {
352 let tmp = tempfile::tempdir().unwrap();
353 let root = tmp.path();
354 let dir = progress_dir(root);
355 assert!(!dir.exists());
356
357 let cp = Checkpoint {
358 role: "eng-1-1".to_string(),
359 task_id: 1,
360 task_title: "t".to_string(),
361 task_description: "d".to_string(),
362 branch: None,
363 last_commit: None,
364 test_summary: None,
365 timestamp: "2026-01-01T00:00:00Z".to_string(),
366 };
367 write_checkpoint(root, &cp).unwrap();
368 assert!(dir.exists());
369 }
370
371 #[test]
372 fn format_checkpoint_without_optional_fields() {
373 let cp = Checkpoint {
374 role: "eng-1-1".to_string(),
375 task_id: 99,
376 task_title: "Minimal task".to_string(),
377 task_description: "Do the thing.".to_string(),
378 branch: None,
379 last_commit: None,
380 test_summary: None,
381 timestamp: "2026-03-22T12:00:00Z".to_string(),
382 };
383 let content = format_checkpoint(&cp);
384 assert!(content.contains("# Progress Checkpoint: eng-1-1"));
385 assert!(content.contains("**Task:** #99 — Minimal task"));
386 assert!(!content.contains("**Branch:**"));
387 assert!(!content.contains("**Last commit:**"));
388 assert!(!content.contains("## Last Test Output"));
389 }
390
391 #[test]
392 fn gather_checkpoint_uses_task_branch() {
393 let tmp = tempfile::tempdir().unwrap();
394 let task = make_task(42, "Test task", "Test description");
395 let cp = gather_checkpoint(tmp.path(), "eng-1-2", &task);
396 assert_eq!(cp.task_id, 42);
397 assert_eq!(cp.task_title, "Test task");
398 assert_eq!(cp.task_description, "Test description");
399 assert_eq!(cp.branch, Some("eng-1-2/42".to_string()));
400 assert_eq!(cp.role, "eng-1-2");
401 assert!(!cp.timestamp.is_empty());
402 }
403
404 #[test]
405 fn last_test_output_reads_batty_test_file() {
406 let tmp = tempfile::tempdir().unwrap();
407 let worktree = tmp.path();
408 let test_file = worktree.join(".batty_test_output");
409 fs::write(&test_file, "test result: ok. 5 passed; 0 failed").unwrap();
410
411 let summary = last_test_output(worktree);
412 assert!(summary.is_some());
413 assert!(summary.unwrap().contains("5 passed"));
414 }
415
416 #[test]
417 fn last_test_output_returns_none_when_no_file() {
418 let tmp = tempfile::tempdir().unwrap();
419 assert!(last_test_output(tmp.path()).is_none());
420 }
421
422 #[test]
423 fn last_test_output_truncates_long_output() {
424 let tmp = tempfile::tempdir().unwrap();
425 let test_file = tmp.path().join(".batty_test_output");
426 let lines: Vec<String> = (0..100).map(|i| format!("line {i}")).collect();
428 fs::write(&test_file, lines.join("\n")).unwrap();
429
430 let summary = last_test_output(tmp.path()).unwrap();
431 let result_lines: Vec<&str> = summary.lines().collect();
432 assert_eq!(result_lines.len(), 50);
433 assert!(result_lines[0].contains("line 50"));
434 assert!(result_lines[49].contains("line 99"));
435 }
436
437 #[test]
438 fn epoch_days_to_date_known_values() {
439 let (y, m, d) = epoch_days_to_date(0);
441 assert_eq!((y, m, d), (1970, 1, 1));
442
443 let (y, m, d) = epoch_days_to_date(10957);
445 assert_eq!((y, m, d), (2000, 1, 1));
446 }
447
448 #[test]
449 fn checkpoint_path_correct() {
450 let root = Path::new("/project");
451 assert_eq!(
452 checkpoint_path(root, "eng-1-1"),
453 PathBuf::from("/project/.batty/progress/eng-1-1.md")
454 );
455 }
456
457 #[test]
458 fn overwrite_existing_checkpoint() {
459 let tmp = tempfile::tempdir().unwrap();
460 let root = tmp.path();
461
462 let cp1 = Checkpoint {
463 role: "eng-1-1".to_string(),
464 task_id: 1,
465 task_title: "First".to_string(),
466 task_description: "First task".to_string(),
467 branch: None,
468 last_commit: None,
469 test_summary: None,
470 timestamp: "2026-01-01T00:00:00Z".to_string(),
471 };
472 write_checkpoint(root, &cp1).unwrap();
473
474 let cp2 = Checkpoint {
475 role: "eng-1-1".to_string(),
476 task_id: 2,
477 task_title: "Second".to_string(),
478 task_description: "Second task".to_string(),
479 branch: Some("eng-1-1/2".to_string()),
480 last_commit: None,
481 test_summary: None,
482 timestamp: "2026-01-02T00:00:00Z".to_string(),
483 };
484 write_checkpoint(root, &cp2).unwrap();
485
486 let content = read_checkpoint(root, "eng-1-1").unwrap();
487 assert!(content.contains("**Task:** #2 — Second"));
488 assert!(!content.contains("First"));
489 }
490
491 #[test]
492 fn write_and_read_restart_context_round_trip() {
493 let tmp = tempfile::tempdir().unwrap();
494 let worktree_dir = tmp.path().join("eng-1-1");
495 let context = RestartContext {
496 role: "eng-1-1".to_string(),
497 task_id: 42,
498 task_title: "Fix the widget".to_string(),
499 task_description: "Widget is broken, needs fixing.".to_string(),
500 branch: Some("eng-1-1/42".to_string()),
501 worktree_path: Some("/tmp/worktrees/eng-1-1".to_string()),
502 restart_count: 2,
503 reason: "context_exhausted".to_string(),
504 output_bytes: Some(512_000),
505 last_commit: Some("abc1234 fix widget".to_string()),
506 created_at_epoch_secs: Some(1_234_567_890),
507 handoff_consumed: false,
508 };
509
510 write_restart_context(&worktree_dir, &context).unwrap();
511
512 let loaded = read_restart_context(&worktree_dir).unwrap();
513 assert_eq!(loaded, context);
514 }
515
516 #[test]
517 fn remove_restart_context_deletes_file() {
518 let tmp = tempfile::tempdir().unwrap();
519 let worktree_dir = tmp.path().join("eng-1-1");
520 let context = RestartContext {
521 role: "eng-1-1".to_string(),
522 task_id: 42,
523 task_title: "Fix the widget".to_string(),
524 task_description: "Widget is broken, needs fixing.".to_string(),
525 branch: None,
526 worktree_path: None,
527 restart_count: 1,
528 reason: "stalled".to_string(),
529 output_bytes: None,
530 last_commit: None,
531 created_at_epoch_secs: None,
532 handoff_consumed: false,
533 };
534
535 write_restart_context(&worktree_dir, &context).unwrap();
536 assert!(restart_context_path(&worktree_dir).exists());
537
538 remove_restart_context(&worktree_dir);
539 assert!(!restart_context_path(&worktree_dir).exists());
540 }
541
542 #[test]
543 fn read_restart_context_returns_none_when_missing_or_invalid() {
544 let tmp = tempfile::tempdir().unwrap();
545 let worktree_dir = tmp.path().join("eng-1-1");
546 assert!(read_restart_context(&worktree_dir).is_none());
547
548 std::fs::create_dir_all(&worktree_dir).unwrap();
549 std::fs::write(restart_context_path(&worktree_dir), b"{not json").unwrap();
550 assert!(read_restart_context(&worktree_dir).is_none());
551 }
552
553 #[test]
556 fn write_checkpoint_to_readonly_dir_fails() {
557 #[cfg(unix)]
558 {
559 use std::os::unix::fs::PermissionsExt;
560 let tmp = tempfile::tempdir().unwrap();
561 let readonly = tmp.path().join("readonly_root");
562 fs::create_dir(&readonly).unwrap();
563 let batty_dir = readonly.join(".batty");
565 fs::create_dir(&batty_dir).unwrap();
566 fs::set_permissions(&batty_dir, fs::Permissions::from_mode(0o444)).unwrap();
567
568 let cp = Checkpoint {
569 role: "eng-1-1".to_string(),
570 task_id: 1,
571 task_title: "t".to_string(),
572 task_description: "d".to_string(),
573 branch: None,
574 last_commit: None,
575 test_summary: None,
576 timestamp: "2026-01-01T00:00:00Z".to_string(),
577 };
578 let result = write_checkpoint(&readonly, &cp);
579 assert!(result.is_err());
580
581 fs::set_permissions(&batty_dir, fs::Permissions::from_mode(0o755)).unwrap();
583 }
584 }
585
586 #[test]
587 fn git_current_branch_returns_none_for_nonexistent_dir() {
588 let result = git_current_branch(Path::new("/tmp/__batty_no_dir_here__"));
589 assert!(result.is_none());
590 }
591
592 #[test]
593 fn git_current_branch_returns_none_for_non_git_dir() {
594 let tmp = tempfile::tempdir().unwrap();
595 let result = git_current_branch(tmp.path());
596 assert!(result.is_none());
597 }
598
599 #[test]
600 fn git_last_commit_returns_none_for_nonexistent_dir() {
601 let result = git_last_commit(Path::new("/tmp/__batty_no_dir_here__"));
602 assert!(result.is_none());
603 }
604
605 #[test]
606 fn git_last_commit_returns_none_for_non_git_dir() {
607 let tmp = tempfile::tempdir().unwrap();
608 let result = git_last_commit(tmp.path());
609 assert!(result.is_none());
610 }
611
612 #[test]
613 fn last_test_output_returns_none_for_empty_file() {
614 let tmp = tempfile::tempdir().unwrap();
615 let test_file = tmp.path().join(".batty_test_output");
616 fs::write(&test_file, "").unwrap();
617 assert!(last_test_output(tmp.path()).is_none());
618 }
619
620 #[test]
621 fn chrono_timestamp_returns_valid_format() {
622 let ts = chrono_timestamp();
623 assert!(ts.ends_with('Z'));
625 assert!(ts.contains('T'));
626 assert_eq!(ts.len(), 20); }
628}