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