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