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