1use anyhow::{Context, Result, bail};
2use chrono::{DateTime, FixedOffset, Utc};
3use serde::{Deserialize, Deserializer};
4use serde_yaml::{Mapping, Value};
5use std::path::{Path, PathBuf};
6
7use crate::config::Policy;
8
9fn deserialize_blocked_field<'de, D>(deserializer: D) -> Result<Option<String>, D::Error>
16where
17 D: Deserializer<'de>,
18{
19 #[derive(Deserialize)]
20 #[serde(untagged)]
21 enum BlockedField {
22 String(String),
23 Bool(bool),
24 }
25
26 let raw: Option<BlockedField> = Option::deserialize(deserializer)?;
27 Ok(match raw {
28 Some(BlockedField::String(s)) => Some(s),
29 Some(BlockedField::Bool(true)) => Some("blocked".to_string()),
30 Some(BlockedField::Bool(false)) => None,
31 None => None,
32 })
33}
34
35#[derive(Debug)]
37pub struct Task {
38 pub id: u32,
39 pub title: String,
40 pub status: String,
41 pub priority: String,
42 pub claimed_by: Option<String>,
43 pub claimed_at: Option<String>,
44 pub claim_ttl_secs: Option<u64>,
45 pub claim_expires_at: Option<String>,
46 pub last_progress_at: Option<String>,
47 pub claim_warning_sent_at: Option<String>,
48 pub claim_extensions: Option<u32>,
49 pub last_output_bytes: Option<u64>,
50 pub blocked: Option<String>,
51 pub tags: Vec<String>,
52 pub depends_on: Vec<u32>,
53 pub review_owner: Option<String>,
54 pub blocked_on: Option<String>,
55 pub worktree_path: Option<String>,
56 pub branch: Option<String>,
57 pub commit: Option<String>,
58 pub artifacts: Vec<String>,
59 pub next_action: Option<String>,
60 pub scheduled_for: Option<String>,
61 pub cron_schedule: Option<String>,
62 pub cron_last_run: Option<String>,
63 pub completed: Option<String>,
64 pub description: String,
65 pub batty_config: Option<TaskBattyConfig>,
66 pub source_path: PathBuf,
67}
68
69#[derive(Debug, Deserialize, Default)]
71pub struct TaskBattyConfig {
72 pub agent: Option<String>,
73 pub policy: Option<Policy>,
74 pub dod: Option<String>,
75 pub max_retries: Option<u32>,
76}
77
78#[derive(Debug, Deserialize)]
80struct Frontmatter {
81 id: u32,
82 title: String,
83 #[serde(default = "default_status")]
84 status: String,
85 #[serde(default)]
86 priority: String,
87 #[serde(default)]
88 claimed_by: Option<String>,
89 #[serde(default)]
90 claimed_at: Option<String>,
91 #[serde(default)]
92 claim_ttl_secs: Option<u64>,
93 #[serde(default)]
94 claim_expires_at: Option<String>,
95 #[serde(default)]
96 last_progress_at: Option<String>,
97 #[serde(default)]
98 claim_warning_sent_at: Option<String>,
99 #[serde(default)]
100 claim_extensions: Option<u32>,
101 #[serde(default)]
102 last_output_bytes: Option<u64>,
103 #[serde(default, deserialize_with = "deserialize_blocked_field")]
104 blocked: Option<String>,
105 #[serde(default)]
106 block_reason: Option<String>,
107 #[serde(default)]
108 tags: Vec<String>,
109 #[serde(default)]
110 depends_on: Vec<u32>,
111 #[serde(default)]
112 review_owner: Option<String>,
113 #[serde(default)]
114 blocked_on: Option<String>,
115 #[serde(default)]
116 worktree_path: Option<String>,
117 #[serde(default)]
118 branch: Option<String>,
119 #[serde(default)]
120 commit: Option<String>,
121 #[serde(default)]
122 artifacts: Vec<String>,
123 #[serde(default)]
124 next_action: Option<String>,
125 #[serde(default)]
126 scheduled_for: Option<String>,
127 #[serde(default)]
128 cron_schedule: Option<String>,
129 #[serde(default)]
130 cron_last_run: Option<String>,
131 #[serde(default)]
132 completed: Option<String>,
133}
134
135fn default_status() -> String {
136 "backlog".to_string()
137}
138
139#[derive(Debug, Clone, Default, PartialEq, Eq)]
140pub(crate) struct TaskFrontmatterCompatRepair {
141 pub repaired_fields: Vec<String>,
142 pub blocked_reason: Option<String>,
143}
144
145const COMPAT_TIMESTAMP_FIELDS: &[&str] = &[
146 "created",
147 "started",
148 "updated",
149 "completed",
150 "claimed_at",
151 "claim_expires_at",
152 "last_progress_at",
153 "claim_warning_sent_at",
154 "scheduled_for",
155 "cron_last_run",
156 "reviewed_at",
157];
158
159impl Task {
160 pub fn is_schedule_blocked(&self) -> bool {
162 self.scheduled_for.as_ref().is_some_and(|scheduled| {
163 parse_frontmatter_timestamp_compat(scheduled).is_some_and(|ts| ts > Utc::now())
164 })
165 }
166
167 pub fn from_file(path: &Path) -> Result<Self> {
169 let contents = std::fs::read_to_string(path)
170 .with_context(|| format!("failed to read task file: {}", path.display()))?;
171 let normalized = normalize_task_frontmatter_content(&contents)?;
172 let contents = match normalized {
173 Some((updated, _)) => {
174 std::fs::write(path, &updated)
175 .with_context(|| format!("failed to repair task file: {}", path.display()))?;
176 updated
177 }
178 None => contents,
179 };
180 let mut task = Self::parse(&contents)
181 .with_context(|| format!("failed to parse task file: {}", path.display()))?;
182 task.source_path = path.to_path_buf();
183 Ok(task)
184 }
185
186 pub fn parse(content: &str) -> Result<Self> {
188 let (frontmatter_str, body) = split_frontmatter(content)?;
189
190 let fm: Frontmatter =
191 serde_yaml::from_str(frontmatter_str).context("failed to parse YAML frontmatter")?;
192
193 let (description, batty_config) = parse_body(body);
194
195 Ok(Task {
196 id: fm.id,
197 title: fm.title,
198 status: fm.status,
199 priority: fm.priority,
200 claimed_by: fm.claimed_by,
201 claimed_at: fm.claimed_at,
202 claim_ttl_secs: fm.claim_ttl_secs,
203 claim_expires_at: fm.claim_expires_at,
204 last_progress_at: fm.last_progress_at,
205 claim_warning_sent_at: fm.claim_warning_sent_at,
206 claim_extensions: fm.claim_extensions,
207 last_output_bytes: fm.last_output_bytes,
208 blocked: fm
211 .block_reason
212 .or(fm.blocked)
213 .or_else(|| fm.blocked_on.clone()),
214 tags: fm.tags,
215 depends_on: fm.depends_on,
216 review_owner: fm.review_owner,
217 blocked_on: fm.blocked_on,
218 worktree_path: fm.worktree_path,
219 branch: fm.branch,
220 commit: fm.commit,
221 artifacts: fm.artifacts,
222 next_action: fm.next_action,
223 scheduled_for: fm.scheduled_for,
224 cron_schedule: fm.cron_schedule,
225 cron_last_run: fm.cron_last_run,
226 completed: fm.completed,
227 description,
228 batty_config,
229 source_path: PathBuf::new(),
230 })
231 }
232}
233
234fn split_frontmatter(content: &str) -> Result<(&str, &str)> {
236 let trimmed = content.trim_start();
237 if !trimmed.starts_with("---") {
238 bail!("task file missing YAML frontmatter (no opening ---)");
239 }
240
241 let after_open = &trimmed[3..];
243 let after_open = after_open.strip_prefix('\n').unwrap_or(after_open);
244
245 let close_pos = after_open
246 .find("\n---")
247 .context("task file missing closing --- for frontmatter")?;
248
249 let frontmatter = &after_open[..close_pos];
250 let body = &after_open[close_pos + 4..]; let body = body.strip_prefix('\n').unwrap_or(body);
252
253 Ok((frontmatter, body))
254}
255
256fn yaml_key(key: &str) -> Value {
257 Value::String(key.to_string())
258}
259
260fn clear_blocked(mapping: &mut Mapping) {
261 mapping.remove(yaml_key("blocked"));
262 mapping.remove(yaml_key("block_reason"));
263 mapping.remove(yaml_key("blocked_on"));
264}
265
266fn set_optional_string(mapping: &mut Mapping, key: &str, value: Option<&str>) {
267 let key = yaml_key(key);
268 match value {
269 Some(value) => {
270 mapping.insert(key, Value::String(value.to_string()));
271 }
272 None => {
273 mapping.remove(key);
274 }
275 }
276}
277
278fn set_blocked_reason(mapping: &mut Mapping, reason: Option<&str>, blocked_on: Option<&str>) {
279 if reason.is_none() && blocked_on.is_none() {
280 clear_blocked(mapping);
281 return;
282 }
283
284 mapping.insert(yaml_key("blocked"), Value::Bool(true));
285 set_optional_string(mapping, "block_reason", reason);
286 set_optional_string(mapping, "blocked_on", blocked_on.or(reason));
287}
288
289fn legacy_offset_candidate(value: &str) -> Option<String> {
290 let trimmed = value.trim();
291 if trimmed.len() < 5 {
292 return None;
293 }
294
295 let sign_idx = trimmed.len().checked_sub(5)?;
296 let suffix = &trimmed[sign_idx..];
297 if !(suffix.starts_with('+') || suffix.starts_with('-')) {
298 return None;
299 }
300 if !suffix[1..].chars().all(|ch| ch.is_ascii_digit()) {
301 return None;
302 }
303
304 let mut normalized = trimmed.to_string();
305 normalized.insert(normalized.len() - 2, ':');
306 Some(normalized)
307}
308
309fn normalize_legacy_timestamp_value(value: &str) -> Option<String> {
310 let trimmed = value.trim();
311 if trimmed.is_empty() || DateTime::parse_from_rfc3339(trimmed).is_ok() {
312 return None;
313 }
314
315 let candidate = legacy_offset_candidate(trimmed)?;
316 DateTime::parse_from_rfc3339(&candidate)
317 .ok()
318 .map(|parsed| parsed.to_rfc3339())
319}
320
321pub(crate) fn parse_frontmatter_timestamp(value: &str) -> Option<DateTime<FixedOffset>> {
322 let trimmed = value.trim();
323 DateTime::parse_from_rfc3339(trimmed).ok().or_else(|| {
324 normalize_legacy_timestamp_value(trimmed)
325 .and_then(|normalized| DateTime::parse_from_rfc3339(&normalized).ok())
326 })
327}
328
329pub(crate) fn parse_frontmatter_timestamp_compat(value: &str) -> Option<DateTime<Utc>> {
330 parse_frontmatter_timestamp(value).map(|timestamp| timestamp.with_timezone(&Utc))
331}
332
333fn normalize_timestamp_frontmatter_fields(mapping: &mut Mapping) -> Vec<String> {
334 let mut repaired = Vec::new();
335 for field in COMPAT_TIMESTAMP_FIELDS {
336 let key = yaml_key(field);
337 let Some(raw_value) = mapping.get(&key).and_then(Value::as_str) else {
338 continue;
339 };
340 let Some(normalized) = normalize_legacy_timestamp_value(raw_value) else {
341 continue;
342 };
343 if raw_value == normalized {
344 continue;
345 }
346 mapping.insert(key, Value::String(normalized));
347 repaired.push((*field).to_string());
348 }
349 repaired
350}
351
352fn render_frontmatter_content(mapping: &Mapping, body: &str) -> Result<String> {
353 let mut rendered =
354 serde_yaml::to_string(mapping).context("failed to serialize task frontmatter")?;
355 if let Some(stripped) = rendered.strip_prefix("---\n") {
356 rendered = stripped.to_string();
357 }
358
359 let mut updated = String::from("---\n");
360 updated.push_str(&rendered);
361 if !updated.ends_with('\n') {
362 updated.push('\n');
363 }
364 updated.push_str("---\n");
365 updated.push_str(body);
366 Ok(updated)
367}
368
369fn normalize_task_frontmatter_content(
370 content: &str,
371) -> Result<Option<(String, TaskFrontmatterCompatRepair)>> {
372 let (frontmatter, body) = split_frontmatter(content)?;
373 let mut mapping: Mapping =
374 serde_yaml::from_str(frontmatter).context("failed to parse YAML frontmatter")?;
375 let mut repaired_fields = normalize_timestamp_frontmatter_fields(&mut mapping);
376
377 let blocked_value = mapping.get(yaml_key("blocked")).cloned();
378 let block_reason = mapping
379 .get(yaml_key("block_reason"))
380 .and_then(Value::as_str)
381 .map(str::to_string);
382 let blocked_on = mapping
383 .get(yaml_key("blocked_on"))
384 .and_then(Value::as_str)
385 .map(str::to_string);
386 let status_is_blocked = mapping
387 .get(yaml_key("status"))
388 .and_then(Value::as_str)
389 .is_some_and(|status| status == "blocked");
390
391 let rewrites_hidden_string_block = matches!(
392 blocked_value.as_ref(),
393 Some(Value::String(reason)) if !reason.trim().is_empty()
394 );
395 let legacy_reason = match blocked_value.as_ref() {
396 Some(Value::String(reason)) if !reason.trim().is_empty() => Some(reason.as_str()),
397 Some(Value::Bool(true)) => block_reason.as_deref().or(blocked_on.as_deref()),
398 Some(Value::Bool(false)) => None,
399 _ => block_reason.as_deref().or(blocked_on.as_deref()),
400 };
401
402 let desired_reason = legacy_reason;
403 let desired_blocked_on = blocked_on.as_deref().or(desired_reason).map(str::to_string);
404 let rewrites_incomplete_blocked_task = status_is_blocked
412 && legacy_reason.is_some()
413 && (!matches!(blocked_value.as_ref(), Some(Value::Bool(true)))
414 || block_reason.as_deref() != desired_reason
415 || blocked_on.as_deref() != desired_blocked_on.as_deref());
416 let rewrites_incomplete_bool_shape = matches!(blocked_value, Some(Value::Bool(true)))
417 && (block_reason.as_deref() != desired_reason
418 || mapping.get(yaml_key("blocked_on")).and_then(Value::as_str)
419 != desired_blocked_on.as_deref());
420
421 if !rewrites_hidden_string_block
422 && !rewrites_incomplete_blocked_task
423 && !rewrites_incomplete_bool_shape
424 && repaired_fields.is_empty()
425 {
426 return Ok(None);
427 }
428
429 if rewrites_hidden_string_block
430 || rewrites_incomplete_blocked_task
431 || rewrites_incomplete_bool_shape
432 {
433 set_blocked_reason(&mut mapping, desired_reason, desired_blocked_on.as_deref());
434 repaired_fields.extend([
435 "blocked".to_string(),
436 "block_reason".to_string(),
437 "blocked_on".to_string(),
438 ]);
439 }
440
441 repaired_fields.sort();
442 repaired_fields.dedup();
443
444 let updated = render_frontmatter_content(&mapping, body)?;
445 Ok(Some((
446 updated,
447 TaskFrontmatterCompatRepair {
448 repaired_fields,
449 blocked_reason: desired_reason.map(str::to_string),
450 },
451 )))
452}
453
454pub(crate) fn repair_task_frontmatter_compat(
455 task_path: &Path,
456) -> Result<Option<TaskFrontmatterCompatRepair>> {
457 let contents = std::fs::read_to_string(task_path)
458 .with_context(|| format!("failed to read task file: {}", task_path.display()))?;
459 let Some((updated, repair)) = normalize_task_frontmatter_content(&contents)? else {
460 return Ok(None);
461 };
462 std::fs::write(task_path, updated)
463 .with_context(|| format!("failed to repair task file: {}", task_path.display()))?;
464 Ok(Some(repair))
465}
466
467fn parse_body(body: &str) -> (String, Option<TaskBattyConfig>) {
469 let marker = "## Batty Config";
470 if let Some(pos) = body.find(marker) {
471 let description = body[..pos].trim().to_string();
472 let config_section = &body[pos + marker.len()..];
473
474 let config_text = config_section.trim();
476
477 if let Ok(config) = toml::from_str::<TaskBattyConfig>(config_text) {
479 return (description, Some(config));
480 }
481
482 if let Some(start) = config_text.find("```") {
484 let after_fence = &config_text[start + 3..];
485 let inner_start = after_fence.find('\n').map(|i| i + 1).unwrap_or(0);
487 let inner = &after_fence[inner_start..];
488 if let Some(end) = inner.find("```") {
489 let block = inner[..end].trim();
490 if let Ok(config) = toml::from_str::<TaskBattyConfig>(block) {
491 return (description, Some(config));
492 }
493 }
494 }
495
496 (description, None)
497 } else {
498 (body.trim().to_string(), None)
499 }
500}
501
502pub fn load_tasks_from_dir(dir: &Path) -> Result<Vec<Task>> {
504 let mut tasks = Vec::new();
505 let entries = std::fs::read_dir(dir)
506 .with_context(|| format!("failed to read tasks directory: {}", dir.display()))?;
507
508 for entry in entries {
509 let entry = entry?;
510 let path = entry.path();
511 if path.extension().is_some_and(|ext| ext == "md") {
512 match Task::from_file(&path) {
513 Ok(task) => tasks.push(task),
514 Err(e) => {
515 tracing::warn!("skipping {}: {e:#}", path.display());
516 }
517 }
518 }
519 }
520
521 tasks.sort_by_key(|t| t.id);
522 Ok(tasks)
523}
524
525fn task_id_from_filename(path: &Path) -> Option<u32> {
526 let name = path.file_name()?.to_str()?;
527 if !name.ends_with(".md") {
528 return None;
529 }
530 name.split('-').next()?.parse::<u32>().ok()
531}
532
533pub fn find_task_path_by_id(tasks_dir: &Path, task_id: u32) -> Result<PathBuf> {
534 let entries = std::fs::read_dir(tasks_dir)
535 .with_context(|| format!("failed to read tasks directory: {}", tasks_dir.display()))?;
536
537 for entry in entries {
538 let entry = entry?;
539 let path = entry.path();
540 if task_id_from_filename(&path) == Some(task_id) {
541 return Ok(path);
542 }
543 }
544
545 load_tasks_from_dir(tasks_dir)?
546 .into_iter()
547 .find(|task| task.id == task_id)
548 .map(|task| task.source_path)
549 .with_context(|| format!("task #{task_id} not found in {}", tasks_dir.display()))
550}
551
552pub fn load_task_by_id(tasks_dir: &Path, task_id: u32) -> Result<Task> {
553 let path = find_task_path_by_id(tasks_dir, task_id)?;
554 Task::from_file(&path)
555}
556
557#[cfg(test)]
558mod tests {
559 use super::*;
560 use std::fs;
561
562 #[test]
563 fn parse_basic_task() {
564 let content = r#"---
565id: 3
566title: kanban-md task file reader
567status: backlog
568priority: critical
569tags:
570 - core
571depends_on:
572 - 1
573class: standard
574---
575
576Read task files from kanban/phase-N/tasks/ directory.
577"#;
578 let task = Task::parse(content).unwrap();
579 assert_eq!(task.id, 3);
580 assert_eq!(task.title, "kanban-md task file reader");
581 assert_eq!(task.status, "backlog");
582 assert_eq!(task.priority, "critical");
583 assert!(task.claimed_by.is_none());
584 assert!(task.blocked.is_none());
585 assert_eq!(task.tags, vec!["core"]);
586 assert_eq!(task.depends_on, vec![1]);
587 assert!(task.review_owner.is_none());
588 assert!(task.blocked_on.is_none());
589 assert!(task.worktree_path.is_none());
590 assert!(task.branch.is_none());
591 assert!(task.commit.is_none());
592 assert!(task.artifacts.is_empty());
593 assert!(task.next_action.is_none());
594 assert!(task.description.contains("Read task files"));
595 assert!(task.batty_config.is_none());
596 }
597
598 #[test]
599 fn parse_task_with_kanban_md_block_flag_uses_block_reason() {
600 let content = r#"---
605id: 42
606title: kanban-md-style blocked task
607status: todo
608priority: high
609blocked: true
610block_reason: "Deferred per architect"
611---
612
613Body.
614"#;
615 let task = Task::parse(content).unwrap();
616 assert_eq!(
617 task.blocked.as_deref(),
618 Some("Deferred per architect"),
619 "block_reason must be surfaced as the blocked reason"
620 );
621 }
622
623 #[test]
624 fn parse_task_with_bool_blocked_only() {
625 let content = r#"---
629id: 43
630title: blocked without reason
631status: todo
632priority: high
633blocked: true
634---
635
636Body.
637"#;
638 let task = Task::parse(content).unwrap();
639 assert!(
640 task.blocked.is_some(),
641 "blocked: true must produce a Some(...) value"
642 );
643 }
644
645 #[test]
646 fn parse_task_with_blocked_on_only_uses_human_reason() {
647 let content = r#"---
648id: 430
649title: blocked via blocked_on only
650status: blocked
651priority: high
652blocked_on: waiting-for-review
653---
654
655Body.
656"#;
657 let task = Task::parse(content).unwrap();
658 assert_eq!(task.blocked.as_deref(), Some("waiting-for-review"));
659 assert_eq!(task.blocked_on.as_deref(), Some("waiting-for-review"));
660 }
661
662 #[test]
663 fn load_tasks_from_dir_repairs_blocked_on_only_shape_to_canonical_frontmatter() {
664 let tmp = tempfile::tempdir().unwrap();
665 let tasks_dir = tmp.path().join("tasks");
666 fs::create_dir_all(&tasks_dir).unwrap();
667 let task_path = tasks_dir.join("430-blocked.md");
668 fs::write(
669 &task_path,
670 "---\nid: 430\ntitle: blocked via blocked_on only\nstatus: blocked\npriority: high\nblocked_on: waiting-for-review\n---\n\nBody.\n",
671 )
672 .unwrap();
673
674 let tasks = load_tasks_from_dir(&tasks_dir).unwrap();
675
676 assert_eq!(tasks.len(), 1);
677 assert_eq!(tasks[0].blocked.as_deref(), Some("waiting-for-review"));
678 let content = fs::read_to_string(&task_path).unwrap();
679 assert!(content.contains("blocked: true"));
680 assert!(content.contains("block_reason: waiting-for-review"));
681 assert!(content.contains("blocked_on: waiting-for-review"));
682 }
683
684 #[test]
685 fn parse_task_with_legacy_string_blocked() {
686 let content = r#"---
689id: 44
690title: legacy blocked task
691status: todo
692priority: high
693blocked: "legacy reason string"
694---
695
696Body.
697"#;
698 let task = Task::parse(content).unwrap();
699 assert_eq!(task.blocked.as_deref(), Some("legacy reason string"));
700 }
701
702 #[test]
703 fn parse_task_with_blocked_false_is_not_blocked() {
704 let content = r#"---
705id: 45
706title: explicitly unblocked
707status: todo
708priority: high
709blocked: false
710---
711
712Body.
713"#;
714 let task = Task::parse(content).unwrap();
715 assert!(task.blocked.is_none());
716 }
717
718 #[test]
719 fn parse_task_with_batty_config_section() {
720 let content = r#"---
721id: 7
722title: PTY supervision
723status: backlog
724priority: high
725tags:
726 - core
727depends_on: []
728class: standard
729---
730
731Implement the PTY supervision layer.
732
733## Batty Config
734
735agent = "codex"
736policy = "act"
737dod = "cargo test"
738max_retries = 5
739"#;
740 let task = Task::parse(content).unwrap();
741 assert_eq!(task.id, 7);
742 assert!(task.description.contains("PTY supervision"));
743 assert!(!task.description.contains("Batty Config"));
744
745 let config = task.batty_config.unwrap();
746 assert_eq!(config.agent.as_deref(), Some("codex"));
747 assert_eq!(config.policy, Some(Policy::Act));
748 assert_eq!(config.dod.as_deref(), Some("cargo test"));
749 assert_eq!(config.max_retries, Some(5));
750 }
751
752 #[test]
753 fn parse_task_with_fenced_batty_config() {
754 let content = r#"---
755id: 8
756title: policy engine
757status: backlog
758priority: high
759tags: []
760depends_on: []
761class: standard
762---
763
764Build the policy engine.
765
766## Batty Config
767
768```toml
769agent = "aider"
770dod = "make test"
771```
772"#;
773 let task = Task::parse(content).unwrap();
774 let config = task.batty_config.unwrap();
775 assert_eq!(config.agent.as_deref(), Some("aider"));
776 assert_eq!(config.dod.as_deref(), Some("make test"));
777 }
778
779 #[test]
780 fn parse_task_no_depends() {
781 let content = r#"---
782id: 1
783title: scaffolding
784status: done
785priority: critical
786tags:
787 - core
788class: standard
789---
790
791Set up the project.
792"#;
793 let task = Task::parse(content).unwrap();
794 assert_eq!(task.id, 1);
795 assert!(task.depends_on.is_empty());
796 }
797
798 #[test]
799 fn parse_task_minimal_frontmatter() {
800 let content = r#"---
801id: 99
802title: minimal task
803---
804
805Just a description.
806"#;
807 let task = Task::parse(content).unwrap();
808 assert_eq!(task.id, 99);
809 assert_eq!(task.status, "backlog");
810 assert!(task.priority.is_empty());
811 assert!(task.claimed_by.is_none());
812 assert!(task.blocked.is_none());
813 assert!(task.tags.is_empty());
814 assert!(task.depends_on.is_empty());
815 assert!(task.review_owner.is_none());
816 assert!(task.blocked_on.is_none());
817 assert!(task.worktree_path.is_none());
818 assert!(task.branch.is_none());
819 assert!(task.commit.is_none());
820 assert!(task.artifacts.is_empty());
821 assert!(task.next_action.is_none());
822 }
823
824 #[test]
825 fn parse_task_without_workflow_metadata_uses_safe_defaults() {
826 let content = r#"---
827id: 100
828title: legacy task
829priority: high
830class: standard
831---
832
833Older task file without workflow metadata.
834"#;
835 let task = Task::parse(content).unwrap();
836 assert_eq!(task.id, 100);
837 assert_eq!(task.status, "backlog");
838 assert!(task.depends_on.is_empty());
839 assert!(task.batty_config.is_none());
840 }
841
842 #[test]
843 fn parse_task_ignores_future_workflow_frontmatter_fields() {
844 let content = r#"---
845id: 101
846title: workflow task
847status: todo
848priority: high
849workflow_state: in_review
850workflow_owner: architect
851class: standard
852---
853
854Task description.
855"#;
856 let task = Task::parse(content).unwrap();
857 assert_eq!(task.id, 101);
858 assert_eq!(task.status, "todo");
859 assert_eq!(task.priority, "high");
860 assert!(task.batty_config.is_none());
861 }
862
863 #[test]
864 fn parse_task_with_claimed_by_and_blocked() {
865 let content = r#"---
866id: 17
867title: assigned task
868status: todo
869priority: high
870claimed_by: eng-1-1
871blocked: waiting-on-review
872class: standard
873---
874
875Task description.
876"#;
877 let task = Task::parse(content).unwrap();
878 assert_eq!(task.claimed_by.as_deref(), Some("eng-1-1"));
879 assert_eq!(task.blocked.as_deref(), Some("waiting-on-review"));
880 }
881
882 #[test]
883 fn parse_task_with_workflow_metadata() {
884 let content = r#"---
885id: 20
886title: workflow metadata
887status: review
888priority: critical
889claimed_by: eng-1-3
890depends_on:
891 - 18
892 - 19
893review_owner: manager
894blocked_on: waiting-for-tests
895worktree_path: .batty/worktrees/eng-1-3
896branch: eng-1-3/task-20
897commit: abc1234
898artifacts:
899 - target/debug/batty
900 - docs/workflow.md
901next_action: Hand off to manager for review
902class: standard
903---
904
905Workflow description.
906"#;
907 let task = Task::parse(content).unwrap();
908 assert_eq!(task.depends_on, vec![18, 19]);
909 assert_eq!(task.review_owner.as_deref(), Some("manager"));
910 assert_eq!(task.blocked_on.as_deref(), Some("waiting-for-tests"));
911 assert_eq!(
912 task.worktree_path.as_deref(),
913 Some(".batty/worktrees/eng-1-3")
914 );
915 assert_eq!(task.branch.as_deref(), Some("eng-1-3/task-20"));
916 assert_eq!(task.commit.as_deref(), Some("abc1234"));
917 assert_eq!(
918 task.artifacts,
919 vec!["target/debug/batty", "docs/workflow.md"]
920 );
921 assert_eq!(
922 task.next_action.as_deref(),
923 Some("Hand off to manager for review")
924 );
925 }
926
927 #[test]
928 fn parse_task_with_all_schedule_fields() {
929 let content = r#"---
930id: 200
931title: scheduled task
932status: backlog
933priority: medium
934scheduled_for: "2026-04-01T09:00:00Z"
935cron_schedule: "0 9 * * 1"
936cron_last_run: "2026-03-21T09:00:00Z"
937---
938
939A task with all schedule fields.
940"#;
941 let task = Task::parse(content).unwrap();
942 assert_eq!(task.id, 200);
943 assert_eq!(task.scheduled_for.as_deref(), Some("2026-04-01T09:00:00Z"));
944 assert_eq!(task.cron_schedule.as_deref(), Some("0 9 * * 1"));
945 assert_eq!(task.cron_last_run.as_deref(), Some("2026-03-21T09:00:00Z"));
946 }
947
948 #[test]
949 fn parse_task_with_no_schedule_fields() {
950 let content = r#"---
951id: 201
952title: no schedule
953status: todo
954---
955
956No schedule fields at all.
957"#;
958 let task = Task::parse(content).unwrap();
959 assert_eq!(task.id, 201);
960 assert!(task.scheduled_for.is_none());
961 assert!(task.cron_schedule.is_none());
962 assert!(task.cron_last_run.is_none());
963 }
964
965 #[test]
966 fn parse_task_with_only_scheduled_for() {
967 let content = r#"---
968id: 202
969title: future task
970status: backlog
971scheduled_for: "2026-06-15T12:00:00Z"
972---
973
974Only scheduled_for set.
975"#;
976 let task = Task::parse(content).unwrap();
977 assert_eq!(task.scheduled_for.as_deref(), Some("2026-06-15T12:00:00Z"));
978 assert!(task.cron_schedule.is_none());
979 assert!(task.cron_last_run.is_none());
980 }
981
982 #[test]
983 fn parse_task_with_only_cron_schedule() {
984 let content = r#"---
985id: 203
986title: recurring task
987status: backlog
988cron_schedule: "30 8 * * *"
989---
990
991Only cron_schedule set.
992"#;
993 let task = Task::parse(content).unwrap();
994 assert!(task.scheduled_for.is_none());
995 assert_eq!(task.cron_schedule.as_deref(), Some("30 8 * * *"));
996 assert!(task.cron_last_run.is_none());
997 }
998
999 #[test]
1000 fn missing_frontmatter_is_error() {
1001 let content = "# No frontmatter here\nJust markdown.";
1002 assert!(Task::parse(content).is_err());
1003 }
1004
1005 #[test]
1006 fn load_from_directory() {
1007 let tmp = tempfile::tempdir().unwrap();
1008 let tasks_dir = tmp.path();
1009
1010 fs::write(
1011 tasks_dir.join("001-first.md"),
1012 r#"---
1013id: 1
1014title: first task
1015status: backlog
1016priority: high
1017tags: []
1018depends_on: []
1019class: standard
1020---
1021
1022First task description.
1023"#,
1024 )
1025 .unwrap();
1026
1027 fs::write(
1028 tasks_dir.join("002-second.md"),
1029 r#"---
1030id: 2
1031title: second task
1032status: todo
1033priority: medium
1034tags: []
1035depends_on:
1036 - 1
1037class: standard
1038---
1039
1040Second task description.
1041"#,
1042 )
1043 .unwrap();
1044
1045 fs::write(tasks_dir.join("notes.txt"), "not a task").unwrap();
1047
1048 let tasks = load_tasks_from_dir(tasks_dir).unwrap();
1049 assert_eq!(tasks.len(), 2);
1050 assert_eq!(tasks[0].id, 1);
1051 assert_eq!(tasks[1].id, 2);
1052 assert_eq!(tasks[1].depends_on, vec![1]);
1053 }
1054
1055 #[test]
1056 fn load_tasks_from_dir_repairs_legacy_timestamp_offsets_in_place() {
1057 let tmp = tempfile::tempdir().unwrap();
1058 let tasks_dir = tmp.path().join("tasks");
1059 fs::create_dir_all(&tasks_dir).unwrap();
1060 let task_path = tasks_dir.join("623-stale-review.md");
1061 fs::write(
1062 &task_path,
1063 "---\nid: 623\ntitle: stale review\nstatus: review\npriority: high\ncreated: 2026-04-10T16:31:02.743151-04:00\nupdated: 2026-04-10T19:26:40-0400\nartifacts:\n - .batty/reports/verification/completion/task-623-eng-1-1-attempt-1.json\nreview_disposition: approved\nreviewed_by: architect\nreviewed_at: 2026-04-10T23:26:40+00:00\n---\n\nTask body.\n",
1064 )
1065 .unwrap();
1066
1067 let tasks = load_tasks_from_dir(&tasks_dir).unwrap();
1068
1069 assert_eq!(tasks.len(), 1);
1070 assert_eq!(tasks[0].id, 623);
1071 let updated = fs::read_to_string(&task_path).unwrap();
1072 assert!(updated.contains("updated: 2026-04-10T19:26:40-04:00"));
1073 assert!(updated.contains("reviewed_by: architect"));
1074 assert!(
1075 updated.contains(
1076 "- .batty/reports/verification/completion/task-623-eng-1-1-attempt-1.json"
1077 )
1078 );
1079 assert!(updated.ends_with("\n\nTask body.\n"));
1080 }
1081
1082 #[test]
1083 fn load_real_phase1_tasks() {
1084 let phase1_dir = Path::new("kanban/phase-1/tasks");
1085 if !phase1_dir.exists() {
1086 return; }
1088 let tasks = load_tasks_from_dir(phase1_dir).unwrap();
1089 assert!(!tasks.is_empty());
1090 let task1 = tasks.iter().find(|t| t.id == 1).unwrap();
1092 assert_eq!(task1.title, "Rust project scaffolding");
1093 }
1094
1095 #[test]
1096 fn is_schedule_blocked_future_returns_true() {
1097 let future = (chrono::Utc::now() + chrono::Duration::hours(1)).to_rfc3339();
1098 let content = format!(
1099 "---\nid: 300\ntitle: future task\nstatus: todo\nscheduled_for: \"{future}\"\n---\n\nDesc.\n"
1100 );
1101 let task = Task::parse(&content).unwrap();
1102 assert!(task.is_schedule_blocked());
1103 }
1104
1105 #[test]
1106 fn is_schedule_blocked_past_returns_false() {
1107 let past = (chrono::Utc::now() - chrono::Duration::hours(1)).to_rfc3339();
1108 let content = format!(
1109 "---\nid: 301\ntitle: past task\nstatus: todo\nscheduled_for: \"{past}\"\n---\n\nDesc.\n"
1110 );
1111 let task = Task::parse(&content).unwrap();
1112 assert!(!task.is_schedule_blocked());
1113 }
1114
1115 #[test]
1116 fn is_schedule_blocked_absent_returns_false() {
1117 let content = "---\nid: 302\ntitle: no schedule\nstatus: todo\n---\n\nDesc.\n";
1118 let task = Task::parse(content).unwrap();
1119 assert!(!task.is_schedule_blocked());
1120 }
1121
1122 #[test]
1123 fn is_schedule_blocked_malformed_returns_false() {
1124 let content = "---\nid: 303\ntitle: bad date\nstatus: todo\nscheduled_for: \"not-a-date\"\n---\n\nDesc.\n";
1125 let task = Task::parse(content).unwrap();
1126 assert!(!task.is_schedule_blocked());
1127 }
1128
1129 #[test]
1130 fn is_schedule_blocked_accepts_legacy_offset_timestamp() {
1131 let future = "2999-04-10T19:26:40-0400";
1132 let content = format!(
1133 "---\nid: 304\ntitle: legacy offset schedule\nstatus: todo\nscheduled_for: \"{future}\"\n---\n\nDesc.\n"
1134 );
1135 let task = Task::parse(&content).unwrap();
1136 assert!(task.is_schedule_blocked());
1137 }
1138
1139 #[test]
1140 fn find_task_path_by_id_handles_slug_rename() {
1141 let tmp = tempfile::tempdir().unwrap();
1142 let tasks_dir = tmp.path().join("tasks");
1143 fs::create_dir_all(&tasks_dir).unwrap();
1144 let renamed = tasks_dir.join("511-renamed-roadmap-item.md");
1145 fs::write(
1146 &renamed,
1147 "---\nid: 511\ntitle: roadmap task renamed\nstatus: todo\npriority: high\nclass: standard\n---\n\nBody.\n",
1148 )
1149 .unwrap();
1150
1151 assert_eq!(find_task_path_by_id(&tasks_dir, 511).unwrap(), renamed);
1152 }
1153
1154 #[test]
1155 fn find_task_path_by_id_uses_unchanged_prefix_fast_path() {
1156 let tmp = tempfile::tempdir().unwrap();
1157 let tasks_dir = tmp.path().join("tasks");
1158 fs::create_dir_all(&tasks_dir).unwrap();
1159 let stable = tasks_dir.join("042-stable-path.md");
1160 fs::write(&stable, "not valid yaml").unwrap();
1161
1162 assert_eq!(find_task_path_by_id(&tasks_dir, 42).unwrap(), stable);
1163 }
1164
1165 #[test]
1166 fn find_task_path_by_id_reports_missing_id() {
1167 let tmp = tempfile::tempdir().unwrap();
1168 let tasks_dir = tmp.path().join("tasks");
1169 fs::create_dir_all(&tasks_dir).unwrap();
1170 fs::write(
1171 tasks_dir.join("001-existing.md"),
1172 "---\nid: 1\ntitle: existing\nstatus: todo\npriority: high\nclass: standard\n---\n\nBody.\n",
1173 )
1174 .unwrap();
1175
1176 let error = find_task_path_by_id(&tasks_dir, 999).unwrap_err();
1177 assert!(error.to_string().contains("task #999 not found"));
1178 }
1179}