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