1use chrono::{DateTime, Local, NaiveDate};
11use regex::Regex;
12use std::collections::BTreeMap;
13use std::path::{Path, PathBuf};
14
15#[derive(Debug, Clone, Copy, PartialEq, Eq)]
16pub enum TasksFormat {
18 Enhanced,
20 Checkbox,
22}
23
24#[derive(Debug, Clone, Copy, PartialEq, Eq)]
25pub enum TaskStatus {
27 Pending,
29 InProgress,
31 Complete,
33 Shelved,
35}
36
37impl TaskStatus {
38 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 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 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)]
71pub struct TaskDiagnostic {
73 pub level: DiagnosticLevel,
75 pub message: String,
77 pub task_id: Option<String>,
79 pub line: Option<usize>,
81}
82
83#[derive(Debug, Clone, Copy, PartialEq, Eq)]
84pub enum DiagnosticLevel {
86 Error,
88 Warning,
90}
91
92impl DiagnosticLevel {
93 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)]
103pub struct TaskItem {
105 pub id: String,
107 pub name: String,
109 pub wave: Option<u32>,
111 pub status: TaskStatus,
113 pub updated_at: Option<String>,
115 pub dependencies: Vec<String>,
117 pub files: Vec<String>,
119 pub action: String,
121 pub verify: Option<String>,
123 pub done_when: Option<String>,
125 pub kind: TaskKind,
127 pub header_line_index: usize,
129}
130
131#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
132pub enum TaskKind {
134 #[default]
135 Normal,
137 Checkpoint,
139}
140
141#[derive(Debug, Clone, PartialEq, Eq)]
142pub struct ProgressInfo {
144 pub total: usize,
146 pub complete: usize,
148 pub shelved: usize,
150 pub in_progress: usize,
152 pub pending: usize,
154 pub remaining: usize,
156}
157
158#[derive(Debug, Clone, PartialEq, Eq)]
159pub struct WaveInfo {
161 pub wave: u32,
163 pub depends_on: Vec<u32>,
165 pub header_line_index: usize,
167 pub depends_on_line_index: Option<usize>,
169}
170
171#[derive(Debug, Clone, PartialEq, Eq)]
172pub struct TasksParseResult {
174 pub format: TasksFormat,
176 pub tasks: Vec<TaskItem>,
178 pub waves: Vec<WaveInfo>,
180 pub diagnostics: Vec<TaskDiagnostic>,
182 pub progress: ProgressInfo,
184}
185
186impl TasksParseResult {
187 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
206pub 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
219pub 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
233pub 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 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 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 } 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 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 } 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 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 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 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 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
797pub 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
808pub 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
821pub 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}