Skip to main content

batty_cli/task/
mod.rs

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
9/// Accept either a string reason or a boolean flag for the `blocked` field.
10///
11/// kanban-md writes `blocked: true` alongside a separate `block_reason` string,
12/// while legacy batty tasks stored `blocked: "reason"` directly. This
13/// deserializer normalizes both shapes into `Option<String>` so downstream
14/// callers can still rely on `task.blocked.is_some()` as "is this blocked".
15fn 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/// A parsed kanban-md task file.
36#[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/// Per-task overrides from `## Batty Config` section.
70#[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/// Raw YAML frontmatter fields from a kanban-md task file.
79#[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    /// Returns true if this task has a `scheduled_for` timestamp in the future.
161    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    /// Parse a kanban-md task file from a path.
168    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    /// Parse a kanban-md task from its string content.
187    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            // Prefer the richer `block_reason` if present so operators see
209            // the real reason, not the "blocked" placeholder from `blocked: true`.
210            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
234/// Split content into YAML frontmatter and Markdown body.
235fn 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    // Skip the opening "---\n"
242    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..]; // skip "\n---"
251    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    // A blocked task is already in canonical form when blocked=true,
405    // block_reason matches the desired reason, and blocked_on matches
406    // the desired blocked_on. Only treat it as needing a rewrite when
407    // one of those fields diverges — otherwise status scans fire
408    // `normalize_blocked_frontmatter` on every call and produce a loop
409    // of "repaired malformed board task frontmatter" log spam even
410    // though the content never changes.
411    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
467/// Parse the Markdown body, extracting an optional `## Batty Config` section.
468fn 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        // Find the TOML content after the heading (skip blank lines)
475        let config_text = config_section.trim();
476
477        // Try to parse as TOML (the natural config format for Batty)
478        if let Ok(config) = toml::from_str::<TaskBattyConfig>(config_text) {
479            return (description, Some(config));
480        }
481
482        // If there's a fenced code block, extract its content
483        if let Some(start) = config_text.find("```") {
484            let after_fence = &config_text[start + 3..];
485            // Skip the language tag line (e.g., "toml\n")
486            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
502/// Load all task files from a kanban-md tasks directory.
503pub 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        // kanban-md writes `blocked: true` + a separate `block_reason` string.
601        // Before the untagged deserializer, `blocked: true` failed to parse
602        // into Option<String> and silently became None, so dispatch treated
603        // the task as runnable and auto-assigned it to benched engineers.
604        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        // If `blocked: true` arrives without a block_reason, fall back to a
626        // placeholder string so `task.blocked.is_some()` still short-circuits
627        // the dispatch filter.
628        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        // Older batty tasks stored the reason directly in `blocked`. That
687        // shape must still parse cleanly so historical archives do not rot.
688        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        // Non-markdown file should be skipped
1046        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; // skip if not in repo root
1087        }
1088        let tasks = load_tasks_from_dir(phase1_dir).unwrap();
1089        assert!(!tasks.is_empty());
1090        // Task #1 should exist and be done
1091        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}