1use anyhow::{Context, Result, bail};
2use serde::Deserialize;
3use std::path::{Path, PathBuf};
4
5use crate::config::Policy;
6
7#[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#[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#[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 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 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 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
142fn 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 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..]; let body = body.strip_prefix('\n').unwrap_or(body);
160
161 Ok((frontmatter, body))
162}
163
164fn 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 let config_text = config_section.trim();
173
174 if let Ok(config) = toml::from_str::<TaskBattyConfig>(config_text) {
176 return (description, Some(config));
177 }
178
179 if let Some(start) = config_text.find("```") {
181 let after_fence = &config_text[start + 3..];
182 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
199pub 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 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; }
606 let tasks = load_tasks_from_dir(phase1_dir).unwrap();
607 assert!(!tasks.is_empty());
608 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}