1use std::path::{Path, PathBuf};
4use std::time::Duration;
5
6use anyhow::{Context, Result, bail};
7use chrono::{DateTime, FixedOffset, NaiveDate, Utc};
8use serde::Deserialize;
9use serde_yaml::{Mapping, Value};
10use tracing::info;
11
12use super::errors::BoardError;
13use super::test_results::TestResults;
14use crate::task::{
15 Task, load_tasks_from_dir, parse_frontmatter_timestamp as parse_task_frontmatter_timestamp,
16 parse_frontmatter_timestamp_compat,
17};
18
19#[derive(Debug, Clone, Default, PartialEq, Eq)]
21pub(crate) struct WorkflowMetadata {
22 pub branch: Option<String>,
23 pub worktree_path: Option<String>,
24 pub commit: Option<String>,
25 pub changed_paths: Vec<String>,
26 pub tests_run: Option<bool>,
27 pub tests_passed: Option<bool>,
28 pub test_results: Option<TestResults>,
29 pub artifacts: Vec<String>,
30 pub outcome: Option<String>,
31 pub review_blockers: Vec<String>,
32}
33
34#[derive(Debug, Deserialize, Default)]
35struct WorkflowFrontmatter {
36 #[serde(default)]
37 branch: Option<String>,
38 #[serde(default)]
39 worktree_path: Option<String>,
40 #[serde(default)]
41 commit: Option<String>,
42 #[serde(default)]
43 changed_paths: Vec<String>,
44 #[serde(default)]
45 tests_run: Option<bool>,
46 #[serde(default)]
47 tests_passed: Option<bool>,
48 #[serde(default)]
49 test_results: Option<TestResults>,
50 #[serde(default)]
51 artifacts: Vec<String>,
52 #[serde(default)]
53 outcome: Option<String>,
54 #[serde(default)]
55 review_blockers: Vec<String>,
56}
57
58#[derive(Debug, Deserialize, Default)]
59struct TaskTimestampFrontmatter {
60 #[serde(default)]
61 created: Option<String>,
62 #[serde(default)]
63 started: Option<String>,
64 #[serde(default)]
65 updated: Option<String>,
66}
67
68#[derive(Debug, Clone, Copy, PartialEq, Eq)]
69pub(crate) struct AgingThresholds {
70 pub stale_in_progress_hours: u64,
71 pub aged_todo_hours: u64,
72 pub stale_review_hours: u64,
73}
74
75impl Default for AgingThresholds {
76 fn default() -> Self {
77 Self {
78 stale_in_progress_hours: 4,
79 aged_todo_hours: 48,
80 stale_review_hours: 1,
81 }
82 }
83}
84
85#[derive(Debug, Clone, PartialEq, Eq)]
86pub(crate) struct AgedTask {
87 pub task_id: u32,
88 pub title: String,
89 pub status: String,
90 pub claimed_by: Option<String>,
91 pub age_secs: u64,
92}
93
94#[derive(Debug, Clone, Default, PartialEq, Eq)]
95pub(crate) struct TaskAgingReport {
96 pub stale_in_progress: Vec<AgedTask>,
97 pub aged_todo: Vec<AgedTask>,
98 pub stale_review: Vec<AgedTask>,
99}
100
101impl From<WorkflowFrontmatter> for WorkflowMetadata {
102 fn from(frontmatter: WorkflowFrontmatter) -> Self {
103 Self {
104 branch: frontmatter.branch,
105 worktree_path: frontmatter.worktree_path,
106 commit: frontmatter.commit,
107 changed_paths: frontmatter.changed_paths,
108 tests_run: frontmatter.tests_run,
109 tests_passed: frontmatter.tests_passed,
110 test_results: frontmatter.test_results,
111 artifacts: frontmatter.artifacts,
112 outcome: frontmatter.outcome,
113 review_blockers: frontmatter.review_blockers,
114 }
115 }
116}
117
118pub(crate) fn read_workflow_metadata(task_path: &Path) -> Result<WorkflowMetadata> {
119 let content = std::fs::read_to_string(task_path)
120 .with_context(|| format!("failed to read {}", task_path.display()))?;
121 let (frontmatter, _) = split_task_frontmatter(&content)?;
122 let parsed: WorkflowFrontmatter =
123 serde_yaml::from_str(frontmatter).context("failed to parse task frontmatter")?;
124 Ok(parsed.into())
125}
126
127pub(crate) fn compute_task_aging(
128 board_dir: &Path,
129 project_root: &Path,
130 thresholds: AgingThresholds,
131) -> Result<TaskAgingReport> {
132 compute_task_aging_at(board_dir, project_root, thresholds, Utc::now())
133}
134
135pub(crate) fn compute_task_aging_at(
136 board_dir: &Path,
137 project_root: &Path,
138 thresholds: AgingThresholds,
139 now: DateTime<Utc>,
140) -> Result<TaskAgingReport> {
141 let tasks_dir = board_dir.join("tasks");
142 if !tasks_dir.is_dir() {
143 return Ok(TaskAgingReport::default());
144 }
145
146 let mut report = TaskAgingReport::default();
147 for task in load_tasks_from_dir(&tasks_dir)? {
148 match task.status.as_str() {
149 "in-progress" | "in_progress" => {
150 let age_secs = task_age_from_frontmatter(&task, now, AgeAnchor::Started)?;
151 if age_secs >= thresholds.stale_in_progress_hours.saturating_mul(3600)
152 && commits_ahead_of_main(project_root, &task)? == 0
153 {
154 report.stale_in_progress.push(AgedTask {
155 task_id: task.id,
156 title: task.title,
157 status: task.status,
158 claimed_by: task.claimed_by,
159 age_secs,
160 });
161 }
162 }
163 "todo" => {
164 let age_secs = task_age_from_frontmatter(&task, now, AgeAnchor::Updated)?;
165 if age_secs >= thresholds.aged_todo_hours.saturating_mul(3600) {
166 report.aged_todo.push(AgedTask {
167 task_id: task.id,
168 title: task.title,
169 status: task.status,
170 claimed_by: task.claimed_by,
171 age_secs,
172 });
173 }
174 }
175 "review" => {
176 let age_secs = task_age_from_frontmatter(&task, now, AgeAnchor::Updated)?;
177 if age_secs >= thresholds.stale_review_hours.saturating_mul(3600) {
178 report.stale_review.push(AgedTask {
179 task_id: task.id,
180 title: task.title,
181 status: task.status,
182 claimed_by: task.claimed_by,
183 age_secs,
184 });
185 }
186 }
187 _ => {}
188 }
189 }
190
191 report
192 .stale_in_progress
193 .sort_by_key(|entry| (entry.task_id, entry.age_secs));
194 report
195 .aged_todo
196 .sort_by_key(|entry| (entry.task_id, entry.age_secs));
197 report
198 .stale_review
199 .sort_by_key(|entry| (entry.task_id, entry.age_secs));
200 Ok(report)
201}
202
203pub(crate) fn write_workflow_metadata(task_path: &Path, metadata: &WorkflowMetadata) -> Result<()> {
204 let content = std::fs::read_to_string(task_path)
205 .with_context(|| format!("failed to read {}", task_path.display()))?;
206 let (frontmatter, body) = split_task_frontmatter(&content)?;
207 let mut mapping: Mapping =
208 serde_yaml::from_str(frontmatter).context("failed to parse task frontmatter")?;
209
210 set_optional_string(&mut mapping, "branch", metadata.branch.as_deref());
211 set_optional_string(
212 &mut mapping,
213 "worktree_path",
214 metadata.worktree_path.as_deref(),
215 );
216 set_optional_string(&mut mapping, "commit", metadata.commit.as_deref());
217 set_string_list(&mut mapping, "changed_paths", &metadata.changed_paths);
218 set_optional_bool(&mut mapping, "tests_run", metadata.tests_run);
219 set_optional_bool(&mut mapping, "tests_passed", metadata.tests_passed);
220 set_optional_value(&mut mapping, "test_results", metadata.test_results.as_ref())?;
221 set_string_list(&mut mapping, "artifacts", &metadata.artifacts);
222 set_optional_string(&mut mapping, "outcome", metadata.outcome.as_deref());
223 set_string_list(&mut mapping, "review_blockers", &metadata.review_blockers);
224
225 let mut rendered =
226 serde_yaml::to_string(&mapping).context("failed to serialize task frontmatter")?;
227 if let Some(stripped) = rendered.strip_prefix("---\n") {
228 rendered = stripped.to_string();
229 }
230
231 let mut updated = String::from("---\n");
232 updated.push_str(&rendered);
233 if !updated.ends_with('\n') {
234 updated.push('\n');
235 }
236 updated.push_str("---\n");
237 updated.push_str(body);
238
239 std::fs::write(task_path, updated)
240 .with_context(|| format!("failed to write {}", task_path.display()))?;
241 Ok(())
242}
243
244#[derive(Debug, Clone, Default, PartialEq, Eq)]
246pub(crate) struct TaskLifecycleTimestamps {
247 pub created: Option<DateTime<FixedOffset>>,
248 pub started: Option<DateTime<FixedOffset>>,
249 pub completed: Option<DateTime<FixedOffset>>,
250}
251
252#[derive(Debug, Deserialize, Default)]
253struct TaskLifecycleFrontmatter {
254 #[serde(default)]
255 created: Option<String>,
256 #[serde(default)]
257 started: Option<String>,
258 #[serde(default)]
259 completed: Option<String>,
260}
261
262pub(crate) fn read_task_lifecycle_timestamps(task_path: &Path) -> Result<TaskLifecycleTimestamps> {
263 let content = std::fs::read_to_string(task_path)
264 .with_context(|| format!("failed to read {}", task_path.display()))?;
265 let (frontmatter, _) = split_task_frontmatter(&content)?;
266 let parsed: TaskLifecycleFrontmatter =
267 serde_yaml::from_str(frontmatter).context("failed to parse task frontmatter")?;
268
269 Ok(TaskLifecycleTimestamps {
270 created: parsed
271 .created
272 .as_deref()
273 .and_then(parse_frontmatter_timestamp),
274 started: parsed
275 .started
276 .as_deref()
277 .and_then(parse_frontmatter_timestamp),
278 completed: parsed
279 .completed
280 .as_deref()
281 .and_then(parse_frontmatter_timestamp),
282 })
283}
284
285#[derive(Debug, Clone, PartialEq, Eq)]
287pub struct ArchiveSummary {
288 pub archived_count: usize,
289 pub skipped_count: usize,
290 pub archive_dir: PathBuf,
291}
292
293pub fn parse_age_threshold(threshold: &str) -> Result<Duration> {
295 let threshold = threshold.trim();
296 if threshold.is_empty() {
297 bail!("empty age threshold");
298 }
299
300 let split_pos = threshold
301 .find(|c: char| !c.is_ascii_digit())
302 .unwrap_or(threshold.len());
303 let (digits, suffix) = threshold.split_at(split_pos);
304
305 if digits.is_empty() {
306 bail!("invalid age threshold: {threshold}");
307 }
308
309 let value: u64 = digits
310 .parse()
311 .with_context(|| format!("invalid age threshold: {threshold}"))?;
312
313 let seconds = match suffix {
314 "s" => value,
315 "m" => value * 60,
316 "h" => value * 3600,
317 "d" => value * 86400,
318 "w" => value * 86400 * 7,
319 _ => bail!("invalid age threshold suffix: {threshold} (expected s, m, h, d, or w)"),
320 };
321
322 Ok(Duration::from_secs(seconds))
323}
324
325pub fn done_tasks_older_than(board_dir: &Path, max_age: Duration) -> Result<Vec<Task>> {
327 let tasks_dir = board_dir.join("tasks");
328 if !tasks_dir.is_dir() {
329 bail!("no tasks directory found at {}", tasks_dir.display());
330 }
331
332 let tasks = load_tasks_from_dir(&tasks_dir)?;
333 let now = Utc::now();
334 let cutoff = now - chrono::Duration::from_std(max_age).unwrap_or(chrono::Duration::zero());
335
336 let matching: Vec<Task> = tasks
337 .into_iter()
338 .filter(|t| t.status == "done")
339 .filter(|t| {
340 if max_age.is_zero() {
341 return true;
342 }
343 match &t.completed {
344 Some(completed_str) => parse_completed_date(completed_str)
345 .map(|completed| completed < cutoff)
346 .unwrap_or(false),
347 None => {
348 std::fs::metadata(&t.source_path)
350 .and_then(|m| m.modified())
351 .ok()
352 .map(|mtime| {
353 let mtime_dt: DateTime<Utc> = mtime.into();
354 mtime_dt < cutoff
355 })
356 .unwrap_or(false)
357 }
358 }
359 })
360 .collect();
361
362 Ok(matching)
363}
364
365pub fn archive_tasks(board_dir: &Path, tasks: &[Task], dry_run: bool) -> Result<ArchiveSummary> {
367 let archive_dir = board_dir.join("archive");
368
369 if tasks.is_empty() {
370 return Ok(ArchiveSummary {
371 archived_count: 0,
372 skipped_count: 0,
373 archive_dir,
374 });
375 }
376
377 if !dry_run {
378 std::fs::create_dir_all(&archive_dir)
379 .with_context(|| format!("failed to create archive dir: {}", archive_dir.display()))?;
380 }
381
382 let mut archived = 0usize;
383 let skipped = 0usize;
384
385 for task in tasks {
386 let source = &task.source_path;
387 let file_name = source.file_name().context("task file has no file name")?;
388 let dest = archive_dir.join(file_name);
389
390 if dry_run {
391 let completed_display = task.completed.as_deref().unwrap_or("unknown date");
392 println!(
393 " - {} (done {})",
394 file_name.to_string_lossy(),
395 completed_display
396 );
397 archived += 1;
398 continue;
399 }
400
401 std::fs::rename(source, &dest).with_context(|| {
402 format!("failed to move {} to {}", source.display(), dest.display())
403 })?;
404 archived += 1;
405 info!(task_id = task.id, "archived task");
406 }
407
408 info!(archived, "archived done tasks");
409 Ok(ArchiveSummary {
410 archived_count: archived,
411 skipped_count: skipped,
412 archive_dir,
413 })
414}
415
416pub fn archive_done_tasks(board_dir: &Path, older_than: Option<&str>) -> Result<u32> {
421 let tasks_dir = board_dir.join("tasks");
422 if !tasks_dir.is_dir() {
423 bail!("no tasks directory found at {}", tasks_dir.display());
424 }
425
426 let cutoff = older_than.map(parse_cutoff_date).transpose()?;
427
428 let tasks = load_tasks_from_dir(&tasks_dir)?;
429 let to_archive: Vec<&Task> = tasks
430 .iter()
431 .filter(|t| t.status == "done")
432 .filter(|t| match (&cutoff, &t.completed) {
433 (Some(cutoff_dt), Some(completed_str)) => parse_completed_date(completed_str)
434 .map(|completed| completed < *cutoff_dt)
435 .unwrap_or(false),
436 (Some(_), None) => false,
437 (None, _) => true,
438 })
439 .collect();
440
441 if to_archive.is_empty() {
442 return Ok(0);
443 }
444
445 let archive_dir = board_dir.join("archive");
446 std::fs::create_dir_all(&archive_dir)
447 .with_context(|| format!("failed to create archive dir: {}", archive_dir.display()))?;
448
449 let mut count = 0u32;
450 for task in &to_archive {
451 let source = &task.source_path;
452 let file_name = source.file_name().context("task file has no file name")?;
453 let dest = archive_dir.join(file_name);
454
455 update_task_status(source, "archived")?;
457
458 std::fs::rename(source, &dest).with_context(|| {
459 format!("failed to move {} to {}", source.display(), dest.display())
460 })?;
461 count += 1;
462 info!(task_id = task.id, "archived task");
463 }
464
465 info!(count, "archived done tasks");
466 Ok(count)
467}
468
469fn parse_cutoff_date(date_str: &str) -> Result<DateTime<FixedOffset>> {
470 if let Ok(naive) = NaiveDate::parse_from_str(date_str, "%Y-%m-%d") {
472 let dt = naive.and_hms_opt(0, 0, 0).context("invalid date")?;
473 return Ok(DateTime::<FixedOffset>::from_naive_utc_and_offset(
474 dt,
475 FixedOffset::east_opt(0).unwrap(),
476 ));
477 }
478 DateTime::parse_from_rfc3339(date_str).with_context(|| {
480 format!("invalid date format: {date_str} (expected YYYY-MM-DD or RFC3339)")
481 })
482}
483
484fn parse_completed_date(completed_str: &str) -> Option<DateTime<FixedOffset>> {
485 parse_task_frontmatter_timestamp(completed_str)
486}
487
488fn parse_frontmatter_timestamp(value: &str) -> Option<DateTime<FixedOffset>> {
489 parse_task_frontmatter_timestamp(value)
490}
491
492fn update_task_status(task_path: &Path, new_status: &str) -> Result<()> {
494 let content = std::fs::read_to_string(task_path)
495 .with_context(|| format!("failed to read {}", task_path.display()))?;
496 let (frontmatter, body) = split_task_frontmatter(&content)?;
497 let mut mapping: Mapping =
498 serde_yaml::from_str(frontmatter).context("failed to parse task frontmatter")?;
499
500 mapping.insert(
501 Value::String("status".to_string()),
502 Value::String(new_status.to_string()),
503 );
504
505 let mut rendered =
506 serde_yaml::to_string(&mapping).context("failed to serialize task frontmatter")?;
507 if let Some(stripped) = rendered.strip_prefix("---\n") {
508 rendered = stripped.to_string();
509 }
510
511 let mut updated = String::from("---\n");
512 updated.push_str(&rendered);
513 if !updated.ends_with('\n') {
514 updated.push('\n');
515 }
516 updated.push_str("---\n");
517 updated.push_str(body);
518
519 std::fs::write(task_path, updated)
520 .with_context(|| format!("failed to write {}", task_path.display()))?;
521 Ok(())
522}
523
524pub fn rotate_done_items(kanban_path: &Path, archive_path: &Path, threshold: u32) -> Result<u32> {
531 let content = std::fs::read_to_string(kanban_path)
532 .with_context(|| format!("failed to read {}", kanban_path.display()))?;
533
534 let (before_done, done_items, after_done) = split_done_section(&content);
535
536 if done_items.len() <= threshold as usize {
537 return Ok(0);
538 }
539
540 let keep_count = threshold as usize;
541 let to_archive = &done_items[..done_items.len() - keep_count];
542 let to_keep = &done_items[done_items.len() - keep_count..];
543 let rotated = to_archive.len() as u32;
544
545 let mut new_kanban = before_done.to_string();
546 new_kanban.push_str("## Done\n");
547 for item in to_keep {
548 new_kanban.push_str(item);
549 new_kanban.push('\n');
550 }
551 if !after_done.is_empty() {
552 new_kanban.push_str(after_done);
553 }
554
555 std::fs::write(kanban_path, &new_kanban)
556 .with_context(|| format!("failed to write {}", kanban_path.display()))?;
557
558 let mut archive_content = if archive_path.exists() {
559 std::fs::read_to_string(archive_path)
560 .with_context(|| format!("failed to read {}", archive_path.display()))?
561 } else {
562 "# Kanban Archive\n".to_string()
563 };
564
565 if !archive_content.ends_with('\n') {
566 archive_content.push('\n');
567 }
568 for item in to_archive {
569 archive_content.push_str(item);
570 archive_content.push('\n');
571 }
572
573 std::fs::write(archive_path, &archive_content)
574 .with_context(|| format!("failed to write {}", archive_path.display()))?;
575
576 info!(rotated, threshold, "rotated done items to archive");
577 Ok(rotated)
578}
579
580fn split_done_section(content: &str) -> (&str, Vec<&str>, &str) {
581 let done_marker = "## Done";
582 let Some(done_start) = content.find(done_marker) else {
583 return (content, Vec::new(), "");
584 };
585
586 let before_done = &content[..done_start];
587 let after_marker = &content[done_start + done_marker.len()..];
588 let items_start = after_marker
589 .find('\n')
590 .map(|i| i + 1)
591 .unwrap_or(after_marker.len());
592 let items_section = &after_marker[items_start..];
593
594 let mut done_items = Vec::new();
595 let mut remaining_start = items_section.len();
596
597 for (i, line) in items_section.lines().enumerate() {
598 if line.starts_with("## ") && i > 0 {
599 remaining_start = items_section
600 .find(&format!("\n{line}"))
601 .map(|pos| pos + 1)
602 .unwrap_or(items_section.len());
603 break;
604 }
605 let trimmed = line.trim();
606 if !trimmed.is_empty() {
607 done_items.push(line);
608 }
609 }
610
611 let after_done = &items_section[remaining_start..];
612 (before_done, done_items, after_done)
613}
614
615fn split_task_frontmatter(content: &str) -> Result<(&str, &str)> {
616 let trimmed = content.trim_start();
617 if !trimmed.starts_with("---") {
618 return Err(BoardError::InvalidFrontmatter {
619 detail: "no opening ---".to_string(),
620 }
621 .into());
622 }
623
624 let after_open = &trimmed[3..];
625 let after_open = after_open.strip_prefix('\n').unwrap_or(after_open);
626 let close_pos = after_open
627 .find("\n---")
628 .context("task file missing closing --- for frontmatter")?;
629
630 let frontmatter = &after_open[..close_pos];
631 let body = &after_open[close_pos + 4..];
632 Ok((frontmatter, body.strip_prefix('\n').unwrap_or(body)))
633}
634
635#[derive(Debug, Clone, Copy)]
636enum AgeAnchor {
637 Started,
638 Updated,
639}
640
641fn task_age_from_frontmatter(task: &Task, now: DateTime<Utc>, anchor: AgeAnchor) -> Result<u64> {
642 let content = std::fs::read_to_string(&task.source_path)
643 .with_context(|| format!("failed to read {}", task.source_path.display()))?;
644 let (frontmatter, _) = split_task_frontmatter(&content)?;
645 let parsed: TaskTimestampFrontmatter =
646 serde_yaml::from_str(frontmatter).context("failed to parse task timestamp frontmatter")?;
647
648 let timestamp = match anchor {
649 AgeAnchor::Started => parsed.started.or(parsed.updated).or(parsed.created),
650 AgeAnchor::Updated => parsed.updated.or(parsed.started).or(parsed.created),
651 };
652
653 Ok(timestamp
654 .as_deref()
655 .and_then(parse_task_timestamp)
656 .map(|value| now.signed_duration_since(value).num_seconds().max(0) as u64)
657 .unwrap_or(0))
658}
659
660fn parse_task_timestamp(value: &str) -> Option<DateTime<Utc>> {
661 parse_frontmatter_timestamp_compat(value)
662}
663
664fn commits_ahead_of_main(project_root: &Path, task: &Task) -> Result<u32> {
665 if let Some(worktree_path) = task.worktree_path.as_deref() {
666 let worktree_dir = resolve_task_path(project_root, worktree_path);
667 if worktree_dir.is_dir() {
668 return crate::team::git_cmd::rev_list_count(&worktree_dir, "main..HEAD")
669 .map_err(Into::into);
670 }
671 }
672
673 if let Some(owner) = task.claimed_by.as_deref() {
674 let worktree_dir = project_root.join(".batty").join("worktrees").join(owner);
675 if worktree_dir.is_dir() {
676 return crate::team::git_cmd::rev_list_count(&worktree_dir, "main..HEAD")
677 .map_err(Into::into);
678 }
679 }
680
681 if let Some(branch) = task.branch.as_deref()
682 && !branch.is_empty()
683 {
684 return crate::team::git_cmd::rev_list_count(project_root, &format!("main..{branch}"))
685 .map_err(Into::into);
686 }
687
688 Ok(0)
689}
690
691fn resolve_task_path(project_root: &Path, value: &str) -> PathBuf {
692 let path = PathBuf::from(value);
693 if path.is_absolute() {
694 path
695 } else {
696 project_root.join(path)
697 }
698}
699
700fn yaml_key(name: &str) -> Value {
701 Value::String(name.to_string())
702}
703
704fn set_optional_string(mapping: &mut Mapping, key: &str, value: Option<&str>) {
705 let key = yaml_key(key);
706 match value {
707 Some(value) => {
708 mapping.insert(key, Value::String(value.to_string()));
709 }
710 None => {
711 mapping.remove(&key);
712 }
713 }
714}
715
716fn set_optional_bool(mapping: &mut Mapping, key: &str, value: Option<bool>) {
717 let key = yaml_key(key);
718 match value {
719 Some(value) => {
720 mapping.insert(key, Value::Bool(value));
721 }
722 None => {
723 mapping.remove(&key);
724 }
725 }
726}
727
728fn set_string_list(mapping: &mut Mapping, key: &str, values: &[String]) {
729 let key = yaml_key(key);
730 if values.is_empty() {
731 mapping.remove(&key);
732 return;
733 }
734
735 mapping.insert(
736 key,
737 Value::Sequence(
738 values
739 .iter()
740 .map(|value| Value::String(value.clone()))
741 .collect(),
742 ),
743 );
744}
745
746fn set_optional_value<T>(mapping: &mut Mapping, key: &str, value: Option<&T>) -> Result<()>
747where
748 T: serde::Serialize,
749{
750 let key = yaml_key(key);
751 match value {
752 Some(value) => {
753 mapping.insert(
754 key,
755 serde_yaml::to_value(value)
756 .context("failed to serialize workflow metadata value")?,
757 );
758 }
759 None => {
760 mapping.remove(&key);
761 }
762 }
763 Ok(())
764}
765
766#[cfg(test)]
767mod tests {
768 use super::*;
769
770 #[test]
771 fn split_done_section_basic() {
772 let content =
773 "# Board\n\n## Backlog\n\n## In Progress\n\n## Done\n- item 1\n- item 2\n- item 3\n";
774 let (before, items, after) = split_done_section(content);
775 assert!(before.contains("## In Progress"));
776 assert_eq!(items.len(), 3);
777 assert_eq!(items[0], "- item 1");
778 assert!(after.is_empty());
779 }
780
781 #[test]
782 fn split_done_section_with_following_section() {
783 let content = "## Done\n- a\n- b\n## Archive\nstuff\n";
784 let (_, items, after) = split_done_section(content);
785 assert_eq!(items.len(), 2);
786 assert!(after.contains("## Archive"));
787 }
788
789 #[test]
790 fn split_done_section_empty() {
791 let content = "## Done\n\n## Other\n";
792 let (_, items, _) = split_done_section(content);
793 assert!(items.is_empty());
794 }
795
796 #[test]
797 fn split_done_section_no_done_header() {
798 let content = "# Board\n## Backlog\n- task\n";
799 let (before, items, _) = split_done_section(content);
800 assert_eq!(before, content);
801 assert!(items.is_empty());
802 }
803
804 #[test]
805 fn read_task_lifecycle_timestamps_parses_all_fields() {
806 let tmp = tempfile::tempdir().unwrap();
807 let task_path = tmp.path().join("001-lifecycle.md");
808 std::fs::write(
809 &task_path,
810 "---\nid: 1\ntitle: lifecycle\nstatus: done\npriority: high\ncreated: 2026-04-05T10:00:00-04:00\nstarted: 2026-04-05T11:00:00-04:00\ncompleted: 2026-04-05T12:30:00-04:00\n---\n\nBody.\n",
811 )
812 .unwrap();
813
814 let timestamps = read_task_lifecycle_timestamps(&task_path).unwrap();
815 assert_eq!(
816 timestamps.created.unwrap().to_rfc3339(),
817 "2026-04-05T10:00:00-04:00"
818 );
819 assert_eq!(
820 timestamps.started.unwrap().to_rfc3339(),
821 "2026-04-05T11:00:00-04:00"
822 );
823 assert_eq!(
824 timestamps.completed.unwrap().to_rfc3339(),
825 "2026-04-05T12:30:00-04:00"
826 );
827 }
828
829 #[test]
830 fn read_task_lifecycle_timestamps_ignores_invalid_values() {
831 let tmp = tempfile::tempdir().unwrap();
832 let task_path = tmp.path().join("002-invalid.md");
833 std::fs::write(
834 &task_path,
835 "---\nid: 2\ntitle: lifecycle\nstatus: in-progress\npriority: medium\ncreated: not-a-timestamp\nstarted: 2026-04-05T11:00:00-04:00\n---\n\nBody.\n",
836 )
837 .unwrap();
838
839 let timestamps = read_task_lifecycle_timestamps(&task_path).unwrap();
840 assert!(timestamps.created.is_none());
841 assert_eq!(
842 timestamps.started.unwrap().to_rfc3339(),
843 "2026-04-05T11:00:00-04:00"
844 );
845 assert!(timestamps.completed.is_none());
846 }
847
848 #[test]
849 fn read_task_lifecycle_timestamps_accepts_legacy_offset_values() {
850 let tmp = tempfile::tempdir().unwrap();
851 let task_path = tmp.path().join("623-legacy-offset.md");
852 std::fs::write(
853 &task_path,
854 "---\nid: 623\ntitle: lifecycle\nstatus: review\npriority: high\ncreated: 2026-04-10T16:31:02.743151-04:00\nstarted: 2026-04-10T17:00:00-0400\ncompleted: 2026-04-10T19:26:40-0400\n---\n\nBody.\n",
855 )
856 .unwrap();
857
858 let timestamps = read_task_lifecycle_timestamps(&task_path).unwrap();
859 assert_eq!(
860 timestamps.started.unwrap().to_rfc3339(),
861 "2026-04-10T17:00:00-04:00"
862 );
863 assert_eq!(
864 timestamps.completed.unwrap().to_rfc3339(),
865 "2026-04-10T19:26:40-04:00"
866 );
867 }
868
869 #[test]
870 fn rotate_moves_excess_items() {
871 let tmp = tempfile::tempdir().unwrap();
872 let kanban = tmp.path().join("kanban.md");
873 let archive = tmp.path().join("archive.md");
874
875 std::fs::write(
876 &kanban,
877 "## Backlog\n\n## In Progress\n\n## Done\n- old 1\n- old 2\n- old 3\n- new 1\n- new 2\n",
878 )
879 .unwrap();
880
881 let rotated = rotate_done_items(&kanban, &archive, 2).unwrap();
882 assert_eq!(rotated, 3);
883
884 let kanban_content = std::fs::read_to_string(&kanban).unwrap();
885 assert!(kanban_content.contains("- new 1"));
886 assert!(kanban_content.contains("- new 2"));
887 assert!(!kanban_content.contains("- old 1"));
888
889 let archive_content = std::fs::read_to_string(&archive).unwrap();
890 assert!(archive_content.contains("- old 1"));
891 assert!(archive_content.contains("- old 2"));
892 assert!(archive_content.contains("- old 3"));
893 }
894
895 #[test]
896 fn rotate_does_nothing_under_threshold() {
897 let tmp = tempfile::tempdir().unwrap();
898 let kanban = tmp.path().join("kanban.md");
899 let archive = tmp.path().join("archive.md");
900
901 std::fs::write(&kanban, "## Done\n- item 1\n- item 2\n").unwrap();
902
903 let rotated = rotate_done_items(&kanban, &archive, 5).unwrap();
904 assert_eq!(rotated, 0);
905 assert!(!archive.exists());
906 }
907
908 #[test]
909 fn rotate_appends_to_existing_archive() {
910 let tmp = tempfile::tempdir().unwrap();
911 let kanban = tmp.path().join("kanban.md");
912 let archive = tmp.path().join("archive.md");
913
914 std::fs::write(&archive, "# Kanban Archive\n- previous\n").unwrap();
915 std::fs::write(&kanban, "## Done\n- a\n- b\n- c\n").unwrap();
916
917 let rotated = rotate_done_items(&kanban, &archive, 1).unwrap();
918 assert_eq!(rotated, 2);
919
920 let archive_content = std::fs::read_to_string(&archive).unwrap();
921 assert!(archive_content.contains("- previous"));
922 assert!(archive_content.contains("- a"));
923 assert!(archive_content.contains("- b"));
924 }
925
926 #[test]
927 fn read_workflow_metadata_defaults_when_fields_are_missing() {
928 let tmp = tempfile::tempdir().unwrap();
929 let task = tmp.path().join("027-task.md");
930 std::fs::write(
931 &task,
932 "---\nid: 27\ntitle: Completion packets\nstatus: in-progress\npriority: medium\nclass: standard\n---\n\nTask body.\n",
933 )
934 .unwrap();
935
936 assert_eq!(
937 read_workflow_metadata(&task).unwrap(),
938 WorkflowMetadata::default()
939 );
940 }
941
942 #[test]
943 fn read_workflow_metadata_parses_all_completion_fields() {
944 let tmp = tempfile::tempdir().unwrap();
945 let task = tmp.path().join("027-task.md");
946 std::fs::write(
947 &task,
948 "---\nid: 27\ntitle: Completion packets\nstatus: review\npriority: medium\nclass: standard\nbranch: eng-1-4/task-27\nworktree_path: .batty/worktrees/eng-1-4\ncommit: abc1234\nchanged_paths:\n - src/team/completion.rs\ntests_run: true\ntests_passed: false\nartifacts:\n - docs/workflow.md\noutcome: ready_for_review\nreview_blockers:\n - missing screenshots\n---\n\nTask body.\n",
949 )
950 .unwrap();
951
952 let metadata = read_workflow_metadata(&task).unwrap();
953 assert_eq!(metadata.branch.as_deref(), Some("eng-1-4/task-27"));
954 assert_eq!(
955 metadata.worktree_path.as_deref(),
956 Some(".batty/worktrees/eng-1-4")
957 );
958 assert_eq!(metadata.commit.as_deref(), Some("abc1234"));
959 assert_eq!(metadata.changed_paths, vec!["src/team/completion.rs"]);
960 assert_eq!(metadata.tests_run, Some(true));
961 assert_eq!(metadata.tests_passed, Some(false));
962 assert_eq!(
963 metadata.test_results.as_ref().map(|results| results.failed),
964 None
965 );
966 assert_eq!(metadata.artifacts, vec!["docs/workflow.md"]);
967 assert_eq!(metadata.outcome.as_deref(), Some("ready_for_review"));
968 assert_eq!(metadata.review_blockers, vec!["missing screenshots"]);
969 }
970
971 #[test]
972 fn write_workflow_metadata_preserves_body_and_other_frontmatter() {
973 let tmp = tempfile::tempdir().unwrap();
974 let task = tmp.path().join("027-task.md");
975 std::fs::write(
976 &task,
977 "---\nid: 27\ntitle: Completion packets\nstatus: review\npriority: medium\nclaimed_by: eng-1-4\nclass: standard\n---\n\nTask body.\n",
978 )
979 .unwrap();
980
981 let metadata = WorkflowMetadata {
982 branch: Some("eng-1-4/task-27".to_string()),
983 worktree_path: Some(".batty/worktrees/eng-1-4".to_string()),
984 commit: Some("abc1234".to_string()),
985 changed_paths: vec!["src/team/completion.rs".to_string()],
986 tests_run: Some(true),
987 tests_passed: Some(true),
988 test_results: Some(TestResults {
989 framework: "cargo".to_string(),
990 total: Some(3),
991 passed: 2,
992 failed: 1,
993 ignored: 0,
994 failures: vec![super::super::test_results::TestFailure {
995 test_name: "tests::fails".to_string(),
996 message: Some("assertion failed".to_string()),
997 location: Some("src/team/completion.rs:10".to_string()),
998 }],
999 summary: Some("test result: FAILED. 2 passed; 1 failed; 0 ignored;".to_string()),
1000 }),
1001 artifacts: vec!["docs/workflow.md".to_string()],
1002 outcome: Some("ready_for_review".to_string()),
1003 review_blockers: vec!["missing screenshots".to_string()],
1004 };
1005
1006 write_workflow_metadata(&task, &metadata).unwrap();
1007
1008 let content = std::fs::read_to_string(&task).unwrap();
1009 assert!(content.contains("claimed_by: eng-1-4"));
1010 assert!(content.contains("branch: eng-1-4/task-27"));
1011 assert!(content.contains("tests_run: true"));
1012 assert!(content.contains("tests_passed: true"));
1013 assert!(content.contains("test_results:"));
1014 assert!(content.contains("review_blockers:"));
1015 assert!(content.contains("Task body."));
1016 assert_eq!(read_workflow_metadata(&task).unwrap(), metadata);
1017 }
1018
1019 #[test]
1020 fn write_workflow_metadata_removes_empty_fields() {
1021 let tmp = tempfile::tempdir().unwrap();
1022 let task = tmp.path().join("027-task.md");
1023 std::fs::write(
1024 &task,
1025 "---\nid: 27\ntitle: Completion packets\nstatus: review\npriority: medium\nclass: standard\nbranch: eng-1-4/task-27\nworktree_path: .batty/worktrees/eng-1-4\ncommit: abc1234\nchanged_paths:\n - src/team/completion.rs\ntests_run: true\ntests_passed: true\nartifacts:\n - docs/workflow.md\noutcome: ready_for_review\nreview_blockers:\n - missing screenshots\n---\n\nTask body.\n",
1026 )
1027 .unwrap();
1028
1029 write_workflow_metadata(&task, &WorkflowMetadata::default()).unwrap();
1030
1031 let content = std::fs::read_to_string(&task).unwrap();
1032 assert!(!content.contains("branch:"));
1033 assert!(!content.contains("worktree_path:"));
1034 assert!(!content.contains("commit:"));
1035 assert!(!content.contains("changed_paths:"));
1036 assert!(!content.contains("tests_run:"));
1037 assert!(!content.contains("tests_passed:"));
1038 assert!(!content.contains("test_results:"));
1039 assert!(!content.contains("artifacts:"));
1040 assert!(!content.contains("outcome:"));
1041 assert!(!content.contains("review_blockers:"));
1042 assert!(content.contains("class: standard"));
1043 }
1044
1045 fn write_task_file(dir: &Path, filename: &str, id: u32, status: &str, completed: Option<&str>) {
1046 let completed_line = completed
1047 .map(|c| format!("completed: {c}\n"))
1048 .unwrap_or_default();
1049 let content = format!(
1050 "---\nid: {id}\ntitle: task {id}\nstatus: {status}\npriority: medium\n{completed_line}class: standard\n---\n\nTask body.\n"
1051 );
1052 std::fs::write(dir.join(filename), content).unwrap();
1053 }
1054
1055 #[test]
1056 fn archive_moves_all_done_tasks() {
1057 let tmp = tempfile::tempdir().unwrap();
1058 let board_dir = tmp.path().join("board");
1059 let tasks_dir = board_dir.join("tasks");
1060 std::fs::create_dir_all(&tasks_dir).unwrap();
1061
1062 write_task_file(
1063 &tasks_dir,
1064 "001-done.md",
1065 1,
1066 "done",
1067 Some("2026-03-20T10:00:00-04:00"),
1068 );
1069 write_task_file(&tasks_dir, "002-progress.md", 2, "in-progress", None);
1070 write_task_file(
1071 &tasks_dir,
1072 "003-done.md",
1073 3,
1074 "done",
1075 Some("2026-03-21T10:00:00-04:00"),
1076 );
1077
1078 let count = archive_done_tasks(&board_dir, None).unwrap();
1079 assert_eq!(count, 2);
1080
1081 let archive_dir = board_dir.join("archive");
1083 assert!(archive_dir.join("001-done.md").exists());
1084 assert!(archive_dir.join("003-done.md").exists());
1085
1086 assert!(tasks_dir.join("002-progress.md").exists());
1088 assert!(!tasks_dir.join("001-done.md").exists());
1089 assert!(!tasks_dir.join("003-done.md").exists());
1090
1091 let archived = std::fs::read_to_string(archive_dir.join("001-done.md")).unwrap();
1093 assert!(archived.contains("status: archived"));
1094 }
1095
1096 #[test]
1097 fn archive_with_older_than_filters_by_date() {
1098 let tmp = tempfile::tempdir().unwrap();
1099 let board_dir = tmp.path().join("board");
1100 let tasks_dir = board_dir.join("tasks");
1101 std::fs::create_dir_all(&tasks_dir).unwrap();
1102
1103 write_task_file(
1104 &tasks_dir,
1105 "001-old.md",
1106 1,
1107 "done",
1108 Some("2026-03-10T10:00:00-04:00"),
1109 );
1110 write_task_file(
1111 &tasks_dir,
1112 "002-recent.md",
1113 2,
1114 "done",
1115 Some("2026-03-21T10:00:00-04:00"),
1116 );
1117
1118 let count = archive_done_tasks(&board_dir, Some("2026-03-15")).unwrap();
1119 assert_eq!(count, 1);
1120
1121 let archive_dir = board_dir.join("archive");
1122 assert!(archive_dir.join("001-old.md").exists());
1123 assert!(!archive_dir.join("002-recent.md").exists());
1124 assert!(tasks_dir.join("002-recent.md").exists());
1125 }
1126
1127 #[test]
1128 fn archive_creates_directory_if_missing() {
1129 let tmp = tempfile::tempdir().unwrap();
1130 let board_dir = tmp.path().join("board");
1131 let tasks_dir = board_dir.join("tasks");
1132 std::fs::create_dir_all(&tasks_dir).unwrap();
1133
1134 write_task_file(
1135 &tasks_dir,
1136 "001-done.md",
1137 1,
1138 "done",
1139 Some("2026-03-20T10:00:00-04:00"),
1140 );
1141
1142 let archive_dir = board_dir.join("archive");
1143 assert!(!archive_dir.exists());
1144
1145 let count = archive_done_tasks(&board_dir, None).unwrap();
1146 assert_eq!(count, 1);
1147 assert!(archive_dir.is_dir());
1148 }
1149
1150 #[test]
1151 fn archive_returns_zero_when_no_done_tasks() {
1152 let tmp = tempfile::tempdir().unwrap();
1153 let board_dir = tmp.path().join("board");
1154 let tasks_dir = board_dir.join("tasks");
1155 std::fs::create_dir_all(&tasks_dir).unwrap();
1156
1157 write_task_file(&tasks_dir, "001-progress.md", 1, "in-progress", None);
1158 write_task_file(&tasks_dir, "002-todo.md", 2, "todo", None);
1159
1160 let count = archive_done_tasks(&board_dir, None).unwrap();
1161 assert_eq!(count, 0);
1162 assert!(!board_dir.join("archive").exists());
1163 }
1164
1165 #[test]
1166 fn archive_skips_done_tasks_without_completed_date_when_older_than_set() {
1167 let tmp = tempfile::tempdir().unwrap();
1168 let board_dir = tmp.path().join("board");
1169 let tasks_dir = board_dir.join("tasks");
1170 std::fs::create_dir_all(&tasks_dir).unwrap();
1171
1172 write_task_file(&tasks_dir, "001-no-date.md", 1, "done", None);
1174 write_task_file(
1175 &tasks_dir,
1176 "002-old.md",
1177 2,
1178 "done",
1179 Some("2026-01-01T00:00:00+00:00"),
1180 );
1181
1182 let count = archive_done_tasks(&board_dir, Some("2026-03-01")).unwrap();
1183 assert_eq!(count, 1);
1184
1185 assert!(tasks_dir.join("001-no-date.md").exists());
1186 assert!(board_dir.join("archive/002-old.md").exists());
1187 }
1188
1189 #[test]
1190 fn archive_excludes_tasks_from_listing() {
1191 let tmp = tempfile::tempdir().unwrap();
1192 let board_dir = tmp.path().join("board");
1193 let tasks_dir = board_dir.join("tasks");
1194 std::fs::create_dir_all(&tasks_dir).unwrap();
1195
1196 write_task_file(
1197 &tasks_dir,
1198 "001-done.md",
1199 1,
1200 "done",
1201 Some("2026-03-20T10:00:00-04:00"),
1202 );
1203 write_task_file(&tasks_dir, "002-todo.md", 2, "todo", None);
1204
1205 archive_done_tasks(&board_dir, None).unwrap();
1206
1207 let tasks = load_tasks_from_dir(&tasks_dir).unwrap();
1209 assert_eq!(tasks.len(), 1);
1210 assert_eq!(tasks[0].id, 2);
1211 }
1212
1213 #[test]
1214 fn parse_cutoff_date_accepts_yyyy_mm_dd() {
1215 let dt = parse_cutoff_date("2026-03-15").unwrap();
1216 assert_eq!(
1217 dt.date_naive(),
1218 NaiveDate::from_ymd_opt(2026, 3, 15).unwrap()
1219 );
1220 }
1221
1222 #[test]
1223 fn parse_cutoff_date_accepts_rfc3339() {
1224 let dt = parse_cutoff_date("2026-03-15T10:30:00-04:00").unwrap();
1225 assert_eq!(
1226 dt.date_naive(),
1227 NaiveDate::from_ymd_opt(2026, 3, 15).unwrap()
1228 );
1229 }
1230
1231 #[test]
1232 fn parse_cutoff_date_rejects_invalid() {
1233 assert!(parse_cutoff_date("not-a-date").is_err());
1234 }
1235
1236 #[test]
1237 fn update_task_status_changes_status_field() {
1238 let tmp = tempfile::tempdir().unwrap();
1239 let task = tmp.path().join("001-task.md");
1240 std::fs::write(
1241 &task,
1242 "---\nid: 1\ntitle: test task\nstatus: done\npriority: medium\n---\n\nBody.\n",
1243 )
1244 .unwrap();
1245
1246 update_task_status(&task, "archived").unwrap();
1247
1248 let content = std::fs::read_to_string(&task).unwrap();
1249 assert!(content.contains("status: archived"));
1250 assert!(!content.contains("status: done"));
1251 assert!(content.contains("Body."));
1252 }
1253
1254 #[test]
1257 fn parse_age_threshold_days() {
1258 let dur = parse_age_threshold("7d").unwrap();
1259 assert_eq!(dur, Duration::from_secs(7 * 86400));
1260 }
1261
1262 #[test]
1263 fn parse_age_threshold_hours() {
1264 let dur = parse_age_threshold("24h").unwrap();
1265 assert_eq!(dur, Duration::from_secs(24 * 3600));
1266 }
1267
1268 #[test]
1269 fn parse_age_threshold_weeks() {
1270 let dur = parse_age_threshold("2w").unwrap();
1271 assert_eq!(dur, Duration::from_secs(14 * 86400));
1272 }
1273
1274 #[test]
1275 fn parse_age_threshold_zero() {
1276 let dur = parse_age_threshold("0s").unwrap();
1277 assert_eq!(dur, Duration::from_secs(0));
1278 }
1279
1280 #[test]
1281 fn parse_age_threshold_invalid() {
1282 assert!(parse_age_threshold("abc").is_err());
1283 }
1284
1285 #[test]
1288 fn done_tasks_older_than_filters_correctly() {
1289 let tmp = tempfile::tempdir().unwrap();
1290 let board_dir = tmp.path().join("board");
1291 let tasks_dir = board_dir.join("tasks");
1292 std::fs::create_dir_all(&tasks_dir).unwrap();
1293
1294 write_task_file(
1296 &tasks_dir,
1297 "001-old.md",
1298 1,
1299 "done",
1300 Some("2020-01-01T00:00:00+00:00"),
1301 );
1302 let now = Utc::now();
1304 let recent = now.format("%Y-%m-%dT%H:%M:%S+00:00").to_string();
1305 write_task_file(&tasks_dir, "002-recent.md", 2, "done", Some(&recent));
1306
1307 let tasks = done_tasks_older_than(&board_dir, Duration::from_secs(7 * 86400)).unwrap();
1308 assert_eq!(tasks.len(), 1);
1309 assert_eq!(tasks[0].id, 1);
1310 }
1311
1312 #[test]
1315 fn archive_tasks_moves_files() {
1316 let tmp = tempfile::tempdir().unwrap();
1317 let board_dir = tmp.path().join("board");
1318 let tasks_dir = board_dir.join("tasks");
1319 std::fs::create_dir_all(&tasks_dir).unwrap();
1320
1321 write_task_file(
1322 &tasks_dir,
1323 "001-done.md",
1324 1,
1325 "done",
1326 Some("2026-03-20T10:00:00+00:00"),
1327 );
1328
1329 let tasks = load_tasks_from_dir(&tasks_dir).unwrap();
1330 let summary = archive_tasks(&board_dir, &tasks, false).unwrap();
1331
1332 assert_eq!(summary.archived_count, 1);
1333 assert_eq!(summary.skipped_count, 0);
1334
1335 let archive_dir = board_dir.join("archive");
1337 assert!(archive_dir.join("001-done.md").exists());
1338 assert!(!tasks_dir.join("001-done.md").exists());
1339
1340 let content = std::fs::read_to_string(archive_dir.join("001-done.md")).unwrap();
1342 assert!(content.contains("status: done"));
1343 assert!(content.contains("Task body."));
1344 }
1345
1346 #[test]
1347 fn archive_tasks_creates_archive_dir() {
1348 let tmp = tempfile::tempdir().unwrap();
1349 let board_dir = tmp.path().join("board");
1350 let tasks_dir = board_dir.join("tasks");
1351 std::fs::create_dir_all(&tasks_dir).unwrap();
1352
1353 write_task_file(
1354 &tasks_dir,
1355 "001-done.md",
1356 1,
1357 "done",
1358 Some("2026-03-20T10:00:00+00:00"),
1359 );
1360
1361 let archive_dir = board_dir.join("archive");
1362 assert!(!archive_dir.exists());
1363
1364 let tasks = load_tasks_from_dir(&tasks_dir).unwrap();
1365 archive_tasks(&board_dir, &tasks, false).unwrap();
1366
1367 assert!(archive_dir.is_dir());
1368 }
1369
1370 #[test]
1371 fn archive_tasks_dry_run_does_not_move() {
1372 let tmp = tempfile::tempdir().unwrap();
1373 let board_dir = tmp.path().join("board");
1374 let tasks_dir = board_dir.join("tasks");
1375 std::fs::create_dir_all(&tasks_dir).unwrap();
1376
1377 write_task_file(
1378 &tasks_dir,
1379 "001-done.md",
1380 1,
1381 "done",
1382 Some("2026-03-20T10:00:00+00:00"),
1383 );
1384
1385 let tasks = load_tasks_from_dir(&tasks_dir).unwrap();
1386 let summary = archive_tasks(&board_dir, &tasks, true).unwrap();
1387
1388 assert_eq!(summary.archived_count, 1);
1389 assert!(tasks_dir.join("001-done.md").exists());
1391 assert!(!board_dir.join("archive").exists());
1393 }
1394
1395 #[test]
1396 fn archive_tasks_skips_non_done() {
1397 let tmp = tempfile::tempdir().unwrap();
1398 let board_dir = tmp.path().join("board");
1399 let tasks_dir = board_dir.join("tasks");
1400 std::fs::create_dir_all(&tasks_dir).unwrap();
1401
1402 write_task_file(&tasks_dir, "001-progress.md", 1, "in-progress", None);
1403 write_task_file(&tasks_dir, "002-todo.md", 2, "todo", None);
1404
1405 let tasks = done_tasks_older_than(&board_dir, Duration::from_secs(0)).unwrap();
1407 assert!(tasks.is_empty());
1408
1409 let summary = archive_tasks(&board_dir, &tasks, false).unwrap();
1410 assert_eq!(summary.archived_count, 0);
1411 assert!(!board_dir.join("archive").exists());
1412 }
1413
1414 #[test]
1415 fn archive_preserves_file_content() {
1416 let tmp = tempfile::tempdir().unwrap();
1417 let board_dir = tmp.path().join("board");
1418 let tasks_dir = board_dir.join("tasks");
1419 std::fs::create_dir_all(&tasks_dir).unwrap();
1420
1421 write_task_file(
1422 &tasks_dir,
1423 "042-done.md",
1424 42,
1425 "done",
1426 Some("2026-03-15T08:00:00+00:00"),
1427 );
1428
1429 let original_bytes = std::fs::read(tasks_dir.join("042-done.md")).unwrap();
1430
1431 let tasks = load_tasks_from_dir(&tasks_dir).unwrap();
1432 archive_tasks(&board_dir, &tasks, false).unwrap();
1433
1434 let archived_bytes = std::fs::read(board_dir.join("archive").join("042-done.md")).unwrap();
1435 assert_eq!(
1436 original_bytes, archived_bytes,
1437 "archived file bytes must match original exactly"
1438 );
1439 }
1440
1441 #[test]
1442 fn archive_summary_counts_correct() {
1443 let tmp = tempfile::tempdir().unwrap();
1444 let board_dir = tmp.path().join("board");
1445 let tasks_dir = board_dir.join("tasks");
1446 std::fs::create_dir_all(&tasks_dir).unwrap();
1447
1448 write_task_file(
1449 &tasks_dir,
1450 "010-done.md",
1451 10,
1452 "done",
1453 Some("2026-03-01T00:00:00+00:00"),
1454 );
1455 write_task_file(
1456 &tasks_dir,
1457 "011-done.md",
1458 11,
1459 "done",
1460 Some("2026-03-02T00:00:00+00:00"),
1461 );
1462 write_task_file(
1463 &tasks_dir,
1464 "012-done.md",
1465 12,
1466 "done",
1467 Some("2026-03-03T00:00:00+00:00"),
1468 );
1469
1470 let tasks = load_tasks_from_dir(&tasks_dir).unwrap();
1471 let done_tasks: Vec<_> = tasks.into_iter().filter(|t| t.status == "done").collect();
1472 assert_eq!(done_tasks.len(), 3);
1473
1474 let summary = archive_tasks(&board_dir, &done_tasks, false).unwrap();
1475 assert_eq!(summary.archived_count, 3);
1476 assert_eq!(summary.skipped_count, 0);
1477 assert_eq!(summary.archive_dir, board_dir.join("archive"));
1478 }
1479
1480 #[test]
1481 fn archive_handles_empty_board() {
1482 let tmp = tempfile::tempdir().unwrap();
1483 let board_dir = tmp.path().join("board");
1484 std::fs::create_dir_all(&board_dir).unwrap();
1486
1487 let empty: Vec<Task> = vec![];
1488 let summary = archive_tasks(&board_dir, &empty, false).unwrap();
1489 assert_eq!(summary.archived_count, 0);
1490 assert_eq!(summary.skipped_count, 0);
1491 assert!(!board_dir.join("archive").exists());
1493 }
1494
1495 #[allow(clippy::too_many_arguments)]
1496 fn write_timed_task(
1497 board_dir: &Path,
1498 id: u32,
1499 title: &str,
1500 status: &str,
1501 claimed_by: Option<&str>,
1502 created: &str,
1503 started: Option<&str>,
1504 updated: Option<&str>,
1505 ) {
1506 let tasks_dir = board_dir.join("tasks");
1507 std::fs::create_dir_all(&tasks_dir).unwrap();
1508 let mut content = format!(
1509 "---\nid: {id}\ntitle: {title}\nstatus: {status}\npriority: medium\ncreated: {created}\n"
1510 );
1511 if let Some(started) = started {
1512 content.push_str(&format!("started: {started}\n"));
1513 }
1514 if let Some(updated) = updated {
1515 content.push_str(&format!("updated: {updated}\n"));
1516 }
1517 if let Some(claimed_by) = claimed_by {
1518 content.push_str(&format!("claimed_by: {claimed_by}\n"));
1519 }
1520 content.push_str("class: standard\n---\n\nTask body.\n");
1521 std::fs::write(tasks_dir.join(format!("{id:03}-{title}.md")), content).unwrap();
1522 }
1523
1524 #[test]
1525 fn aging_flags_tasks_at_threshold() {
1526 let tmp = tempfile::tempdir().unwrap();
1527 let board_dir = tmp.path().join(".batty").join("team_config").join("board");
1528 let now = DateTime::parse_from_rfc3339("2026-04-06T12:00:00Z")
1529 .unwrap()
1530 .with_timezone(&Utc);
1531 write_timed_task(
1532 &board_dir,
1533 1,
1534 "stale-progress",
1535 "in-progress",
1536 Some("eng-1"),
1537 "2026-04-06T08:00:00Z",
1538 Some("2026-04-06T08:00:00Z"),
1539 Some("2026-04-06T08:00:00Z"),
1540 );
1541 write_timed_task(
1542 &board_dir,
1543 2,
1544 "aged-todo",
1545 "todo",
1546 None,
1547 "2026-04-04T12:00:00Z",
1548 None,
1549 Some("2026-04-04T12:00:00Z"),
1550 );
1551 write_timed_task(
1552 &board_dir,
1553 3,
1554 "stale-review",
1555 "review",
1556 Some("eng-2"),
1557 "2026-04-06T11:00:00Z",
1558 None,
1559 Some("2026-04-06T11:00:00Z"),
1560 );
1561
1562 let report =
1563 compute_task_aging_at(&board_dir, tmp.path(), AgingThresholds::default(), now).unwrap();
1564
1565 assert_eq!(report.stale_in_progress.len(), 1);
1566 assert_eq!(report.stale_in_progress[0].task_id, 1);
1567 assert_eq!(report.aged_todo.len(), 1);
1568 assert_eq!(report.aged_todo[0].task_id, 2);
1569 assert_eq!(report.stale_review.len(), 1);
1570 assert_eq!(report.stale_review[0].task_id, 3);
1571 }
1572
1573 #[test]
1574 fn aging_ignores_fresh_tasks() {
1575 let tmp = tempfile::tempdir().unwrap();
1576 let board_dir = tmp.path().join(".batty").join("team_config").join("board");
1577 let now = DateTime::parse_from_rfc3339("2026-04-06T12:00:00Z")
1578 .unwrap()
1579 .with_timezone(&Utc);
1580 write_timed_task(
1581 &board_dir,
1582 1,
1583 "fresh-progress",
1584 "in-progress",
1585 Some("eng-1"),
1586 "2026-04-06T08:00:01Z",
1587 Some("2026-04-06T08:00:01Z"),
1588 Some("2026-04-06T08:00:01Z"),
1589 );
1590 write_timed_task(
1591 &board_dir,
1592 2,
1593 "fresh-todo",
1594 "todo",
1595 None,
1596 "2026-04-04T12:00:01Z",
1597 None,
1598 Some("2026-04-04T12:00:01Z"),
1599 );
1600 write_timed_task(
1601 &board_dir,
1602 3,
1603 "fresh-review",
1604 "review",
1605 Some("eng-2"),
1606 "2026-04-06T11:00:01Z",
1607 None,
1608 Some("2026-04-06T11:00:01Z"),
1609 );
1610
1611 let report =
1612 compute_task_aging_at(&board_dir, tmp.path(), AgingThresholds::default(), now).unwrap();
1613
1614 assert!(report.stale_in_progress.is_empty());
1615 assert!(report.aged_todo.is_empty());
1616 assert!(report.stale_review.is_empty());
1617 }
1618
1619 #[test]
1620 fn aging_respects_threshold_overrides() {
1621 let tmp = tempfile::tempdir().unwrap();
1622 let board_dir = tmp.path().join(".batty").join("team_config").join("board");
1623 let now = DateTime::parse_from_rfc3339("2026-04-06T12:00:00Z")
1624 .unwrap()
1625 .with_timezone(&Utc);
1626 write_timed_task(
1627 &board_dir,
1628 1,
1629 "progress",
1630 "in-progress",
1631 Some("eng-1"),
1632 "2026-04-06T10:30:00Z",
1633 Some("2026-04-06T10:30:00Z"),
1634 Some("2026-04-06T10:30:00Z"),
1635 );
1636 write_timed_task(
1637 &board_dir,
1638 2,
1639 "todo",
1640 "todo",
1641 None,
1642 "2026-04-05T12:00:00Z",
1643 None,
1644 Some("2026-04-05T12:00:00Z"),
1645 );
1646 write_timed_task(
1647 &board_dir,
1648 3,
1649 "review",
1650 "review",
1651 Some("eng-2"),
1652 "2026-04-06T10:30:00Z",
1653 None,
1654 Some("2026-04-06T10:30:00Z"),
1655 );
1656
1657 let report = compute_task_aging_at(
1658 &board_dir,
1659 tmp.path(),
1660 AgingThresholds {
1661 stale_in_progress_hours: 1,
1662 aged_todo_hours: 24,
1663 stale_review_hours: 1,
1664 },
1665 now,
1666 )
1667 .unwrap();
1668
1669 assert_eq!(report.stale_in_progress.len(), 1);
1670 assert_eq!(report.aged_todo.len(), 1);
1671 assert_eq!(report.stale_review.len(), 1);
1672 }
1673}