Skip to main content

ito_domain/tasks/
parse.rs

1//! Parsing for Ito `tasks.md` tracking files.
2//!
3//! Ito supports two tasks formats:
4//! - a legacy checkbox list (minimal structure)
5//! - an enhanced format with waves, explicit dependencies, and status metadata
6//!
7//! This module parses either format into a single normalized representation
8//! ([`TasksParseResult`]) used by the tasks CLI and workflow execution.
9
10use chrono::{DateTime, Local, NaiveDate};
11use regex::Regex;
12use std::collections::BTreeMap;
13use std::path::{Path, PathBuf};
14
15#[derive(Debug, Clone, Copy, PartialEq, Eq)]
16/// The detected format of a `tasks.md` file.
17pub enum TasksFormat {
18    /// Enhanced wave-based format.
19    Enhanced,
20    /// Legacy checkbox list format.
21    Checkbox,
22}
23
24#[derive(Debug, Clone, Copy, PartialEq, Eq)]
25/// Status values supported by Ito task tracking.
26pub enum TaskStatus {
27    /// Not started.
28    Pending,
29    /// Currently being worked.
30    InProgress,
31    /// Finished.
32    Complete,
33    /// Intentionally deferred/paused.
34    Shelved,
35}
36
37impl TaskStatus {
38    /// Status label used by the enhanced tasks format.
39    pub fn as_enhanced_label(self) -> &'static str {
40        match self {
41            TaskStatus::Pending => "pending",
42            TaskStatus::InProgress => "in-progress",
43            TaskStatus::Complete => "complete",
44            TaskStatus::Shelved => "shelved",
45        }
46    }
47
48    /// Parse an enhanced-format status label.
49    pub fn from_enhanced_label(s: &str) -> Option<Self> {
50        match s {
51            "pending" => Some(TaskStatus::Pending),
52            "in-progress" => Some(TaskStatus::InProgress),
53            "complete" => Some(TaskStatus::Complete),
54            "shelved" => Some(TaskStatus::Shelved),
55            _ => None,
56        }
57    }
58
59    /// Return true when the status counts as "done" for gating.
60    pub fn is_done(self) -> bool {
61        match self {
62            TaskStatus::Pending => false,
63            TaskStatus::InProgress => false,
64            TaskStatus::Complete => true,
65            TaskStatus::Shelved => true,
66        }
67    }
68}
69
70#[derive(Debug, Clone, PartialEq, Eq)]
71/// A parse-time diagnostic emitted while reading a tasks file.
72pub struct TaskDiagnostic {
73    /// Severity level.
74    pub level: DiagnosticLevel,
75    /// Human-readable message.
76    pub message: String,
77    /// Optional task id the diagnostic refers to.
78    pub task_id: Option<String>,
79    /// Optional 0-based line index.
80    pub line: Option<usize>,
81}
82
83#[derive(Debug, Clone, Copy, PartialEq, Eq)]
84/// Diagnostic severity.
85pub enum DiagnosticLevel {
86    /// The file is malformed and results may be incomplete.
87    Error,
88    /// The file is parseable but contains suspicious content.
89    Warning,
90}
91
92impl DiagnosticLevel {
93    /// Render as a stable string label.
94    pub fn as_str(self) -> &'static str {
95        match self {
96            DiagnosticLevel::Error => "error",
97            DiagnosticLevel::Warning => "warning",
98        }
99    }
100}
101
102#[derive(Debug, Clone, PartialEq, Eq)]
103/// A normalized task entry parsed from a tasks tracking file.
104pub struct TaskItem {
105    /// Task identifier (e.g. `1.1`).
106    pub id: String,
107    /// Task title/name.
108    pub name: String,
109    /// Optional wave number (enhanced format).
110    pub wave: Option<u32>,
111    /// Current status.
112    pub status: TaskStatus,
113    /// Optional `YYYY-MM-DD` updated date.
114    pub updated_at: Option<String>,
115    /// Explicit task dependencies by id.
116    pub dependencies: Vec<String>,
117    /// File paths mentioned for the task.
118    pub files: Vec<String>,
119    /// Freeform action description.
120    pub action: String,
121    /// Optional verification command.
122    pub verify: Option<String>,
123    /// Optional completion criteria.
124    pub done_when: Option<String>,
125    /// Task kind (normal vs checkpoint).
126    pub kind: TaskKind,
127    /// 0-based line index where the task header was found.
128    pub header_line_index: usize,
129}
130
131#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
132/// Task classification.
133pub enum TaskKind {
134    #[default]
135    /// A runnable task.
136    Normal,
137    /// A checkpoint that requires explicit approval.
138    Checkpoint,
139}
140
141#[derive(Debug, Clone, PartialEq, Eq)]
142/// Summary counts computed from the parsed tasks.
143pub struct ProgressInfo {
144    /// Total tasks.
145    pub total: usize,
146    /// Completed tasks.
147    pub complete: usize,
148    /// Shelved tasks.
149    pub shelved: usize,
150    /// In-progress tasks.
151    pub in_progress: usize,
152    /// Pending tasks.
153    pub pending: usize,
154    /// Remaining work (`total - complete - shelved`).
155    pub remaining: usize,
156}
157
158#[derive(Debug, Clone, PartialEq, Eq)]
159/// Wave metadata parsed from enhanced tasks files.
160pub struct WaveInfo {
161    /// Wave number.
162    pub wave: u32,
163    /// Other waves that must be complete before this wave is unlocked.
164    pub depends_on: Vec<u32>,
165    /// 0-based line index for the wave heading.
166    pub header_line_index: usize,
167    /// 0-based line index for the depends-on line, when present.
168    pub depends_on_line_index: Option<usize>,
169}
170
171#[derive(Debug, Clone, PartialEq, Eq)]
172/// Output of parsing a `tasks.md` file.
173pub struct TasksParseResult {
174    /// Detected file format.
175    pub format: TasksFormat,
176    /// Parsed tasks in source order.
177    pub tasks: Vec<TaskItem>,
178    /// Parsed wave declarations.
179    pub waves: Vec<WaveInfo>,
180    /// Parse diagnostics.
181    pub diagnostics: Vec<TaskDiagnostic>,
182    /// Aggregate progress counts.
183    pub progress: ProgressInfo,
184}
185
186impl TasksParseResult {
187    /// Create an empty result (for when no tasks file exists).
188    pub fn empty() -> Self {
189        Self {
190            format: TasksFormat::Checkbox,
191            tasks: Vec::new(),
192            waves: Vec::new(),
193            diagnostics: Vec::new(),
194            progress: ProgressInfo {
195                total: 0,
196                complete: 0,
197                shelved: 0,
198                in_progress: 0,
199                pending: 0,
200                remaining: 0,
201            },
202        }
203    }
204}
205
206/// Default template for an enhanced-format `tasks.md`.
207pub fn enhanced_tasks_template(change_id: &str, now: DateTime<Local>) -> String {
208    let date = now.format("%Y-%m-%d").to_string();
209    format!(
210        "# Tasks for: {change_id}\n\n## Execution Notes\n\n- **Tool**: Any (OpenCode, Codex, Claude Code)\n- **Mode**: Sequential (or parallel if tool supports)\n- **Template**: Enhanced task format with waves, verification, and status tracking\n- **Tracking**: Prefer the tasks CLI to drive status updates and pick work\n\n```bash\nito tasks status {change_id}\nito tasks next {change_id}\nito tasks start {change_id} 1.1\nito tasks complete {change_id} 1.1\nito tasks shelve {change_id} 1.1\nito tasks unshelve {change_id} 1.1\nito tasks show {change_id}\n```\n\n______________________________________________________________________\n\n## Wave 1\n\n- **Depends On**: None\n\n### Task 1.1: [Task Name]\n\n- **Files**: `path/to/file.rs`\n- **Dependencies**: None\n- **Action**:\n  [Describe what needs to be done]\n- **Verify**: `cargo test --workspace`\n- **Done When**: [Success criteria]\n- **Updated At**: {date}\n- **Status**: [ ] pending\n\n______________________________________________________________________\n\n## Checkpoints\n\n### Checkpoint: Review Implementation\n\n- **Type**: checkpoint (requires human approval)\n- **Dependencies**: All Wave 1 tasks\n- **Action**: Review the implementation before proceeding\n- **Done When**: User confirms implementation is correct\n- **Updated At**: {date}\n- **Status**: [ ] pending\n"
211    )
212}
213
214/// Detect whether the file is in enhanced or checkbox format.
215pub fn detect_tasks_format(contents: &str) -> TasksFormat {
216    let enhanced_heading = Regex::new(r"(?m)^###\s+(Task\s+)?[^:]+:\s+.+$").unwrap();
217    let has_status = contents.contains("- **Status**:");
218    if enhanced_heading.is_match(contents) && has_status {
219        return TasksFormat::Enhanced;
220    }
221    let checkbox = Regex::new(r"(?m)^\s*[-*]\s+\[[ xX~>]\]").unwrap();
222    if checkbox.is_match(contents) {
223        return TasksFormat::Checkbox;
224    }
225    TasksFormat::Checkbox
226}
227
228/// Parse a `tasks.md` tracking file into a normalized representation.
229pub fn parse_tasks_tracking_file(contents: &str) -> TasksParseResult {
230    match detect_tasks_format(contents) {
231        TasksFormat::Enhanced => parse_enhanced_tasks(contents),
232        TasksFormat::Checkbox => parse_checkbox_tasks(contents),
233    }
234}
235
236fn parse_checkbox_tasks(contents: &str) -> TasksParseResult {
237    // Minimal compat: tasks are numbered 1..N.
238    let mut tasks: Vec<TaskItem> = Vec::new();
239    for (line_idx, line) in contents.lines().enumerate() {
240        let l = line.trim_start();
241        let bytes = l.as_bytes();
242        if bytes.len() < 6 {
243            continue;
244        }
245        let bullet = bytes[0] as char;
246        if bullet != '-' && bullet != '*' {
247            continue;
248        }
249        if bytes[1] != b' ' || bytes[2] != b'[' || bytes[4] != b']' || bytes[5] != b' ' {
250            continue;
251        }
252        let marker = bytes[3] as char;
253        let status = match marker {
254            'x' | 'X' => TaskStatus::Complete,
255            ' ' => TaskStatus::Pending,
256            '~' | '>' => TaskStatus::InProgress,
257            _ => continue,
258        };
259        let rest = &l[6..];
260        tasks.push(TaskItem {
261            id: (tasks.len() + 1).to_string(),
262            name: rest.trim().to_string(),
263            wave: None,
264            status,
265            updated_at: None,
266            dependencies: Vec::new(),
267            files: Vec::new(),
268            action: String::new(),
269            verify: None,
270            done_when: None,
271            kind: TaskKind::Normal,
272            header_line_index: line_idx,
273        });
274    }
275    let progress = compute_progress(&tasks);
276    TasksParseResult {
277        format: TasksFormat::Checkbox,
278        tasks,
279        waves: Vec::new(),
280        diagnostics: Vec::new(),
281        progress,
282    }
283}
284
285fn parse_enhanced_tasks(contents: &str) -> TasksParseResult {
286    let mut diagnostics: Vec<TaskDiagnostic> = Vec::new();
287    let mut tasks: Vec<TaskItem> = Vec::new();
288
289    let wave_re = Regex::new(r"^##\s+Wave\s+(\d+)\s*$").unwrap();
290    let wave_dep_re = Regex::new(r"^\s*[-*]\s+\*\*Depends On\*\*:\s*(.+?)\s*$").unwrap();
291    let task_re = Regex::new(r"^###\s+(?:Task\s+)?([^:]+):\s+(.+?)\s*$").unwrap();
292    let deps_re = Regex::new(r"\*\*Dependencies\*\*:\s*(.+?)\s*$").unwrap();
293    let status_re = Regex::new(
294        r"\*\*Status\*\*:\s*\[([ xX\-~])\]\s+(pending|in-progress|complete|shelved)\s*$",
295    )
296    .unwrap();
297    let updated_at_re = Regex::new(r"\*\*Updated At\*\*:\s*(\d{4}-\d{2}-\d{2})\s*$").unwrap();
298    let files_re = Regex::new(r"\*\*Files\*\*:\s*`([^`]+)`\s*$").unwrap();
299    let verify_re = Regex::new(r"\*\*Verify\*\*:\s*`([^`]+)`\s*$").unwrap();
300    let done_when_re = Regex::new(r"\*\*Done When\*\*:\s*(.+?)\s*$").unwrap();
301
302    let mut current_wave: Option<u32> = None;
303    let mut in_checkpoints = false;
304
305    #[derive(Debug, Default, Clone)]
306    struct WaveBuilder {
307        header_line_index: usize,
308        depends_on_raw: Option<String>,
309        depends_on_line_index: Option<usize>,
310    }
311
312    let mut waves: BTreeMap<u32, WaveBuilder> = BTreeMap::new();
313
314    #[derive(Debug, Default)]
315    struct CurrentTask {
316        id: Option<String>,
317        desc: Option<String>,
318        wave: Option<u32>,
319        header_line_index: usize,
320        kind: TaskKind,
321        deps_raw: Option<String>,
322        updated_at_raw: Option<String>,
323        status_raw: Option<String>,
324        status_marker_raw: Option<char>,
325        files: Vec<String>,
326        action_lines: Vec<String>,
327        verify: Option<String>,
328        done_when: Option<String>,
329    }
330
331    fn flush_current(
332        current: &mut CurrentTask,
333        tasks: &mut Vec<TaskItem>,
334        diagnostics: &mut Vec<TaskDiagnostic>,
335    ) {
336        let Some(id) = current.id.take() else {
337            current.desc = None;
338            current.deps_raw = None;
339            current.updated_at_raw = None;
340            current.status_raw = None;
341            current.kind = TaskKind::Normal;
342            return;
343        };
344        let desc = current.desc.take().unwrap_or_default();
345        let wave = current.wave.take();
346        let header_line_index = current.header_line_index;
347        let deps_raw = current.deps_raw.take().unwrap_or_default();
348        let updated_at_raw = current.updated_at_raw.take();
349        let status_raw = current.status_raw.take();
350        let status_marker_raw = current.status_marker_raw.take();
351        let files = std::mem::take(&mut current.files);
352        let action = std::mem::take(&mut current.action_lines)
353            .join("\n")
354            .trim()
355            .to_string();
356        let verify = current.verify.take();
357        let done_when = current.done_when.take();
358
359        let status = match status_raw
360            .as_deref()
361            .and_then(TaskStatus::from_enhanced_label)
362        {
363            Some(s) => s,
364            None => {
365                diagnostics.push(TaskDiagnostic {
366                    level: DiagnosticLevel::Error,
367                    message: "Invalid or missing status".to_string(),
368                    task_id: Some(id.clone()),
369                    line: Some(header_line_index + 1),
370                });
371                TaskStatus::Pending
372            }
373        };
374
375        // Validate marker conventions to make manual edits harder to corrupt.
376        // We treat `[x] complete` as the only marker with semantic meaning and keep the others
377        // as formatting conventions.
378        if let Some(marker) = status_marker_raw {
379            match status {
380                TaskStatus::Complete => {
381                    if marker != 'x' && marker != 'X' {
382                        diagnostics.push(TaskDiagnostic {
383                            level: DiagnosticLevel::Warning,
384                            message: "Status marker for complete should be [x]".to_string(),
385                            task_id: Some(id.clone()),
386                            line: Some(header_line_index + 1),
387                        });
388                    }
389                }
390                TaskStatus::Shelved => {
391                    if marker != '-' && marker != '~' {
392                        diagnostics.push(TaskDiagnostic {
393                            level: DiagnosticLevel::Warning,
394                            message: "Status marker for shelved should be [-]".to_string(),
395                            task_id: Some(id.clone()),
396                            line: Some(header_line_index + 1),
397                        });
398                    }
399                }
400                TaskStatus::Pending | TaskStatus::InProgress => {
401                    if marker == 'x' || marker == 'X' {
402                        diagnostics.push(TaskDiagnostic {
403                            level: DiagnosticLevel::Warning,
404                            message: "Only complete tasks should use [x]".to_string(),
405                            task_id: Some(id.clone()),
406                            line: Some(header_line_index + 1),
407                        });
408                    }
409                }
410            }
411        }
412        let deps = parse_dependencies(&deps_raw);
413
414        let updated_at = match updated_at_raw.as_deref() {
415            Some(s) => {
416                if NaiveDate::parse_from_str(s, "%Y-%m-%d").is_ok() {
417                    Some(s.to_string())
418                } else {
419                    diagnostics.push(TaskDiagnostic {
420                        level: DiagnosticLevel::Error,
421                        message: format!("Invalid Updated At date: {s} (expected YYYY-MM-DD)"),
422                        task_id: Some(id.clone()),
423                        line: Some(header_line_index + 1),
424                    });
425                    None
426                }
427            }
428            None => {
429                diagnostics.push(TaskDiagnostic {
430                    level: DiagnosticLevel::Error,
431                    message: "Missing Updated At field (expected YYYY-MM-DD)".to_string(),
432                    task_id: Some(id.clone()),
433                    line: Some(header_line_index + 1),
434                });
435                None
436            }
437        };
438
439        tasks.push(TaskItem {
440            id,
441            name: desc,
442            wave,
443            status,
444            updated_at,
445            dependencies: deps,
446            files,
447            action,
448            verify,
449            done_when,
450            kind: current.kind,
451            header_line_index,
452        });
453        current.kind = TaskKind::Normal;
454    }
455
456    let mut current_task = CurrentTask {
457        id: None,
458        desc: None,
459        wave: None,
460        header_line_index: 0,
461        kind: TaskKind::Normal,
462        deps_raw: None,
463        updated_at_raw: None,
464        status_raw: None,
465        status_marker_raw: None,
466        files: Vec::new(),
467        action_lines: Vec::new(),
468        verify: None,
469        done_when: None,
470    };
471
472    let mut in_action = false;
473
474    for (line_idx, line) in contents.lines().enumerate() {
475        if in_action && current_task.id.is_some() {
476            if line.starts_with("- **") || line.starts_with("### ") || line.starts_with("## ") {
477                in_action = false;
478                // fall through to process this line normally
479            } else {
480                let trimmed = line.trim();
481                if !trimmed.is_empty() {
482                    current_task.action_lines.push(trimmed.to_string());
483                }
484                continue;
485            }
486        }
487
488        if let Some(cap) = wave_re.captures(line) {
489            flush_current(&mut current_task, &mut tasks, &mut diagnostics);
490            current_wave = cap.get(1).and_then(|m| m.as_str().parse::<u32>().ok());
491            in_checkpoints = false;
492            if let Some(w) = current_wave {
493                waves.entry(w).or_insert_with(|| WaveBuilder {
494                    header_line_index: line_idx,
495                    depends_on_raw: None,
496                    depends_on_line_index: None,
497                });
498            }
499            continue;
500        }
501        if line.trim() == "## Checkpoints" {
502            flush_current(&mut current_task, &mut tasks, &mut diagnostics);
503            current_wave = None;
504            in_checkpoints = true;
505            continue;
506        }
507
508        if current_task.id.is_none()
509            && let Some(w) = current_wave
510            && let Some(cap) = wave_dep_re.captures(line)
511        {
512            let raw = cap[1].trim().to_string();
513            let entry = waves.entry(w).or_insert_with(|| WaveBuilder {
514                header_line_index: line_idx,
515                depends_on_raw: None,
516                depends_on_line_index: None,
517            });
518            if entry.depends_on_raw.is_some() {
519                diagnostics.push(TaskDiagnostic {
520                    level: DiagnosticLevel::Warning,
521                    message: format!("Wave {w}: duplicate Depends On line; using the first one"),
522                    task_id: None,
523                    line: Some(line_idx + 1),
524                });
525            } else {
526                entry.depends_on_raw = Some(raw);
527                entry.depends_on_line_index = Some(line_idx);
528            }
529            continue;
530        }
531
532        if let Some(cap) = task_re.captures(line) {
533            flush_current(&mut current_task, &mut tasks, &mut diagnostics);
534            let id = cap[1].trim().to_string();
535            let desc = cap[2].trim().to_string();
536            current_task.id = Some(id.clone());
537            current_task.desc = Some(desc);
538            current_task.wave = current_wave;
539            current_task.header_line_index = line_idx;
540            current_task.kind = TaskKind::Normal;
541            current_task.deps_raw = None;
542            current_task.updated_at_raw = None;
543            current_task.status_raw = None;
544            current_task.status_marker_raw = None;
545            current_task.files.clear();
546            current_task.action_lines.clear();
547            current_task.verify = None;
548            current_task.done_when = None;
549            in_action = false;
550
551            if current_wave.is_none() && !in_checkpoints {
552                diagnostics.push(TaskDiagnostic {
553                    level: DiagnosticLevel::Warning,
554                    message: format!(
555                        "{id}: Task '{id}' appears outside any Wave section; wave gating may not behave as expected"
556                    ),
557                    task_id: None,
558                    line: Some(line_idx + 1),
559                });
560            }
561            continue;
562        }
563
564        if current_task.id.is_some() {
565            if line.trim() == "- **Action**:" {
566                in_action = true;
567                current_task.action_lines.clear();
568                continue;
569            }
570            if let Some(cap) = deps_re.captures(line) {
571                current_task.deps_raw = Some(cap[1].trim().to_string());
572                continue;
573            }
574            if let Some(cap) = updated_at_re.captures(line) {
575                current_task.updated_at_raw = Some(cap[1].trim().to_string());
576                continue;
577            }
578            if let Some(cap) = status_re.captures(line) {
579                let marker = cap
580                    .get(1)
581                    .and_then(|m| m.as_str().chars().next())
582                    .unwrap_or(' ');
583                current_task.status_marker_raw = Some(marker);
584                current_task.status_raw = Some(cap[2].trim().to_string());
585                continue;
586            }
587            if let Some(cap) = files_re.captures(line) {
588                let inner = cap[1].trim();
589                current_task.files = inner
590                    .split(',')
591                    .map(|s| s.trim().to_string())
592                    .filter(|s| !s.is_empty())
593                    .collect();
594                continue;
595            }
596            if let Some(cap) = verify_re.captures(line) {
597                current_task.verify = Some(cap[1].trim().to_string());
598                continue;
599            }
600            if let Some(cap) = done_when_re.captures(line) {
601                current_task.done_when = Some(cap[1].trim().to_string());
602                continue;
603            }
604        }
605    }
606
607    flush_current(&mut current_task, &mut tasks, &mut diagnostics);
608
609    // Build wave dependency model.
610    let mut wave_nums: Vec<u32> = waves.keys().copied().collect();
611    wave_nums.sort();
612    wave_nums.dedup();
613    let wave_set: std::collections::BTreeSet<u32> = wave_nums.iter().copied().collect();
614
615    let mut waves_out: Vec<WaveInfo> = Vec::new();
616    for w in &wave_nums {
617        let builder = waves.get(w).cloned().unwrap_or_default();
618
619        let mut depends_on: Vec<u32> = Vec::new();
620        if let Some(raw) = builder.depends_on_raw.as_deref() {
621            let trimmed = raw.trim();
622            if trimmed.is_empty() {
623                diagnostics.push(TaskDiagnostic {
624                    level: DiagnosticLevel::Error,
625                    message: format!("Wave {w}: Depends On is empty"),
626                    task_id: None,
627                    line: Some(builder.header_line_index + 1),
628                });
629            } else if trimmed.eq_ignore_ascii_case("none") {
630                // no deps
631            } else {
632                for part in trimmed.split(',') {
633                    let p = part.trim();
634                    if p.is_empty() {
635                        continue;
636                    }
637                    let p2 = if p.to_ascii_lowercase().starts_with("wave ") {
638                        p[5..].trim()
639                    } else {
640                        p
641                    };
642                    match p2.parse::<u32>() {
643                        Ok(n) => depends_on.push(n),
644                        Err(_) => diagnostics.push(TaskDiagnostic {
645                            level: DiagnosticLevel::Error,
646                            message: format!("Wave {w}: invalid Depends On entry '{p}'"),
647                            task_id: None,
648                            line: Some(
649                                builder
650                                    .depends_on_line_index
651                                    .unwrap_or(builder.header_line_index)
652                                    + 1,
653                            ),
654                        }),
655                    }
656                }
657            }
658        } else {
659            diagnostics.push(TaskDiagnostic {
660                level: DiagnosticLevel::Error,
661                message: format!("Wave {w}: missing Depends On line"),
662                task_id: None,
663                line: Some(builder.header_line_index + 1),
664            });
665
666            // Preserve behavior for readiness calculations, but refuse to operate due to error.
667            depends_on = wave_nums.iter().copied().filter(|n| *n < *w).collect();
668        }
669
670        depends_on.sort();
671        depends_on.dedup();
672
673        for dep_wave in &depends_on {
674            if dep_wave == w {
675                diagnostics.push(TaskDiagnostic {
676                    level: DiagnosticLevel::Error,
677                    message: format!("Wave {w}: cannot depend on itself"),
678                    task_id: None,
679                    line: Some(
680                        builder
681                            .depends_on_line_index
682                            .unwrap_or(builder.header_line_index)
683                            + 1,
684                    ),
685                });
686                continue;
687            }
688            if !wave_set.contains(dep_wave) {
689                diagnostics.push(TaskDiagnostic {
690                    level: DiagnosticLevel::Error,
691                    message: format!("Wave {w}: depends on missing Wave {dep_wave}"),
692                    task_id: None,
693                    line: Some(
694                        builder
695                            .depends_on_line_index
696                            .unwrap_or(builder.header_line_index)
697                            + 1,
698                    ),
699                });
700            }
701        }
702
703        waves_out.push(WaveInfo {
704            wave: *w,
705            depends_on,
706            header_line_index: builder.header_line_index,
707            depends_on_line_index: builder.depends_on_line_index,
708        });
709    }
710
711    // Relational invariants (cycles, task deps rules) on the finalized model.
712    diagnostics.extend(super::relational::validate_relational(&tasks, &waves_out));
713
714    let progress = compute_progress(&tasks);
715
716    TasksParseResult {
717        format: TasksFormat::Enhanced,
718        tasks,
719        waves: waves_out,
720        diagnostics,
721        progress,
722    }
723}
724
725fn parse_dependencies(raw: &str) -> Vec<String> {
726    parse_dependencies_with_checkpoint(raw, TaskKind::Normal).0
727}
728
729fn parse_dependencies_with_checkpoint(raw: &str, kind: TaskKind) -> (Vec<String>, Option<u32>) {
730    let r = raw.trim();
731    if r.is_empty() {
732        return (Vec::new(), None);
733    }
734    let lower = r.to_ascii_lowercase();
735    if lower == "none" {
736        return (Vec::new(), None);
737    }
738
739    // Special-case strings from the enhanced template.
740    let all_wave_capture = Regex::new(r"(?i)^all\s+wave\s+(\d+)\s+tasks$").unwrap();
741    if let Some(cap) = all_wave_capture.captures(r) {
742        let wave = cap.get(1).and_then(|m| m.as_str().parse::<u32>().ok());
743        if kind == TaskKind::Checkpoint {
744            return (Vec::new(), wave);
745        }
746        return (Vec::new(), None);
747    }
748    if lower == "all previous waves" {
749        // We don't expand this into explicit deps here.
750        return (Vec::new(), None);
751    }
752
753    let deps = r
754        .split(',')
755        .map(|s| s.trim())
756        .filter(|s| !s.is_empty())
757        .map(|s| s.strip_prefix("Task ").unwrap_or(s).trim().to_string())
758        .collect();
759    (deps, None)
760}
761
762fn compute_progress(tasks: &[TaskItem]) -> ProgressInfo {
763    let total = tasks.len();
764    let complete = tasks
765        .iter()
766        .filter(|t| t.status == TaskStatus::Complete)
767        .count();
768    let shelved = tasks
769        .iter()
770        .filter(|t| t.status == TaskStatus::Shelved)
771        .count();
772    let in_progress = tasks
773        .iter()
774        .filter(|t| t.status == TaskStatus::InProgress)
775        .count();
776    let pending = tasks
777        .iter()
778        .filter(|t| t.status == TaskStatus::Pending)
779        .count();
780    let done = tasks.iter().filter(|t| t.status.is_done()).count();
781    let remaining = total.saturating_sub(done);
782    ProgressInfo {
783        total,
784        complete,
785        shelved,
786        in_progress,
787        pending,
788        remaining,
789    }
790}
791
792/// Path to `{ito_path}/changes/{change_id}/tasks.md`.
793pub fn tasks_path(ito_path: &Path, change_id: &str) -> PathBuf {
794    ito_path.join("changes").join(change_id).join("tasks.md")
795}