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