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 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/// Workflow metadata stored in task frontmatter.
20#[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/// Lifecycle timestamps stored in task frontmatter.
245#[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/// Summary returned by [`archive_tasks`].
286#[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
293/// Parse an age threshold string ("7d", "24h", "2w", "0s") into a [`Duration`].
294pub 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
325/// List done tasks older than the given age threshold.
326pub 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                    // Fall back to filesystem mtime
349                    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
365/// Move task files to archive subdirectory, preserving content unchanged.
366pub 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
416/// Archive done tasks by moving their files from `tasks/` to `archive/`.
417///
418/// Returns the number of tasks archived. If `older_than` is provided, only
419/// tasks completed before that date are archived.
420pub 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 status to "archived" before moving
456        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    // Try YYYY-MM-DD first, treating it as start of day UTC
471    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    // Try RFC3339
479    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
492/// Update the `status` field in a task file's YAML frontmatter.
493fn 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
524/// Rotate done items from kanban.md to kanban-archive.md when the count
525/// exceeds `threshold`.
526///
527/// Done items are lines under the `## Done` section. When the count exceeds
528/// the threshold, the oldest items (first in the list) are moved to the
529/// archive file.
530pub 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        // Files moved to archive
1082        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        // In-progress task stays
1087        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        // Archived file has status updated
1092        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        // Done task with no completed date — should be skipped when --older-than is set
1173        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        // load_tasks_from_dir only reads from tasks/, not archive/
1208        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    // --- parse_age_threshold tests ---
1255
1256    #[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    // --- done_tasks_older_than tests ---
1286
1287    #[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        // Task completed long ago — should be included
1295        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        // Task completed very recently — should be excluded with 7d threshold
1303        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    // --- archive_tasks tests ---
1313
1314    #[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        // File moved to archive
1336        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        // Content preserved unchanged
1341        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        // File still in original location
1390        assert!(tasks_dir.join("001-done.md").exists());
1391        // Archive dir not created
1392        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        // done_tasks_older_than filters to done only
1406        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        // Create board dir but no tasks dir — simulates an empty board
1485        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        // Archive dir should not be created when there's nothing to archive
1492        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}