Skip to main content

batty_cli/task/
mod.rs

1use anyhow::{Context, Result, bail};
2use serde::Deserialize;
3use std::path::{Path, PathBuf};
4
5use crate::config::Policy;
6
7/// A parsed kanban-md task file.
8#[derive(Debug)]
9pub struct Task {
10    pub id: u32,
11    pub title: String,
12    pub status: String,
13    pub priority: String,
14    pub claimed_by: Option<String>,
15    pub blocked: Option<String>,
16    pub tags: Vec<String>,
17    pub depends_on: Vec<u32>,
18    pub review_owner: Option<String>,
19    pub blocked_on: Option<String>,
20    pub worktree_path: Option<String>,
21    pub branch: Option<String>,
22    pub commit: Option<String>,
23    pub artifacts: Vec<String>,
24    pub next_action: Option<String>,
25    pub scheduled_for: Option<String>,
26    pub cron_schedule: Option<String>,
27    pub cron_last_run: Option<String>,
28    pub completed: Option<String>,
29    pub description: String,
30    pub batty_config: Option<TaskBattyConfig>,
31    pub source_path: PathBuf,
32}
33
34/// Per-task overrides from `## Batty Config` section.
35#[derive(Debug, Deserialize, Default)]
36pub struct TaskBattyConfig {
37    pub agent: Option<String>,
38    pub policy: Option<Policy>,
39    pub dod: Option<String>,
40    pub max_retries: Option<u32>,
41}
42
43/// Raw YAML frontmatter fields from a kanban-md task file.
44#[derive(Debug, Deserialize)]
45struct Frontmatter {
46    id: u32,
47    title: String,
48    #[serde(default = "default_status")]
49    status: String,
50    #[serde(default)]
51    priority: String,
52    #[serde(default)]
53    claimed_by: Option<String>,
54    #[serde(default)]
55    blocked: Option<String>,
56    #[serde(default)]
57    tags: Vec<String>,
58    #[serde(default)]
59    depends_on: Vec<u32>,
60    #[serde(default)]
61    review_owner: Option<String>,
62    #[serde(default)]
63    blocked_on: Option<String>,
64    #[serde(default)]
65    worktree_path: Option<String>,
66    #[serde(default)]
67    branch: Option<String>,
68    #[serde(default)]
69    commit: Option<String>,
70    #[serde(default)]
71    artifacts: Vec<String>,
72    #[serde(default)]
73    next_action: Option<String>,
74    #[serde(default)]
75    scheduled_for: Option<String>,
76    #[serde(default)]
77    cron_schedule: Option<String>,
78    #[serde(default)]
79    cron_last_run: Option<String>,
80    #[serde(default)]
81    completed: Option<String>,
82}
83
84fn default_status() -> String {
85    "backlog".to_string()
86}
87
88impl Task {
89    /// Returns true if this task has a `scheduled_for` timestamp in the future.
90    pub fn is_schedule_blocked(&self) -> bool {
91        self.scheduled_for.as_ref().is_some_and(|scheduled| {
92            chrono::DateTime::parse_from_rfc3339(scheduled).is_ok_and(|ts| ts > chrono::Utc::now())
93        })
94    }
95
96    /// Parse a kanban-md task file from a path.
97    pub fn from_file(path: &Path) -> Result<Self> {
98        let contents = std::fs::read_to_string(path)
99            .with_context(|| format!("failed to read task file: {}", path.display()))?;
100        let mut task = Self::parse(&contents)
101            .with_context(|| format!("failed to parse task file: {}", path.display()))?;
102        task.source_path = path.to_path_buf();
103        Ok(task)
104    }
105
106    /// Parse a kanban-md task from its string content.
107    pub fn parse(content: &str) -> Result<Self> {
108        let (frontmatter_str, body) = split_frontmatter(content)?;
109
110        let fm: Frontmatter =
111            serde_yaml::from_str(frontmatter_str).context("failed to parse YAML frontmatter")?;
112
113        let (description, batty_config) = parse_body(body);
114
115        Ok(Task {
116            id: fm.id,
117            title: fm.title,
118            status: fm.status,
119            priority: fm.priority,
120            claimed_by: fm.claimed_by,
121            blocked: fm.blocked,
122            tags: fm.tags,
123            depends_on: fm.depends_on,
124            review_owner: fm.review_owner,
125            blocked_on: fm.blocked_on,
126            worktree_path: fm.worktree_path,
127            branch: fm.branch,
128            commit: fm.commit,
129            artifacts: fm.artifacts,
130            next_action: fm.next_action,
131            scheduled_for: fm.scheduled_for,
132            cron_schedule: fm.cron_schedule,
133            cron_last_run: fm.cron_last_run,
134            completed: fm.completed,
135            description,
136            batty_config,
137            source_path: PathBuf::new(),
138        })
139    }
140}
141
142/// Split content into YAML frontmatter and Markdown body.
143fn split_frontmatter(content: &str) -> Result<(&str, &str)> {
144    let trimmed = content.trim_start();
145    if !trimmed.starts_with("---") {
146        bail!("task file missing YAML frontmatter (no opening ---)");
147    }
148
149    // Skip the opening "---\n"
150    let after_open = &trimmed[3..];
151    let after_open = after_open.strip_prefix('\n').unwrap_or(after_open);
152
153    let close_pos = after_open
154        .find("\n---")
155        .context("task file missing closing --- for frontmatter")?;
156
157    let frontmatter = &after_open[..close_pos];
158    let body = &after_open[close_pos + 4..]; // skip "\n---"
159    let body = body.strip_prefix('\n').unwrap_or(body);
160
161    Ok((frontmatter, body))
162}
163
164/// Parse the Markdown body, extracting an optional `## Batty Config` section.
165fn parse_body(body: &str) -> (String, Option<TaskBattyConfig>) {
166    let marker = "## Batty Config";
167    if let Some(pos) = body.find(marker) {
168        let description = body[..pos].trim().to_string();
169        let config_section = &body[pos + marker.len()..];
170
171        // Find the TOML content after the heading (skip blank lines)
172        let config_text = config_section.trim();
173
174        // Try to parse as TOML (the natural config format for Batty)
175        if let Ok(config) = toml::from_str::<TaskBattyConfig>(config_text) {
176            return (description, Some(config));
177        }
178
179        // If there's a fenced code block, extract its content
180        if let Some(start) = config_text.find("```") {
181            let after_fence = &config_text[start + 3..];
182            // Skip the language tag line (e.g., "toml\n")
183            let inner_start = after_fence.find('\n').map(|i| i + 1).unwrap_or(0);
184            let inner = &after_fence[inner_start..];
185            if let Some(end) = inner.find("```") {
186                let block = inner[..end].trim();
187                if let Ok(config) = toml::from_str::<TaskBattyConfig>(block) {
188                    return (description, Some(config));
189                }
190            }
191        }
192
193        (description, None)
194    } else {
195        (body.trim().to_string(), None)
196    }
197}
198
199/// Load all task files from a kanban-md tasks directory.
200pub fn load_tasks_from_dir(dir: &Path) -> Result<Vec<Task>> {
201    let mut tasks = Vec::new();
202    let entries = std::fs::read_dir(dir)
203        .with_context(|| format!("failed to read tasks directory: {}", dir.display()))?;
204
205    for entry in entries {
206        let entry = entry?;
207        let path = entry.path();
208        if path.extension().is_some_and(|ext| ext == "md") {
209            match Task::from_file(&path) {
210                Ok(task) => tasks.push(task),
211                Err(e) => {
212                    tracing::warn!("skipping {}: {e:#}", path.display());
213                }
214            }
215        }
216    }
217
218    tasks.sort_by_key(|t| t.id);
219    Ok(tasks)
220}
221
222#[cfg(test)]
223mod tests {
224    use super::*;
225    use std::fs;
226
227    #[test]
228    fn parse_basic_task() {
229        let content = r#"---
230id: 3
231title: kanban-md task file reader
232status: backlog
233priority: critical
234tags:
235    - core
236depends_on:
237    - 1
238class: standard
239---
240
241Read task files from kanban/phase-N/tasks/ directory.
242"#;
243        let task = Task::parse(content).unwrap();
244        assert_eq!(task.id, 3);
245        assert_eq!(task.title, "kanban-md task file reader");
246        assert_eq!(task.status, "backlog");
247        assert_eq!(task.priority, "critical");
248        assert!(task.claimed_by.is_none());
249        assert!(task.blocked.is_none());
250        assert_eq!(task.tags, vec!["core"]);
251        assert_eq!(task.depends_on, vec![1]);
252        assert!(task.review_owner.is_none());
253        assert!(task.blocked_on.is_none());
254        assert!(task.worktree_path.is_none());
255        assert!(task.branch.is_none());
256        assert!(task.commit.is_none());
257        assert!(task.artifacts.is_empty());
258        assert!(task.next_action.is_none());
259        assert!(task.description.contains("Read task files"));
260        assert!(task.batty_config.is_none());
261    }
262
263    #[test]
264    fn parse_task_with_batty_config_section() {
265        let content = r#"---
266id: 7
267title: PTY supervision
268status: backlog
269priority: high
270tags:
271    - core
272depends_on: []
273class: standard
274---
275
276Implement the PTY supervision layer.
277
278## Batty Config
279
280agent = "codex"
281policy = "act"
282dod = "cargo test"
283max_retries = 5
284"#;
285        let task = Task::parse(content).unwrap();
286        assert_eq!(task.id, 7);
287        assert!(task.description.contains("PTY supervision"));
288        assert!(!task.description.contains("Batty Config"));
289
290        let config = task.batty_config.unwrap();
291        assert_eq!(config.agent.as_deref(), Some("codex"));
292        assert_eq!(config.policy, Some(Policy::Act));
293        assert_eq!(config.dod.as_deref(), Some("cargo test"));
294        assert_eq!(config.max_retries, Some(5));
295    }
296
297    #[test]
298    fn parse_task_with_fenced_batty_config() {
299        let content = r#"---
300id: 8
301title: policy engine
302status: backlog
303priority: high
304tags: []
305depends_on: []
306class: standard
307---
308
309Build the policy engine.
310
311## Batty Config
312
313```toml
314agent = "aider"
315dod = "make test"
316```
317"#;
318        let task = Task::parse(content).unwrap();
319        let config = task.batty_config.unwrap();
320        assert_eq!(config.agent.as_deref(), Some("aider"));
321        assert_eq!(config.dod.as_deref(), Some("make test"));
322    }
323
324    #[test]
325    fn parse_task_no_depends() {
326        let content = r#"---
327id: 1
328title: scaffolding
329status: done
330priority: critical
331tags:
332    - core
333class: standard
334---
335
336Set up the project.
337"#;
338        let task = Task::parse(content).unwrap();
339        assert_eq!(task.id, 1);
340        assert!(task.depends_on.is_empty());
341    }
342
343    #[test]
344    fn parse_task_minimal_frontmatter() {
345        let content = r#"---
346id: 99
347title: minimal task
348---
349
350Just a description.
351"#;
352        let task = Task::parse(content).unwrap();
353        assert_eq!(task.id, 99);
354        assert_eq!(task.status, "backlog");
355        assert!(task.priority.is_empty());
356        assert!(task.claimed_by.is_none());
357        assert!(task.blocked.is_none());
358        assert!(task.tags.is_empty());
359        assert!(task.depends_on.is_empty());
360        assert!(task.review_owner.is_none());
361        assert!(task.blocked_on.is_none());
362        assert!(task.worktree_path.is_none());
363        assert!(task.branch.is_none());
364        assert!(task.commit.is_none());
365        assert!(task.artifacts.is_empty());
366        assert!(task.next_action.is_none());
367    }
368
369    #[test]
370    fn parse_task_without_workflow_metadata_uses_safe_defaults() {
371        let content = r#"---
372id: 100
373title: legacy task
374priority: high
375class: standard
376---
377
378Older task file without workflow metadata.
379"#;
380        let task = Task::parse(content).unwrap();
381        assert_eq!(task.id, 100);
382        assert_eq!(task.status, "backlog");
383        assert!(task.depends_on.is_empty());
384        assert!(task.batty_config.is_none());
385    }
386
387    #[test]
388    fn parse_task_ignores_future_workflow_frontmatter_fields() {
389        let content = r#"---
390id: 101
391title: workflow task
392status: todo
393priority: high
394workflow_state: in_review
395workflow_owner: architect
396class: standard
397---
398
399Task description.
400"#;
401        let task = Task::parse(content).unwrap();
402        assert_eq!(task.id, 101);
403        assert_eq!(task.status, "todo");
404        assert_eq!(task.priority, "high");
405        assert!(task.batty_config.is_none());
406    }
407
408    #[test]
409    fn parse_task_with_claimed_by_and_blocked() {
410        let content = r#"---
411id: 17
412title: assigned task
413status: todo
414priority: high
415claimed_by: eng-1-1
416blocked: waiting-on-review
417class: standard
418---
419
420Task description.
421"#;
422        let task = Task::parse(content).unwrap();
423        assert_eq!(task.claimed_by.as_deref(), Some("eng-1-1"));
424        assert_eq!(task.blocked.as_deref(), Some("waiting-on-review"));
425    }
426
427    #[test]
428    fn parse_task_with_workflow_metadata() {
429        let content = r#"---
430id: 20
431title: workflow metadata
432status: review
433priority: critical
434claimed_by: eng-1-3
435depends_on:
436    - 18
437    - 19
438review_owner: manager
439blocked_on: waiting-for-tests
440worktree_path: .batty/worktrees/eng-1-3
441branch: eng-1-3/task-20
442commit: abc1234
443artifacts:
444    - target/debug/batty
445    - docs/workflow.md
446next_action: Hand off to manager for review
447class: standard
448---
449
450Workflow description.
451"#;
452        let task = Task::parse(content).unwrap();
453        assert_eq!(task.depends_on, vec![18, 19]);
454        assert_eq!(task.review_owner.as_deref(), Some("manager"));
455        assert_eq!(task.blocked_on.as_deref(), Some("waiting-for-tests"));
456        assert_eq!(
457            task.worktree_path.as_deref(),
458            Some(".batty/worktrees/eng-1-3")
459        );
460        assert_eq!(task.branch.as_deref(), Some("eng-1-3/task-20"));
461        assert_eq!(task.commit.as_deref(), Some("abc1234"));
462        assert_eq!(
463            task.artifacts,
464            vec!["target/debug/batty", "docs/workflow.md"]
465        );
466        assert_eq!(
467            task.next_action.as_deref(),
468            Some("Hand off to manager for review")
469        );
470    }
471
472    #[test]
473    fn parse_task_with_all_schedule_fields() {
474        let content = r#"---
475id: 200
476title: scheduled task
477status: backlog
478priority: medium
479scheduled_for: "2026-04-01T09:00:00Z"
480cron_schedule: "0 9 * * 1"
481cron_last_run: "2026-03-21T09:00:00Z"
482---
483
484A task with all schedule fields.
485"#;
486        let task = Task::parse(content).unwrap();
487        assert_eq!(task.id, 200);
488        assert_eq!(task.scheduled_for.as_deref(), Some("2026-04-01T09:00:00Z"));
489        assert_eq!(task.cron_schedule.as_deref(), Some("0 9 * * 1"));
490        assert_eq!(task.cron_last_run.as_deref(), Some("2026-03-21T09:00:00Z"));
491    }
492
493    #[test]
494    fn parse_task_with_no_schedule_fields() {
495        let content = r#"---
496id: 201
497title: no schedule
498status: todo
499---
500
501No schedule fields at all.
502"#;
503        let task = Task::parse(content).unwrap();
504        assert_eq!(task.id, 201);
505        assert!(task.scheduled_for.is_none());
506        assert!(task.cron_schedule.is_none());
507        assert!(task.cron_last_run.is_none());
508    }
509
510    #[test]
511    fn parse_task_with_only_scheduled_for() {
512        let content = r#"---
513id: 202
514title: future task
515status: backlog
516scheduled_for: "2026-06-15T12:00:00Z"
517---
518
519Only scheduled_for set.
520"#;
521        let task = Task::parse(content).unwrap();
522        assert_eq!(task.scheduled_for.as_deref(), Some("2026-06-15T12:00:00Z"));
523        assert!(task.cron_schedule.is_none());
524        assert!(task.cron_last_run.is_none());
525    }
526
527    #[test]
528    fn parse_task_with_only_cron_schedule() {
529        let content = r#"---
530id: 203
531title: recurring task
532status: backlog
533cron_schedule: "30 8 * * *"
534---
535
536Only cron_schedule set.
537"#;
538        let task = Task::parse(content).unwrap();
539        assert!(task.scheduled_for.is_none());
540        assert_eq!(task.cron_schedule.as_deref(), Some("30 8 * * *"));
541        assert!(task.cron_last_run.is_none());
542    }
543
544    #[test]
545    fn missing_frontmatter_is_error() {
546        let content = "# No frontmatter here\nJust markdown.";
547        assert!(Task::parse(content).is_err());
548    }
549
550    #[test]
551    fn load_from_directory() {
552        let tmp = tempfile::tempdir().unwrap();
553        let tasks_dir = tmp.path();
554
555        fs::write(
556            tasks_dir.join("001-first.md"),
557            r#"---
558id: 1
559title: first task
560status: backlog
561priority: high
562tags: []
563depends_on: []
564class: standard
565---
566
567First task description.
568"#,
569        )
570        .unwrap();
571
572        fs::write(
573            tasks_dir.join("002-second.md"),
574            r#"---
575id: 2
576title: second task
577status: todo
578priority: medium
579tags: []
580depends_on:
581    - 1
582class: standard
583---
584
585Second task description.
586"#,
587        )
588        .unwrap();
589
590        // Non-markdown file should be skipped
591        fs::write(tasks_dir.join("notes.txt"), "not a task").unwrap();
592
593        let tasks = load_tasks_from_dir(tasks_dir).unwrap();
594        assert_eq!(tasks.len(), 2);
595        assert_eq!(tasks[0].id, 1);
596        assert_eq!(tasks[1].id, 2);
597        assert_eq!(tasks[1].depends_on, vec![1]);
598    }
599
600    #[test]
601    fn load_real_phase1_tasks() {
602        let phase1_dir = Path::new("kanban/phase-1/tasks");
603        if !phase1_dir.exists() {
604            return; // skip if not in repo root
605        }
606        let tasks = load_tasks_from_dir(phase1_dir).unwrap();
607        assert!(!tasks.is_empty());
608        // Task #1 should exist and be done
609        let task1 = tasks.iter().find(|t| t.id == 1).unwrap();
610        assert_eq!(task1.title, "Rust project scaffolding");
611    }
612
613    #[test]
614    fn is_schedule_blocked_future_returns_true() {
615        let future = (chrono::Utc::now() + chrono::Duration::hours(1)).to_rfc3339();
616        let content = format!(
617            "---\nid: 300\ntitle: future task\nstatus: todo\nscheduled_for: \"{future}\"\n---\n\nDesc.\n"
618        );
619        let task = Task::parse(&content).unwrap();
620        assert!(task.is_schedule_blocked());
621    }
622
623    #[test]
624    fn is_schedule_blocked_past_returns_false() {
625        let past = (chrono::Utc::now() - chrono::Duration::hours(1)).to_rfc3339();
626        let content = format!(
627            "---\nid: 301\ntitle: past task\nstatus: todo\nscheduled_for: \"{past}\"\n---\n\nDesc.\n"
628        );
629        let task = Task::parse(&content).unwrap();
630        assert!(!task.is_schedule_blocked());
631    }
632
633    #[test]
634    fn is_schedule_blocked_absent_returns_false() {
635        let content = "---\nid: 302\ntitle: no schedule\nstatus: todo\n---\n\nDesc.\n";
636        let task = Task::parse(content).unwrap();
637        assert!(!task.is_schedule_blocked());
638    }
639
640    #[test]
641    fn is_schedule_blocked_malformed_returns_false() {
642        let content = "---\nid: 303\ntitle: bad date\nstatus: todo\nscheduled_for: \"not-a-date\"\n---\n\nDesc.\n";
643        let task = Task::parse(content).unwrap();
644        assert!(!task.is_schedule_blocked());
645    }
646}