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