Skip to main content

batty_cli/team/
task_cmd.rs

1use std::collections::HashMap;
2use std::path::{Path, PathBuf};
3
4use anyhow::{Context, Result, bail};
5use serde_yaml::{Mapping, Value};
6
7use crate::task::{Task, load_tasks_from_dir};
8
9use super::board::{read_workflow_metadata, write_workflow_metadata};
10use super::workflow::{ReviewDisposition, TaskState, can_transition};
11
12pub fn cmd_transition(board_dir: &Path, task_id: u32, target: &str) -> Result<()> {
13    transition_task(board_dir, task_id, target)?;
14    println!("Task #{task_id} transitioned to {}.", target.trim());
15    Ok(())
16}
17
18pub(crate) fn transition_task(board_dir: &Path, task_id: u32, target: &str) -> Result<()> {
19    let task_path = find_task_path(board_dir, task_id)?;
20    let task = Task::from_file(&task_path)?;
21    let current = parse_task_state(&task.status)?;
22    let target = parse_task_state(target)?;
23
24    can_transition(current, target).map_err(anyhow::Error::msg)?;
25
26    update_task_frontmatter(&task_path, |mapping| {
27        set_status(mapping, target);
28        if target != TaskState::Blocked {
29            clear_blocked(mapping);
30        }
31    })?;
32    Ok(())
33}
34
35pub fn cmd_assign(
36    board_dir: &Path,
37    task_id: u32,
38    exec_owner: Option<&str>,
39    review_owner: Option<&str>,
40) -> Result<()> {
41    if exec_owner.is_none() && review_owner.is_none() {
42        bail!("at least one owner must be provided");
43    }
44
45    assign_task_owners(board_dir, task_id, exec_owner, review_owner)?;
46
47    println!("Task #{task_id} ownership updated.");
48    Ok(())
49}
50
51pub(crate) fn assign_task_owners(
52    board_dir: &Path,
53    task_id: u32,
54    exec_owner: Option<&str>,
55    review_owner: Option<&str>,
56) -> Result<()> {
57    if exec_owner.is_none() && review_owner.is_none() {
58        bail!("at least one owner must be provided");
59    }
60
61    let task_path = find_task_path(board_dir, task_id)?;
62    update_task_frontmatter(&task_path, |mapping| {
63        if let Some(owner) = exec_owner {
64            set_optional_string(mapping, "claimed_by", normalize_optional(owner));
65        }
66        if let Some(owner) = review_owner {
67            set_optional_string(mapping, "review_owner", normalize_optional(owner));
68        }
69    })?;
70    Ok(())
71}
72
73/// Remove the claimed_by and review_owner fields from a task.
74pub(crate) fn unclaim_task(board_dir: &Path, task_id: u32) -> Result<()> {
75    let task_path = find_task_path(board_dir, task_id)?;
76    update_task_frontmatter(&task_path, |mapping| {
77        set_optional_string(mapping, "claimed_by", None);
78        set_optional_string(mapping, "review_owner", None);
79        set_optional_string(mapping, "claimed_at", None);
80    })?;
81    Ok(())
82}
83
84pub fn cmd_review(
85    board_dir: &Path,
86    task_id: u32,
87    disposition: &str,
88    feedback: Option<&str>,
89) -> Result<()> {
90    let task_path = find_task_path(board_dir, task_id)?;
91    let task = Task::from_file(&task_path)?;
92    let current = parse_task_state(&task.status)?;
93    let disposition = parse_review_disposition(disposition)?;
94    let target = match disposition {
95        ReviewDisposition::Approved => TaskState::Done,
96        ReviewDisposition::ChangesRequested => TaskState::InProgress,
97        ReviewDisposition::Rejected => TaskState::Archived,
98    };
99
100    can_transition(current, target).map_err(anyhow::Error::msg)?;
101
102    update_task_frontmatter(&task_path, |mapping| {
103        set_status(mapping, target);
104        clear_blocked(mapping);
105        if let Some(text) = feedback {
106            set_optional_string(mapping, "review_feedback", Some(text));
107        }
108    })?;
109
110    let mut metadata = read_workflow_metadata(&task_path)?;
111    metadata.outcome = Some(review_disposition_name(disposition).to_string());
112    if disposition == ReviewDisposition::Approved {
113        metadata.review_blockers.clear();
114    }
115    write_workflow_metadata(&task_path, &metadata)?;
116
117    if disposition == ReviewDisposition::ChangesRequested {
118        if let Some(text) = feedback {
119            // Deliver feedback to the engineer's inbox.
120            // board_dir is <project_root>/.batty/team_config/board
121            if let Some(engineer) = task.claimed_by.as_deref() {
122                if let Some(project_root) = board_dir
123                    .parent() // team_config
124                    .and_then(|p| p.parent()) // .batty
125                    .and_then(|p| p.parent())
126                // project_root
127                {
128                    let inbox_root = super::inbox::inboxes_root(project_root);
129                    if let Ok(()) = queue_review_feedback(&inbox_root, engineer, task_id, text) {
130                        println!("Review feedback delivered to {engineer}'s inbox.");
131                    }
132                }
133            }
134        }
135    }
136
137    println!(
138        "Task #{task_id} review recorded as {}.",
139        review_disposition_name(disposition)
140    );
141    Ok(())
142}
143
144fn queue_review_feedback(
145    inbox_root: &Path,
146    engineer: &str,
147    task_id: u32,
148    feedback: &str,
149) -> Result<()> {
150    use super::inbox;
151    let message = format!("Review feedback for task #{task_id}: {feedback}");
152    let msg = inbox::InboxMessage::new_send("reviewer", engineer, &message);
153    inbox::deliver_to_inbox(inbox_root, &msg)?;
154    Ok(())
155}
156
157/// Structured review: stores review_disposition, review_feedback, reviewed_by,
158/// reviewed_at in task frontmatter and applies the correct state transition.
159///
160/// Disposition mapping:
161///   approve         → Done
162///   request-changes → InProgress (feedback delivered to engineer inbox)
163///   reject          → Blocked (reason stored in blocked_on)
164pub fn cmd_review_structured(
165    board_dir: &Path,
166    task_id: u32,
167    disposition: &str,
168    feedback: Option<&str>,
169    reviewer: &str,
170) -> Result<()> {
171    let task_path = find_task_path(board_dir, task_id)?;
172    let task = Task::from_file(&task_path)?;
173    let current = parse_task_state(&task.status)?;
174
175    let (target_state, disposition_str) = match disposition {
176        "approve" => (TaskState::Done, "approved"),
177        "request-changes" | "request_changes" => (TaskState::InProgress, "changes_requested"),
178        "reject" => (TaskState::Blocked, "rejected"),
179        other => bail!("unknown review disposition: {other}"),
180    };
181
182    can_transition(current, target_state).map_err(anyhow::Error::msg)?;
183
184    let now = chrono::Utc::now().to_rfc3339();
185    let default_reject_reason = format!("rejected by {reviewer}");
186
187    update_task_frontmatter(&task_path, |mapping| {
188        set_status(mapping, target_state);
189        set_optional_string(mapping, "review_disposition", Some(disposition_str));
190        set_optional_string(mapping, "reviewed_by", Some(reviewer));
191        set_optional_string(mapping, "reviewed_at", Some(&now));
192        if let Some(text) = feedback {
193            set_optional_string(mapping, "review_feedback", Some(text));
194        }
195        if target_state == TaskState::Blocked {
196            let reason = feedback.unwrap_or(&default_reject_reason);
197            set_optional_string(mapping, "blocked_on", Some(reason));
198        } else {
199            clear_blocked(mapping);
200        }
201    })?;
202
203    // Update workflow metadata outcome
204    let mut metadata = read_workflow_metadata(&task_path)?;
205    metadata.outcome = Some(disposition_str.to_string());
206    if disposition == "approve" {
207        metadata.review_blockers.clear();
208    }
209    write_workflow_metadata(&task_path, &metadata)?;
210
211    // Deliver feedback to engineer inbox on request-changes
212    if disposition == "request-changes" || disposition == "request_changes" {
213        if let Some(text) = feedback {
214            if let Some(engineer) = task.claimed_by.as_deref() {
215                if let Some(project_root) = board_dir
216                    .parent()
217                    .and_then(|p| p.parent())
218                    .and_then(|p| p.parent())
219                {
220                    let inbox_root = super::inbox::inboxes_root(project_root);
221                    if let Ok(()) = queue_review_feedback(&inbox_root, engineer, task_id, text) {
222                        println!("Review feedback delivered to {engineer}'s inbox.");
223                    }
224                }
225            }
226        }
227    }
228
229    println!("Task #{task_id} review recorded as {disposition_str} by {reviewer}.");
230    Ok(())
231}
232
233pub fn cmd_update(board_dir: &Path, task_id: u32, fields: HashMap<String, String>) -> Result<()> {
234    if fields.is_empty() {
235        bail!("no workflow fields provided");
236    }
237
238    let task_path = find_task_path(board_dir, task_id)?;
239    let mut metadata = read_workflow_metadata(&task_path)?;
240    let mut metadata_changed = false;
241
242    if let Some(branch) = fields.get("branch") {
243        metadata.branch = normalize_optional(branch).map(str::to_string);
244        metadata_changed = true;
245    }
246    if let Some(commit) = fields.get("commit") {
247        metadata.commit = normalize_optional(commit).map(str::to_string);
248        metadata_changed = true;
249    }
250    if metadata_changed {
251        write_workflow_metadata(&task_path, &metadata)?;
252    }
253
254    let blocked_on = fields.get("blocked_on").cloned();
255    let should_clear_blocked = fields.contains_key("clear_blocked");
256    if blocked_on.is_some() || should_clear_blocked {
257        update_task_frontmatter(&task_path, |mapping| {
258            if should_clear_blocked {
259                clear_blocked(mapping);
260            }
261            if let Some(reason) = blocked_on.as_deref() {
262                let reason = normalize_optional(reason);
263                set_optional_string(mapping, "blocked", reason);
264                set_optional_string(mapping, "blocked_on", reason);
265            }
266        })?;
267    }
268
269    println!("Task #{task_id} metadata updated.");
270    Ok(())
271}
272
273pub fn cmd_schedule(
274    board_dir: &Path,
275    task_id: u32,
276    at: Option<&str>,
277    cron_expr: Option<&str>,
278    clear: bool,
279) -> Result<()> {
280    if !clear && at.is_none() && cron_expr.is_none() {
281        bail!("at least one of --at, --cron, or --clear is required");
282    }
283
284    // Validate --at as RFC3339
285    if let Some(ts) = at {
286        chrono::DateTime::parse_from_rfc3339(ts)
287            .with_context(|| format!("invalid RFC3339 timestamp: {ts}"))?;
288    }
289
290    // Validate --cron expression (the cron crate requires 6-7 fields with seconds;
291    // auto-prepend "0 " for standard 5-field cron expressions)
292    if let Some(expr) = cron_expr {
293        use std::str::FromStr;
294        let normalized = normalize_cron(expr);
295        cron::Schedule::from_str(&normalized)
296            .map_err(|e| anyhow::anyhow!("invalid cron expression: {e}"))?;
297    }
298
299    let task_path = find_task_path(board_dir, task_id)?;
300    update_task_frontmatter(&task_path, |mapping| {
301        if clear {
302            mapping.remove(yaml_key("scheduled_for"));
303            mapping.remove(yaml_key("cron_schedule"));
304        } else {
305            if let Some(ts) = at {
306                mapping.insert(yaml_key("scheduled_for"), Value::String(ts.to_string()));
307            }
308            if let Some(expr) = cron_expr {
309                mapping.insert(yaml_key("cron_schedule"), Value::String(expr.to_string()));
310            }
311        }
312    })?;
313
314    if clear {
315        println!("Task #{task_id} schedule cleared.");
316    } else {
317        let mut parts = Vec::new();
318        if let Some(ts) = at {
319            parts.push(format!("scheduled_for={ts}"));
320        }
321        if let Some(expr) = cron_expr {
322            parts.push(format!("cron_schedule={expr}"));
323        }
324        println!("Task #{task_id} schedule updated: {}", parts.join(", "));
325    }
326    Ok(())
327}
328
329pub fn cmd_auto_merge(task_id: u32, enabled: bool, project_root: &Path) -> Result<()> {
330    super::auto_merge::save_override(project_root, task_id, enabled)?;
331    let action = if enabled { "enabled" } else { "disabled" };
332    println!(
333        "Auto-merge {action} for task #{task_id}. The daemon will pick this up on its next completion evaluation."
334    );
335    Ok(())
336}
337
338pub(crate) fn find_task_path(board_dir: &Path, task_id: u32) -> Result<PathBuf> {
339    let tasks_dir = board_dir.join("tasks");
340    let tasks = load_tasks_from_dir(&tasks_dir)
341        .with_context(|| format!("failed to load tasks from {}", tasks_dir.display()))?;
342    tasks
343        .into_iter()
344        .find(|task| task.id == task_id)
345        .map(|task| task.source_path)
346        .with_context(|| format!("task #{task_id} not found in {}", tasks_dir.display()))
347}
348
349fn parse_task_state(value: &str) -> Result<TaskState> {
350    match value.trim().replace('-', "_").as_str() {
351        "backlog" => Ok(TaskState::Backlog),
352        "todo" => Ok(TaskState::Todo),
353        "in_progress" => Ok(TaskState::InProgress),
354        "review" => Ok(TaskState::Review),
355        "blocked" => Ok(TaskState::Blocked),
356        "done" => Ok(TaskState::Done),
357        "archived" => Ok(TaskState::Archived),
358        other => bail!("unknown task state `{other}`"),
359    }
360}
361
362fn parse_review_disposition(value: &str) -> Result<ReviewDisposition> {
363    match value.trim().replace('-', "_").as_str() {
364        "approved" => Ok(ReviewDisposition::Approved),
365        "changes_requested" => Ok(ReviewDisposition::ChangesRequested),
366        "rejected" => Ok(ReviewDisposition::Rejected),
367        other => bail!("unknown review disposition `{other}`"),
368    }
369}
370
371fn state_name(state: TaskState) -> &'static str {
372    match state {
373        TaskState::Backlog => "backlog",
374        TaskState::Todo => "todo",
375        TaskState::InProgress => "in-progress",
376        TaskState::Review => "review",
377        TaskState::Blocked => "blocked",
378        TaskState::Done => "done",
379        TaskState::Archived => "archived",
380    }
381}
382
383fn review_disposition_name(disposition: ReviewDisposition) -> &'static str {
384    match disposition {
385        ReviewDisposition::Approved => "approved",
386        ReviewDisposition::ChangesRequested => "changes_requested",
387        ReviewDisposition::Rejected => "rejected",
388    }
389}
390
391pub(crate) fn update_task_frontmatter<F>(task_path: &Path, mutator: F) -> Result<()>
392where
393    F: FnOnce(&mut Mapping),
394{
395    let content = std::fs::read_to_string(task_path)
396        .with_context(|| format!("failed to read {}", task_path.display()))?;
397    let (frontmatter, body) = split_task_frontmatter(&content)?;
398    let mut mapping: Mapping =
399        serde_yaml::from_str(frontmatter).context("failed to parse task frontmatter")?;
400    mutator(&mut mapping);
401
402    let mut rendered =
403        serde_yaml::to_string(&mapping).context("failed to serialize task frontmatter")?;
404    if let Some(stripped) = rendered.strip_prefix("---\n") {
405        rendered = stripped.to_string();
406    }
407
408    let mut updated = String::from("---\n");
409    updated.push_str(&rendered);
410    if !updated.ends_with('\n') {
411        updated.push('\n');
412    }
413    updated.push_str("---\n");
414    updated.push_str(body);
415
416    std::fs::write(task_path, updated)
417        .with_context(|| format!("failed to write {}", task_path.display()))?;
418    Ok(())
419}
420
421fn split_task_frontmatter(content: &str) -> Result<(&str, &str)> {
422    let trimmed = content.trim_start();
423    if !trimmed.starts_with("---") {
424        bail!("task file missing YAML frontmatter (no opening ---)");
425    }
426
427    let after_open = &trimmed[3..];
428    let after_open = after_open.strip_prefix('\n').unwrap_or(after_open);
429    let close_pos = after_open
430        .find("\n---")
431        .context("task file missing closing --- for frontmatter")?;
432
433    let frontmatter = &after_open[..close_pos];
434    let body = &after_open[close_pos + 4..];
435    Ok((frontmatter, body.strip_prefix('\n').unwrap_or(body)))
436}
437
438fn set_status(mapping: &mut Mapping, state: TaskState) {
439    mapping.insert(
440        yaml_key("status"),
441        Value::String(state_name(state).to_string()),
442    );
443}
444
445fn clear_blocked(mapping: &mut Mapping) {
446    mapping.remove(yaml_key("blocked"));
447    mapping.remove(yaml_key("blocked_on"));
448}
449
450pub(crate) fn set_optional_string(mapping: &mut Mapping, key: &str, value: Option<&str>) {
451    let key = yaml_key(key);
452    match value {
453        Some(value) => {
454            mapping.insert(key, Value::String(value.to_string()));
455        }
456        None => {
457            mapping.remove(key);
458        }
459    }
460}
461
462pub(crate) fn yaml_key(name: &str) -> Value {
463    Value::String(name.to_string())
464}
465
466/// Normalize a cron expression for the `cron` crate which requires 6-7 fields
467/// (sec min hour dom month dow [year]). If the user provides a standard 5-field
468/// expression, prepend "0 " to add a seconds field.
469fn normalize_cron(expr: &str) -> String {
470    let fields: Vec<&str> = expr.split_whitespace().collect();
471    if fields.len() == 5 {
472        format!("0 {expr}")
473    } else {
474        expr.to_string()
475    }
476}
477
478fn normalize_optional(value: &str) -> Option<&str> {
479    let trimmed = value.trim();
480    if trimmed.is_empty() {
481        None
482    } else {
483        Some(trimmed)
484    }
485}
486
487#[cfg(test)]
488mod tests {
489    use super::*;
490
491    fn write_task_file(dir: &Path, id: u32, status: &str) -> PathBuf {
492        let tasks_dir = dir.join("tasks");
493        std::fs::create_dir_all(&tasks_dir).unwrap();
494        let path = tasks_dir.join(format!("{id:03}-task-{id}.md"));
495        std::fs::write(
496            &path,
497            format!(
498                "---\nid: {id}\ntitle: Task {id}\nstatus: {status}\npriority: high\nclass: standard\n---\n\nTask body.\n"
499            ),
500        )
501        .unwrap();
502        path
503    }
504
505    #[test]
506    fn transition_updates_task_status() {
507        let tmp = tempfile::tempdir().unwrap();
508        let board_dir = tmp.path();
509        let task_path = write_task_file(board_dir, 7, "todo");
510
511        cmd_transition(board_dir, 7, "in-progress").unwrap();
512
513        let task = Task::from_file(&task_path).unwrap();
514        assert_eq!(task.status, "in-progress");
515    }
516
517    #[test]
518    fn illegal_transition_returns_error() {
519        let tmp = tempfile::tempdir().unwrap();
520        let board_dir = tmp.path();
521        write_task_file(board_dir, 8, "backlog");
522
523        let error = cmd_transition(board_dir, 8, "done")
524            .unwrap_err()
525            .to_string();
526        assert!(error.contains("illegal task state transition"));
527    }
528
529    #[test]
530    fn assign_updates_execution_and_review_owners() {
531        let tmp = tempfile::tempdir().unwrap();
532        let board_dir = tmp.path();
533        let task_path = write_task_file(board_dir, 9, "todo");
534
535        cmd_assign(board_dir, 9, Some("eng-1-2"), Some("manager-1")).unwrap();
536
537        let task = Task::from_file(&task_path).unwrap();
538        assert_eq!(task.claimed_by.as_deref(), Some("eng-1-2"));
539        assert_eq!(task.review_owner.as_deref(), Some("manager-1"));
540    }
541
542    #[test]
543    fn review_updates_status_and_outcome() {
544        let tmp = tempfile::tempdir().unwrap();
545        let board_dir = tmp.path();
546        let task_path = write_task_file(board_dir, 10, "review");
547
548        cmd_review(board_dir, 10, "approved", None).unwrap();
549
550        let task = Task::from_file(&task_path).unwrap();
551        assert_eq!(task.status, "done");
552        let metadata = read_workflow_metadata(&task_path).unwrap();
553        assert_eq!(metadata.outcome.as_deref(), Some("approved"));
554    }
555
556    #[test]
557    fn update_writes_board_metadata_and_block_reason() {
558        let tmp = tempfile::tempdir().unwrap();
559        let board_dir = tmp.path();
560        let task_path = write_task_file(board_dir, 11, "blocked");
561
562        let fields = HashMap::from([
563            ("branch".to_string(), "eng-1-2/task-11".to_string()),
564            ("commit".to_string(), "abc1234".to_string()),
565            ("blocked_on".to_string(), "waiting for review".to_string()),
566        ]);
567        cmd_update(board_dir, 11, fields).unwrap();
568
569        let metadata = read_workflow_metadata(&task_path).unwrap();
570        assert_eq!(metadata.branch.as_deref(), Some("eng-1-2/task-11"));
571        assert_eq!(metadata.commit.as_deref(), Some("abc1234"));
572
573        let task = Task::from_file(&task_path).unwrap();
574        assert_eq!(task.blocked.as_deref(), Some("waiting for review"));
575        assert_eq!(task.blocked_on.as_deref(), Some("waiting for review"));
576
577        cmd_update(
578            board_dir,
579            11,
580            HashMap::from([("clear_blocked".to_string(), "true".to_string())]),
581        )
582        .unwrap();
583
584        let task = Task::from_file(&task_path).unwrap();
585        assert!(task.blocked.is_none());
586        assert!(task.blocked_on.is_none());
587    }
588
589    #[test]
590    fn update_requires_at_least_one_field() {
591        let tmp = tempfile::tempdir().unwrap();
592        let board_dir = tmp.path();
593        write_task_file(board_dir, 12, "todo");
594
595        let error = cmd_update(board_dir, 12, HashMap::new())
596            .unwrap_err()
597            .to_string();
598        assert!(error.contains("no workflow fields provided"));
599    }
600
601    #[test]
602    fn task_commands_work_without_orchestrator_runtime() {
603        let tmp = tempfile::tempdir().unwrap();
604        let board_dir = tmp.path();
605        let task_path = write_task_file(board_dir, 13, "todo");
606
607        cmd_assign(board_dir, 13, Some("eng-1-2"), Some("manager-1")).unwrap();
608        cmd_transition(board_dir, 13, "in-progress").unwrap();
609        cmd_transition(board_dir, 13, "review").unwrap();
610        cmd_update(
611            board_dir,
612            13,
613            HashMap::from([
614                ("branch".to_string(), "eng-1-2/task-13".to_string()),
615                ("commit".to_string(), "deadbeef".to_string()),
616            ]),
617        )
618        .unwrap();
619        cmd_review(board_dir, 13, "approved", None).unwrap();
620
621        let task = Task::from_file(&task_path).unwrap();
622        let metadata = read_workflow_metadata(&task_path).unwrap();
623        assert_eq!(task.status, "done");
624        assert_eq!(task.claimed_by.as_deref(), Some("eng-1-2"));
625        assert_eq!(task.review_owner.as_deref(), Some("manager-1"));
626        assert_eq!(metadata.branch.as_deref(), Some("eng-1-2/task-13"));
627        assert_eq!(metadata.commit.as_deref(), Some("deadbeef"));
628        assert_eq!(metadata.outcome.as_deref(), Some("approved"));
629    }
630
631    fn write_review_task_with_engineer(dir: &Path, id: u32, engineer: &str) -> PathBuf {
632        let tasks_dir = dir.join("tasks");
633        std::fs::create_dir_all(&tasks_dir).unwrap();
634        let path = tasks_dir.join(format!("{id:03}-task-{id}.md"));
635        std::fs::write(
636            &path,
637            format!(
638                "---\nid: {id}\ntitle: Task {id}\nstatus: review\npriority: high\nclass: standard\nclaimed_by: {engineer}\n---\n\nTask body.\n"
639            ),
640        )
641        .unwrap();
642        path
643    }
644
645    #[test]
646    fn review_feedback_stored_in_task() {
647        let tmp = tempfile::tempdir().unwrap();
648        let board_dir = tmp.path();
649        let task_path = write_review_task_with_engineer(board_dir, 42, "eng-1-2");
650
651        cmd_review(
652            board_dir,
653            42,
654            "changes_requested",
655            Some("fix the error handling"),
656        )
657        .unwrap();
658
659        let content = std::fs::read_to_string(&task_path).unwrap();
660        assert!(
661            content.contains("fix the error handling"),
662            "feedback should be stored in task frontmatter"
663        );
664    }
665
666    #[test]
667    fn review_feedback_delivered_to_engineer() {
668        let tmp = tempfile::tempdir().unwrap();
669
670        // Create project structure: board_dir must be at <root>/.batty/team_config/board
671        let project_root = tmp.path().join("project");
672        let actual_board_dir = project_root
673            .join(".batty")
674            .join("team_config")
675            .join("board");
676        std::fs::create_dir_all(actual_board_dir.join("tasks")).unwrap();
677
678        // Create inbox for engineer
679        let inbox_root = crate::team::inbox::inboxes_root(&project_root);
680        crate::team::inbox::init_inbox(&inbox_root, "eng-1-2").unwrap();
681
682        // Write task in the actual board dir
683        let task_path = actual_board_dir.join("tasks").join("042-task-42.md");
684        std::fs::write(
685            &task_path,
686            "---\nid: 42\ntitle: Task 42\nstatus: review\npriority: high\nclass: standard\nclaimed_by: eng-1-2\n---\n\nTask body.\n",
687        )
688        .unwrap();
689
690        cmd_review(
691            &actual_board_dir,
692            42,
693            "changes_requested",
694            Some("fix the error handling"),
695        )
696        .unwrap();
697
698        let pending = crate::team::inbox::pending_messages(&inbox_root, "eng-1-2").unwrap();
699        assert_eq!(pending.len(), 1);
700        assert!(
701            pending[0].body.contains("fix the error handling"),
702            "feedback message should be delivered to engineer inbox"
703        );
704        assert!(pending[0].body.contains("#42"));
705    }
706
707    #[test]
708    fn schedule_task_sets_scheduled_for() {
709        let tmp = tempfile::tempdir().unwrap();
710        let board_dir = tmp.path();
711        let task_path = write_task_file(board_dir, 60, "todo");
712
713        cmd_schedule(
714            board_dir,
715            60,
716            Some("2026-03-25T09:00:00-04:00"),
717            None,
718            false,
719        )
720        .unwrap();
721
722        let task = Task::from_file(&task_path).unwrap();
723        assert_eq!(
724            task.scheduled_for.as_deref(),
725            Some("2026-03-25T09:00:00-04:00")
726        );
727        assert!(task.cron_schedule.is_none());
728    }
729
730    #[test]
731    fn schedule_task_sets_cron_schedule() {
732        let tmp = tempfile::tempdir().unwrap();
733        let board_dir = tmp.path();
734        let task_path = write_task_file(board_dir, 61, "todo");
735
736        cmd_schedule(board_dir, 61, None, Some("0 9 * * *"), false).unwrap();
737
738        let task = Task::from_file(&task_path).unwrap();
739        assert!(task.scheduled_for.is_none());
740        assert_eq!(task.cron_schedule.as_deref(), Some("0 9 * * *"));
741    }
742
743    #[test]
744    fn schedule_task_clear_removes_fields() {
745        let tmp = tempfile::tempdir().unwrap();
746        let board_dir = tmp.path();
747        let task_path = write_task_file(board_dir, 62, "todo");
748
749        // Set both fields first
750        cmd_schedule(
751            board_dir,
752            62,
753            Some("2026-04-01T00:00:00Z"),
754            Some("0 9 * * 1"),
755            false,
756        )
757        .unwrap();
758        let task = Task::from_file(&task_path).unwrap();
759        assert!(task.scheduled_for.is_some());
760        assert!(task.cron_schedule.is_some());
761
762        // Clear
763        cmd_schedule(board_dir, 62, None, None, true).unwrap();
764        let task = Task::from_file(&task_path).unwrap();
765        assert!(task.scheduled_for.is_none());
766        assert!(task.cron_schedule.is_none());
767    }
768
769    #[test]
770    fn schedule_task_sets_both() {
771        let tmp = tempfile::tempdir().unwrap();
772        let board_dir = tmp.path();
773        let task_path = write_task_file(board_dir, 63, "todo");
774
775        cmd_schedule(
776            board_dir,
777            63,
778            Some("2026-04-01T00:00:00Z"),
779            Some("0 9 * * 1"),
780            false,
781        )
782        .unwrap();
783
784        let task = Task::from_file(&task_path).unwrap();
785        assert_eq!(task.scheduled_for.as_deref(), Some("2026-04-01T00:00:00Z"));
786        assert_eq!(task.cron_schedule.as_deref(), Some("0 9 * * 1"));
787    }
788
789    #[test]
790    fn schedule_rejects_invalid_timestamp() {
791        let tmp = tempfile::tempdir().unwrap();
792        let board_dir = tmp.path();
793        write_task_file(board_dir, 64, "todo");
794
795        let err = cmd_schedule(board_dir, 64, Some("not-a-date"), None, false)
796            .unwrap_err()
797            .to_string();
798        assert!(err.contains("invalid RFC3339 timestamp"));
799    }
800
801    #[test]
802    fn schedule_rejects_invalid_cron() {
803        let tmp = tempfile::tempdir().unwrap();
804        let board_dir = tmp.path();
805        write_task_file(board_dir, 65, "todo");
806
807        let err = cmd_schedule(board_dir, 65, None, Some("not-a-cron"), false)
808            .unwrap_err()
809            .to_string();
810        assert!(err.contains("invalid cron expression"));
811    }
812
813    #[test]
814    fn schedule_requires_at_least_one_flag() {
815        let tmp = tempfile::tempdir().unwrap();
816        let board_dir = tmp.path();
817        write_task_file(board_dir, 66, "todo");
818
819        let err = cmd_schedule(board_dir, 66, None, None, false)
820            .unwrap_err()
821            .to_string();
822        assert!(err.contains("at least one of --at, --cron, or --clear"));
823    }
824
825    // --- Structured review tests ---
826
827    #[test]
828    fn structured_review_approve_stores_frontmatter_and_moves_to_done() {
829        let tmp = tempfile::tempdir().unwrap();
830        let board_dir = tmp.path();
831        let task_path = write_task_file(board_dir, 70, "review");
832
833        cmd_review_structured(board_dir, 70, "approve", None, "manager-1").unwrap();
834
835        let task = Task::from_file(&task_path).unwrap();
836        assert_eq!(task.status, "done");
837
838        let content = std::fs::read_to_string(&task_path).unwrap();
839        assert!(content.contains("review_disposition: approved"));
840        assert!(content.contains("reviewed_by: manager-1"));
841        assert!(content.contains("reviewed_at:"));
842
843        let metadata = read_workflow_metadata(&task_path).unwrap();
844        assert_eq!(metadata.outcome.as_deref(), Some("approved"));
845    }
846
847    #[test]
848    fn structured_review_request_changes_stores_feedback_and_moves_to_in_progress() {
849        let tmp = tempfile::tempdir().unwrap();
850        let board_dir = tmp.path();
851        let task_path = write_task_file(board_dir, 71, "review");
852
853        cmd_review_structured(
854            board_dir,
855            71,
856            "request-changes",
857            Some("fix the error handling"),
858            "manager-1",
859        )
860        .unwrap();
861
862        let task = Task::from_file(&task_path).unwrap();
863        assert_eq!(task.status, "in-progress");
864
865        let content = std::fs::read_to_string(&task_path).unwrap();
866        assert!(content.contains("review_disposition: changes_requested"));
867        assert!(content.contains("review_feedback: fix the error handling"));
868        assert!(content.contains("reviewed_by: manager-1"));
869        assert!(content.contains("reviewed_at:"));
870    }
871
872    #[test]
873    fn structured_review_reject_moves_to_blocked_with_reason() {
874        let tmp = tempfile::tempdir().unwrap();
875        let board_dir = tmp.path();
876        let task_path = write_task_file(board_dir, 72, "review");
877
878        cmd_review_structured(
879            board_dir,
880            72,
881            "reject",
882            Some("does not meet requirements"),
883            "manager-1",
884        )
885        .unwrap();
886
887        let task = Task::from_file(&task_path).unwrap();
888        assert_eq!(task.status, "blocked");
889
890        let content = std::fs::read_to_string(&task_path).unwrap();
891        assert!(content.contains("review_disposition: rejected"));
892        assert!(content.contains("review_feedback: does not meet requirements"));
893        assert!(content.contains("reviewed_by: manager-1"));
894        assert!(content.contains("blocked_on: does not meet requirements"));
895    }
896
897    #[test]
898    fn structured_review_reject_without_feedback_uses_default_reason() {
899        let tmp = tempfile::tempdir().unwrap();
900        let board_dir = tmp.path();
901        let task_path = write_task_file(board_dir, 73, "review");
902
903        cmd_review_structured(board_dir, 73, "reject", None, "manager-1").unwrap();
904
905        let task = Task::from_file(&task_path).unwrap();
906        assert_eq!(task.status, "blocked");
907
908        let content = std::fs::read_to_string(&task_path).unwrap();
909        assert!(content.contains("blocked_on: rejected by manager-1"));
910    }
911
912    #[test]
913    fn structured_review_rejects_non_review_state() {
914        let tmp = tempfile::tempdir().unwrap();
915        let board_dir = tmp.path();
916        write_task_file(board_dir, 74, "in-progress");
917
918        let err = cmd_review_structured(board_dir, 74, "approve", None, "manager-1")
919            .unwrap_err()
920            .to_string();
921        assert!(err.contains("illegal task state transition"));
922    }
923
924    #[test]
925    fn structured_review_feedback_delivered_to_engineer_inbox() {
926        let tmp = tempfile::tempdir().unwrap();
927
928        // Create project structure: board_dir must be at <root>/.batty/team_config/board
929        let project_root = tmp.path().join("project");
930        let actual_board_dir = project_root
931            .join(".batty")
932            .join("team_config")
933            .join("board");
934        std::fs::create_dir_all(actual_board_dir.join("tasks")).unwrap();
935
936        // Create inbox for engineer
937        let inbox_root = crate::team::inbox::inboxes_root(&project_root);
938        crate::team::inbox::init_inbox(&inbox_root, "eng-1-2").unwrap();
939
940        // Write task in the actual board dir
941        let task_path = actual_board_dir.join("tasks").join("075-task-75.md");
942        std::fs::write(
943            &task_path,
944            "---\nid: 75\ntitle: Task 75\nstatus: review\npriority: high\nclass: standard\nclaimed_by: eng-1-2\n---\n\nTask body.\n",
945        )
946        .unwrap();
947
948        cmd_review_structured(
949            &actual_board_dir,
950            75,
951            "request-changes",
952            Some("add more tests"),
953            "manager-1",
954        )
955        .unwrap();
956
957        let pending = crate::team::inbox::pending_messages(&inbox_root, "eng-1-2").unwrap();
958        assert_eq!(pending.len(), 1);
959        assert!(pending[0].body.contains("add more tests"));
960        assert!(pending[0].body.contains("#75"));
961    }
962}