Skip to main content

ito_core/
tasks.rs

1//! Task-oriented orchestration use-cases for adapters.
2
3use std::path::{Path, PathBuf};
4
5use crate::error_bridge::IntoCoreResult;
6use crate::errors::{CoreError, CoreResult};
7use ito_domain::changes::ChangeRepository as DomainChangeRepository;
8
9// Re-export domain types and functions for CLI convenience
10pub 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> {
19    let Some(path) = ito_domain::tasks::tasks_path_checked(ito_path, change_id) else {
20        return Err(CoreError::validation(format!(
21            "invalid change id path segment: \"{change_id}\""
22        )));
23    };
24    Ok(path)
25}
26
27/// Ready task list for a single change.
28#[derive(Debug, Clone)]
29pub struct ReadyTasksForChange {
30    /// Canonical change id.
31    pub change_id: String,
32    /// Ready tasks from `tasks.md` after dependency computation.
33    pub ready_tasks: Vec<TaskItem>,
34}
35
36/// Collect ready tasks across all currently ready changes.
37///
38/// This use-case keeps repository traversal and task orchestration in core,
39/// while adapters remain focused on argument parsing and presentation.
40pub fn list_ready_tasks_across_changes(
41    change_repo: &impl DomainChangeRepository,
42    ito_path: &Path,
43) -> CoreResult<Vec<ReadyTasksForChange>> {
44    let summaries = change_repo.list().into_core()?;
45
46    let mut results: Vec<ReadyTasksForChange> = Vec::new();
47    for summary in &summaries {
48        if !summary.is_ready() {
49            continue;
50        }
51
52        let Ok(path) = checked_tasks_path(ito_path, &summary.id) else {
53            continue;
54        };
55        let Ok(contents) = ito_common::io::read_to_string(&path) else {
56            continue;
57        };
58
59        let parsed = parse_tasks_tracking_file(&contents);
60        if parsed
61            .diagnostics
62            .iter()
63            .any(|d| d.level == ito_domain::tasks::DiagnosticLevel::Error)
64        {
65            continue;
66        }
67
68        let (ready, _blocked) = compute_ready_and_blocked(&parsed);
69        if ready.is_empty() {
70            continue;
71        }
72
73        results.push(ReadyTasksForChange {
74            change_id: summary.id.clone(),
75            ready_tasks: ready,
76        });
77    }
78
79    Ok(results)
80}
81
82/// Result of getting task status for a change.
83#[derive(Debug, Clone)]
84pub struct TaskStatusResult {
85    /// Detected file format.
86    pub format: TasksFormat,
87    /// All parsed tasks.
88    pub items: Vec<TaskItem>,
89    /// Progress summary.
90    pub progress: ProgressInfo,
91    /// Parse diagnostics.
92    pub diagnostics: Vec<TaskDiagnostic>,
93    /// Ready tasks (computed).
94    pub ready: Vec<TaskItem>,
95    /// Blocked tasks with their blockers.
96    pub blocked: Vec<(TaskItem, Vec<String>)>,
97}
98
99/// Initialize a tasks.md file for a change.
100///
101/// Returns the path to the created file and whether it already existed.
102pub fn init_tasks(ito_path: &Path, change_id: &str) -> CoreResult<(PathBuf, bool)> {
103    let path = checked_tasks_path(ito_path, change_id)?;
104
105    if path.exists() {
106        return Ok((path, true));
107    }
108
109    let now = chrono::Local::now();
110    let contents = enhanced_tasks_template(change_id, now);
111
112    if let Some(parent) = path.parent() {
113        ito_common::io::create_dir_all_std(parent)
114            .map_err(|e| CoreError::io("create tasks.md parent directory", e))?;
115    }
116
117    ito_common::io::write_std(&path, contents.as_bytes())
118        .map_err(|e| CoreError::io("write tasks.md", e))?;
119
120    Ok((path, false))
121}
122
123/// Get task status for a change.
124///
125/// Reads and parses the tasks.md file, computes ready/blocked tasks.
126pub fn get_task_status(ito_path: &Path, change_id: &str) -> CoreResult<TaskStatusResult> {
127    let path = checked_tasks_path(ito_path, change_id)?;
128
129    if !path.exists() {
130        return Err(CoreError::not_found(format!(
131            "No tasks.md found for \"{change_id}\". Run \"ito tasks init {change_id}\" first."
132        )));
133    }
134
135    let contents = ito_common::io::read_to_string_std(&path)
136        .map_err(|e| CoreError::io(format!("read {}", path.display()), e))?;
137
138    let parsed = parse_tasks_tracking_file(&contents);
139    let (ready, blocked) = compute_ready_and_blocked(&parsed);
140
141    Ok(TaskStatusResult {
142        format: parsed.format,
143        items: parsed.tasks,
144        progress: parsed.progress,
145        diagnostics: parsed.diagnostics,
146        ready,
147        blocked,
148    })
149}
150
151/// Get the next actionable task for a change.
152///
153/// Returns None if all tasks are complete or if no tasks are ready.
154pub fn get_next_task(ito_path: &Path, change_id: &str) -> CoreResult<Option<TaskItem>> {
155    let status = get_task_status(ito_path, change_id)?;
156
157    // Check for errors
158    if status
159        .diagnostics
160        .iter()
161        .any(|d| d.level == DiagnosticLevel::Error)
162    {
163        return Err(CoreError::validation("tasks.md contains errors"));
164    }
165
166    // All complete?
167    if status.progress.remaining == 0 {
168        return Ok(None);
169    }
170
171    match status.format {
172        TasksFormat::Checkbox => {
173            // Check for current in-progress task
174            if let Some(current) = status
175                .items
176                .iter()
177                .find(|t| t.status == TaskStatus::InProgress)
178            {
179                return Ok(Some(current.clone()));
180            }
181
182            // Find first pending task
183            Ok(status
184                .items
185                .iter()
186                .find(|t| t.status == TaskStatus::Pending)
187                .cloned())
188        }
189        TasksFormat::Enhanced => {
190            // Return first ready task
191            Ok(status.ready.first().cloned())
192        }
193    }
194}
195
196/// Start a task (transition to in_progress).
197///
198/// Validates preconditions and updates the tasks.md file.
199pub fn start_task(ito_path: &Path, change_id: &str, task_id: &str) -> CoreResult<TaskItem> {
200    let path = checked_tasks_path(ito_path, change_id)?;
201    let contents = ito_common::io::read_to_string_std(&path)
202        .map_err(|e| CoreError::io(format!("read {}", path.display()), e))?;
203
204    let parsed = parse_tasks_tracking_file(&contents);
205
206    // Check for errors
207    if parsed
208        .diagnostics
209        .iter()
210        .any(|d| d.level == DiagnosticLevel::Error)
211    {
212        return Err(CoreError::validation("tasks.md contains errors"));
213    }
214
215    // Find the task
216    let Some(task) = parsed.tasks.iter().find(|t| t.id == task_id) else {
217        return Err(CoreError::not_found(format!(
218            "Task \"{task_id}\" not found in tasks.md"
219        )));
220    };
221
222    // Checkbox format: check for existing in-progress task
223    if parsed.format == TasksFormat::Checkbox
224        && let Some(current) = parsed
225            .tasks
226            .iter()
227            .find(|t| t.status == TaskStatus::InProgress)
228        && current.id != task_id
229    {
230        return Err(CoreError::validation(format!(
231            "Task \"{}\" is already in-progress (complete it before starting another task)",
232            current.id
233        )));
234    }
235
236    if parsed.format == TasksFormat::Checkbox {
237        // Validate status
238        match task.status {
239            TaskStatus::Pending => {}
240            TaskStatus::InProgress => {
241                return Err(CoreError::validation(format!(
242                    "Task \"{task_id}\" is already in-progress"
243                )));
244            }
245            TaskStatus::Complete => {
246                return Err(CoreError::validation(format!(
247                    "Task \"{task_id}\" is already complete"
248                )));
249            }
250            TaskStatus::Shelved => {
251                return Err(CoreError::validation(
252                    "Checkbox-only tasks.md does not support shelving".to_string(),
253                ));
254            }
255        }
256
257        let updated = update_checkbox_task_status(&contents, task_id, TaskStatus::InProgress)
258            .map_err(CoreError::validation)?;
259        ito_common::io::write_std(&path, updated.as_bytes())
260            .map_err(|e| CoreError::io("write tasks.md", e))?;
261
262        let mut result = task.clone();
263        result.status = TaskStatus::InProgress;
264        return Ok(result);
265    }
266
267    // Enhanced format: validate status and check if ready
268    if task.status == TaskStatus::Shelved {
269        return Err(CoreError::validation(format!(
270            "Task \"{task_id}\" is shelved (run \"ito tasks unshelve {change_id} {task_id}\" first)"
271        )));
272    }
273
274    if task.status != TaskStatus::Pending {
275        return Err(CoreError::validation(format!(
276            "Task \"{task_id}\" is not pending (current: {})",
277            task.status.as_enhanced_label()
278        )));
279    }
280
281    let (ready, blocked) = compute_ready_and_blocked(&parsed);
282    if !ready.iter().any(|t| t.id == task_id) {
283        if let Some((_, blockers)) = blocked.iter().find(|(t, _)| t.id == task_id) {
284            let mut msg = String::from("Task is blocked:");
285            for b in blockers {
286                msg.push_str("\n- ");
287                msg.push_str(b);
288            }
289            return Err(CoreError::validation(msg));
290        }
291        return Err(CoreError::validation("Task is blocked"));
292    }
293
294    let updated = update_enhanced_task_status(
295        &contents,
296        task_id,
297        TaskStatus::InProgress,
298        chrono::Local::now(),
299    );
300    ito_common::io::write_std(&path, updated.as_bytes())
301        .map_err(|e| CoreError::io("write tasks.md", e))?;
302
303    let mut result = task.clone();
304    result.status = TaskStatus::InProgress;
305    Ok(result)
306}
307
308/// Complete a task (transition to complete).
309///
310/// Validates preconditions and updates the tasks.md file.
311pub fn complete_task(
312    ito_path: &Path,
313    change_id: &str,
314    task_id: &str,
315    _note: Option<String>,
316) -> CoreResult<TaskItem> {
317    let path = checked_tasks_path(ito_path, change_id)?;
318    let contents = ito_common::io::read_to_string_std(&path)
319        .map_err(|e| CoreError::io(format!("read {}", path.display()), e))?;
320
321    let parsed = parse_tasks_tracking_file(&contents);
322
323    // Check for errors
324    if parsed
325        .diagnostics
326        .iter()
327        .any(|d| d.level == DiagnosticLevel::Error)
328    {
329        return Err(CoreError::validation("tasks.md contains errors"));
330    }
331
332    // Find the task
333    let Some(task) = parsed.tasks.iter().find(|t| t.id == task_id) else {
334        return Err(CoreError::not_found(format!(
335            "Task \"{task_id}\" not found in tasks.md"
336        )));
337    };
338
339    let updated = if parsed.format == TasksFormat::Checkbox {
340        update_checkbox_task_status(&contents, task_id, TaskStatus::Complete)
341            .map_err(CoreError::validation)?
342    } else {
343        update_enhanced_task_status(
344            &contents,
345            task_id,
346            TaskStatus::Complete,
347            chrono::Local::now(),
348        )
349    };
350
351    ito_common::io::write_std(&path, updated.as_bytes())
352        .map_err(|e| CoreError::io("write tasks.md", e))?;
353
354    let mut result = task.clone();
355    result.status = TaskStatus::Complete;
356    Ok(result)
357}
358
359/// Shelve a task (transition to shelved).
360///
361/// Only supported for enhanced format. Validates preconditions and updates the tasks.md file.
362pub fn shelve_task(
363    ito_path: &Path,
364    change_id: &str,
365    task_id: &str,
366    _reason: Option<String>,
367) -> CoreResult<TaskItem> {
368    let path = checked_tasks_path(ito_path, change_id)?;
369    let contents = ito_common::io::read_to_string_std(&path)
370        .map_err(|e| CoreError::io(format!("read {}", path.display()), e))?;
371
372    let parsed = parse_tasks_tracking_file(&contents);
373
374    if parsed.format == TasksFormat::Checkbox {
375        return Err(CoreError::validation(
376            "Checkbox-only tasks.md does not support shelving",
377        ));
378    }
379
380    // Check for errors
381    if parsed
382        .diagnostics
383        .iter()
384        .any(|d| d.level == DiagnosticLevel::Error)
385    {
386        return Err(CoreError::validation("tasks.md contains errors"));
387    }
388
389    // Find the task
390    let Some(task) = parsed.tasks.iter().find(|t| t.id == task_id) else {
391        return Err(CoreError::not_found(format!(
392            "Task \"{task_id}\" not found in tasks.md"
393        )));
394    };
395
396    if task.status == TaskStatus::Complete {
397        return Err(CoreError::validation(format!(
398            "Task \"{task_id}\" is already complete"
399        )));
400    }
401
402    let updated = update_enhanced_task_status(
403        &contents,
404        task_id,
405        TaskStatus::Shelved,
406        chrono::Local::now(),
407    );
408
409    ito_common::io::write_std(&path, updated.as_bytes())
410        .map_err(|e| CoreError::io("write tasks.md", e))?;
411
412    let mut result = task.clone();
413    result.status = TaskStatus::Shelved;
414    Ok(result)
415}
416
417/// Unshelve a task (transition back to pending).
418///
419/// Only supported for enhanced format. Validates preconditions and updates the tasks.md file.
420pub fn unshelve_task(ito_path: &Path, change_id: &str, task_id: &str) -> CoreResult<TaskItem> {
421    let path = checked_tasks_path(ito_path, change_id)?;
422    let contents = ito_common::io::read_to_string_std(&path)
423        .map_err(|e| CoreError::io(format!("read {}", path.display()), e))?;
424
425    let parsed = parse_tasks_tracking_file(&contents);
426
427    if parsed.format == TasksFormat::Checkbox {
428        return Err(CoreError::validation(
429            "Checkbox-only tasks.md does not support shelving",
430        ));
431    }
432
433    // Check for errors
434    if parsed
435        .diagnostics
436        .iter()
437        .any(|d| d.level == DiagnosticLevel::Error)
438    {
439        return Err(CoreError::validation("tasks.md contains errors"));
440    }
441
442    // Find the task
443    let Some(task) = parsed.tasks.iter().find(|t| t.id == task_id) else {
444        return Err(CoreError::not_found(format!(
445            "Task \"{task_id}\" not found in tasks.md"
446        )));
447    };
448
449    if task.status != TaskStatus::Shelved {
450        return Err(CoreError::validation(format!(
451            "Task \"{task_id}\" is not shelved"
452        )));
453    }
454
455    let updated = update_enhanced_task_status(
456        &contents,
457        task_id,
458        TaskStatus::Pending,
459        chrono::Local::now(),
460    );
461
462    ito_common::io::write_std(&path, updated.as_bytes())
463        .map_err(|e| CoreError::io("write tasks.md", e))?;
464
465    let mut result = task.clone();
466    result.status = TaskStatus::Pending;
467    Ok(result)
468}
469
470/// Add a new task to a change's tasks.md.
471///
472/// Only supported for enhanced format. Computes the next task ID and inserts the task.
473pub fn add_task(
474    ito_path: &Path,
475    change_id: &str,
476    title: &str,
477    wave: Option<u32>,
478) -> CoreResult<TaskItem> {
479    let wave = wave.unwrap_or(1);
480    let path = checked_tasks_path(ito_path, change_id)?;
481    let contents = ito_common::io::read_to_string_std(&path)
482        .map_err(|e| CoreError::io(format!("read {}", path.display()), e))?;
483
484    let parsed = parse_tasks_tracking_file(&contents);
485
486    if parsed.format != TasksFormat::Enhanced {
487        return Err(CoreError::validation(
488            "Cannot add tasks to checkbox-only tracking file. Convert to enhanced format first.",
489        ));
490    }
491
492    // Check for errors
493    if parsed
494        .diagnostics
495        .iter()
496        .any(|d| d.level == DiagnosticLevel::Error)
497    {
498        return Err(CoreError::validation("tasks.md contains errors"));
499    }
500
501    // Compute next task ID for this wave
502    let mut max_n = 0u32;
503    for t in &parsed.tasks {
504        if let Some((w, n)) = t.id.split_once('.')
505            && let (Ok(w), Ok(n)) = (w.parse::<u32>(), n.parse::<u32>())
506            && w == wave
507        {
508            max_n = max_n.max(n);
509        }
510    }
511    let new_id = format!("{wave}.{}", max_n + 1);
512
513    let date = chrono::Local::now().format("%Y-%m-%d").to_string();
514    let block = format!(
515        "\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"
516    );
517
518    let mut out = contents.clone();
519    if out.contains(&format!("## Wave {wave}")) {
520        // Insert before the next major section after this wave.
521        if let Some(pos) = out.find("## Checkpoints") {
522            out.insert_str(pos, &block);
523        } else {
524            out.push_str(&block);
525        }
526    } else {
527        // Create wave section before checkpoints (or at end).
528        if let Some(pos) = out.find("## Checkpoints") {
529            out.insert_str(
530                pos,
531                &format!("\n---\n\n## Wave {wave}\n- **Depends On**: None\n"),
532            );
533            let pos2 = out.find("## Checkpoints").unwrap_or(out.len());
534            out.insert_str(pos2, &block);
535        } else {
536            out.push_str(&format!(
537                "\n---\n\n## Wave {wave}\n- **Depends On**: None\n"
538            ));
539            out.push_str(&block);
540        }
541    }
542
543    ito_common::io::write_std(&path, out.as_bytes())
544        .map_err(|e| CoreError::io("write tasks.md", e))?;
545
546    Ok(TaskItem {
547        id: new_id,
548        name: title.to_string(),
549        wave: Some(wave),
550        status: TaskStatus::Pending,
551        updated_at: Some(date),
552        dependencies: Vec::new(),
553        files: vec!["path/to/file.rs".to_string()],
554        action: "[Describe what needs to be done]".to_string(),
555        verify: Some("cargo test --workspace".to_string()),
556        done_when: Some("[Success criteria]".to_string()),
557        kind: TaskKind::Normal,
558        header_line_index: 0,
559    })
560}
561
562/// Show a specific task by ID.
563///
564/// Returns the full task details.
565pub fn show_task(ito_path: &Path, change_id: &str, task_id: &str) -> CoreResult<TaskItem> {
566    let path = checked_tasks_path(ito_path, change_id)?;
567    let contents = ito_common::io::read_to_string_std(&path)
568        .map_err(|e| CoreError::io(format!("read {}", path.display()), e))?;
569
570    let parsed = parse_tasks_tracking_file(&contents);
571
572    // Check for errors
573    if parsed
574        .diagnostics
575        .iter()
576        .any(|d| d.level == DiagnosticLevel::Error)
577    {
578        return Err(CoreError::validation("tasks.md contains errors"));
579    }
580
581    parsed
582        .tasks
583        .iter()
584        .find(|t| t.id == task_id)
585        .cloned()
586        .ok_or_else(|| CoreError::not_found(format!("Task \"{task_id}\" not found")))
587}
588
589/// Read the raw markdown contents of a change's tasks.md file.
590pub fn read_tasks_markdown(ito_path: &Path, change_id: &str) -> CoreResult<String> {
591    let path = checked_tasks_path(ito_path, change_id)?;
592    ito_common::io::read_to_string(&path).map_err(|e| {
593        CoreError::io(
594            format!("reading tasks.md for \"{change_id}\""),
595            std::io::Error::other(e),
596        )
597    })
598}
599
600#[cfg(test)]
601mod tests {
602    use std::path::Path;
603
604    use crate::change_repository::FsChangeRepository;
605
606    use super::list_ready_tasks_across_changes;
607
608    fn write(path: impl AsRef<Path>, contents: &str) {
609        let path = path.as_ref();
610        if let Some(parent) = path.parent() {
611            std::fs::create_dir_all(parent).expect("parent dirs should exist");
612        }
613        std::fs::write(path, contents).expect("test fixture should write");
614    }
615
616    fn make_ready_change(root: &Path, id: &str) {
617        write(
618            root.join(".ito/changes").join(id).join("proposal.md"),
619            "## Why\nfixture\n\n## What Changes\n- fixture\n\n## Impact\n- fixture\n",
620        );
621        write(
622            root.join(".ito/changes")
623                .join(id)
624                .join("specs")
625                .join("alpha")
626                .join("spec.md"),
627            "## ADDED Requirements\n\n### Requirement: Fixture\nFixture requirement.\n\n#### Scenario: Works\n- **WHEN** fixture runs\n- **THEN** it is ready\n",
628        );
629        write(
630            root.join(".ito/changes").join(id).join("tasks.md"),
631            "## 1. Implementation\n- [ ] 1.1 pending\n",
632        );
633    }
634
635    fn make_complete_change(root: &Path, id: &str) {
636        write(
637            root.join(".ito/changes").join(id).join("proposal.md"),
638            "## Why\nfixture\n\n## What Changes\n- fixture\n\n## Impact\n- fixture\n",
639        );
640        write(
641            root.join(".ito/changes")
642                .join(id)
643                .join("specs")
644                .join("alpha")
645                .join("spec.md"),
646            "## ADDED Requirements\n\n### Requirement: Fixture\nFixture requirement.\n\n#### Scenario: Works\n- **WHEN** fixture runs\n- **THEN** it is ready\n",
647        );
648        write(
649            root.join(".ito/changes").join(id).join("tasks.md"),
650            "## 1. Implementation\n- [x] 1.1 done\n",
651        );
652    }
653
654    #[test]
655    fn returns_ready_tasks_for_ready_changes() {
656        let repo = tempfile::tempdir().expect("repo tempdir");
657        let ito_path = repo.path().join(".ito");
658        make_ready_change(repo.path(), "000-01_alpha");
659        make_complete_change(repo.path(), "000-02_beta");
660
661        let change_repo = FsChangeRepository::new(&ito_path);
662        let ready =
663            list_ready_tasks_across_changes(&change_repo, &ito_path).expect("ready task listing");
664
665        assert_eq!(ready.len(), 1);
666        assert_eq!(ready[0].change_id, "000-01_alpha");
667        assert_eq!(ready[0].ready_tasks.len(), 1);
668        assert_eq!(ready[0].ready_tasks[0].id, "1");
669    }
670
671    #[test]
672    fn returns_empty_when_no_ready_tasks_exist() {
673        let repo = tempfile::tempdir().expect("repo tempdir");
674        let ito_path = repo.path().join(".ito");
675        make_complete_change(repo.path(), "000-01_alpha");
676
677        let change_repo = FsChangeRepository::new(&ito_path);
678        let ready =
679            list_ready_tasks_across_changes(&change_repo, &ito_path).expect("ready task listing");
680
681        assert!(ready.is_empty());
682    }
683
684    #[test]
685    fn read_tasks_markdown_returns_contents_for_existing_file() {
686        let repo = tempfile::tempdir().expect("repo tempdir");
687        let ito_path = repo.path().join(".ito");
688        let change_id = "000-01_alpha";
689        let tasks_content = "## 1. Implementation\n- [ ] 1.1 pending\n";
690        write(
691            ito_path.join("changes").join(change_id).join("tasks.md"),
692            tasks_content,
693        );
694
695        let result =
696            super::read_tasks_markdown(&ito_path, change_id).expect("should read tasks.md");
697        assert_eq!(result, tasks_content);
698    }
699
700    #[test]
701    fn read_tasks_markdown_returns_error_for_missing_file() {
702        let repo = tempfile::tempdir().expect("repo tempdir");
703        let ito_path = repo.path().join(".ito");
704
705        let result = super::read_tasks_markdown(&ito_path, "nonexistent-change");
706        assert!(result.is_err(), "should fail for missing tasks.md");
707        let err = result.unwrap_err();
708        let msg = err.to_string();
709        assert!(
710            msg.contains("tasks.md"),
711            "error should mention tasks.md, got: {msg}"
712        );
713    }
714
715    #[test]
716    fn read_tasks_markdown_rejects_traversal_like_change_id() {
717        let repo = tempfile::tempdir().expect("repo tempdir");
718        let ito_path = repo.path().join(".ito");
719
720        let result = super::read_tasks_markdown(&ito_path, "../escape");
721        assert!(result.is_err(), "traversal-like ids should fail");
722    }
723}