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
18/// Computes and validated filesystem path to a change's tasks.md file.
19///
20/// # Arguments
21///
22/// * `ito_path` - Root repository path containing change directories.
23/// * `change_id` - Change identifier used as a path segment; must not contain invalid traversal or path characters.
24///
25/// # Returns
26///
27/// `PathBuf` pointing to the change's tasks.md on success. Returns `CoreError::validation` when `change_id` is an invalid path segment.
28///
29/// # Examples
30///
31/// ```ignore
32/// use std::path::Path;
33/// let p = checked_tasks_path(Path::new("repo"), "1.1").unwrap();
34/// assert!(p.ends_with("tasks.md"));
35/// ```
36fn 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
45/// Resolve a user-supplied task identifier to a canonical parsed task id.
46///
47/// For enhanced-format tasks, this returns the input id unchanged.
48/// For checkbox-format tasks, this accepts either a canonical id or a 1-based numeric index.
49fn 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/// Ready task list for a single change.
96#[derive(Debug, Clone)]
97pub struct ReadyTasksForChange {
98    /// Canonical change id.
99    pub change_id: String,
100    /// Ready tasks from `tasks.md` after dependency computation.
101    pub ready_tasks: Vec<TaskItem>,
102}
103
104/// Collect ready tasks across all currently ready changes.
105///
106/// This use-case keeps repository traversal and task orchestration in core,
107/// while adapters remain focused on argument parsing and presentation.
108pub 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/// Result of getting task status for a change.
153#[derive(Debug, Clone)]
154pub struct TaskStatusResult {
155    /// Detected file format.
156    pub format: TasksFormat,
157    /// All parsed tasks.
158    pub items: Vec<TaskItem>,
159    /// Progress summary.
160    pub progress: ProgressInfo,
161    /// Parse diagnostics.
162    pub diagnostics: Vec<TaskDiagnostic>,
163    /// Ready tasks (computed).
164    pub ready: Vec<TaskItem>,
165    /// Blocked tasks with their blockers.
166    pub blocked: Vec<(TaskItem, Vec<String>)>,
167}
168
169/// Initialize a tasks.md file for a change.
170///
171/// Returns the path to the created file and whether it already existed.
172pub 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
193/// Get task status for a change.
194///
195/// Reads and parses the tasks.md file, computes ready/blocked tasks.
196pub 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
225/// Get the next actionable task for a change.
226///
227/// Returns None if all tasks are complete or if no tasks are ready.
228pub 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    // Check for errors
232    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    // All complete?
241    if status.progress.remaining == 0 {
242        return Ok(None);
243    }
244
245    match status.format {
246        TasksFormat::Checkbox => {
247            // Check for current in-progress task
248            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            // Find first pending task
257            Ok(status
258                .items
259                .iter()
260                .find(|t| t.status == TaskStatus::Pending)
261                .cloned())
262        }
263        TasksFormat::Enhanced => {
264            // Return first ready task
265            Ok(status.ready.first().cloned())
266        }
267    }
268}
269
270/// Mark a task as in-progress in a change's tasks.md.
271///
272/// Validates parsing diagnostics and task preconditions, updates the tasks.md file on disk,
273/// and returns the updated TaskItem with its status set to `InProgress`.
274///
275/// Parameters:
276/// - `ito_path`: root repository path used to resolve the change's tasks.md.
277/// - `change_id`: canonical change identifier whose tasks.md will be modified.
278/// - `task_id`: task identifier to start; for checkbox-format files this may be a numeric index
279///   that will be resolved to the canonical task id.
280///
281/// Errors:
282/// Returns a `CoreError` when the tasks.md cannot be read/written, when parsing diagnostics
283/// contain errors, when the task cannot be resolved or located, or when preconditions for
284/// transitioning the task to `InProgress` are not met (including blocked, already in-progress,
285/// completed, or shelved states).
286///
287/// # Examples
288///
289/// ```
290/// use std::path::Path;
291/// // Start task "1.1" for change "1" in the repository at "/repo"
292/// let repo = Path::new("/repo");
293/// let _ = ito_core::tasks::start_task(repo, "1", "1.1");
294/// ```
295pub 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    // Check for errors
303    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    // Find the task
314    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    // Checkbox format: check for existing in-progress task
321    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        // Validate status
336        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    // Enhanced format: validate status and check if ready
367    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
407/// Mark a task in a change's tasks.md as complete.
408///
409/// Reads and validates the change's tasks.md, resolves the provided task identifier
410/// (supports enhanced ids and numeric indexes for checkbox format), updates the file
411/// setting the task's status to `Complete`, and returns the updated task item.
412///
413/// # Returns
414///
415/// `TaskItem` representing the task with its status set to `Complete`.
416///
417/// # Errors
418///
419/// Returns a `CoreError::validation` if the tasks.md contains parse errors or the update
420/// operation is rejected; `CoreError::not_found` if the specified task cannot be located;
421/// and `CoreError::io` for filesystem read/write failures.
422///
423/// # Examples
424///
425/// ```
426/// # use std::path::Path;
427/// # use ito_core::tasks::complete_task;
428/// // Attempt to mark task "1.1" complete for change "1" in the repository at "."
429/// let res = complete_task(Path::new("."), "1", "1.1", None);
430/// // `res` will be `Ok(task)` on success or an error describing the failure.
431/// ```
432pub 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    // Check for errors
445    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    // Find the task
456    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
482/// Shelve a task (transition to shelved).
483///
484/// Only supported for enhanced format. Validates preconditions and updates the tasks.md file.
485pub 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    // Check for errors
504    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    // Find the task
513    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
540/// Unshelve a task (transition back to pending).
541///
542/// Only supported for enhanced format. Validates preconditions and updates the tasks.md file.
543pub 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    // Check for errors
557    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    // Find the task
566    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
593/// Add a new task to a change's tasks.md.
594///
595/// Only supported for enhanced format. Computes the next task ID and inserts the task.
596pub 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    // Check for errors
616    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    // Compute next task ID for this wave
625    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        // Insert before the next major section after this wave.
644        if let Some(pos) = out.find("## Checkpoints") {
645            out.insert_str(pos, &block);
646        } else {
647            out.push_str(&block);
648        }
649    } else {
650        // Create wave section before checkpoints (or at end).
651        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
685/// Show a specific task by ID.
686///
687/// Returns the full task details.
688pub 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    // Check for errors
696    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
712/// Read the raw markdown contents of a change's tasks.md file.
713pub 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}