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 ALL_WAVE_CAPTURE_RE: LazyLock<Regex> =
63 LazyLock::new(|| Regex::new(r"(?i)^all\s+wave\s+(\d+)\s+tasks$").unwrap());
64
65#[derive(Debug, Clone, Copy, PartialEq, Eq)]
66pub enum TasksFormat {
68 Enhanced,
70 Checkbox,
72}
73
74#[derive(Debug, Clone, Copy, PartialEq, Eq)]
75pub enum TaskStatus {
77 Pending,
79 InProgress,
81 Complete,
83 Shelved,
85}
86
87impl TaskStatus {
88 pub fn as_enhanced_label(self) -> &'static str {
90 match self {
91 TaskStatus::Pending => "pending",
92 TaskStatus::InProgress => "in-progress",
93 TaskStatus::Complete => "complete",
94 TaskStatus::Shelved => "shelved",
95 }
96 }
97
98 pub fn from_enhanced_label(s: &str) -> Option<Self> {
100 match s {
101 "pending" => Some(TaskStatus::Pending),
102 "in-progress" => Some(TaskStatus::InProgress),
103 "complete" => Some(TaskStatus::Complete),
104 "shelved" => Some(TaskStatus::Shelved),
105 _ => None,
106 }
107 }
108
109 pub fn is_done(self) -> bool {
111 match self {
112 TaskStatus::Pending => false,
113 TaskStatus::InProgress => false,
114 TaskStatus::Complete => true,
115 TaskStatus::Shelved => true,
116 }
117 }
118}
119
120#[derive(Debug, Clone, PartialEq, Eq)]
121pub struct TaskDiagnostic {
123 pub level: DiagnosticLevel,
125 pub message: String,
127 pub task_id: Option<String>,
129 pub line: Option<usize>,
131}
132
133#[derive(Debug, Clone, Copy, PartialEq, Eq)]
134pub enum DiagnosticLevel {
136 Error,
138 Warning,
140}
141
142impl DiagnosticLevel {
143 pub fn as_str(self) -> &'static str {
145 match self {
146 DiagnosticLevel::Error => "error",
147 DiagnosticLevel::Warning => "warning",
148 }
149 }
150}
151
152#[derive(Debug, Clone, PartialEq, Eq)]
153pub struct TaskItem {
155 pub id: String,
157 pub name: String,
159 pub wave: Option<u32>,
161 pub status: TaskStatus,
163 pub updated_at: Option<String>,
165 pub dependencies: Vec<String>,
167 pub files: Vec<String>,
169 pub action: String,
171 pub verify: Option<String>,
173 pub done_when: Option<String>,
175 pub kind: TaskKind,
177 pub header_line_index: usize,
179}
180
181#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
182pub enum TaskKind {
184 #[default]
185 Normal,
187 Checkpoint,
189}
190
191#[derive(Debug, Clone, PartialEq, Eq)]
192pub struct ProgressInfo {
194 pub total: usize,
196 pub complete: usize,
198 pub shelved: usize,
200 pub in_progress: usize,
202 pub pending: usize,
204 pub remaining: usize,
206}
207
208#[derive(Debug, Clone, PartialEq, Eq)]
209pub struct WaveInfo {
211 pub wave: u32,
213 pub depends_on: Vec<u32>,
215 pub header_line_index: usize,
217 pub depends_on_line_index: Option<usize>,
219}
220
221#[derive(Debug, Clone, PartialEq, Eq)]
222pub struct TasksParseResult {
224 pub format: TasksFormat,
226 pub tasks: Vec<TaskItem>,
228 pub waves: Vec<WaveInfo>,
230 pub diagnostics: Vec<TaskDiagnostic>,
232 pub progress: ProgressInfo,
234}
235
236impl TasksParseResult {
237 pub fn empty() -> Self {
239 Self {
240 format: TasksFormat::Checkbox,
241 tasks: Vec::new(),
242 waves: Vec::new(),
243 diagnostics: Vec::new(),
244 progress: ProgressInfo {
245 total: 0,
246 complete: 0,
247 shelved: 0,
248 in_progress: 0,
249 pending: 0,
250 remaining: 0,
251 },
252 }
253 }
254}
255
256pub fn enhanced_tasks_template(change_id: &str, now: DateTime<Local>) -> String {
263 let date = now.format("%Y-%m-%d").to_string();
264 format!(
265 "# Tasks for: {change_id}\n\n## Execution Notes\n\n- **Tool**: Any (OpenCode, Codex, Claude Code)\n- **Mode**: Sequential (or parallel if tool supports)\n- **Template**: Enhanced task format with waves, verification, and status tracking\n- **Tracking**: Prefer the tasks CLI to drive status updates and pick work\n\n```bash\nito tasks status {change_id}\nito tasks next {change_id}\nito tasks start {change_id} 1.1\nito tasks complete {change_id} 1.1\nito tasks shelve {change_id} 1.1\nito tasks unshelve {change_id} 1.1\nito tasks show {change_id}\n```\n\n______________________________________________________________________\n\n## Wave 1\n\n- **Depends On**: None\n\n### Task 1.1: [Task Name]\n\n- **Files**: `path/to/file.rs`\n- **Dependencies**: None\n- **Action**:\n [Describe what needs to be done]\n- **Verify**: `cargo test --workspace`\n- **Done When**: [Success criteria]\n- **Updated At**: {date}\n- **Status**: [ ] pending\n\n______________________________________________________________________\n\n## Checkpoints\n\n### Checkpoint: Review Implementation\n\n- **Type**: checkpoint (requires human approval)\n- **Dependencies**: All Wave 1 tasks\n- **Action**: Review the implementation before proceeding\n- **Done When**: User confirms implementation is correct\n- **Updated At**: {date}\n- **Status**: [ ] pending\n"
266 )
267}
268
269pub fn detect_tasks_format(contents: &str) -> TasksFormat {
285 let enhanced_heading = &*ENHANCED_HEADING_RE;
286 if enhanced_heading.is_match(contents) {
287 return TasksFormat::Enhanced;
288 }
289 let checkbox = &*CHECKBOX_RE;
290 if checkbox.is_match(contents) {
291 return TasksFormat::Checkbox;
292 }
293 TasksFormat::Checkbox
294}
295
296pub fn parse_tasks_tracking_file(contents: &str) -> TasksParseResult {
298 match detect_tasks_format(contents) {
299 TasksFormat::Enhanced => parse_enhanced_tasks(contents),
300 TasksFormat::Checkbox => parse_checkbox_tasks(contents),
301 }
302}
303
304fn parse_checkbox_tasks(contents: &str) -> TasksParseResult {
329 let mut tasks: Vec<TaskItem> = Vec::new();
331 for (line_idx, line) in contents.lines().enumerate() {
332 let l = line.trim_start();
333 let bytes = l.as_bytes();
334 if bytes.len() < 5 {
335 continue;
336 }
337 let bullet = bytes[0] as char;
338 if bullet != '-' && bullet != '*' {
339 continue;
340 }
341 if bytes[1] != b' ' || bytes[2] != b'[' || bytes[4] != b']' {
342 continue;
343 }
344 let marker = bytes[3] as char;
345 let status = if marker == 'x' || marker == 'X' {
346 TaskStatus::Complete
347 } else if marker == ' ' {
348 TaskStatus::Pending
349 } else if marker == '~' || marker == '>' {
350 TaskStatus::InProgress
351 } else {
352 continue;
353 };
354
355 let rest_start = if let Some(b' ') = bytes.get(5) { 6 } else { 5 };
356 let rest = &l[rest_start..];
357 let rest = rest.trim();
358
359 let (id, name) = match split_checkbox_task_label(rest) {
360 Some((id, name)) => (id.to_string(), name.to_string()),
361 None => ((tasks.len() + 1).to_string(), rest.to_string()),
362 };
363 tasks.push(TaskItem {
364 id,
365 name,
366 wave: None,
367 status,
368 updated_at: None,
369 dependencies: Vec::new(),
370 files: Vec::new(),
371 action: String::new(),
372 verify: None,
373 done_when: None,
374 kind: TaskKind::Normal,
375 header_line_index: line_idx,
376 });
377 }
378 let progress = compute_progress(&tasks);
379 TasksParseResult {
380 format: TasksFormat::Checkbox,
381 tasks,
382 waves: Vec::new(),
383 diagnostics: Vec::new(),
384 progress,
385 }
386}
387
388fn parse_enhanced_tasks(contents: &str) -> TasksParseResult {
410 let mut diagnostics: Vec<TaskDiagnostic> = Vec::new();
411 let mut tasks: Vec<TaskItem> = Vec::new();
412
413 let wave_re = &*WAVE_RE;
414 let wave_dep_re = &*WAVE_DEP_RE;
415 let task_re = &*TASK_RE;
416 let deps_re = &*DEPS_RE;
417 let status_re = &*STATUS_RE;
418 let updated_at_re = &*UPDATED_AT_RE;
419 let files_re = &*FILES_RE;
420 let verify_re = &*VERIFY_RE;
421 let done_when_re = &*DONE_WHEN_RE;
422
423 let mut current_wave: Option<u32> = None;
424 let mut in_checkpoints = false;
425
426 #[derive(Debug, Default, Clone)]
427 struct WaveBuilder {
428 header_line_index: usize,
429 depends_on_raw: Option<String>,
430 depends_on_line_index: Option<usize>,
431 }
432
433 let mut waves: BTreeMap<u32, WaveBuilder> = BTreeMap::new();
434
435 #[derive(Debug, Default)]
436 struct CurrentTask {
437 id: Option<String>,
438 desc: Option<String>,
439 wave: Option<u32>,
440 header_line_index: usize,
441 kind: TaskKind,
442 deps_raw: Option<String>,
443 updated_at_raw: Option<String>,
444 status_raw: Option<String>,
445 status_marker_raw: Option<char>,
446 files: Vec<String>,
447 action_lines: Vec<String>,
448 verify: Option<String>,
449 done_when: Option<String>,
450 }
451
452 fn flush_current(
453 current: &mut CurrentTask,
454 tasks: &mut Vec<TaskItem>,
455 diagnostics: &mut Vec<TaskDiagnostic>,
456 ) {
457 let Some(id) = current.id.take() else {
458 current.desc = None;
459 current.deps_raw = None;
460 current.updated_at_raw = None;
461 current.status_raw = None;
462 current.kind = TaskKind::Normal;
463 return;
464 };
465 let desc = current.desc.take().unwrap_or_default();
466 let wave = current.wave.take();
467 let header_line_index = current.header_line_index;
468 let deps_raw = current.deps_raw.take().unwrap_or_default();
469 let updated_at_raw = current.updated_at_raw.take();
470 let status_raw = current.status_raw.take();
471 let status_marker_raw = current.status_marker_raw.take();
472 let files = std::mem::take(&mut current.files);
473 let action = std::mem::take(&mut current.action_lines)
474 .join("\n")
475 .trim()
476 .to_string();
477 let verify = current.verify.take();
478 let done_when = current.done_when.take();
479
480 let status = match status_raw
481 .as_deref()
482 .and_then(TaskStatus::from_enhanced_label)
483 {
484 Some(s) => s,
485 None => {
486 diagnostics.push(TaskDiagnostic {
487 level: DiagnosticLevel::Error,
488 message: "Invalid or missing status".to_string(),
489 task_id: Some(id.clone()),
490 line: Some(header_line_index + 1),
491 });
492 TaskStatus::Pending
493 }
494 };
495
496 if let Some(marker) = status_marker_raw {
500 match status {
501 TaskStatus::Complete => {
502 if marker != 'x' && marker != 'X' {
503 diagnostics.push(TaskDiagnostic {
504 level: DiagnosticLevel::Warning,
505 message: "Status marker for complete should be [x]".to_string(),
506 task_id: Some(id.clone()),
507 line: Some(header_line_index + 1),
508 });
509 }
510 }
511 TaskStatus::Shelved => {
512 if marker != '-' && marker != '~' {
513 diagnostics.push(TaskDiagnostic {
514 level: DiagnosticLevel::Warning,
515 message: "Status marker for shelved should be [-]".to_string(),
516 task_id: Some(id.clone()),
517 line: Some(header_line_index + 1),
518 });
519 }
520 }
521 TaskStatus::Pending | TaskStatus::InProgress => {
522 if marker == 'x' || marker == 'X' {
523 diagnostics.push(TaskDiagnostic {
524 level: DiagnosticLevel::Warning,
525 message: "Only complete tasks should use [x]".to_string(),
526 task_id: Some(id.clone()),
527 line: Some(header_line_index + 1),
528 });
529 }
530 }
531 }
532 }
533 let deps = parse_dependencies(&deps_raw);
534
535 let updated_at = match updated_at_raw.as_deref() {
536 Some(s) => {
537 if NaiveDate::parse_from_str(s, "%Y-%m-%d").is_ok() {
538 Some(s.to_string())
539 } else {
540 diagnostics.push(TaskDiagnostic {
541 level: DiagnosticLevel::Error,
542 message: format!("Invalid Updated At date: {s} (expected YYYY-MM-DD)"),
543 task_id: Some(id.clone()),
544 line: Some(header_line_index + 1),
545 });
546 None
547 }
548 }
549 None => {
550 diagnostics.push(TaskDiagnostic {
551 level: DiagnosticLevel::Error,
552 message: "Missing Updated At field (expected YYYY-MM-DD)".to_string(),
553 task_id: Some(id.clone()),
554 line: Some(header_line_index + 1),
555 });
556 None
557 }
558 };
559
560 tasks.push(TaskItem {
561 id,
562 name: desc,
563 wave,
564 status,
565 updated_at,
566 dependencies: deps,
567 files,
568 action,
569 verify,
570 done_when,
571 kind: current.kind,
572 header_line_index,
573 });
574 current.kind = TaskKind::Normal;
575 }
576
577 let mut current_task = CurrentTask {
578 id: None,
579 desc: None,
580 wave: None,
581 header_line_index: 0,
582 kind: TaskKind::Normal,
583 deps_raw: None,
584 updated_at_raw: None,
585 status_raw: None,
586 status_marker_raw: None,
587 files: Vec::new(),
588 action_lines: Vec::new(),
589 verify: None,
590 done_when: None,
591 };
592
593 let mut in_action = false;
594
595 for (line_idx, line) in contents.lines().enumerate() {
596 if in_action && current_task.id.is_some() {
597 if line.starts_with("- **") || line.starts_with("### ") || line.starts_with("## ") {
598 in_action = false;
599 } else {
601 let trimmed = line.trim();
602 if !trimmed.is_empty() {
603 current_task.action_lines.push(trimmed.to_string());
604 }
605 continue;
606 }
607 }
608
609 if let Some(cap) = wave_re.captures(line) {
610 flush_current(&mut current_task, &mut tasks, &mut diagnostics);
611 current_wave = cap.get(1).and_then(|m| m.as_str().parse::<u32>().ok());
612 in_checkpoints = false;
613 if let Some(w) = current_wave {
614 waves.entry(w).or_insert_with(|| WaveBuilder {
615 header_line_index: line_idx,
616 depends_on_raw: None,
617 depends_on_line_index: None,
618 });
619 }
620 continue;
621 }
622 if line.trim() == "## Checkpoints" {
623 flush_current(&mut current_task, &mut tasks, &mut diagnostics);
624 current_wave = None;
625 in_checkpoints = true;
626 continue;
627 }
628
629 if current_task.id.is_none()
630 && let Some(w) = current_wave
631 && let Some(cap) = wave_dep_re.captures(line)
632 {
633 let raw = cap[1].trim().to_string();
634 let entry = waves.entry(w).or_insert_with(|| WaveBuilder {
635 header_line_index: line_idx,
636 depends_on_raw: None,
637 depends_on_line_index: None,
638 });
639 if entry.depends_on_raw.is_some() {
640 diagnostics.push(TaskDiagnostic {
641 level: DiagnosticLevel::Warning,
642 message: format!("Wave {w}: duplicate Depends On line; using the first one"),
643 task_id: None,
644 line: Some(line_idx + 1),
645 });
646 } else {
647 entry.depends_on_raw = Some(raw);
648 entry.depends_on_line_index = Some(line_idx);
649 }
650 continue;
651 }
652
653 if let Some(cap) = task_re.captures(line) {
654 flush_current(&mut current_task, &mut tasks, &mut diagnostics);
655 let id = cap[1].trim().to_string();
656 let desc = cap[2].trim().to_string();
657 current_task.id = Some(id.clone());
658 current_task.desc = Some(desc);
659 current_task.wave = current_wave;
660 current_task.header_line_index = line_idx;
661 current_task.kind = TaskKind::Normal;
662 current_task.deps_raw = None;
663 current_task.updated_at_raw = None;
664 current_task.status_raw = None;
665 current_task.status_marker_raw = None;
666 current_task.files.clear();
667 current_task.action_lines.clear();
668 current_task.verify = None;
669 current_task.done_when = None;
670 in_action = false;
671
672 if current_wave.is_none() && !in_checkpoints {
673 diagnostics.push(TaskDiagnostic {
674 level: DiagnosticLevel::Warning,
675 message: format!(
676 "{id}: Task '{id}' appears outside any Wave section; wave gating may not behave as expected"
677 ),
678 task_id: None,
679 line: Some(line_idx + 1),
680 });
681 }
682 continue;
683 }
684
685 if current_task.id.is_some() {
686 if line.trim() == "- **Action**:" {
687 in_action = true;
688 current_task.action_lines.clear();
689 continue;
690 }
691 if let Some(cap) = deps_re.captures(line) {
692 current_task.deps_raw = Some(cap[1].trim().to_string());
693 continue;
694 }
695 if let Some(cap) = updated_at_re.captures(line) {
696 current_task.updated_at_raw = Some(cap[1].trim().to_string());
697 continue;
698 }
699 if let Some(cap) = status_re.captures(line) {
700 let marker = cap
701 .get(1)
702 .and_then(|m| m.as_str().chars().next())
703 .unwrap_or(' ');
704 current_task.status_marker_raw = Some(marker);
705 current_task.status_raw = Some(cap[2].trim().to_string());
706 continue;
707 }
708 if let Some(cap) = files_re.captures(line) {
709 let inner = cap[1].trim();
710 current_task.files = inner
711 .split(',')
712 .map(|s| s.trim().to_string())
713 .filter(|s| !s.is_empty())
714 .collect();
715 continue;
716 }
717 if let Some(cap) = verify_re.captures(line) {
718 current_task.verify = Some(cap[1].trim().to_string());
719 continue;
720 }
721 if let Some(cap) = done_when_re.captures(line) {
722 current_task.done_when = Some(cap[1].trim().to_string());
723 continue;
724 }
725 }
726 }
727
728 flush_current(&mut current_task, &mut tasks, &mut diagnostics);
729
730 let mut wave_nums: Vec<u32> = waves.keys().copied().collect();
732 wave_nums.sort();
733 wave_nums.dedup();
734 let wave_set: std::collections::BTreeSet<u32> = wave_nums.iter().copied().collect();
735
736 let mut waves_out: Vec<WaveInfo> = Vec::new();
737 for w in &wave_nums {
738 let builder = waves.get(w).cloned().unwrap_or_default();
739
740 let mut depends_on: Vec<u32> = Vec::new();
741 if let Some(raw) = builder.depends_on_raw.as_deref() {
742 let trimmed = raw.trim();
743 if trimmed.is_empty() {
744 diagnostics.push(TaskDiagnostic {
745 level: DiagnosticLevel::Error,
746 message: format!("Wave {w}: Depends On is empty"),
747 task_id: None,
748 line: Some(builder.header_line_index + 1),
749 });
750 } else if trimmed.eq_ignore_ascii_case("none") {
751 } else {
753 for part in trimmed.split(',') {
754 let p = part.trim();
755 if p.is_empty() {
756 continue;
757 }
758 let p2 = if p.to_ascii_lowercase().starts_with("wave ") {
759 p[5..].trim()
760 } else {
761 p
762 };
763 match p2.parse::<u32>() {
764 Ok(n) => depends_on.push(n),
765 Err(_) => diagnostics.push(TaskDiagnostic {
766 level: DiagnosticLevel::Error,
767 message: format!("Wave {w}: invalid Depends On entry '{p}'"),
768 task_id: None,
769 line: Some(
770 builder
771 .depends_on_line_index
772 .unwrap_or(builder.header_line_index)
773 + 1,
774 ),
775 }),
776 }
777 }
778 }
779 } else {
780 diagnostics.push(TaskDiagnostic {
781 level: DiagnosticLevel::Error,
782 message: format!("Wave {w}: missing Depends On line"),
783 task_id: None,
784 line: Some(builder.header_line_index + 1),
785 });
786
787 depends_on = wave_nums.iter().copied().filter(|n| *n < *w).collect();
789 }
790
791 depends_on.sort();
792 depends_on.dedup();
793
794 for dep_wave in &depends_on {
795 if dep_wave == w {
796 diagnostics.push(TaskDiagnostic {
797 level: DiagnosticLevel::Error,
798 message: format!("Wave {w}: cannot depend on itself"),
799 task_id: None,
800 line: Some(
801 builder
802 .depends_on_line_index
803 .unwrap_or(builder.header_line_index)
804 + 1,
805 ),
806 });
807 continue;
808 }
809 if !wave_set.contains(dep_wave) {
810 diagnostics.push(TaskDiagnostic {
811 level: DiagnosticLevel::Error,
812 message: format!("Wave {w}: depends on missing Wave {dep_wave}"),
813 task_id: None,
814 line: Some(
815 builder
816 .depends_on_line_index
817 .unwrap_or(builder.header_line_index)
818 + 1,
819 ),
820 });
821 }
822 }
823
824 waves_out.push(WaveInfo {
825 wave: *w,
826 depends_on,
827 header_line_index: builder.header_line_index,
828 depends_on_line_index: builder.depends_on_line_index,
829 });
830 }
831
832 diagnostics.extend(super::relational::validate_relational(&tasks, &waves_out));
834
835 let progress = compute_progress(&tasks);
836
837 TasksParseResult {
838 format: TasksFormat::Enhanced,
839 tasks,
840 waves: waves_out,
841 diagnostics,
842 progress,
843 }
844}
845
846fn parse_dependencies(raw: &str) -> Vec<String> {
847 parse_dependencies_with_checkpoint(raw, TaskKind::Normal).0
848}
849
850fn parse_dependencies_with_checkpoint(raw: &str, kind: TaskKind) -> (Vec<String>, Option<u32>) {
883 let r = raw.trim();
884 if r.is_empty() {
885 return (Vec::new(), None);
886 }
887 let lower = r.to_ascii_lowercase();
888 if lower == "none" {
889 return (Vec::new(), None);
890 }
891
892 let all_wave_capture = &*ALL_WAVE_CAPTURE_RE;
894 if let Some(cap) = all_wave_capture.captures(r) {
895 let wave = cap.get(1).and_then(|m| m.as_str().parse::<u32>().ok());
896 if kind == TaskKind::Checkpoint {
897 return (Vec::new(), wave);
898 }
899 return (Vec::new(), None);
900 }
901 if lower == "all previous waves" || lower == "all prior tasks" {
902 return (Vec::new(), None);
904 }
905
906 let deps = r
907 .split(',')
908 .map(|s| s.trim())
909 .filter(|s| !s.is_empty())
910 .map(|s| s.strip_prefix("Task ").unwrap_or(s).trim().to_string())
911 .collect();
912 (deps, None)
913}
914
915fn compute_progress(tasks: &[TaskItem]) -> ProgressInfo {
916 let total = tasks.len();
917 let complete = tasks
918 .iter()
919 .filter(|t| t.status == TaskStatus::Complete)
920 .count();
921 let shelved = tasks
922 .iter()
923 .filter(|t| t.status == TaskStatus::Shelved)
924 .count();
925 let in_progress = tasks
926 .iter()
927 .filter(|t| t.status == TaskStatus::InProgress)
928 .count();
929 let pending = tasks
930 .iter()
931 .filter(|t| t.status == TaskStatus::Pending)
932 .count();
933 let done = tasks.iter().filter(|t| t.status.is_done()).count();
934 let remaining = total.saturating_sub(done);
935 ProgressInfo {
936 total,
937 complete,
938 shelved,
939 in_progress,
940 pending,
941 remaining,
942 }
943}
944
945pub fn tasks_path(ito_path: &Path, change_id: &str) -> PathBuf {
947 let Some(path) = tasks_path_checked(ito_path, change_id) else {
948 return ito_path
949 .join("changes")
950 .join("invalid-change-id")
951 .join("tasks.md");
952 };
953 path
954}
955
956pub fn tasks_path_checked(ito_path: &Path, change_id: &str) -> Option<PathBuf> {
962 if !is_safe_change_id_segment(change_id) {
963 return None;
964 }
965
966 Some(ito_path.join("changes").join(change_id).join("tasks.md"))
967}
968
969pub fn is_safe_change_id_segment(change_id: &str) -> bool {
971 let change_id = change_id.trim();
972 if change_id.is_empty() {
973 return false;
974 }
975 if change_id.len() > 256 {
976 return false;
977 }
978 if change_id.contains('/') || change_id.contains('\\') || change_id.contains("..") {
979 return false;
980 }
981 true
982}
983
984pub fn is_safe_tracking_filename(tracking_file: &str) -> bool {
989 let tracking_file = tracking_file.trim();
990 if tracking_file.is_empty() {
991 return false;
992 }
993 if tracking_file == "." {
994 return false;
995 }
996 if tracking_file.len() > 256 {
997 return false;
998 }
999 if tracking_file.starts_with('/') || tracking_file.starts_with('\\') {
1000 return false;
1001 }
1002 if tracking_file.contains('/') || tracking_file.contains('\\') {
1003 return false;
1004 }
1005 if tracking_file.contains("..") {
1006 return false;
1007 }
1008 true
1009}
1010
1011pub fn tracking_path_checked(
1013 ito_path: &Path,
1014 change_id: &str,
1015 tracking_file: &str,
1016) -> Option<PathBuf> {
1017 if !is_safe_change_id_segment(change_id) {
1018 return None;
1019 }
1020 if !is_safe_tracking_filename(tracking_file) {
1021 return None;
1022 }
1023 Some(ito_path.join("changes").join(change_id).join(tracking_file))
1024}