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