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/// Generate the default template for an enhanced-format `tasks.md`.
207///
208/// The template includes:
209/// - A header with execution notes and CLI hints.
210/// - A sample Wave 1 with a placeholder task.
211/// - A sample Checkpoint for review.
212pub fn enhanced_tasks_template(change_id: &str, now: DateTime<Local>) -> String {
213    let date = now.format("%Y-%m-%d").to_string();
214    format!(
215        "# 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"
216    )
217}
218
219/// Detect whether the file is in enhanced or checkbox format.
220pub fn detect_tasks_format(contents: &str) -> TasksFormat {
221    let enhanced_heading = Regex::new(r"(?m)^###\s+(Task\s+)?[^:]+:\s+.+$").unwrap();
222    let has_status = contents.contains("- **Status**:");
223    if enhanced_heading.is_match(contents) && has_status {
224        return TasksFormat::Enhanced;
225    }
226    let checkbox = Regex::new(r"(?m)^\s*[-*]\s+\[[ xX~>]\]").unwrap();
227    if checkbox.is_match(contents) {
228        return TasksFormat::Checkbox;
229    }
230    TasksFormat::Checkbox
231}
232
233/// Parse a `tasks.md` tracking file into a normalized representation.
234pub fn parse_tasks_tracking_file(contents: &str) -> TasksParseResult {
235    match detect_tasks_format(contents) {
236        TasksFormat::Enhanced => parse_enhanced_tasks(contents),
237        TasksFormat::Checkbox => parse_checkbox_tasks(contents),
238    }
239}
240
241fn parse_checkbox_tasks(contents: &str) -> TasksParseResult {
242    // Minimal compat: tasks are numbered 1..N.
243    let mut tasks: Vec<TaskItem> = Vec::new();
244    for (line_idx, line) in contents.lines().enumerate() {
245        let l = line.trim_start();
246        let bytes = l.as_bytes();
247        if bytes.len() < 6 {
248            continue;
249        }
250        let bullet = bytes[0] as char;
251        if bullet != '-' && bullet != '*' {
252            continue;
253        }
254        if bytes[1] != b' ' || bytes[2] != b'[' || bytes[4] != b']' || bytes[5] != b' ' {
255            continue;
256        }
257        let marker = bytes[3] as char;
258        let status = match marker {
259            'x' | 'X' => TaskStatus::Complete,
260            ' ' => TaskStatus::Pending,
261            '~' | '>' => TaskStatus::InProgress,
262            _ => continue,
263        };
264        let rest = &l[6..];
265        tasks.push(TaskItem {
266            id: (tasks.len() + 1).to_string(),
267            name: rest.trim().to_string(),
268            wave: None,
269            status,
270            updated_at: None,
271            dependencies: Vec::new(),
272            files: Vec::new(),
273            action: String::new(),
274            verify: None,
275            done_when: None,
276            kind: TaskKind::Normal,
277            header_line_index: line_idx,
278        });
279    }
280    let progress = compute_progress(&tasks);
281    TasksParseResult {
282        format: TasksFormat::Checkbox,
283        tasks,
284        waves: Vec::new(),
285        diagnostics: Vec::new(),
286        progress,
287    }
288}
289
290fn parse_enhanced_tasks(contents: &str) -> TasksParseResult {
291    let mut diagnostics: Vec<TaskDiagnostic> = Vec::new();
292    let mut tasks: Vec<TaskItem> = Vec::new();
293
294    let wave_re = Regex::new(r"^##\s+Wave\s+(\d+)\s*$").unwrap();
295    let wave_dep_re = Regex::new(r"^\s*[-*]\s+\*\*Depends On\*\*:\s*(.+?)\s*$").unwrap();
296    let task_re = Regex::new(r"^###\s+(?:Task\s+)?([^:]+):\s+(.+?)\s*$").unwrap();
297    let deps_re = Regex::new(r"\*\*Dependencies\*\*:\s*(.+?)\s*$").unwrap();
298    let status_re = Regex::new(
299        r"\*\*Status\*\*:\s*\[([ xX\-~])\]\s+(pending|in-progress|complete|shelved)\s*$",
300    )
301    .unwrap();
302    let updated_at_re = Regex::new(r"\*\*Updated At\*\*:\s*(\d{4}-\d{2}-\d{2})\s*$").unwrap();
303    let files_re = Regex::new(r"\*\*Files\*\*:\s*`([^`]+)`\s*$").unwrap();
304    let verify_re = Regex::new(r"\*\*Verify\*\*:\s*`([^`]+)`\s*$").unwrap();
305    let done_when_re = Regex::new(r"\*\*Done When\*\*:\s*(.+?)\s*$").unwrap();
306
307    let mut current_wave: Option<u32> = None;
308    let mut in_checkpoints = false;
309
310    #[derive(Debug, Default, Clone)]
311    struct WaveBuilder {
312        header_line_index: usize,
313        depends_on_raw: Option<String>,
314        depends_on_line_index: Option<usize>,
315    }
316
317    let mut waves: BTreeMap<u32, WaveBuilder> = BTreeMap::new();
318
319    #[derive(Debug, Default)]
320    struct CurrentTask {
321        id: Option<String>,
322        desc: Option<String>,
323        wave: Option<u32>,
324        header_line_index: usize,
325        kind: TaskKind,
326        deps_raw: Option<String>,
327        updated_at_raw: Option<String>,
328        status_raw: Option<String>,
329        status_marker_raw: Option<char>,
330        files: Vec<String>,
331        action_lines: Vec<String>,
332        verify: Option<String>,
333        done_when: Option<String>,
334    }
335
336    fn flush_current(
337        current: &mut CurrentTask,
338        tasks: &mut Vec<TaskItem>,
339        diagnostics: &mut Vec<TaskDiagnostic>,
340    ) {
341        let Some(id) = current.id.take() else {
342            current.desc = None;
343            current.deps_raw = None;
344            current.updated_at_raw = None;
345            current.status_raw = None;
346            current.kind = TaskKind::Normal;
347            return;
348        };
349        let desc = current.desc.take().unwrap_or_default();
350        let wave = current.wave.take();
351        let header_line_index = current.header_line_index;
352        let deps_raw = current.deps_raw.take().unwrap_or_default();
353        let updated_at_raw = current.updated_at_raw.take();
354        let status_raw = current.status_raw.take();
355        let status_marker_raw = current.status_marker_raw.take();
356        let files = std::mem::take(&mut current.files);
357        let action = std::mem::take(&mut current.action_lines)
358            .join("\n")
359            .trim()
360            .to_string();
361        let verify = current.verify.take();
362        let done_when = current.done_when.take();
363
364        let status = match status_raw
365            .as_deref()
366            .and_then(TaskStatus::from_enhanced_label)
367        {
368            Some(s) => s,
369            None => {
370                diagnostics.push(TaskDiagnostic {
371                    level: DiagnosticLevel::Error,
372                    message: "Invalid or missing status".to_string(),
373                    task_id: Some(id.clone()),
374                    line: Some(header_line_index + 1),
375                });
376                TaskStatus::Pending
377            }
378        };
379
380        // Validate marker conventions to make manual edits harder to corrupt.
381        // We treat `[x] complete` as the only marker with semantic meaning and keep the others
382        // as formatting conventions.
383        if let Some(marker) = status_marker_raw {
384            match status {
385                TaskStatus::Complete => {
386                    if marker != 'x' && marker != 'X' {
387                        diagnostics.push(TaskDiagnostic {
388                            level: DiagnosticLevel::Warning,
389                            message: "Status marker for complete should be [x]".to_string(),
390                            task_id: Some(id.clone()),
391                            line: Some(header_line_index + 1),
392                        });
393                    }
394                }
395                TaskStatus::Shelved => {
396                    if marker != '-' && marker != '~' {
397                        diagnostics.push(TaskDiagnostic {
398                            level: DiagnosticLevel::Warning,
399                            message: "Status marker for shelved should be [-]".to_string(),
400                            task_id: Some(id.clone()),
401                            line: Some(header_line_index + 1),
402                        });
403                    }
404                }
405                TaskStatus::Pending | TaskStatus::InProgress => {
406                    if marker == 'x' || marker == 'X' {
407                        diagnostics.push(TaskDiagnostic {
408                            level: DiagnosticLevel::Warning,
409                            message: "Only complete tasks should use [x]".to_string(),
410                            task_id: Some(id.clone()),
411                            line: Some(header_line_index + 1),
412                        });
413                    }
414                }
415            }
416        }
417        let deps = parse_dependencies(&deps_raw);
418
419        let updated_at = match updated_at_raw.as_deref() {
420            Some(s) => {
421                if NaiveDate::parse_from_str(s, "%Y-%m-%d").is_ok() {
422                    Some(s.to_string())
423                } else {
424                    diagnostics.push(TaskDiagnostic {
425                        level: DiagnosticLevel::Error,
426                        message: format!("Invalid Updated At date: {s} (expected YYYY-MM-DD)"),
427                        task_id: Some(id.clone()),
428                        line: Some(header_line_index + 1),
429                    });
430                    None
431                }
432            }
433            None => {
434                diagnostics.push(TaskDiagnostic {
435                    level: DiagnosticLevel::Error,
436                    message: "Missing Updated At field (expected YYYY-MM-DD)".to_string(),
437                    task_id: Some(id.clone()),
438                    line: Some(header_line_index + 1),
439                });
440                None
441            }
442        };
443
444        tasks.push(TaskItem {
445            id,
446            name: desc,
447            wave,
448            status,
449            updated_at,
450            dependencies: deps,
451            files,
452            action,
453            verify,
454            done_when,
455            kind: current.kind,
456            header_line_index,
457        });
458        current.kind = TaskKind::Normal;
459    }
460
461    let mut current_task = CurrentTask {
462        id: None,
463        desc: None,
464        wave: None,
465        header_line_index: 0,
466        kind: TaskKind::Normal,
467        deps_raw: None,
468        updated_at_raw: None,
469        status_raw: None,
470        status_marker_raw: None,
471        files: Vec::new(),
472        action_lines: Vec::new(),
473        verify: None,
474        done_when: None,
475    };
476
477    let mut in_action = false;
478
479    for (line_idx, line) in contents.lines().enumerate() {
480        if in_action && current_task.id.is_some() {
481            if line.starts_with("- **") || line.starts_with("### ") || line.starts_with("## ") {
482                in_action = false;
483                // fall through to process this line normally
484            } else {
485                let trimmed = line.trim();
486                if !trimmed.is_empty() {
487                    current_task.action_lines.push(trimmed.to_string());
488                }
489                continue;
490            }
491        }
492
493        if let Some(cap) = wave_re.captures(line) {
494            flush_current(&mut current_task, &mut tasks, &mut diagnostics);
495            current_wave = cap.get(1).and_then(|m| m.as_str().parse::<u32>().ok());
496            in_checkpoints = false;
497            if let Some(w) = current_wave {
498                waves.entry(w).or_insert_with(|| WaveBuilder {
499                    header_line_index: line_idx,
500                    depends_on_raw: None,
501                    depends_on_line_index: None,
502                });
503            }
504            continue;
505        }
506        if line.trim() == "## Checkpoints" {
507            flush_current(&mut current_task, &mut tasks, &mut diagnostics);
508            current_wave = None;
509            in_checkpoints = true;
510            continue;
511        }
512
513        if current_task.id.is_none()
514            && let Some(w) = current_wave
515            && let Some(cap) = wave_dep_re.captures(line)
516        {
517            let raw = cap[1].trim().to_string();
518            let entry = waves.entry(w).or_insert_with(|| WaveBuilder {
519                header_line_index: line_idx,
520                depends_on_raw: None,
521                depends_on_line_index: None,
522            });
523            if entry.depends_on_raw.is_some() {
524                diagnostics.push(TaskDiagnostic {
525                    level: DiagnosticLevel::Warning,
526                    message: format!("Wave {w}: duplicate Depends On line; using the first one"),
527                    task_id: None,
528                    line: Some(line_idx + 1),
529                });
530            } else {
531                entry.depends_on_raw = Some(raw);
532                entry.depends_on_line_index = Some(line_idx);
533            }
534            continue;
535        }
536
537        if let Some(cap) = task_re.captures(line) {
538            flush_current(&mut current_task, &mut tasks, &mut diagnostics);
539            let id = cap[1].trim().to_string();
540            let desc = cap[2].trim().to_string();
541            current_task.id = Some(id.clone());
542            current_task.desc = Some(desc);
543            current_task.wave = current_wave;
544            current_task.header_line_index = line_idx;
545            current_task.kind = TaskKind::Normal;
546            current_task.deps_raw = None;
547            current_task.updated_at_raw = None;
548            current_task.status_raw = None;
549            current_task.status_marker_raw = None;
550            current_task.files.clear();
551            current_task.action_lines.clear();
552            current_task.verify = None;
553            current_task.done_when = None;
554            in_action = false;
555
556            if current_wave.is_none() && !in_checkpoints {
557                diagnostics.push(TaskDiagnostic {
558                    level: DiagnosticLevel::Warning,
559                    message: format!(
560                        "{id}: Task '{id}' appears outside any Wave section; wave gating may not behave as expected"
561                    ),
562                    task_id: None,
563                    line: Some(line_idx + 1),
564                });
565            }
566            continue;
567        }
568
569        if current_task.id.is_some() {
570            if line.trim() == "- **Action**:" {
571                in_action = true;
572                current_task.action_lines.clear();
573                continue;
574            }
575            if let Some(cap) = deps_re.captures(line) {
576                current_task.deps_raw = Some(cap[1].trim().to_string());
577                continue;
578            }
579            if let Some(cap) = updated_at_re.captures(line) {
580                current_task.updated_at_raw = Some(cap[1].trim().to_string());
581                continue;
582            }
583            if let Some(cap) = status_re.captures(line) {
584                let marker = cap
585                    .get(1)
586                    .and_then(|m| m.as_str().chars().next())
587                    .unwrap_or(' ');
588                current_task.status_marker_raw = Some(marker);
589                current_task.status_raw = Some(cap[2].trim().to_string());
590                continue;
591            }
592            if let Some(cap) = files_re.captures(line) {
593                let inner = cap[1].trim();
594                current_task.files = inner
595                    .split(',')
596                    .map(|s| s.trim().to_string())
597                    .filter(|s| !s.is_empty())
598                    .collect();
599                continue;
600            }
601            if let Some(cap) = verify_re.captures(line) {
602                current_task.verify = Some(cap[1].trim().to_string());
603                continue;
604            }
605            if let Some(cap) = done_when_re.captures(line) {
606                current_task.done_when = Some(cap[1].trim().to_string());
607                continue;
608            }
609        }
610    }
611
612    flush_current(&mut current_task, &mut tasks, &mut diagnostics);
613
614    // Build wave dependency model.
615    let mut wave_nums: Vec<u32> = waves.keys().copied().collect();
616    wave_nums.sort();
617    wave_nums.dedup();
618    let wave_set: std::collections::BTreeSet<u32> = wave_nums.iter().copied().collect();
619
620    let mut waves_out: Vec<WaveInfo> = Vec::new();
621    for w in &wave_nums {
622        let builder = waves.get(w).cloned().unwrap_or_default();
623
624        let mut depends_on: Vec<u32> = Vec::new();
625        if let Some(raw) = builder.depends_on_raw.as_deref() {
626            let trimmed = raw.trim();
627            if trimmed.is_empty() {
628                diagnostics.push(TaskDiagnostic {
629                    level: DiagnosticLevel::Error,
630                    message: format!("Wave {w}: Depends On is empty"),
631                    task_id: None,
632                    line: Some(builder.header_line_index + 1),
633                });
634            } else if trimmed.eq_ignore_ascii_case("none") {
635                // no deps
636            } else {
637                for part in trimmed.split(',') {
638                    let p = part.trim();
639                    if p.is_empty() {
640                        continue;
641                    }
642                    let p2 = if p.to_ascii_lowercase().starts_with("wave ") {
643                        p[5..].trim()
644                    } else {
645                        p
646                    };
647                    match p2.parse::<u32>() {
648                        Ok(n) => depends_on.push(n),
649                        Err(_) => diagnostics.push(TaskDiagnostic {
650                            level: DiagnosticLevel::Error,
651                            message: format!("Wave {w}: invalid Depends On entry '{p}'"),
652                            task_id: None,
653                            line: Some(
654                                builder
655                                    .depends_on_line_index
656                                    .unwrap_or(builder.header_line_index)
657                                    + 1,
658                            ),
659                        }),
660                    }
661                }
662            }
663        } else {
664            diagnostics.push(TaskDiagnostic {
665                level: DiagnosticLevel::Error,
666                message: format!("Wave {w}: missing Depends On line"),
667                task_id: None,
668                line: Some(builder.header_line_index + 1),
669            });
670
671            // Preserve behavior for readiness calculations, but refuse to operate due to error.
672            depends_on = wave_nums.iter().copied().filter(|n| *n < *w).collect();
673        }
674
675        depends_on.sort();
676        depends_on.dedup();
677
678        for dep_wave in &depends_on {
679            if dep_wave == w {
680                diagnostics.push(TaskDiagnostic {
681                    level: DiagnosticLevel::Error,
682                    message: format!("Wave {w}: cannot depend on itself"),
683                    task_id: None,
684                    line: Some(
685                        builder
686                            .depends_on_line_index
687                            .unwrap_or(builder.header_line_index)
688                            + 1,
689                    ),
690                });
691                continue;
692            }
693            if !wave_set.contains(dep_wave) {
694                diagnostics.push(TaskDiagnostic {
695                    level: DiagnosticLevel::Error,
696                    message: format!("Wave {w}: depends on missing Wave {dep_wave}"),
697                    task_id: None,
698                    line: Some(
699                        builder
700                            .depends_on_line_index
701                            .unwrap_or(builder.header_line_index)
702                            + 1,
703                    ),
704                });
705            }
706        }
707
708        waves_out.push(WaveInfo {
709            wave: *w,
710            depends_on,
711            header_line_index: builder.header_line_index,
712            depends_on_line_index: builder.depends_on_line_index,
713        });
714    }
715
716    // Relational invariants (cycles, task deps rules) on the finalized model.
717    diagnostics.extend(super::relational::validate_relational(&tasks, &waves_out));
718
719    let progress = compute_progress(&tasks);
720
721    TasksParseResult {
722        format: TasksFormat::Enhanced,
723        tasks,
724        waves: waves_out,
725        diagnostics,
726        progress,
727    }
728}
729
730fn parse_dependencies(raw: &str) -> Vec<String> {
731    parse_dependencies_with_checkpoint(raw, TaskKind::Normal).0
732}
733
734fn parse_dependencies_with_checkpoint(raw: &str, kind: TaskKind) -> (Vec<String>, Option<u32>) {
735    let r = raw.trim();
736    if r.is_empty() {
737        return (Vec::new(), None);
738    }
739    let lower = r.to_ascii_lowercase();
740    if lower == "none" {
741        return (Vec::new(), None);
742    }
743
744    // Special-case strings from the enhanced template.
745    let all_wave_capture = Regex::new(r"(?i)^all\s+wave\s+(\d+)\s+tasks$").unwrap();
746    if let Some(cap) = all_wave_capture.captures(r) {
747        let wave = cap.get(1).and_then(|m| m.as_str().parse::<u32>().ok());
748        if kind == TaskKind::Checkpoint {
749            return (Vec::new(), wave);
750        }
751        return (Vec::new(), None);
752    }
753    if lower == "all previous waves" {
754        // We don't expand this into explicit deps here.
755        return (Vec::new(), None);
756    }
757
758    let deps = r
759        .split(',')
760        .map(|s| s.trim())
761        .filter(|s| !s.is_empty())
762        .map(|s| s.strip_prefix("Task ").unwrap_or(s).trim().to_string())
763        .collect();
764    (deps, None)
765}
766
767fn compute_progress(tasks: &[TaskItem]) -> ProgressInfo {
768    let total = tasks.len();
769    let complete = tasks
770        .iter()
771        .filter(|t| t.status == TaskStatus::Complete)
772        .count();
773    let shelved = tasks
774        .iter()
775        .filter(|t| t.status == TaskStatus::Shelved)
776        .count();
777    let in_progress = tasks
778        .iter()
779        .filter(|t| t.status == TaskStatus::InProgress)
780        .count();
781    let pending = tasks
782        .iter()
783        .filter(|t| t.status == TaskStatus::Pending)
784        .count();
785    let done = tasks.iter().filter(|t| t.status.is_done()).count();
786    let remaining = total.saturating_sub(done);
787    ProgressInfo {
788        total,
789        complete,
790        shelved,
791        in_progress,
792        pending,
793        remaining,
794    }
795}
796
797/// Path to `{ito_path}/changes/{change_id}/tasks.md`.
798pub fn tasks_path(ito_path: &Path, change_id: &str) -> PathBuf {
799    let Some(path) = tasks_path_checked(ito_path, change_id) else {
800        return ito_path
801            .join("changes")
802            .join("invalid-change-id")
803            .join("tasks.md");
804    };
805    path
806}
807
808/// Path to `{ito_path}/changes/{change_id}/tasks.md` when `change_id` is safe.
809///
810/// This rejects path traversal tokens, path separators, empty ids, and overlong
811/// ids to ensure the resulting path cannot escape the intended `changes/`
812/// subtree.
813pub fn tasks_path_checked(ito_path: &Path, change_id: &str) -> Option<PathBuf> {
814    if !is_safe_change_id_segment(change_id) {
815        return None;
816    }
817
818    Some(ito_path.join("changes").join(change_id).join("tasks.md"))
819}
820
821/// Return `true` when `change_id` is safe as a single path segment.
822pub fn is_safe_change_id_segment(change_id: &str) -> bool {
823    let change_id = change_id.trim();
824    if change_id.is_empty() {
825        return false;
826    }
827    if change_id.len() > 256 {
828        return false;
829    }
830    if change_id.contains('/') || change_id.contains('\\') || change_id.contains("..") {
831        return false;
832    }
833    true
834}