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
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    /// Returns true if this task has a `scheduled_for` timestamp in the future.
167    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    /// Parse a kanban-md task file from a path.
174    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    /// Parse a kanban-md task from its string content.
193    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            // Prefer the richer `block_reason` if present so operators see
215            // the real reason, not the "blocked" placeholder from `blocked: true`.
216            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
240/// Split content into YAML frontmatter and Markdown body.
241fn 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    // Skip the opening "---\n"
248    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..]; // skip "\n---"
257    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    // A blocked task is already in canonical form when blocked=true,
461    // block_reason matches the desired reason, and blocked_on matches
462    // the desired blocked_on. Only treat it as needing a rewrite when
463    // one of those fields diverges — otherwise status scans fire
464    // `normalize_blocked_frontmatter` on every call and produce a loop
465    // of "repaired malformed board task frontmatter" log spam even
466    // though the content never changes.
467    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
523/// Parse the Markdown body, extracting an optional `## Batty Config` section.
524fn 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        // Find the TOML content after the heading (skip blank lines)
531        let config_text = config_section.trim();
532
533        // Try to parse as TOML (the natural config format for Batty)
534        if let Ok(config) = toml::from_str::<TaskBattyConfig>(config_text) {
535            return (description, Some(config));
536        }
537
538        // If there's a fenced code block, extract its content
539        if let Some(start) = config_text.find("```") {
540            let after_fence = &config_text[start + 3..];
541            // Skip the language tag line (e.g., "toml\n")
542            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
558/// Load all task files from a kanban-md tasks directory.
559pub 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        // kanban-md writes `blocked: true` + a separate `block_reason` string.
657        // Before the untagged deserializer, `blocked: true` failed to parse
658        // into Option<String> and silently became None, so dispatch treated
659        // the task as runnable and auto-assigned it to benched engineers.
660        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        // If `blocked: true` arrives without a block_reason, fall back to a
682        // placeholder string so `task.blocked.is_some()` still short-circuits
683        // the dispatch filter.
684        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        // Older batty tasks stored the reason directly in `blocked`. That
805        // shape must still parse cleanly so historical archives do not rot.
806        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        // Non-markdown file should be skipped
1164        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; // skip if not in repo root
1205        }
1206        let tasks = load_tasks_from_dir(phase1_dir).unwrap();
1207        assert!(!tasks.is_empty());
1208        // Task #1 should exist and be done
1209        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}