1use std::path::{Path, PathBuf};
4
5use crate::error_bridge::IntoCoreResult;
6use crate::errors::{CoreError, CoreResult};
7use ito_domain::changes::ChangeRepository as DomainChangeRepository;
8
9pub use ito_domain::changes::ChangeTargetResolution;
11pub use ito_domain::tasks::{
12 DiagnosticLevel, ProgressInfo, TaskDiagnostic, TaskItem, TaskKind, TaskStatus, TasksFormat,
13 TasksParseResult, WaveInfo, compute_ready_and_blocked, enhanced_tasks_template,
14 parse_tasks_tracking_file, tasks_path, update_checkbox_task_status,
15 update_enhanced_task_status,
16};
17
18fn checked_tasks_path(ito_path: &Path, change_id: &str) -> CoreResult<PathBuf> {
37 let Some(path) = ito_domain::tasks::tasks_path_checked(ito_path, change_id) else {
38 return Err(CoreError::validation(format!(
39 "invalid change id path segment: \"{change_id}\""
40 )));
41 };
42 Ok(path)
43}
44
45fn resolve_task_id<'a>(parsed: &'a TasksParseResult, task_id: &'a str) -> CoreResult<&'a str> {
50 if parsed.format != TasksFormat::Checkbox {
51 return Ok(task_id);
52 }
53
54 if parsed.tasks.iter().any(|t| t.id == task_id) {
55 return Ok(task_id);
56 }
57
58 let not_found_err =
59 || CoreError::not_found(format!("Task \"{task_id}\" not found in tasks.md"));
60
61 let Ok(idx) = task_id.parse::<usize>() else {
62 return Err(not_found_err());
63 };
64 if idx == 0 || idx > parsed.tasks.len() {
65 return Err(not_found_err());
66 }
67
68 Ok(parsed.tasks[idx - 1].id.as_str())
69}
70
71fn parse_numeric_task_id(id: &str) -> Option<(u32, u32)> {
72 let (wave, task) = id.split_once('.')?;
73 let wave = wave.parse::<u32>().ok()?;
74 let task = task.parse::<u32>().ok()?;
75 Some((wave, task))
76}
77
78fn compare_task_ids(a: &str, b: &str) -> std::cmp::Ordering {
79 match (parse_numeric_task_id(a), parse_numeric_task_id(b)) {
80 (Some(aa), Some(bb)) => aa.cmp(&bb).then(a.cmp(b)),
81 (Some(_), None) => std::cmp::Ordering::Less,
82 (None, Some(_)) => std::cmp::Ordering::Greater,
83 (None, None) => a.cmp(b),
84 }
85}
86
87fn sort_task_items_by_id(items: &mut [TaskItem]) {
88 items.sort_by(|a, b| compare_task_ids(&a.id, &b.id));
89}
90
91fn sort_blocked_tasks_by_id(items: &mut [(TaskItem, Vec<String>)]) {
92 items.sort_by(|(a, _), (b, _)| compare_task_ids(&a.id, &b.id));
93}
94
95#[derive(Debug, Clone)]
97pub struct ReadyTasksForChange {
98 pub change_id: String,
100 pub ready_tasks: Vec<TaskItem>,
102}
103
104pub fn list_ready_tasks_across_changes(
109 change_repo: &impl DomainChangeRepository,
110 ito_path: &Path,
111) -> CoreResult<Vec<ReadyTasksForChange>> {
112 let summaries = change_repo.list().into_core()?;
113
114 let mut results: Vec<ReadyTasksForChange> = Vec::new();
115 for summary in &summaries {
116 if !summary.is_ready() {
117 continue;
118 }
119
120 let Ok(path) = checked_tasks_path(ito_path, &summary.id) else {
121 continue;
122 };
123 let Ok(contents) = ito_common::io::read_to_string(&path) else {
124 continue;
125 };
126
127 let parsed = parse_tasks_tracking_file(&contents);
128 if parsed
129 .diagnostics
130 .iter()
131 .any(|d| d.level == ito_domain::tasks::DiagnosticLevel::Error)
132 {
133 continue;
134 }
135
136 let (mut ready, _blocked) = compute_ready_and_blocked(&parsed);
137 if ready.is_empty() {
138 continue;
139 }
140
141 sort_task_items_by_id(&mut ready);
142
143 results.push(ReadyTasksForChange {
144 change_id: summary.id.clone(),
145 ready_tasks: ready,
146 });
147 }
148
149 Ok(results)
150}
151
152#[derive(Debug, Clone)]
154pub struct TaskStatusResult {
155 pub format: TasksFormat,
157 pub items: Vec<TaskItem>,
159 pub progress: ProgressInfo,
161 pub diagnostics: Vec<TaskDiagnostic>,
163 pub ready: Vec<TaskItem>,
165 pub blocked: Vec<(TaskItem, Vec<String>)>,
167}
168
169pub fn init_tasks(ito_path: &Path, change_id: &str) -> CoreResult<(PathBuf, bool)> {
173 let path = checked_tasks_path(ito_path, change_id)?;
174
175 if path.exists() {
176 return Ok((path, true));
177 }
178
179 let now = chrono::Local::now();
180 let contents = enhanced_tasks_template(change_id, now);
181
182 if let Some(parent) = path.parent() {
183 ito_common::io::create_dir_all_std(parent)
184 .map_err(|e| CoreError::io("create tasks.md parent directory", e))?;
185 }
186
187 ito_common::io::write_std(&path, contents.as_bytes())
188 .map_err(|e| CoreError::io("write tasks.md", e))?;
189
190 Ok((path, false))
191}
192
193pub fn get_task_status(ito_path: &Path, change_id: &str) -> CoreResult<TaskStatusResult> {
197 let path = checked_tasks_path(ito_path, change_id)?;
198
199 if !path.exists() {
200 return Err(CoreError::not_found(format!(
201 "No tasks.md found for \"{change_id}\". Run \"ito tasks init {change_id}\" first."
202 )));
203 }
204
205 let contents = ito_common::io::read_to_string_std(&path)
206 .map_err(|e| CoreError::io(format!("read {}", path.display()), e))?;
207
208 let parsed = parse_tasks_tracking_file(&contents);
209 let (mut ready, mut blocked) = compute_ready_and_blocked(&parsed);
210 sort_task_items_by_id(&mut ready);
211 sort_blocked_tasks_by_id(&mut blocked);
212 let mut items = parsed.tasks;
213 sort_task_items_by_id(&mut items);
214
215 Ok(TaskStatusResult {
216 format: parsed.format,
217 items,
218 progress: parsed.progress,
219 diagnostics: parsed.diagnostics,
220 ready,
221 blocked,
222 })
223}
224
225pub fn get_next_task(ito_path: &Path, change_id: &str) -> CoreResult<Option<TaskItem>> {
229 let status = get_task_status(ito_path, change_id)?;
230
231 if status
233 .diagnostics
234 .iter()
235 .any(|d| d.level == DiagnosticLevel::Error)
236 {
237 return Err(CoreError::validation("tasks.md contains errors"));
238 }
239
240 if status.progress.remaining == 0 {
242 return Ok(None);
243 }
244
245 match status.format {
246 TasksFormat::Checkbox => {
247 if let Some(current) = status
249 .items
250 .iter()
251 .find(|t| t.status == TaskStatus::InProgress)
252 {
253 return Ok(Some(current.clone()));
254 }
255
256 Ok(status
258 .items
259 .iter()
260 .find(|t| t.status == TaskStatus::Pending)
261 .cloned())
262 }
263 TasksFormat::Enhanced => {
264 Ok(status.ready.first().cloned())
266 }
267 }
268}
269
270pub fn start_task(ito_path: &Path, change_id: &str, task_id: &str) -> CoreResult<TaskItem> {
296 let path = checked_tasks_path(ito_path, change_id)?;
297 let contents = ito_common::io::read_to_string_std(&path)
298 .map_err(|e| CoreError::io(format!("read {}", path.display()), e))?;
299
300 let parsed = parse_tasks_tracking_file(&contents);
301
302 if parsed
304 .diagnostics
305 .iter()
306 .any(|d| d.level == DiagnosticLevel::Error)
307 {
308 return Err(CoreError::validation("tasks.md contains errors"));
309 }
310
311 let resolved_task_id = resolve_task_id(&parsed, task_id)?;
312
313 let Some(task) = parsed.tasks.iter().find(|t| t.id == resolved_task_id) else {
315 return Err(CoreError::not_found(format!(
316 "Task \"{task_id}\" not found in tasks.md"
317 )));
318 };
319
320 if parsed.format == TasksFormat::Checkbox
322 && let Some(current) = parsed
323 .tasks
324 .iter()
325 .find(|t| t.status == TaskStatus::InProgress)
326 && current.id != resolved_task_id
327 {
328 return Err(CoreError::validation(format!(
329 "Task \"{}\" is already in-progress (complete it before starting another task)",
330 current.id
331 )));
332 }
333
334 if parsed.format == TasksFormat::Checkbox {
335 match task.status {
337 TaskStatus::Pending => {}
338 TaskStatus::InProgress => {
339 return Err(CoreError::validation(format!(
340 "Task \"{resolved_task_id}\" is already in-progress"
341 )));
342 }
343 TaskStatus::Complete => {
344 return Err(CoreError::validation(format!(
345 "Task \"{resolved_task_id}\" is already complete"
346 )));
347 }
348 TaskStatus::Shelved => {
349 return Err(CoreError::validation(
350 "Checkbox-only tasks.md does not support shelving".to_string(),
351 ));
352 }
353 }
354
355 let updated =
356 update_checkbox_task_status(&contents, resolved_task_id, TaskStatus::InProgress)
357 .map_err(CoreError::validation)?;
358 ito_common::io::write_std(&path, updated.as_bytes())
359 .map_err(|e| CoreError::io("write tasks.md", e))?;
360
361 let mut result = task.clone();
362 result.status = TaskStatus::InProgress;
363 return Ok(result);
364 }
365
366 if task.status == TaskStatus::Shelved {
368 return Err(CoreError::validation(format!(
369 "Task \"{task_id}\" is shelved (run \"ito tasks unshelve {change_id} {task_id}\" first)"
370 )));
371 }
372
373 if task.status != TaskStatus::Pending {
374 return Err(CoreError::validation(format!(
375 "Task \"{task_id}\" is not pending (current: {})",
376 task.status.as_enhanced_label()
377 )));
378 }
379
380 let (ready, blocked) = compute_ready_and_blocked(&parsed);
381 if !ready.iter().any(|t| t.id == task_id) {
382 if let Some((_, blockers)) = blocked.iter().find(|(t, _)| t.id == task_id) {
383 let mut msg = String::from("Task is blocked:");
384 for b in blockers {
385 msg.push_str("\n- ");
386 msg.push_str(b);
387 }
388 return Err(CoreError::validation(msg));
389 }
390 return Err(CoreError::validation("Task is blocked"));
391 }
392
393 let updated = update_enhanced_task_status(
394 &contents,
395 task_id,
396 TaskStatus::InProgress,
397 chrono::Local::now(),
398 );
399 ito_common::io::write_std(&path, updated.as_bytes())
400 .map_err(|e| CoreError::io("write tasks.md", e))?;
401
402 let mut result = task.clone();
403 result.status = TaskStatus::InProgress;
404 Ok(result)
405}
406
407pub fn complete_task(
433 ito_path: &Path,
434 change_id: &str,
435 task_id: &str,
436 _note: Option<String>,
437) -> CoreResult<TaskItem> {
438 let path = checked_tasks_path(ito_path, change_id)?;
439 let contents = ito_common::io::read_to_string_std(&path)
440 .map_err(|e| CoreError::io(format!("read {}", path.display()), e))?;
441
442 let parsed = parse_tasks_tracking_file(&contents);
443
444 if parsed
446 .diagnostics
447 .iter()
448 .any(|d| d.level == DiagnosticLevel::Error)
449 {
450 return Err(CoreError::validation("tasks.md contains errors"));
451 }
452
453 let resolved_task_id = resolve_task_id(&parsed, task_id)?;
454
455 let Some(task) = parsed.tasks.iter().find(|t| t.id == resolved_task_id) else {
457 return Err(CoreError::not_found(format!(
458 "Task \"{task_id}\" not found in tasks.md"
459 )));
460 };
461
462 let updated = if parsed.format == TasksFormat::Checkbox {
463 update_checkbox_task_status(&contents, resolved_task_id, TaskStatus::Complete)
464 .map_err(CoreError::validation)?
465 } else {
466 update_enhanced_task_status(
467 &contents,
468 task_id,
469 TaskStatus::Complete,
470 chrono::Local::now(),
471 )
472 };
473
474 ito_common::io::write_std(&path, updated.as_bytes())
475 .map_err(|e| CoreError::io("write tasks.md", e))?;
476
477 let mut result = task.clone();
478 result.status = TaskStatus::Complete;
479 Ok(result)
480}
481
482pub fn shelve_task(
486 ito_path: &Path,
487 change_id: &str,
488 task_id: &str,
489 _reason: Option<String>,
490) -> CoreResult<TaskItem> {
491 let path = checked_tasks_path(ito_path, change_id)?;
492 let contents = ito_common::io::read_to_string_std(&path)
493 .map_err(|e| CoreError::io(format!("read {}", path.display()), e))?;
494
495 let parsed = parse_tasks_tracking_file(&contents);
496
497 if parsed.format == TasksFormat::Checkbox {
498 return Err(CoreError::validation(
499 "Checkbox-only tasks.md does not support shelving",
500 ));
501 }
502
503 if parsed
505 .diagnostics
506 .iter()
507 .any(|d| d.level == DiagnosticLevel::Error)
508 {
509 return Err(CoreError::validation("tasks.md contains errors"));
510 }
511
512 let Some(task) = parsed.tasks.iter().find(|t| t.id == task_id) else {
514 return Err(CoreError::not_found(format!(
515 "Task \"{task_id}\" not found in tasks.md"
516 )));
517 };
518
519 if task.status == TaskStatus::Complete {
520 return Err(CoreError::validation(format!(
521 "Task \"{task_id}\" is already complete"
522 )));
523 }
524
525 let updated = update_enhanced_task_status(
526 &contents,
527 task_id,
528 TaskStatus::Shelved,
529 chrono::Local::now(),
530 );
531
532 ito_common::io::write_std(&path, updated.as_bytes())
533 .map_err(|e| CoreError::io("write tasks.md", e))?;
534
535 let mut result = task.clone();
536 result.status = TaskStatus::Shelved;
537 Ok(result)
538}
539
540pub fn unshelve_task(ito_path: &Path, change_id: &str, task_id: &str) -> CoreResult<TaskItem> {
544 let path = checked_tasks_path(ito_path, change_id)?;
545 let contents = ito_common::io::read_to_string_std(&path)
546 .map_err(|e| CoreError::io(format!("read {}", path.display()), e))?;
547
548 let parsed = parse_tasks_tracking_file(&contents);
549
550 if parsed.format == TasksFormat::Checkbox {
551 return Err(CoreError::validation(
552 "Checkbox-only tasks.md does not support shelving",
553 ));
554 }
555
556 if parsed
558 .diagnostics
559 .iter()
560 .any(|d| d.level == DiagnosticLevel::Error)
561 {
562 return Err(CoreError::validation("tasks.md contains errors"));
563 }
564
565 let Some(task) = parsed.tasks.iter().find(|t| t.id == task_id) else {
567 return Err(CoreError::not_found(format!(
568 "Task \"{task_id}\" not found in tasks.md"
569 )));
570 };
571
572 if task.status != TaskStatus::Shelved {
573 return Err(CoreError::validation(format!(
574 "Task \"{task_id}\" is not shelved"
575 )));
576 }
577
578 let updated = update_enhanced_task_status(
579 &contents,
580 task_id,
581 TaskStatus::Pending,
582 chrono::Local::now(),
583 );
584
585 ito_common::io::write_std(&path, updated.as_bytes())
586 .map_err(|e| CoreError::io("write tasks.md", e))?;
587
588 let mut result = task.clone();
589 result.status = TaskStatus::Pending;
590 Ok(result)
591}
592
593pub fn add_task(
597 ito_path: &Path,
598 change_id: &str,
599 title: &str,
600 wave: Option<u32>,
601) -> CoreResult<TaskItem> {
602 let wave = wave.unwrap_or(1);
603 let path = checked_tasks_path(ito_path, change_id)?;
604 let contents = ito_common::io::read_to_string_std(&path)
605 .map_err(|e| CoreError::io(format!("read {}", path.display()), e))?;
606
607 let parsed = parse_tasks_tracking_file(&contents);
608
609 if parsed.format != TasksFormat::Enhanced {
610 return Err(CoreError::validation(
611 "Cannot add tasks to checkbox-only tracking file. Convert to enhanced format first.",
612 ));
613 }
614
615 if parsed
617 .diagnostics
618 .iter()
619 .any(|d| d.level == DiagnosticLevel::Error)
620 {
621 return Err(CoreError::validation("tasks.md contains errors"));
622 }
623
624 let mut max_n = 0u32;
626 for t in &parsed.tasks {
627 if let Some((w, n)) = t.id.split_once('.')
628 && let (Ok(w), Ok(n)) = (w.parse::<u32>(), n.parse::<u32>())
629 && w == wave
630 {
631 max_n = max_n.max(n);
632 }
633 }
634 let new_id = format!("{wave}.{}", max_n + 1);
635
636 let date = chrono::Local::now().format("%Y-%m-%d").to_string();
637 let block = format!(
638 "\n### Task {new_id}: {title}\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"
639 );
640
641 let mut out = contents.clone();
642 if out.contains(&format!("## Wave {wave}")) {
643 if let Some(pos) = out.find("## Checkpoints") {
645 out.insert_str(pos, &block);
646 } else {
647 out.push_str(&block);
648 }
649 } else {
650 if let Some(pos) = out.find("## Checkpoints") {
652 out.insert_str(
653 pos,
654 &format!("\n---\n\n## Wave {wave}\n- **Depends On**: None\n"),
655 );
656 let pos2 = out.find("## Checkpoints").unwrap_or(out.len());
657 out.insert_str(pos2, &block);
658 } else {
659 out.push_str(&format!(
660 "\n---\n\n## Wave {wave}\n- **Depends On**: None\n"
661 ));
662 out.push_str(&block);
663 }
664 }
665
666 ito_common::io::write_std(&path, out.as_bytes())
667 .map_err(|e| CoreError::io("write tasks.md", e))?;
668
669 Ok(TaskItem {
670 id: new_id,
671 name: title.to_string(),
672 wave: Some(wave),
673 status: TaskStatus::Pending,
674 updated_at: Some(date),
675 dependencies: Vec::new(),
676 files: vec!["path/to/file.rs".to_string()],
677 action: "[Describe what needs to be done]".to_string(),
678 verify: Some("cargo test --workspace".to_string()),
679 done_when: Some("[Success criteria]".to_string()),
680 kind: TaskKind::Normal,
681 header_line_index: 0,
682 })
683}
684
685pub fn show_task(ito_path: &Path, change_id: &str, task_id: &str) -> CoreResult<TaskItem> {
689 let path = checked_tasks_path(ito_path, change_id)?;
690 let contents = ito_common::io::read_to_string_std(&path)
691 .map_err(|e| CoreError::io(format!("read {}", path.display()), e))?;
692
693 let parsed = parse_tasks_tracking_file(&contents);
694
695 if parsed
697 .diagnostics
698 .iter()
699 .any(|d| d.level == DiagnosticLevel::Error)
700 {
701 return Err(CoreError::validation("tasks.md contains errors"));
702 }
703
704 parsed
705 .tasks
706 .iter()
707 .find(|t| t.id == task_id)
708 .cloned()
709 .ok_or_else(|| CoreError::not_found(format!("Task \"{task_id}\" not found")))
710}
711
712pub fn read_tasks_markdown(ito_path: &Path, change_id: &str) -> CoreResult<String> {
714 let path = checked_tasks_path(ito_path, change_id)?;
715 ito_common::io::read_to_string(&path).map_err(|e| {
716 CoreError::io(
717 format!("reading tasks.md for \"{change_id}\""),
718 std::io::Error::other(e),
719 )
720 })
721}
722
723#[cfg(test)]
724mod tests {
725 use std::path::Path;
726
727 use crate::change_repository::FsChangeRepository;
728
729 use super::list_ready_tasks_across_changes;
730
731 fn write(path: impl AsRef<Path>, contents: &str) {
732 let path = path.as_ref();
733 if let Some(parent) = path.parent() {
734 std::fs::create_dir_all(parent).expect("parent dirs should exist");
735 }
736 std::fs::write(path, contents).expect("test fixture should write");
737 }
738
739 fn make_ready_change(root: &Path, id: &str) {
740 write(
741 root.join(".ito/changes").join(id).join("proposal.md"),
742 "## Why\nfixture\n\n## What Changes\n- fixture\n\n## Impact\n- fixture\n",
743 );
744 write(
745 root.join(".ito/changes")
746 .join(id)
747 .join("specs")
748 .join("alpha")
749 .join("spec.md"),
750 "## ADDED Requirements\n\n### Requirement: Fixture\nFixture requirement.\n\n#### Scenario: Works\n- **WHEN** fixture runs\n- **THEN** it is ready\n",
751 );
752 write(
753 root.join(".ito/changes").join(id).join("tasks.md"),
754 "## 1. Implementation\n- [ ] 1.1 pending\n",
755 );
756 }
757
758 fn make_complete_change(root: &Path, id: &str) {
759 write(
760 root.join(".ito/changes").join(id).join("proposal.md"),
761 "## Why\nfixture\n\n## What Changes\n- fixture\n\n## Impact\n- fixture\n",
762 );
763 write(
764 root.join(".ito/changes")
765 .join(id)
766 .join("specs")
767 .join("alpha")
768 .join("spec.md"),
769 "## ADDED Requirements\n\n### Requirement: Fixture\nFixture requirement.\n\n#### Scenario: Works\n- **WHEN** fixture runs\n- **THEN** it is ready\n",
770 );
771 write(
772 root.join(".ito/changes").join(id).join("tasks.md"),
773 "## 1. Implementation\n- [x] 1.1 done\n",
774 );
775 }
776
777 #[test]
778 fn returns_ready_tasks_for_ready_changes() {
779 let repo = tempfile::tempdir().expect("repo tempdir");
780 let ito_path = repo.path().join(".ito");
781 make_ready_change(repo.path(), "000-01_alpha");
782 make_complete_change(repo.path(), "000-02_beta");
783
784 let change_repo = FsChangeRepository::new(&ito_path);
785 let ready =
786 list_ready_tasks_across_changes(&change_repo, &ito_path).expect("ready task listing");
787
788 assert_eq!(ready.len(), 1);
789 assert_eq!(ready[0].change_id, "000-01_alpha");
790 assert_eq!(ready[0].ready_tasks.len(), 1);
791 assert_eq!(ready[0].ready_tasks[0].id, "1.1");
792 }
793
794 #[test]
795 fn returns_empty_when_no_ready_tasks_exist() {
796 let repo = tempfile::tempdir().expect("repo tempdir");
797 let ito_path = repo.path().join(".ito");
798 make_complete_change(repo.path(), "000-01_alpha");
799
800 let change_repo = FsChangeRepository::new(&ito_path);
801 let ready =
802 list_ready_tasks_across_changes(&change_repo, &ito_path).expect("ready task listing");
803
804 assert!(ready.is_empty());
805 }
806
807 #[test]
808 fn read_tasks_markdown_returns_contents_for_existing_file() {
809 let repo = tempfile::tempdir().expect("repo tempdir");
810 let ito_path = repo.path().join(".ito");
811 let change_id = "000-01_alpha";
812 let tasks_content = "## 1. Implementation\n- [ ] 1.1 pending\n";
813 write(
814 ito_path.join("changes").join(change_id).join("tasks.md"),
815 tasks_content,
816 );
817
818 let result =
819 super::read_tasks_markdown(&ito_path, change_id).expect("should read tasks.md");
820 assert_eq!(result, tasks_content);
821 }
822
823 #[test]
824 fn read_tasks_markdown_returns_error_for_missing_file() {
825 let repo = tempfile::tempdir().expect("repo tempdir");
826 let ito_path = repo.path().join(".ito");
827
828 let result = super::read_tasks_markdown(&ito_path, "nonexistent-change");
829 assert!(result.is_err(), "should fail for missing tasks.md");
830 let err = result.unwrap_err();
831 let msg = err.to_string();
832 assert!(
833 msg.contains("tasks.md"),
834 "error should mention tasks.md, got: {msg}"
835 );
836 }
837
838 #[test]
839 fn read_tasks_markdown_rejects_traversal_like_change_id() {
840 let repo = tempfile::tempdir().expect("repo tempdir");
841 let ito_path = repo.path().join(".ito");
842
843 let result = super::read_tasks_markdown(&ito_path, "../escape");
844 assert!(result.is_err(), "traversal-like ids should fail");
845 }
846}