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