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