1use 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 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)]
69pub enum TasksFormat {
71 Enhanced,
73 Checkbox,
75}
76
77#[derive(Debug, Clone, Copy, PartialEq, Eq)]
78pub enum TaskStatus {
80 Pending,
82 InProgress,
84 Complete,
86 Shelved,
88}
89
90impl TaskStatus {
91 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 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 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)]
124pub struct TaskDiagnostic {
126 pub level: DiagnosticLevel,
128 pub message: String,
130 pub task_id: Option<String>,
132 pub line: Option<usize>,
134}
135
136#[derive(Debug, Clone, Copy, PartialEq, Eq)]
137pub enum DiagnosticLevel {
139 Error,
141 Warning,
143}
144
145impl DiagnosticLevel {
146 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)]
156pub struct TaskItem {
158 pub id: String,
160 pub name: String,
162 pub wave: Option<u32>,
164 pub status: TaskStatus,
166 pub updated_at: Option<String>,
168 pub dependencies: Vec<String>,
170 pub files: Vec<String>,
172 pub action: String,
174 pub verify: Option<String>,
176 pub done_when: Option<String>,
178 pub kind: TaskKind,
180 pub header_line_index: usize,
182 pub requirements: Vec<String>,
184}
185
186#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
187pub enum TaskKind {
189 #[default]
190 Normal,
192 Checkpoint,
194}
195
196#[derive(Debug, Clone, PartialEq, Eq)]
197pub struct ProgressInfo {
199 pub total: usize,
201 pub complete: usize,
203 pub shelved: usize,
205 pub in_progress: usize,
207 pub pending: usize,
209 pub remaining: usize,
211}
212
213#[derive(Debug, Clone, PartialEq, Eq)]
214pub struct WaveInfo {
216 pub wave: u32,
218 pub depends_on: Vec<u32>,
220 pub header_line_index: usize,
222 pub depends_on_line_index: Option<usize>,
224}
225
226#[derive(Debug, Clone, PartialEq, Eq)]
227pub struct TasksParseResult {
229 pub format: TasksFormat,
231 pub tasks: Vec<TaskItem>,
233 pub waves: Vec<WaveInfo>,
235 pub diagnostics: Vec<TaskDiagnostic>,
237 pub progress: ProgressInfo,
239}
240
241impl TasksParseResult {
242 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
261pub 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
285pub 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
312pub 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
320fn parse_checkbox_tasks(contents: &str) -> TasksParseResult {
341 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
401fn 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 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 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 } 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 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 } 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 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 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
923fn 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 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 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
1018pub 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
1029pub 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
1042pub 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
1057pub 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
1084pub 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}