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};
14use std::sync::LazyLock;
15
16use super::checkbox::split_checkbox_task_label;
17
18static ENHANCED_HEADING_RE: LazyLock<Regex> = LazyLock::new(|| {
19    Regex::new(
20        r"(?m)^(#\s+Tasks\s+for:.*$|##\s+Wave\s+\d+(?:\s*[:-]\s*.*)?\s*$|###\s+(Task\s+)?[^:]+:\s+.+$)",
21    )
22    .unwrap()
23});
24
25static CHECKBOX_RE: LazyLock<Regex> =
26    LazyLock::new(|| Regex::new(r"(?m)^\s*[-*]\s+\[[ xX~>]\]").unwrap());
27
28static WAVE_RE: LazyLock<Regex> = LazyLock::new(|| {
29    // Accept a strict `## Wave <N>` heading, optionally followed by a human title.
30    // Supported separators after the wave number:
31    // - `:` (e.g. `## Wave 1: Foundations`)
32    // - `-` (e.g. `## Wave 1 - Foundations`)
33    Regex::new(r"^##\s+Wave\s+(\d+)(?:\s*[:-]\s*.*)?\s*$").unwrap()
34});
35
36static WAVE_DEP_RE: LazyLock<Regex> =
37    LazyLock::new(|| Regex::new(r"^\s*[-*]\s+\*\*Depends On\*\*:\s*(.+?)\s*$").unwrap());
38
39static TASK_RE: LazyLock<Regex> =
40    LazyLock::new(|| Regex::new(r"^###\s+(?:Task\s+)?([^:]+):\s+(.+?)\s*$").unwrap());
41
42static DEPS_RE: LazyLock<Regex> =
43    LazyLock::new(|| Regex::new(r"\*\*Dependencies\*\*:\s*(.+?)\s*$").unwrap());
44
45static STATUS_RE: LazyLock<Regex> = LazyLock::new(|| {
46    Regex::new(r"\*\*Status\*\*:\s*\[([ xX\-~>])\]\s+(pending|in-progress|complete|shelved)\s*$")
47        .unwrap()
48});
49
50static UPDATED_AT_RE: LazyLock<Regex> =
51    LazyLock::new(|| Regex::new(r"\*\*Updated At\*\*:\s*(\d{4}-\d{2}-\d{2})\s*$").unwrap());
52
53static FILES_RE: LazyLock<Regex> =
54    LazyLock::new(|| Regex::new(r"\*\*Files\*\*:\s*`([^`]+)`\s*$").unwrap());
55
56static VERIFY_RE: LazyLock<Regex> =
57    LazyLock::new(|| Regex::new(r"\*\*Verify\*\*:\s*`([^`]+)`\s*$").unwrap());
58
59static DONE_WHEN_RE: LazyLock<Regex> =
60    LazyLock::new(|| Regex::new(r"\*\*Done When\*\*:\s*(.+?)\s*$").unwrap());
61
62static ALL_WAVE_CAPTURE_RE: LazyLock<Regex> =
63    LazyLock::new(|| Regex::new(r"(?i)^all\s+wave\s+(\d+)\s+tasks$").unwrap());
64
65#[derive(Debug, Clone, Copy, PartialEq, Eq)]
66/// The detected format of a `tasks.md` file.
67pub enum TasksFormat {
68    /// Enhanced wave-based format.
69    Enhanced,
70    /// Legacy checkbox list format.
71    Checkbox,
72}
73
74#[derive(Debug, Clone, Copy, PartialEq, Eq)]
75/// Status values supported by Ito task tracking.
76pub enum TaskStatus {
77    /// Not started.
78    Pending,
79    /// Currently being worked.
80    InProgress,
81    /// Finished.
82    Complete,
83    /// Intentionally deferred/paused.
84    Shelved,
85}
86
87impl TaskStatus {
88    /// Status label used by the enhanced tasks format.
89    pub fn as_enhanced_label(self) -> &'static str {
90        match self {
91            TaskStatus::Pending => "pending",
92            TaskStatus::InProgress => "in-progress",
93            TaskStatus::Complete => "complete",
94            TaskStatus::Shelved => "shelved",
95        }
96    }
97
98    /// Parse an enhanced-format status label.
99    pub fn from_enhanced_label(s: &str) -> Option<Self> {
100        match s {
101            "pending" => Some(TaskStatus::Pending),
102            "in-progress" => Some(TaskStatus::InProgress),
103            "complete" => Some(TaskStatus::Complete),
104            "shelved" => Some(TaskStatus::Shelved),
105            _ => None,
106        }
107    }
108
109    /// Return true when the status counts as "done" for gating.
110    pub fn is_done(self) -> bool {
111        match self {
112            TaskStatus::Pending => false,
113            TaskStatus::InProgress => false,
114            TaskStatus::Complete => true,
115            TaskStatus::Shelved => true,
116        }
117    }
118}
119
120#[derive(Debug, Clone, PartialEq, Eq)]
121/// A parse-time diagnostic emitted while reading a tasks file.
122pub struct TaskDiagnostic {
123    /// Severity level.
124    pub level: DiagnosticLevel,
125    /// Human-readable message.
126    pub message: String,
127    /// Optional task id the diagnostic refers to.
128    pub task_id: Option<String>,
129    /// Optional 0-based line index.
130    pub line: Option<usize>,
131}
132
133#[derive(Debug, Clone, Copy, PartialEq, Eq)]
134/// Diagnostic severity.
135pub enum DiagnosticLevel {
136    /// The file is malformed and results may be incomplete.
137    Error,
138    /// The file is parseable but contains suspicious content.
139    Warning,
140}
141
142impl DiagnosticLevel {
143    /// Render as a stable string label.
144    pub fn as_str(self) -> &'static str {
145        match self {
146            DiagnosticLevel::Error => "error",
147            DiagnosticLevel::Warning => "warning",
148        }
149    }
150}
151
152#[derive(Debug, Clone, PartialEq, Eq)]
153/// A normalized task entry parsed from a tasks tracking file.
154pub struct TaskItem {
155    /// Task identifier (e.g. `1.1`).
156    pub id: String,
157    /// Task title/name.
158    pub name: String,
159    /// Optional wave number (enhanced format).
160    pub wave: Option<u32>,
161    /// Current status.
162    pub status: TaskStatus,
163    /// Optional `YYYY-MM-DD` updated date.
164    pub updated_at: Option<String>,
165    /// Explicit task dependencies by id.
166    pub dependencies: Vec<String>,
167    /// File paths mentioned for the task.
168    pub files: Vec<String>,
169    /// Freeform action description.
170    pub action: String,
171    /// Optional verification command.
172    pub verify: Option<String>,
173    /// Optional completion criteria.
174    pub done_when: Option<String>,
175    /// Task kind (normal vs checkpoint).
176    pub kind: TaskKind,
177    /// 0-based line index where the task header was found.
178    pub header_line_index: usize,
179}
180
181#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
182/// Task classification.
183pub enum TaskKind {
184    #[default]
185    /// A runnable task.
186    Normal,
187    /// A checkpoint that requires explicit approval.
188    Checkpoint,
189}
190
191#[derive(Debug, Clone, PartialEq, Eq)]
192/// Summary counts computed from the parsed tasks.
193pub struct ProgressInfo {
194    /// Total tasks.
195    pub total: usize,
196    /// Completed tasks.
197    pub complete: usize,
198    /// Shelved tasks.
199    pub shelved: usize,
200    /// In-progress tasks.
201    pub in_progress: usize,
202    /// Pending tasks.
203    pub pending: usize,
204    /// Remaining work (`total - complete - shelved`).
205    pub remaining: usize,
206}
207
208#[derive(Debug, Clone, PartialEq, Eq)]
209/// Wave metadata parsed from enhanced tasks files.
210pub struct WaveInfo {
211    /// Wave number.
212    pub wave: u32,
213    /// Other waves that must be complete before this wave is unlocked.
214    pub depends_on: Vec<u32>,
215    /// 0-based line index for the wave heading.
216    pub header_line_index: usize,
217    /// 0-based line index for the depends-on line, when present.
218    pub depends_on_line_index: Option<usize>,
219}
220
221#[derive(Debug, Clone, PartialEq, Eq)]
222/// Output of parsing a `tasks.md` file.
223pub struct TasksParseResult {
224    /// Detected file format.
225    pub format: TasksFormat,
226    /// Parsed tasks in source order.
227    pub tasks: Vec<TaskItem>,
228    /// Parsed wave declarations.
229    pub waves: Vec<WaveInfo>,
230    /// Parse diagnostics.
231    pub diagnostics: Vec<TaskDiagnostic>,
232    /// Aggregate progress counts.
233    pub progress: ProgressInfo,
234}
235
236impl TasksParseResult {
237    /// Create an empty result (for when no tasks file exists).
238    pub fn empty() -> Self {
239        Self {
240            format: TasksFormat::Checkbox,
241            tasks: Vec::new(),
242            waves: Vec::new(),
243            diagnostics: Vec::new(),
244            progress: ProgressInfo {
245                total: 0,
246                complete: 0,
247                shelved: 0,
248                in_progress: 0,
249                pending: 0,
250                remaining: 0,
251            },
252        }
253    }
254}
255
256/// Generate the default template for an enhanced-format `tasks.md`.
257///
258/// The template includes:
259/// - A header with execution notes and CLI hints.
260/// - A sample Wave 1 with a placeholder task.
261/// - A sample Checkpoint for review.
262pub fn enhanced_tasks_template(change_id: &str, now: DateTime<Local>) -> String {
263    let date = now.format("%Y-%m-%d").to_string();
264    format!(
265        "# 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"
266    )
267}
268
269/// Detects whether task tracking contents use the enhanced wave-based format or the legacy checkbox format.
270///
271/// The enhanced format is recognized by enhanced structural headings (e.g. `# Tasks for: ...`, `## Wave N`, `### Task ...`).
272/// This intentionally classifies partially-written enhanced files (missing `- **Status**:` lines) as enhanced so the parser can emit diagnostics.
273///
274/// # Examples
275///
276/// ```
277/// use ito_domain::tasks::{TasksFormat, detect_tasks_format};
278/// let enhanced = "# Tasks for: 001-01\n\n## Wave 1\n\n### Task 1.1: Example\n";
279/// assert_eq!(detect_tasks_format(enhanced), TasksFormat::Enhanced);
280///
281/// let checkbox = "- [ ] Task 1";
282/// assert_eq!(detect_tasks_format(checkbox), TasksFormat::Checkbox);
283/// ```
284pub fn detect_tasks_format(contents: &str) -> TasksFormat {
285    let enhanced_heading = &*ENHANCED_HEADING_RE;
286    if enhanced_heading.is_match(contents) {
287        return TasksFormat::Enhanced;
288    }
289    let checkbox = &*CHECKBOX_RE;
290    if checkbox.is_match(contents) {
291        return TasksFormat::Checkbox;
292    }
293    TasksFormat::Checkbox
294}
295
296/// Parse a `tasks.md` tracking file into a normalized representation.
297pub fn parse_tasks_tracking_file(contents: &str) -> TasksParseResult {
298    match detect_tasks_format(contents) {
299        TasksFormat::Enhanced => parse_enhanced_tasks(contents),
300        TasksFormat::Checkbox => parse_checkbox_tasks(contents),
301    }
302}
303
304/// Parses a legacy checkbox-style tasks.md into a normalized TasksParseResult.
305///
306/// This recognizes list items that start with `- ` or `* ` followed by a status checkbox
307/// (`[ ]`, `[x]`, `[~]`, `[>]`) and an optional label of the form `ID: Name`. Each matched
308/// line produces a TaskItem with a sequential numeric id if no explicit id is present.
309/// The returned result uses the Checkbox format, contains no wave metadata, and includes
310/// computed progress information.
311///
312/// # Returns
313///
314/// A TasksParseResult containing all parsed TaskItem entries, no waves, no diagnostics,
315/// and computed ProgressInfo.
316///
317/// # Examples
318///
319/// ```
320/// use ito_domain::tasks::{parse_tasks_tracking_file, TasksFormat};
321/// let contents = "- [ ] 1: First task\n- [x] Second task\n";
322/// let result = parse_tasks_tracking_file(contents);
323/// assert_eq!(result.format, TasksFormat::Checkbox);
324/// assert_eq!(result.tasks.len(), 2);
325/// assert_eq!(result.tasks[0].id, "1");
326/// assert_eq!(result.tasks[1].id, "2");
327/// ```
328fn parse_checkbox_tasks(contents: &str) -> TasksParseResult {
329    // Minimal compat: tasks are numbered 1..N.
330    let mut tasks: Vec<TaskItem> = Vec::new();
331    for (line_idx, line) in contents.lines().enumerate() {
332        let l = line.trim_start();
333        let bytes = l.as_bytes();
334        if bytes.len() < 5 {
335            continue;
336        }
337        let bullet = bytes[0] as char;
338        if bullet != '-' && bullet != '*' {
339            continue;
340        }
341        if bytes[1] != b' ' || bytes[2] != b'[' || bytes[4] != b']' {
342            continue;
343        }
344        let marker = bytes[3] as char;
345        let status = if marker == 'x' || marker == 'X' {
346            TaskStatus::Complete
347        } else if marker == ' ' {
348            TaskStatus::Pending
349        } else if marker == '~' || marker == '>' {
350            TaskStatus::InProgress
351        } else {
352            continue;
353        };
354
355        let rest_start = if let Some(b' ') = bytes.get(5) { 6 } else { 5 };
356        let rest = &l[rest_start..];
357        let rest = rest.trim();
358
359        let (id, name) = match split_checkbox_task_label(rest) {
360            Some((id, name)) => (id.to_string(), name.to_string()),
361            None => ((tasks.len() + 1).to_string(), rest.to_string()),
362        };
363        tasks.push(TaskItem {
364            id,
365            name,
366            wave: None,
367            status,
368            updated_at: None,
369            dependencies: Vec::new(),
370            files: Vec::new(),
371            action: String::new(),
372            verify: None,
373            done_when: None,
374            kind: TaskKind::Normal,
375            header_line_index: line_idx,
376        });
377    }
378    let progress = compute_progress(&tasks);
379    TasksParseResult {
380        format: TasksFormat::Checkbox,
381        tasks,
382        waves: Vec::new(),
383        diagnostics: Vec::new(),
384        progress,
385    }
386}
387
388/// Parses a tasks.md document written in the enhanced (wave-based) format into a normalized TasksParseResult.
389///
390/// The parser extracts waves, per-task metadata (id, name, wave, status, updated date, dependencies, files, action, verify, and done-when),
391/// validates fields (emitting diagnostics for missing/invalid status or dates, duplicate or missing wave dependency lines, self-dependencies, and missing referenced waves),
392/// and computes overall progress information.
393///
394/// # Returns
395///
396/// A `TasksParseResult` containing the detected `TasksFormat::Enhanced`, the parsed `tasks`, `waves`, any `diagnostics` produced during parsing, and computed `progress`.
397///
398/// # Examples
399///
400/// ```
401/// use chrono::Local;
402/// use ito_domain::tasks::{enhanced_tasks_template, parse_tasks_tracking_file, TasksFormat};
403/// let src = enhanced_tasks_template("001-01", Local::now());
404/// let res = parse_tasks_tracking_file(&src);
405/// assert_eq!(res.format, TasksFormat::Enhanced);
406/// assert_eq!(res.tasks.len(), 2);
407/// assert_eq!(res.waves.len(), 1);
408/// ```
409fn parse_enhanced_tasks(contents: &str) -> TasksParseResult {
410    let mut diagnostics: Vec<TaskDiagnostic> = Vec::new();
411    let mut tasks: Vec<TaskItem> = Vec::new();
412
413    let wave_re = &*WAVE_RE;
414    let wave_dep_re = &*WAVE_DEP_RE;
415    let task_re = &*TASK_RE;
416    let deps_re = &*DEPS_RE;
417    let status_re = &*STATUS_RE;
418    let updated_at_re = &*UPDATED_AT_RE;
419    let files_re = &*FILES_RE;
420    let verify_re = &*VERIFY_RE;
421    let done_when_re = &*DONE_WHEN_RE;
422
423    let mut current_wave: Option<u32> = None;
424    let mut in_checkpoints = false;
425
426    #[derive(Debug, Default, Clone)]
427    struct WaveBuilder {
428        header_line_index: usize,
429        depends_on_raw: Option<String>,
430        depends_on_line_index: Option<usize>,
431    }
432
433    let mut waves: BTreeMap<u32, WaveBuilder> = BTreeMap::new();
434
435    #[derive(Debug, Default)]
436    struct CurrentTask {
437        id: Option<String>,
438        desc: Option<String>,
439        wave: Option<u32>,
440        header_line_index: usize,
441        kind: TaskKind,
442        deps_raw: Option<String>,
443        updated_at_raw: Option<String>,
444        status_raw: Option<String>,
445        status_marker_raw: Option<char>,
446        files: Vec<String>,
447        action_lines: Vec<String>,
448        verify: Option<String>,
449        done_when: Option<String>,
450    }
451
452    fn flush_current(
453        current: &mut CurrentTask,
454        tasks: &mut Vec<TaskItem>,
455        diagnostics: &mut Vec<TaskDiagnostic>,
456    ) {
457        let Some(id) = current.id.take() else {
458            current.desc = None;
459            current.deps_raw = None;
460            current.updated_at_raw = None;
461            current.status_raw = None;
462            current.kind = TaskKind::Normal;
463            return;
464        };
465        let desc = current.desc.take().unwrap_or_default();
466        let wave = current.wave.take();
467        let header_line_index = current.header_line_index;
468        let deps_raw = current.deps_raw.take().unwrap_or_default();
469        let updated_at_raw = current.updated_at_raw.take();
470        let status_raw = current.status_raw.take();
471        let status_marker_raw = current.status_marker_raw.take();
472        let files = std::mem::take(&mut current.files);
473        let action = std::mem::take(&mut current.action_lines)
474            .join("\n")
475            .trim()
476            .to_string();
477        let verify = current.verify.take();
478        let done_when = current.done_when.take();
479
480        let status = match status_raw
481            .as_deref()
482            .and_then(TaskStatus::from_enhanced_label)
483        {
484            Some(s) => s,
485            None => {
486                diagnostics.push(TaskDiagnostic {
487                    level: DiagnosticLevel::Error,
488                    message: "Invalid or missing status".to_string(),
489                    task_id: Some(id.clone()),
490                    line: Some(header_line_index + 1),
491                });
492                TaskStatus::Pending
493            }
494        };
495
496        // Validate marker conventions to make manual edits harder to corrupt.
497        // We treat `[x] complete` as the only marker with semantic meaning and keep the others
498        // as formatting conventions.
499        if let Some(marker) = status_marker_raw {
500            match status {
501                TaskStatus::Complete => {
502                    if marker != 'x' && marker != 'X' {
503                        diagnostics.push(TaskDiagnostic {
504                            level: DiagnosticLevel::Warning,
505                            message: "Status marker for complete should be [x]".to_string(),
506                            task_id: Some(id.clone()),
507                            line: Some(header_line_index + 1),
508                        });
509                    }
510                }
511                TaskStatus::Shelved => {
512                    if marker != '-' && marker != '~' {
513                        diagnostics.push(TaskDiagnostic {
514                            level: DiagnosticLevel::Warning,
515                            message: "Status marker for shelved should be [-]".to_string(),
516                            task_id: Some(id.clone()),
517                            line: Some(header_line_index + 1),
518                        });
519                    }
520                }
521                TaskStatus::Pending | TaskStatus::InProgress => {
522                    if marker == 'x' || marker == 'X' {
523                        diagnostics.push(TaskDiagnostic {
524                            level: DiagnosticLevel::Warning,
525                            message: "Only complete tasks should use [x]".to_string(),
526                            task_id: Some(id.clone()),
527                            line: Some(header_line_index + 1),
528                        });
529                    }
530                }
531            }
532        }
533        let deps = parse_dependencies(&deps_raw);
534
535        let updated_at = match updated_at_raw.as_deref() {
536            Some(s) => {
537                if NaiveDate::parse_from_str(s, "%Y-%m-%d").is_ok() {
538                    Some(s.to_string())
539                } else {
540                    diagnostics.push(TaskDiagnostic {
541                        level: DiagnosticLevel::Error,
542                        message: format!("Invalid Updated At date: {s} (expected YYYY-MM-DD)"),
543                        task_id: Some(id.clone()),
544                        line: Some(header_line_index + 1),
545                    });
546                    None
547                }
548            }
549            None => {
550                diagnostics.push(TaskDiagnostic {
551                    level: DiagnosticLevel::Error,
552                    message: "Missing Updated At field (expected YYYY-MM-DD)".to_string(),
553                    task_id: Some(id.clone()),
554                    line: Some(header_line_index + 1),
555                });
556                None
557            }
558        };
559
560        tasks.push(TaskItem {
561            id,
562            name: desc,
563            wave,
564            status,
565            updated_at,
566            dependencies: deps,
567            files,
568            action,
569            verify,
570            done_when,
571            kind: current.kind,
572            header_line_index,
573        });
574        current.kind = TaskKind::Normal;
575    }
576
577    let mut current_task = CurrentTask {
578        id: None,
579        desc: None,
580        wave: None,
581        header_line_index: 0,
582        kind: TaskKind::Normal,
583        deps_raw: None,
584        updated_at_raw: None,
585        status_raw: None,
586        status_marker_raw: None,
587        files: Vec::new(),
588        action_lines: Vec::new(),
589        verify: None,
590        done_when: None,
591    };
592
593    let mut in_action = false;
594
595    for (line_idx, line) in contents.lines().enumerate() {
596        if in_action && current_task.id.is_some() {
597            if line.starts_with("- **") || line.starts_with("### ") || line.starts_with("## ") {
598                in_action = false;
599                // fall through to process this line normally
600            } else {
601                let trimmed = line.trim();
602                if !trimmed.is_empty() {
603                    current_task.action_lines.push(trimmed.to_string());
604                }
605                continue;
606            }
607        }
608
609        if let Some(cap) = wave_re.captures(line) {
610            flush_current(&mut current_task, &mut tasks, &mut diagnostics);
611            current_wave = cap.get(1).and_then(|m| m.as_str().parse::<u32>().ok());
612            in_checkpoints = false;
613            if let Some(w) = current_wave {
614                waves.entry(w).or_insert_with(|| WaveBuilder {
615                    header_line_index: line_idx,
616                    depends_on_raw: None,
617                    depends_on_line_index: None,
618                });
619            }
620            continue;
621        }
622        if line.trim() == "## Checkpoints" {
623            flush_current(&mut current_task, &mut tasks, &mut diagnostics);
624            current_wave = None;
625            in_checkpoints = true;
626            continue;
627        }
628
629        if current_task.id.is_none()
630            && let Some(w) = current_wave
631            && let Some(cap) = wave_dep_re.captures(line)
632        {
633            let raw = cap[1].trim().to_string();
634            let entry = waves.entry(w).or_insert_with(|| WaveBuilder {
635                header_line_index: line_idx,
636                depends_on_raw: None,
637                depends_on_line_index: None,
638            });
639            if entry.depends_on_raw.is_some() {
640                diagnostics.push(TaskDiagnostic {
641                    level: DiagnosticLevel::Warning,
642                    message: format!("Wave {w}: duplicate Depends On line; using the first one"),
643                    task_id: None,
644                    line: Some(line_idx + 1),
645                });
646            } else {
647                entry.depends_on_raw = Some(raw);
648                entry.depends_on_line_index = Some(line_idx);
649            }
650            continue;
651        }
652
653        if let Some(cap) = task_re.captures(line) {
654            flush_current(&mut current_task, &mut tasks, &mut diagnostics);
655            let id = cap[1].trim().to_string();
656            let desc = cap[2].trim().to_string();
657            current_task.id = Some(id.clone());
658            current_task.desc = Some(desc);
659            current_task.wave = current_wave;
660            current_task.header_line_index = line_idx;
661            current_task.kind = TaskKind::Normal;
662            current_task.deps_raw = None;
663            current_task.updated_at_raw = None;
664            current_task.status_raw = None;
665            current_task.status_marker_raw = None;
666            current_task.files.clear();
667            current_task.action_lines.clear();
668            current_task.verify = None;
669            current_task.done_when = None;
670            in_action = false;
671
672            if current_wave.is_none() && !in_checkpoints {
673                diagnostics.push(TaskDiagnostic {
674                    level: DiagnosticLevel::Warning,
675                    message: format!(
676                        "{id}: Task '{id}' appears outside any Wave section; wave gating may not behave as expected"
677                    ),
678                    task_id: None,
679                    line: Some(line_idx + 1),
680                });
681            }
682            continue;
683        }
684
685        if current_task.id.is_some() {
686            if line.trim() == "- **Action**:" {
687                in_action = true;
688                current_task.action_lines.clear();
689                continue;
690            }
691            if let Some(cap) = deps_re.captures(line) {
692                current_task.deps_raw = Some(cap[1].trim().to_string());
693                continue;
694            }
695            if let Some(cap) = updated_at_re.captures(line) {
696                current_task.updated_at_raw = Some(cap[1].trim().to_string());
697                continue;
698            }
699            if let Some(cap) = status_re.captures(line) {
700                let marker = cap
701                    .get(1)
702                    .and_then(|m| m.as_str().chars().next())
703                    .unwrap_or(' ');
704                current_task.status_marker_raw = Some(marker);
705                current_task.status_raw = Some(cap[2].trim().to_string());
706                continue;
707            }
708            if let Some(cap) = files_re.captures(line) {
709                let inner = cap[1].trim();
710                current_task.files = inner
711                    .split(',')
712                    .map(|s| s.trim().to_string())
713                    .filter(|s| !s.is_empty())
714                    .collect();
715                continue;
716            }
717            if let Some(cap) = verify_re.captures(line) {
718                current_task.verify = Some(cap[1].trim().to_string());
719                continue;
720            }
721            if let Some(cap) = done_when_re.captures(line) {
722                current_task.done_when = Some(cap[1].trim().to_string());
723                continue;
724            }
725        }
726    }
727
728    flush_current(&mut current_task, &mut tasks, &mut diagnostics);
729
730    // Build wave dependency model.
731    let mut wave_nums: Vec<u32> = waves.keys().copied().collect();
732    wave_nums.sort();
733    wave_nums.dedup();
734    let wave_set: std::collections::BTreeSet<u32> = wave_nums.iter().copied().collect();
735
736    let mut waves_out: Vec<WaveInfo> = Vec::new();
737    for w in &wave_nums {
738        let builder = waves.get(w).cloned().unwrap_or_default();
739
740        let mut depends_on: Vec<u32> = Vec::new();
741        if let Some(raw) = builder.depends_on_raw.as_deref() {
742            let trimmed = raw.trim();
743            if trimmed.is_empty() {
744                diagnostics.push(TaskDiagnostic {
745                    level: DiagnosticLevel::Error,
746                    message: format!("Wave {w}: Depends On is empty"),
747                    task_id: None,
748                    line: Some(builder.header_line_index + 1),
749                });
750            } else if trimmed.eq_ignore_ascii_case("none") {
751                // no deps
752            } else {
753                for part in trimmed.split(',') {
754                    let p = part.trim();
755                    if p.is_empty() {
756                        continue;
757                    }
758                    let p2 = if p.to_ascii_lowercase().starts_with("wave ") {
759                        p[5..].trim()
760                    } else {
761                        p
762                    };
763                    match p2.parse::<u32>() {
764                        Ok(n) => depends_on.push(n),
765                        Err(_) => diagnostics.push(TaskDiagnostic {
766                            level: DiagnosticLevel::Error,
767                            message: format!("Wave {w}: invalid Depends On entry '{p}'"),
768                            task_id: None,
769                            line: Some(
770                                builder
771                                    .depends_on_line_index
772                                    .unwrap_or(builder.header_line_index)
773                                    + 1,
774                            ),
775                        }),
776                    }
777                }
778            }
779        } else {
780            diagnostics.push(TaskDiagnostic {
781                level: DiagnosticLevel::Error,
782                message: format!("Wave {w}: missing Depends On line"),
783                task_id: None,
784                line: Some(builder.header_line_index + 1),
785            });
786
787            // Preserve behavior for readiness calculations, but refuse to operate due to error.
788            depends_on = wave_nums.iter().copied().filter(|n| *n < *w).collect();
789        }
790
791        depends_on.sort();
792        depends_on.dedup();
793
794        for dep_wave in &depends_on {
795            if dep_wave == w {
796                diagnostics.push(TaskDiagnostic {
797                    level: DiagnosticLevel::Error,
798                    message: format!("Wave {w}: cannot depend on itself"),
799                    task_id: None,
800                    line: Some(
801                        builder
802                            .depends_on_line_index
803                            .unwrap_or(builder.header_line_index)
804                            + 1,
805                    ),
806                });
807                continue;
808            }
809            if !wave_set.contains(dep_wave) {
810                diagnostics.push(TaskDiagnostic {
811                    level: DiagnosticLevel::Error,
812                    message: format!("Wave {w}: depends on missing Wave {dep_wave}"),
813                    task_id: None,
814                    line: Some(
815                        builder
816                            .depends_on_line_index
817                            .unwrap_or(builder.header_line_index)
818                            + 1,
819                    ),
820                });
821            }
822        }
823
824        waves_out.push(WaveInfo {
825            wave: *w,
826            depends_on,
827            header_line_index: builder.header_line_index,
828            depends_on_line_index: builder.depends_on_line_index,
829        });
830    }
831
832    // Relational invariants (cycles, task deps rules) on the finalized model.
833    diagnostics.extend(super::relational::validate_relational(&tasks, &waves_out));
834
835    let progress = compute_progress(&tasks);
836
837    TasksParseResult {
838        format: TasksFormat::Enhanced,
839        tasks,
840        waves: waves_out,
841        diagnostics,
842        progress,
843    }
844}
845
846fn parse_dependencies(raw: &str) -> Vec<String> {
847    parse_dependencies_with_checkpoint(raw, TaskKind::Normal).0
848}
849
850/// Parses a dependency string and returns explicit task IDs and an optional checkpoint wave.
851///
852/// This accepts comma-separated dependency lists (optionally prefixed with "Task "), trims whitespace,
853/// and treats the following special cases:
854/// - empty or "none" yields no dependencies and no wave;
855/// - "all previous waves" or "all prior tasks" yields no explicit dependencies and no wave;
856/// - strings matching "all wave N tasks" capture N and return it as `Some(N)` only when `kind` is `Checkpoint`.
857///
858/// The returned tuple is (dependencies, checkpoint_wave). `dependencies` contains parsed task identifiers
859/// (without the "Task " prefix). `checkpoint_wave` is `Some(wave)` only for the captured "all wave N tasks"
860/// case when `kind` is `Checkpoint`.
861///
862/// # Examples
863///
864/// ```ignore
865/// use ito_domain::tasks::TaskKind;
866///
867/// assert_eq!(
868///     parse_dependencies_with_checkpoint("Task 1, Task 2", TaskKind::Normal),
869///     (vec!["1".to_string(), "2".to_string()], None)
870/// );
871///
872/// assert_eq!(
873///     parse_dependencies_with_checkpoint("none", TaskKind::Normal),
874///     (Vec::<String>::new(), None)
875/// );
876///
877/// assert_eq!(
878///     parse_dependencies_with_checkpoint("All wave 3 tasks", TaskKind::Checkpoint),
879///     (Vec::<String>::new(), Some(3))
880/// );
881/// ```
882fn parse_dependencies_with_checkpoint(raw: &str, kind: TaskKind) -> (Vec<String>, Option<u32>) {
883    let r = raw.trim();
884    if r.is_empty() {
885        return (Vec::new(), None);
886    }
887    let lower = r.to_ascii_lowercase();
888    if lower == "none" {
889        return (Vec::new(), None);
890    }
891
892    // Special-case strings from the enhanced template.
893    let all_wave_capture = &*ALL_WAVE_CAPTURE_RE;
894    if let Some(cap) = all_wave_capture.captures(r) {
895        let wave = cap.get(1).and_then(|m| m.as_str().parse::<u32>().ok());
896        if kind == TaskKind::Checkpoint {
897            return (Vec::new(), wave);
898        }
899        return (Vec::new(), None);
900    }
901    if lower == "all previous waves" || lower == "all prior tasks" {
902        // We don't expand this into explicit deps here.
903        return (Vec::new(), None);
904    }
905
906    let deps = r
907        .split(',')
908        .map(|s| s.trim())
909        .filter(|s| !s.is_empty())
910        .map(|s| s.strip_prefix("Task ").unwrap_or(s).trim().to_string())
911        .collect();
912    (deps, None)
913}
914
915fn compute_progress(tasks: &[TaskItem]) -> ProgressInfo {
916    let total = tasks.len();
917    let complete = tasks
918        .iter()
919        .filter(|t| t.status == TaskStatus::Complete)
920        .count();
921    let shelved = tasks
922        .iter()
923        .filter(|t| t.status == TaskStatus::Shelved)
924        .count();
925    let in_progress = tasks
926        .iter()
927        .filter(|t| t.status == TaskStatus::InProgress)
928        .count();
929    let pending = tasks
930        .iter()
931        .filter(|t| t.status == TaskStatus::Pending)
932        .count();
933    let done = tasks.iter().filter(|t| t.status.is_done()).count();
934    let remaining = total.saturating_sub(done);
935    ProgressInfo {
936        total,
937        complete,
938        shelved,
939        in_progress,
940        pending,
941        remaining,
942    }
943}
944
945/// Path to `{ito_path}/changes/{change_id}/tasks.md`.
946pub fn tasks_path(ito_path: &Path, change_id: &str) -> PathBuf {
947    let Some(path) = tasks_path_checked(ito_path, change_id) else {
948        return ito_path
949            .join("changes")
950            .join("invalid-change-id")
951            .join("tasks.md");
952    };
953    path
954}
955
956/// Path to `{ito_path}/changes/{change_id}/tasks.md` when `change_id` is safe.
957///
958/// This rejects path traversal tokens, path separators, empty ids, and overlong
959/// ids to ensure the resulting path cannot escape the intended `changes/`
960/// subtree.
961pub fn tasks_path_checked(ito_path: &Path, change_id: &str) -> Option<PathBuf> {
962    if !is_safe_change_id_segment(change_id) {
963        return None;
964    }
965
966    Some(ito_path.join("changes").join(change_id).join("tasks.md"))
967}
968
969/// Return `true` when `change_id` is safe as a single path segment.
970pub fn is_safe_change_id_segment(change_id: &str) -> bool {
971    let change_id = change_id.trim();
972    if change_id.is_empty() {
973        return false;
974    }
975    if change_id.len() > 256 {
976        return false;
977    }
978    if change_id.contains('/') || change_id.contains('\\') || change_id.contains("..") {
979        return false;
980    }
981    true
982}
983
984/// Return `true` when `tracking_file` is safe as a single filename.
985///
986/// Tracking file paths are intentionally stricter than other schema-relative paths:
987/// callers treat `apply.tracks` as a filename at the change directory root.
988pub fn is_safe_tracking_filename(tracking_file: &str) -> bool {
989    let tracking_file = tracking_file.trim();
990    if tracking_file.is_empty() {
991        return false;
992    }
993    if tracking_file == "." {
994        return false;
995    }
996    if tracking_file.len() > 256 {
997        return false;
998    }
999    if tracking_file.starts_with('/') || tracking_file.starts_with('\\') {
1000        return false;
1001    }
1002    if tracking_file.contains('/') || tracking_file.contains('\\') {
1003        return false;
1004    }
1005    if tracking_file.contains("..") {
1006        return false;
1007    }
1008    true
1009}
1010
1011/// Path to `{ito_path}/changes/{change_id}/{tracking_file}` when both inputs are safe.
1012pub fn tracking_path_checked(
1013    ito_path: &Path,
1014    change_id: &str,
1015    tracking_file: &str,
1016) -> Option<PathBuf> {
1017    if !is_safe_change_id_segment(change_id) {
1018        return None;
1019    }
1020    if !is_safe_tracking_filename(tracking_file) {
1021        return None;
1022    }
1023    Some(ito_path.join("changes").join(change_id).join(tracking_file))
1024}