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 crate::task::{Task, load_tasks_from_dir};
14
15#[derive(Debug, Clone, Default, PartialEq, Eq)]
17pub(crate) struct WorkflowMetadata {
18 pub branch: Option<String>,
19 pub worktree_path: Option<String>,
20 pub commit: Option<String>,
21 pub changed_paths: Vec<String>,
22 pub tests_run: Option<bool>,
23 pub tests_passed: Option<bool>,
24 pub artifacts: Vec<String>,
25 pub outcome: Option<String>,
26 pub review_blockers: Vec<String>,
27}
28
29#[derive(Debug, Deserialize, Default)]
30struct WorkflowFrontmatter {
31 #[serde(default)]
32 branch: Option<String>,
33 #[serde(default)]
34 worktree_path: Option<String>,
35 #[serde(default)]
36 commit: Option<String>,
37 #[serde(default)]
38 changed_paths: Vec<String>,
39 #[serde(default)]
40 tests_run: Option<bool>,
41 #[serde(default)]
42 tests_passed: Option<bool>,
43 #[serde(default)]
44 artifacts: Vec<String>,
45 #[serde(default)]
46 outcome: Option<String>,
47 #[serde(default)]
48 review_blockers: Vec<String>,
49}
50
51impl From<WorkflowFrontmatter> for WorkflowMetadata {
52 fn from(frontmatter: WorkflowFrontmatter) -> Self {
53 Self {
54 branch: frontmatter.branch,
55 worktree_path: frontmatter.worktree_path,
56 commit: frontmatter.commit,
57 changed_paths: frontmatter.changed_paths,
58 tests_run: frontmatter.tests_run,
59 tests_passed: frontmatter.tests_passed,
60 artifacts: frontmatter.artifacts,
61 outcome: frontmatter.outcome,
62 review_blockers: frontmatter.review_blockers,
63 }
64 }
65}
66
67pub(crate) fn read_workflow_metadata(task_path: &Path) -> Result<WorkflowMetadata> {
68 let content = std::fs::read_to_string(task_path)
69 .with_context(|| format!("failed to read {}", task_path.display()))?;
70 let (frontmatter, _) = split_task_frontmatter(&content)?;
71 let parsed: WorkflowFrontmatter =
72 serde_yaml::from_str(frontmatter).context("failed to parse task frontmatter")?;
73 Ok(parsed.into())
74}
75
76pub(crate) fn write_workflow_metadata(task_path: &Path, metadata: &WorkflowMetadata) -> Result<()> {
77 let content = std::fs::read_to_string(task_path)
78 .with_context(|| format!("failed to read {}", task_path.display()))?;
79 let (frontmatter, body) = split_task_frontmatter(&content)?;
80 let mut mapping: Mapping =
81 serde_yaml::from_str(frontmatter).context("failed to parse task frontmatter")?;
82
83 set_optional_string(&mut mapping, "branch", metadata.branch.as_deref());
84 set_optional_string(
85 &mut mapping,
86 "worktree_path",
87 metadata.worktree_path.as_deref(),
88 );
89 set_optional_string(&mut mapping, "commit", metadata.commit.as_deref());
90 set_string_list(&mut mapping, "changed_paths", &metadata.changed_paths);
91 set_optional_bool(&mut mapping, "tests_run", metadata.tests_run);
92 set_optional_bool(&mut mapping, "tests_passed", metadata.tests_passed);
93 set_string_list(&mut mapping, "artifacts", &metadata.artifacts);
94 set_optional_string(&mut mapping, "outcome", metadata.outcome.as_deref());
95 set_string_list(&mut mapping, "review_blockers", &metadata.review_blockers);
96
97 let mut rendered =
98 serde_yaml::to_string(&mapping).context("failed to serialize task frontmatter")?;
99 if let Some(stripped) = rendered.strip_prefix("---\n") {
100 rendered = stripped.to_string();
101 }
102
103 let mut updated = String::from("---\n");
104 updated.push_str(&rendered);
105 if !updated.ends_with('\n') {
106 updated.push('\n');
107 }
108 updated.push_str("---\n");
109 updated.push_str(body);
110
111 std::fs::write(task_path, updated)
112 .with_context(|| format!("failed to write {}", task_path.display()))?;
113 Ok(())
114}
115
116#[derive(Debug, Clone, PartialEq, Eq)]
118pub struct ArchiveSummary {
119 pub archived_count: usize,
120 pub skipped_count: usize,
121 pub archive_dir: PathBuf,
122}
123
124pub fn parse_age_threshold(threshold: &str) -> Result<Duration> {
126 let threshold = threshold.trim();
127 if threshold.is_empty() {
128 bail!("empty age threshold");
129 }
130
131 let split_pos = threshold
132 .find(|c: char| !c.is_ascii_digit())
133 .unwrap_or(threshold.len());
134 let (digits, suffix) = threshold.split_at(split_pos);
135
136 if digits.is_empty() {
137 bail!("invalid age threshold: {threshold}");
138 }
139
140 let value: u64 = digits
141 .parse()
142 .with_context(|| format!("invalid age threshold: {threshold}"))?;
143
144 let seconds = match suffix {
145 "s" => value,
146 "m" => value * 60,
147 "h" => value * 3600,
148 "d" => value * 86400,
149 "w" => value * 86400 * 7,
150 _ => bail!("invalid age threshold suffix: {threshold} (expected s, m, h, d, or w)"),
151 };
152
153 Ok(Duration::from_secs(seconds))
154}
155
156pub fn done_tasks_older_than(board_dir: &Path, max_age: Duration) -> Result<Vec<Task>> {
158 let tasks_dir = board_dir.join("tasks");
159 if !tasks_dir.is_dir() {
160 bail!("no tasks directory found at {}", tasks_dir.display());
161 }
162
163 let tasks = load_tasks_from_dir(&tasks_dir)?;
164 let now = Utc::now();
165 let cutoff = now - chrono::Duration::from_std(max_age).unwrap_or(chrono::Duration::zero());
166
167 let matching: Vec<Task> = tasks
168 .into_iter()
169 .filter(|t| t.status == "done")
170 .filter(|t| {
171 if max_age.is_zero() {
172 return true;
173 }
174 match &t.completed {
175 Some(completed_str) => parse_completed_date(completed_str)
176 .map(|completed| completed < cutoff)
177 .unwrap_or(false),
178 None => {
179 std::fs::metadata(&t.source_path)
181 .and_then(|m| m.modified())
182 .ok()
183 .map(|mtime| {
184 let mtime_dt: DateTime<Utc> = mtime.into();
185 mtime_dt < cutoff
186 })
187 .unwrap_or(false)
188 }
189 }
190 })
191 .collect();
192
193 Ok(matching)
194}
195
196pub fn archive_tasks(board_dir: &Path, tasks: &[Task], dry_run: bool) -> Result<ArchiveSummary> {
198 let archive_dir = board_dir.join("archive");
199
200 if tasks.is_empty() {
201 return Ok(ArchiveSummary {
202 archived_count: 0,
203 skipped_count: 0,
204 archive_dir,
205 });
206 }
207
208 if !dry_run {
209 std::fs::create_dir_all(&archive_dir)
210 .with_context(|| format!("failed to create archive dir: {}", archive_dir.display()))?;
211 }
212
213 let mut archived = 0usize;
214 let skipped = 0usize;
215
216 for task in tasks {
217 let source = &task.source_path;
218 let file_name = source.file_name().context("task file has no file name")?;
219 let dest = archive_dir.join(file_name);
220
221 if dry_run {
222 let completed_display = task.completed.as_deref().unwrap_or("unknown date");
223 println!(
224 " - {} (done {})",
225 file_name.to_string_lossy(),
226 completed_display
227 );
228 archived += 1;
229 continue;
230 }
231
232 std::fs::rename(source, &dest).with_context(|| {
233 format!("failed to move {} to {}", source.display(), dest.display())
234 })?;
235 archived += 1;
236 info!(task_id = task.id, "archived task");
237 }
238
239 info!(archived, "archived done tasks");
240 Ok(ArchiveSummary {
241 archived_count: archived,
242 skipped_count: skipped,
243 archive_dir,
244 })
245}
246
247pub fn archive_done_tasks(board_dir: &Path, older_than: Option<&str>) -> Result<u32> {
252 let tasks_dir = board_dir.join("tasks");
253 if !tasks_dir.is_dir() {
254 bail!("no tasks directory found at {}", tasks_dir.display());
255 }
256
257 let cutoff = older_than.map(parse_cutoff_date).transpose()?;
258
259 let tasks = load_tasks_from_dir(&tasks_dir)?;
260 let to_archive: Vec<&Task> = tasks
261 .iter()
262 .filter(|t| t.status == "done")
263 .filter(|t| match (&cutoff, &t.completed) {
264 (Some(cutoff_dt), Some(completed_str)) => parse_completed_date(completed_str)
265 .map(|completed| completed < *cutoff_dt)
266 .unwrap_or(false),
267 (Some(_), None) => false,
268 (None, _) => true,
269 })
270 .collect();
271
272 if to_archive.is_empty() {
273 return Ok(0);
274 }
275
276 let archive_dir = board_dir.join("archive");
277 std::fs::create_dir_all(&archive_dir)
278 .with_context(|| format!("failed to create archive dir: {}", archive_dir.display()))?;
279
280 let mut count = 0u32;
281 for task in &to_archive {
282 let source = &task.source_path;
283 let file_name = source.file_name().context("task file has no file name")?;
284 let dest = archive_dir.join(file_name);
285
286 update_task_status(source, "archived")?;
288
289 std::fs::rename(source, &dest).with_context(|| {
290 format!("failed to move {} to {}", source.display(), dest.display())
291 })?;
292 count += 1;
293 info!(task_id = task.id, "archived task");
294 }
295
296 info!(count, "archived done tasks");
297 Ok(count)
298}
299
300fn parse_cutoff_date(date_str: &str) -> Result<DateTime<FixedOffset>> {
301 if let Ok(naive) = NaiveDate::parse_from_str(date_str, "%Y-%m-%d") {
303 let dt = naive.and_hms_opt(0, 0, 0).context("invalid date")?;
304 return Ok(DateTime::<FixedOffset>::from_naive_utc_and_offset(
305 dt,
306 FixedOffset::east_opt(0).unwrap(),
307 ));
308 }
309 DateTime::parse_from_rfc3339(date_str).with_context(|| {
311 format!("invalid date format: {date_str} (expected YYYY-MM-DD or RFC3339)")
312 })
313}
314
315fn parse_completed_date(completed_str: &str) -> Option<DateTime<FixedOffset>> {
316 DateTime::parse_from_rfc3339(completed_str).ok()
317}
318
319fn update_task_status(task_path: &Path, new_status: &str) -> Result<()> {
321 let content = std::fs::read_to_string(task_path)
322 .with_context(|| format!("failed to read {}", task_path.display()))?;
323 let (frontmatter, body) = split_task_frontmatter(&content)?;
324 let mut mapping: Mapping =
325 serde_yaml::from_str(frontmatter).context("failed to parse task frontmatter")?;
326
327 mapping.insert(
328 Value::String("status".to_string()),
329 Value::String(new_status.to_string()),
330 );
331
332 let mut rendered =
333 serde_yaml::to_string(&mapping).context("failed to serialize task frontmatter")?;
334 if let Some(stripped) = rendered.strip_prefix("---\n") {
335 rendered = stripped.to_string();
336 }
337
338 let mut updated = String::from("---\n");
339 updated.push_str(&rendered);
340 if !updated.ends_with('\n') {
341 updated.push('\n');
342 }
343 updated.push_str("---\n");
344 updated.push_str(body);
345
346 std::fs::write(task_path, updated)
347 .with_context(|| format!("failed to write {}", task_path.display()))?;
348 Ok(())
349}
350
351pub fn rotate_done_items(kanban_path: &Path, archive_path: &Path, threshold: u32) -> Result<u32> {
358 let content = std::fs::read_to_string(kanban_path)
359 .with_context(|| format!("failed to read {}", kanban_path.display()))?;
360
361 let (before_done, done_items, after_done) = split_done_section(&content);
362
363 if done_items.len() <= threshold as usize {
364 return Ok(0);
365 }
366
367 let keep_count = threshold as usize;
368 let to_archive = &done_items[..done_items.len() - keep_count];
369 let to_keep = &done_items[done_items.len() - keep_count..];
370 let rotated = to_archive.len() as u32;
371
372 let mut new_kanban = before_done.to_string();
373 new_kanban.push_str("## Done\n");
374 for item in to_keep {
375 new_kanban.push_str(item);
376 new_kanban.push('\n');
377 }
378 if !after_done.is_empty() {
379 new_kanban.push_str(after_done);
380 }
381
382 std::fs::write(kanban_path, &new_kanban)
383 .with_context(|| format!("failed to write {}", kanban_path.display()))?;
384
385 let mut archive_content = if archive_path.exists() {
386 std::fs::read_to_string(archive_path)
387 .with_context(|| format!("failed to read {}", archive_path.display()))?
388 } else {
389 "# Kanban Archive\n".to_string()
390 };
391
392 if !archive_content.ends_with('\n') {
393 archive_content.push('\n');
394 }
395 for item in to_archive {
396 archive_content.push_str(item);
397 archive_content.push('\n');
398 }
399
400 std::fs::write(archive_path, &archive_content)
401 .with_context(|| format!("failed to write {}", archive_path.display()))?;
402
403 info!(rotated, threshold, "rotated done items to archive");
404 Ok(rotated)
405}
406
407fn split_done_section(content: &str) -> (&str, Vec<&str>, &str) {
408 let done_marker = "## Done";
409 let Some(done_start) = content.find(done_marker) else {
410 return (content, Vec::new(), "");
411 };
412
413 let before_done = &content[..done_start];
414 let after_marker = &content[done_start + done_marker.len()..];
415 let items_start = after_marker
416 .find('\n')
417 .map(|i| i + 1)
418 .unwrap_or(after_marker.len());
419 let items_section = &after_marker[items_start..];
420
421 let mut done_items = Vec::new();
422 let mut remaining_start = items_section.len();
423
424 for (i, line) in items_section.lines().enumerate() {
425 if line.starts_with("## ") && i > 0 {
426 remaining_start = items_section
427 .find(&format!("\n{line}"))
428 .map(|pos| pos + 1)
429 .unwrap_or(items_section.len());
430 break;
431 }
432 let trimmed = line.trim();
433 if !trimmed.is_empty() {
434 done_items.push(line);
435 }
436 }
437
438 let after_done = &items_section[remaining_start..];
439 (before_done, done_items, after_done)
440}
441
442fn split_task_frontmatter(content: &str) -> Result<(&str, &str)> {
443 let trimmed = content.trim_start();
444 if !trimmed.starts_with("---") {
445 return Err(BoardError::InvalidFrontmatter {
446 detail: "no opening ---".to_string(),
447 }
448 .into());
449 }
450
451 let after_open = &trimmed[3..];
452 let after_open = after_open.strip_prefix('\n').unwrap_or(after_open);
453 let close_pos = after_open
454 .find("\n---")
455 .context("task file missing closing --- for frontmatter")?;
456
457 let frontmatter = &after_open[..close_pos];
458 let body = &after_open[close_pos + 4..];
459 Ok((frontmatter, body.strip_prefix('\n').unwrap_or(body)))
460}
461
462fn yaml_key(name: &str) -> Value {
463 Value::String(name.to_string())
464}
465
466fn set_optional_string(mapping: &mut Mapping, key: &str, value: Option<&str>) {
467 let key = yaml_key(key);
468 match value {
469 Some(value) => {
470 mapping.insert(key, Value::String(value.to_string()));
471 }
472 None => {
473 mapping.remove(&key);
474 }
475 }
476}
477
478fn set_optional_bool(mapping: &mut Mapping, key: &str, value: Option<bool>) {
479 let key = yaml_key(key);
480 match value {
481 Some(value) => {
482 mapping.insert(key, Value::Bool(value));
483 }
484 None => {
485 mapping.remove(&key);
486 }
487 }
488}
489
490fn set_string_list(mapping: &mut Mapping, key: &str, values: &[String]) {
491 let key = yaml_key(key);
492 if values.is_empty() {
493 mapping.remove(&key);
494 return;
495 }
496
497 mapping.insert(
498 key,
499 Value::Sequence(
500 values
501 .iter()
502 .map(|value| Value::String(value.clone()))
503 .collect(),
504 ),
505 );
506}
507
508#[cfg(test)]
509mod tests {
510 use super::*;
511
512 #[test]
513 fn split_done_section_basic() {
514 let content =
515 "# Board\n\n## Backlog\n\n## In Progress\n\n## Done\n- item 1\n- item 2\n- item 3\n";
516 let (before, items, after) = split_done_section(content);
517 assert!(before.contains("## In Progress"));
518 assert_eq!(items.len(), 3);
519 assert_eq!(items[0], "- item 1");
520 assert!(after.is_empty());
521 }
522
523 #[test]
524 fn split_done_section_with_following_section() {
525 let content = "## Done\n- a\n- b\n## Archive\nstuff\n";
526 let (_, items, after) = split_done_section(content);
527 assert_eq!(items.len(), 2);
528 assert!(after.contains("## Archive"));
529 }
530
531 #[test]
532 fn split_done_section_empty() {
533 let content = "## Done\n\n## Other\n";
534 let (_, items, _) = split_done_section(content);
535 assert!(items.is_empty());
536 }
537
538 #[test]
539 fn split_done_section_no_done_header() {
540 let content = "# Board\n## Backlog\n- task\n";
541 let (before, items, _) = split_done_section(content);
542 assert_eq!(before, content);
543 assert!(items.is_empty());
544 }
545
546 #[test]
547 fn rotate_moves_excess_items() {
548 let tmp = tempfile::tempdir().unwrap();
549 let kanban = tmp.path().join("kanban.md");
550 let archive = tmp.path().join("archive.md");
551
552 std::fs::write(
553 &kanban,
554 "## Backlog\n\n## In Progress\n\n## Done\n- old 1\n- old 2\n- old 3\n- new 1\n- new 2\n",
555 )
556 .unwrap();
557
558 let rotated = rotate_done_items(&kanban, &archive, 2).unwrap();
559 assert_eq!(rotated, 3);
560
561 let kanban_content = std::fs::read_to_string(&kanban).unwrap();
562 assert!(kanban_content.contains("- new 1"));
563 assert!(kanban_content.contains("- new 2"));
564 assert!(!kanban_content.contains("- old 1"));
565
566 let archive_content = std::fs::read_to_string(&archive).unwrap();
567 assert!(archive_content.contains("- old 1"));
568 assert!(archive_content.contains("- old 2"));
569 assert!(archive_content.contains("- old 3"));
570 }
571
572 #[test]
573 fn rotate_does_nothing_under_threshold() {
574 let tmp = tempfile::tempdir().unwrap();
575 let kanban = tmp.path().join("kanban.md");
576 let archive = tmp.path().join("archive.md");
577
578 std::fs::write(&kanban, "## Done\n- item 1\n- item 2\n").unwrap();
579
580 let rotated = rotate_done_items(&kanban, &archive, 5).unwrap();
581 assert_eq!(rotated, 0);
582 assert!(!archive.exists());
583 }
584
585 #[test]
586 fn rotate_appends_to_existing_archive() {
587 let tmp = tempfile::tempdir().unwrap();
588 let kanban = tmp.path().join("kanban.md");
589 let archive = tmp.path().join("archive.md");
590
591 std::fs::write(&archive, "# Kanban Archive\n- previous\n").unwrap();
592 std::fs::write(&kanban, "## Done\n- a\n- b\n- c\n").unwrap();
593
594 let rotated = rotate_done_items(&kanban, &archive, 1).unwrap();
595 assert_eq!(rotated, 2);
596
597 let archive_content = std::fs::read_to_string(&archive).unwrap();
598 assert!(archive_content.contains("- previous"));
599 assert!(archive_content.contains("- a"));
600 assert!(archive_content.contains("- b"));
601 }
602
603 #[test]
604 fn read_workflow_metadata_defaults_when_fields_are_missing() {
605 let tmp = tempfile::tempdir().unwrap();
606 let task = tmp.path().join("027-task.md");
607 std::fs::write(
608 &task,
609 "---\nid: 27\ntitle: Completion packets\nstatus: in-progress\npriority: medium\nclass: standard\n---\n\nTask body.\n",
610 )
611 .unwrap();
612
613 assert_eq!(
614 read_workflow_metadata(&task).unwrap(),
615 WorkflowMetadata::default()
616 );
617 }
618
619 #[test]
620 fn read_workflow_metadata_parses_all_completion_fields() {
621 let tmp = tempfile::tempdir().unwrap();
622 let task = tmp.path().join("027-task.md");
623 std::fs::write(
624 &task,
625 "---\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",
626 )
627 .unwrap();
628
629 let metadata = read_workflow_metadata(&task).unwrap();
630 assert_eq!(metadata.branch.as_deref(), Some("eng-1-4/task-27"));
631 assert_eq!(
632 metadata.worktree_path.as_deref(),
633 Some(".batty/worktrees/eng-1-4")
634 );
635 assert_eq!(metadata.commit.as_deref(), Some("abc1234"));
636 assert_eq!(metadata.changed_paths, vec!["src/team/completion.rs"]);
637 assert_eq!(metadata.tests_run, Some(true));
638 assert_eq!(metadata.tests_passed, Some(false));
639 assert_eq!(metadata.artifacts, vec!["docs/workflow.md"]);
640 assert_eq!(metadata.outcome.as_deref(), Some("ready_for_review"));
641 assert_eq!(metadata.review_blockers, vec!["missing screenshots"]);
642 }
643
644 #[test]
645 fn write_workflow_metadata_preserves_body_and_other_frontmatter() {
646 let tmp = tempfile::tempdir().unwrap();
647 let task = tmp.path().join("027-task.md");
648 std::fs::write(
649 &task,
650 "---\nid: 27\ntitle: Completion packets\nstatus: review\npriority: medium\nclaimed_by: eng-1-4\nclass: standard\n---\n\nTask body.\n",
651 )
652 .unwrap();
653
654 let metadata = WorkflowMetadata {
655 branch: Some("eng-1-4/task-27".to_string()),
656 worktree_path: Some(".batty/worktrees/eng-1-4".to_string()),
657 commit: Some("abc1234".to_string()),
658 changed_paths: vec!["src/team/completion.rs".to_string()],
659 tests_run: Some(true),
660 tests_passed: Some(true),
661 artifacts: vec!["docs/workflow.md".to_string()],
662 outcome: Some("ready_for_review".to_string()),
663 review_blockers: vec!["missing screenshots".to_string()],
664 };
665
666 write_workflow_metadata(&task, &metadata).unwrap();
667
668 let content = std::fs::read_to_string(&task).unwrap();
669 assert!(content.contains("claimed_by: eng-1-4"));
670 assert!(content.contains("branch: eng-1-4/task-27"));
671 assert!(content.contains("tests_run: true"));
672 assert!(content.contains("tests_passed: true"));
673 assert!(content.contains("review_blockers:"));
674 assert!(content.contains("Task body."));
675 assert_eq!(read_workflow_metadata(&task).unwrap(), metadata);
676 }
677
678 #[test]
679 fn write_workflow_metadata_removes_empty_fields() {
680 let tmp = tempfile::tempdir().unwrap();
681 let task = tmp.path().join("027-task.md");
682 std::fs::write(
683 &task,
684 "---\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",
685 )
686 .unwrap();
687
688 write_workflow_metadata(&task, &WorkflowMetadata::default()).unwrap();
689
690 let content = std::fs::read_to_string(&task).unwrap();
691 assert!(!content.contains("branch:"));
692 assert!(!content.contains("worktree_path:"));
693 assert!(!content.contains("commit:"));
694 assert!(!content.contains("changed_paths:"));
695 assert!(!content.contains("tests_run:"));
696 assert!(!content.contains("tests_passed:"));
697 assert!(!content.contains("artifacts:"));
698 assert!(!content.contains("outcome:"));
699 assert!(!content.contains("review_blockers:"));
700 assert!(content.contains("class: standard"));
701 }
702
703 fn write_task_file(dir: &Path, filename: &str, id: u32, status: &str, completed: Option<&str>) {
704 let completed_line = completed
705 .map(|c| format!("completed: {c}\n"))
706 .unwrap_or_default();
707 let content = format!(
708 "---\nid: {id}\ntitle: task {id}\nstatus: {status}\npriority: medium\n{completed_line}class: standard\n---\n\nTask body.\n"
709 );
710 std::fs::write(dir.join(filename), content).unwrap();
711 }
712
713 #[test]
714 fn archive_moves_all_done_tasks() {
715 let tmp = tempfile::tempdir().unwrap();
716 let board_dir = tmp.path().join("board");
717 let tasks_dir = board_dir.join("tasks");
718 std::fs::create_dir_all(&tasks_dir).unwrap();
719
720 write_task_file(
721 &tasks_dir,
722 "001-done.md",
723 1,
724 "done",
725 Some("2026-03-20T10:00:00-04:00"),
726 );
727 write_task_file(&tasks_dir, "002-progress.md", 2, "in-progress", None);
728 write_task_file(
729 &tasks_dir,
730 "003-done.md",
731 3,
732 "done",
733 Some("2026-03-21T10:00:00-04:00"),
734 );
735
736 let count = archive_done_tasks(&board_dir, None).unwrap();
737 assert_eq!(count, 2);
738
739 let archive_dir = board_dir.join("archive");
741 assert!(archive_dir.join("001-done.md").exists());
742 assert!(archive_dir.join("003-done.md").exists());
743
744 assert!(tasks_dir.join("002-progress.md").exists());
746 assert!(!tasks_dir.join("001-done.md").exists());
747 assert!(!tasks_dir.join("003-done.md").exists());
748
749 let archived = std::fs::read_to_string(archive_dir.join("001-done.md")).unwrap();
751 assert!(archived.contains("status: archived"));
752 }
753
754 #[test]
755 fn archive_with_older_than_filters_by_date() {
756 let tmp = tempfile::tempdir().unwrap();
757 let board_dir = tmp.path().join("board");
758 let tasks_dir = board_dir.join("tasks");
759 std::fs::create_dir_all(&tasks_dir).unwrap();
760
761 write_task_file(
762 &tasks_dir,
763 "001-old.md",
764 1,
765 "done",
766 Some("2026-03-10T10:00:00-04:00"),
767 );
768 write_task_file(
769 &tasks_dir,
770 "002-recent.md",
771 2,
772 "done",
773 Some("2026-03-21T10:00:00-04:00"),
774 );
775
776 let count = archive_done_tasks(&board_dir, Some("2026-03-15")).unwrap();
777 assert_eq!(count, 1);
778
779 let archive_dir = board_dir.join("archive");
780 assert!(archive_dir.join("001-old.md").exists());
781 assert!(!archive_dir.join("002-recent.md").exists());
782 assert!(tasks_dir.join("002-recent.md").exists());
783 }
784
785 #[test]
786 fn archive_creates_directory_if_missing() {
787 let tmp = tempfile::tempdir().unwrap();
788 let board_dir = tmp.path().join("board");
789 let tasks_dir = board_dir.join("tasks");
790 std::fs::create_dir_all(&tasks_dir).unwrap();
791
792 write_task_file(
793 &tasks_dir,
794 "001-done.md",
795 1,
796 "done",
797 Some("2026-03-20T10:00:00-04:00"),
798 );
799
800 let archive_dir = board_dir.join("archive");
801 assert!(!archive_dir.exists());
802
803 let count = archive_done_tasks(&board_dir, None).unwrap();
804 assert_eq!(count, 1);
805 assert!(archive_dir.is_dir());
806 }
807
808 #[test]
809 fn archive_returns_zero_when_no_done_tasks() {
810 let tmp = tempfile::tempdir().unwrap();
811 let board_dir = tmp.path().join("board");
812 let tasks_dir = board_dir.join("tasks");
813 std::fs::create_dir_all(&tasks_dir).unwrap();
814
815 write_task_file(&tasks_dir, "001-progress.md", 1, "in-progress", None);
816 write_task_file(&tasks_dir, "002-todo.md", 2, "todo", None);
817
818 let count = archive_done_tasks(&board_dir, None).unwrap();
819 assert_eq!(count, 0);
820 assert!(!board_dir.join("archive").exists());
821 }
822
823 #[test]
824 fn archive_skips_done_tasks_without_completed_date_when_older_than_set() {
825 let tmp = tempfile::tempdir().unwrap();
826 let board_dir = tmp.path().join("board");
827 let tasks_dir = board_dir.join("tasks");
828 std::fs::create_dir_all(&tasks_dir).unwrap();
829
830 write_task_file(&tasks_dir, "001-no-date.md", 1, "done", None);
832 write_task_file(
833 &tasks_dir,
834 "002-old.md",
835 2,
836 "done",
837 Some("2026-01-01T00:00:00+00:00"),
838 );
839
840 let count = archive_done_tasks(&board_dir, Some("2026-03-01")).unwrap();
841 assert_eq!(count, 1);
842
843 assert!(tasks_dir.join("001-no-date.md").exists());
844 assert!(board_dir.join("archive/002-old.md").exists());
845 }
846
847 #[test]
848 fn archive_excludes_tasks_from_listing() {
849 let tmp = tempfile::tempdir().unwrap();
850 let board_dir = tmp.path().join("board");
851 let tasks_dir = board_dir.join("tasks");
852 std::fs::create_dir_all(&tasks_dir).unwrap();
853
854 write_task_file(
855 &tasks_dir,
856 "001-done.md",
857 1,
858 "done",
859 Some("2026-03-20T10:00:00-04:00"),
860 );
861 write_task_file(&tasks_dir, "002-todo.md", 2, "todo", None);
862
863 archive_done_tasks(&board_dir, None).unwrap();
864
865 let tasks = load_tasks_from_dir(&tasks_dir).unwrap();
867 assert_eq!(tasks.len(), 1);
868 assert_eq!(tasks[0].id, 2);
869 }
870
871 #[test]
872 fn parse_cutoff_date_accepts_yyyy_mm_dd() {
873 let dt = parse_cutoff_date("2026-03-15").unwrap();
874 assert_eq!(
875 dt.date_naive(),
876 NaiveDate::from_ymd_opt(2026, 3, 15).unwrap()
877 );
878 }
879
880 #[test]
881 fn parse_cutoff_date_accepts_rfc3339() {
882 let dt = parse_cutoff_date("2026-03-15T10:30:00-04:00").unwrap();
883 assert_eq!(
884 dt.date_naive(),
885 NaiveDate::from_ymd_opt(2026, 3, 15).unwrap()
886 );
887 }
888
889 #[test]
890 fn parse_cutoff_date_rejects_invalid() {
891 assert!(parse_cutoff_date("not-a-date").is_err());
892 }
893
894 #[test]
895 fn update_task_status_changes_status_field() {
896 let tmp = tempfile::tempdir().unwrap();
897 let task = tmp.path().join("001-task.md");
898 std::fs::write(
899 &task,
900 "---\nid: 1\ntitle: test task\nstatus: done\npriority: medium\n---\n\nBody.\n",
901 )
902 .unwrap();
903
904 update_task_status(&task, "archived").unwrap();
905
906 let content = std::fs::read_to_string(&task).unwrap();
907 assert!(content.contains("status: archived"));
908 assert!(!content.contains("status: done"));
909 assert!(content.contains("Body."));
910 }
911
912 #[test]
915 fn parse_age_threshold_days() {
916 let dur = parse_age_threshold("7d").unwrap();
917 assert_eq!(dur, Duration::from_secs(7 * 86400));
918 }
919
920 #[test]
921 fn parse_age_threshold_hours() {
922 let dur = parse_age_threshold("24h").unwrap();
923 assert_eq!(dur, Duration::from_secs(24 * 3600));
924 }
925
926 #[test]
927 fn parse_age_threshold_weeks() {
928 let dur = parse_age_threshold("2w").unwrap();
929 assert_eq!(dur, Duration::from_secs(14 * 86400));
930 }
931
932 #[test]
933 fn parse_age_threshold_zero() {
934 let dur = parse_age_threshold("0s").unwrap();
935 assert_eq!(dur, Duration::from_secs(0));
936 }
937
938 #[test]
939 fn parse_age_threshold_invalid() {
940 assert!(parse_age_threshold("abc").is_err());
941 }
942
943 #[test]
946 fn done_tasks_older_than_filters_correctly() {
947 let tmp = tempfile::tempdir().unwrap();
948 let board_dir = tmp.path().join("board");
949 let tasks_dir = board_dir.join("tasks");
950 std::fs::create_dir_all(&tasks_dir).unwrap();
951
952 write_task_file(
954 &tasks_dir,
955 "001-old.md",
956 1,
957 "done",
958 Some("2020-01-01T00:00:00+00:00"),
959 );
960 let now = Utc::now();
962 let recent = now.format("%Y-%m-%dT%H:%M:%S+00:00").to_string();
963 write_task_file(&tasks_dir, "002-recent.md", 2, "done", Some(&recent));
964
965 let tasks = done_tasks_older_than(&board_dir, Duration::from_secs(7 * 86400)).unwrap();
966 assert_eq!(tasks.len(), 1);
967 assert_eq!(tasks[0].id, 1);
968 }
969
970 #[test]
973 fn archive_tasks_moves_files() {
974 let tmp = tempfile::tempdir().unwrap();
975 let board_dir = tmp.path().join("board");
976 let tasks_dir = board_dir.join("tasks");
977 std::fs::create_dir_all(&tasks_dir).unwrap();
978
979 write_task_file(
980 &tasks_dir,
981 "001-done.md",
982 1,
983 "done",
984 Some("2026-03-20T10:00:00+00:00"),
985 );
986
987 let tasks = load_tasks_from_dir(&tasks_dir).unwrap();
988 let summary = archive_tasks(&board_dir, &tasks, false).unwrap();
989
990 assert_eq!(summary.archived_count, 1);
991 assert_eq!(summary.skipped_count, 0);
992
993 let archive_dir = board_dir.join("archive");
995 assert!(archive_dir.join("001-done.md").exists());
996 assert!(!tasks_dir.join("001-done.md").exists());
997
998 let content = std::fs::read_to_string(archive_dir.join("001-done.md")).unwrap();
1000 assert!(content.contains("status: done"));
1001 assert!(content.contains("Task body."));
1002 }
1003
1004 #[test]
1005 fn archive_tasks_creates_archive_dir() {
1006 let tmp = tempfile::tempdir().unwrap();
1007 let board_dir = tmp.path().join("board");
1008 let tasks_dir = board_dir.join("tasks");
1009 std::fs::create_dir_all(&tasks_dir).unwrap();
1010
1011 write_task_file(
1012 &tasks_dir,
1013 "001-done.md",
1014 1,
1015 "done",
1016 Some("2026-03-20T10:00:00+00:00"),
1017 );
1018
1019 let archive_dir = board_dir.join("archive");
1020 assert!(!archive_dir.exists());
1021
1022 let tasks = load_tasks_from_dir(&tasks_dir).unwrap();
1023 archive_tasks(&board_dir, &tasks, false).unwrap();
1024
1025 assert!(archive_dir.is_dir());
1026 }
1027
1028 #[test]
1029 fn archive_tasks_dry_run_does_not_move() {
1030 let tmp = tempfile::tempdir().unwrap();
1031 let board_dir = tmp.path().join("board");
1032 let tasks_dir = board_dir.join("tasks");
1033 std::fs::create_dir_all(&tasks_dir).unwrap();
1034
1035 write_task_file(
1036 &tasks_dir,
1037 "001-done.md",
1038 1,
1039 "done",
1040 Some("2026-03-20T10:00:00+00:00"),
1041 );
1042
1043 let tasks = load_tasks_from_dir(&tasks_dir).unwrap();
1044 let summary = archive_tasks(&board_dir, &tasks, true).unwrap();
1045
1046 assert_eq!(summary.archived_count, 1);
1047 assert!(tasks_dir.join("001-done.md").exists());
1049 assert!(!board_dir.join("archive").exists());
1051 }
1052
1053 #[test]
1054 fn archive_tasks_skips_non_done() {
1055 let tmp = tempfile::tempdir().unwrap();
1056 let board_dir = tmp.path().join("board");
1057 let tasks_dir = board_dir.join("tasks");
1058 std::fs::create_dir_all(&tasks_dir).unwrap();
1059
1060 write_task_file(&tasks_dir, "001-progress.md", 1, "in-progress", None);
1061 write_task_file(&tasks_dir, "002-todo.md", 2, "todo", None);
1062
1063 let tasks = done_tasks_older_than(&board_dir, Duration::from_secs(0)).unwrap();
1065 assert!(tasks.is_empty());
1066
1067 let summary = archive_tasks(&board_dir, &tasks, false).unwrap();
1068 assert_eq!(summary.archived_count, 0);
1069 assert!(!board_dir.join("archive").exists());
1070 }
1071
1072 #[test]
1073 fn archive_preserves_file_content() {
1074 let tmp = tempfile::tempdir().unwrap();
1075 let board_dir = tmp.path().join("board");
1076 let tasks_dir = board_dir.join("tasks");
1077 std::fs::create_dir_all(&tasks_dir).unwrap();
1078
1079 write_task_file(
1080 &tasks_dir,
1081 "042-done.md",
1082 42,
1083 "done",
1084 Some("2026-03-15T08:00:00+00:00"),
1085 );
1086
1087 let original_bytes = std::fs::read(tasks_dir.join("042-done.md")).unwrap();
1088
1089 let tasks = load_tasks_from_dir(&tasks_dir).unwrap();
1090 archive_tasks(&board_dir, &tasks, false).unwrap();
1091
1092 let archived_bytes = std::fs::read(board_dir.join("archive").join("042-done.md")).unwrap();
1093 assert_eq!(
1094 original_bytes, archived_bytes,
1095 "archived file bytes must match original exactly"
1096 );
1097 }
1098
1099 #[test]
1100 fn archive_summary_counts_correct() {
1101 let tmp = tempfile::tempdir().unwrap();
1102 let board_dir = tmp.path().join("board");
1103 let tasks_dir = board_dir.join("tasks");
1104 std::fs::create_dir_all(&tasks_dir).unwrap();
1105
1106 write_task_file(
1107 &tasks_dir,
1108 "010-done.md",
1109 10,
1110 "done",
1111 Some("2026-03-01T00:00:00+00:00"),
1112 );
1113 write_task_file(
1114 &tasks_dir,
1115 "011-done.md",
1116 11,
1117 "done",
1118 Some("2026-03-02T00:00:00+00:00"),
1119 );
1120 write_task_file(
1121 &tasks_dir,
1122 "012-done.md",
1123 12,
1124 "done",
1125 Some("2026-03-03T00:00:00+00:00"),
1126 );
1127
1128 let tasks = load_tasks_from_dir(&tasks_dir).unwrap();
1129 let done_tasks: Vec<_> = tasks.into_iter().filter(|t| t.status == "done").collect();
1130 assert_eq!(done_tasks.len(), 3);
1131
1132 let summary = archive_tasks(&board_dir, &done_tasks, false).unwrap();
1133 assert_eq!(summary.archived_count, 3);
1134 assert_eq!(summary.skipped_count, 0);
1135 assert_eq!(summary.archive_dir, board_dir.join("archive"));
1136 }
1137
1138 #[test]
1139 fn archive_handles_empty_board() {
1140 let tmp = tempfile::tempdir().unwrap();
1141 let board_dir = tmp.path().join("board");
1142 std::fs::create_dir_all(&board_dir).unwrap();
1144
1145 let empty: Vec<Task> = vec![];
1146 let summary = archive_tasks(&board_dir, &empty, false).unwrap();
1147 assert_eq!(summary.archived_count, 0);
1148 assert_eq!(summary.skipped_count, 0);
1149 assert!(!board_dir.join("archive").exists());
1151 }
1152}