Skip to main content

batty_cli/team/
board.rs

1//! Board management — kanban.md rotation of done items to archive.
2
3use 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/// Workflow metadata stored in task frontmatter.
16#[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/// Summary returned by [`archive_tasks`].
117#[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
124/// Parse an age threshold string ("7d", "24h", "2w", "0s") into a [`Duration`].
125pub 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
156/// List done tasks older than the given age threshold.
157pub 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                    // Fall back to filesystem mtime
180                    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
196/// Move task files to archive subdirectory, preserving content unchanged.
197pub 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
247/// Archive done tasks by moving their files from `tasks/` to `archive/`.
248///
249/// Returns the number of tasks archived. If `older_than` is provided, only
250/// tasks completed before that date are archived.
251pub 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 status to "archived" before moving
287        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    // Try YYYY-MM-DD first, treating it as start of day UTC
302    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    // Try RFC3339
310    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
319/// Update the `status` field in a task file's YAML frontmatter.
320fn 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
351/// Rotate done items from kanban.md to kanban-archive.md when the count
352/// exceeds `threshold`.
353///
354/// Done items are lines under the `## Done` section. When the count exceeds
355/// the threshold, the oldest items (first in the list) are moved to the
356/// archive file.
357pub 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        // Files moved to archive
740        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        // In-progress task stays
745        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        // Archived file has status updated
750        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        // Done task with no completed date — should be skipped when --older-than is set
831        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        // load_tasks_from_dir only reads from tasks/, not archive/
866        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    // --- parse_age_threshold tests ---
913
914    #[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    // --- done_tasks_older_than tests ---
944
945    #[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        // Task completed long ago — should be included
953        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        // Task completed very recently — should be excluded with 7d threshold
961        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    // --- archive_tasks tests ---
971
972    #[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        // File moved to archive
994        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        // Content preserved unchanged
999        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        // File still in original location
1048        assert!(tasks_dir.join("001-done.md").exists());
1049        // Archive dir not created
1050        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        // done_tasks_older_than filters to done only
1064        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        // Create board dir but no tasks dir — simulates an empty board
1143        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        // Archive dir should not be created when there's nothing to archive
1150        assert!(!board_dir.join("archive").exists());
1151    }
1152}