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
673/// Appends a new task block to an enhanced-format tracking markdown and returns the created task and updated contents.
674///
675/// Validates that the input is enhanced format and contains no parse errors, assigns the new task the next numeric ID in the target wave (defaults to 1), inserts a template task block into the markdown, and returns a `TaskMutationOutcome` with the new `TaskItem` and the rewritten markdown.
676///
677/// # Parameters
678/// - `wave`: Optional target wave number; when `None` the task is added to wave 1.
679/// - `file_label`: Short label used in validation error messages when the parsed content contains errors.
680///
681/// # Returns
682/// A `TaskMutationOutcome` containing the created `TaskItem` (with `status = Pending`) and the updated tracking markdown as `updated_content`.
683///
684/// # Examples
685///
686/// ```ignore
687/// let contents = "## Checkpoints\n";
688/// let outcome = apply_add_task(contents, "Add feature X", None, "tracking file").unwrap();
689/// assert!(outcome.task.id.starts_with("1."));
690/// assert!(outcome.updated_content.contains("### Task "));
691/// ```
692pub(crate) fn apply_add_task(
693    contents: &str,
694    title: &str,
695    wave: Option<u32>,
696    file_label: &str,
697) -> CoreResult<TaskMutationOutcome> {
698    let parsed = parse_tasks_tracking_file(contents);
699    if parsed.format != TasksFormat::Enhanced {
700        return Err(CoreError::validation(
701            "Cannot add tasks to checkbox-only tracking file. Convert to enhanced format first.",
702        ));
703    }
704
705    if parsed
706        .diagnostics
707        .iter()
708        .any(|d| d.level == DiagnosticLevel::Error)
709    {
710        return Err(CoreError::validation(format!(
711            "{file_label} contains errors"
712        )));
713    }
714
715    let wave = wave.unwrap_or(1);
716    let mut max_n = 0u32;
717    for t in &parsed.tasks {
718        if let Some((w, n)) = t.id.split_once('.')
719            && let (Ok(w), Ok(n)) = (w.parse::<u32>(), n.parse::<u32>())
720            && w == wave
721        {
722            max_n = max_n.max(n);
723        }
724    }
725    let new_id = format!("{wave}.{}", max_n + 1);
726
727    let date = chrono::Local::now().format("%Y-%m-%d").to_string();
728    let block = format!(
729        "\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"
730    );
731
732    let mut out = contents.to_string();
733    if out.contains(&format!("## Wave {wave}")) {
734        if let Some(pos) = out.find("## Checkpoints") {
735            out.insert_str(pos, &block);
736        } else {
737            out.push_str(&block);
738        }
739    } else if let Some(pos) = out.find("## Checkpoints") {
740        out.insert_str(
741            pos,
742            &format!("\n---\n\n## Wave {wave}\n- **Depends On**: None\n"),
743        );
744        let pos2 = out.find("## Checkpoints").unwrap_or(out.len());
745        out.insert_str(pos2, &block);
746    } else {
747        out.push_str(&format!(
748            "\n---\n\n## Wave {wave}\n- **Depends On**: None\n"
749        ));
750        out.push_str(&block);
751    }
752
753    Ok(TaskMutationOutcome {
754        task: TaskItem {
755            id: new_id,
756            name: title.to_string(),
757            wave: Some(wave),
758            status: TaskStatus::Pending,
759            updated_at: Some(date),
760            dependencies: Vec::new(),
761            files: vec!["path/to/file.rs".to_string()],
762            action: "[Describe what needs to be done]".to_string(),
763            verify: Some("cargo test --workspace".to_string()),
764            done_when: Some("[Success criteria]".to_string()),
765            kind: TaskKind::Normal,
766            header_line_index: 0,
767            requirements: Vec::new(),
768        },
769        updated_content: out,
770    })
771}
772
773/// Mark a task as in-progress in a change's tracking file.
774///
775/// Validates parsing diagnostics and task preconditions, updates the tracking file on disk,
776/// and returns the updated TaskItem with its status set to `InProgress`.
777///
778/// Parameters:
779/// - `ito_path`: root repository path used to resolve the change's tracking file.
780/// - `change_id`: canonical change identifier whose tracking file will be modified.
781/// - `task_id`: task identifier to start; for checkbox-format files this may be a numeric index
782///   that will be resolved to the canonical task id.
783///
784/// Errors:
785/// Returns a `CoreError` when the tracking file cannot be read/written, when parsing diagnostics
786/// contain errors, when the task cannot be resolved or located, or when preconditions for
787/// transitioning the task to `InProgress` are not met (including blocked, already in-progress,
788/// completed, or shelved states).
789///
790/// # Examples
791///
792/// ```
793/// use std::path::Path;
794/// // Start task "1.1" for change "1" in the repository at "/repo"
795/// let repo = Path::new("/repo");
796/// let _ = ito_core::tasks::start_task(repo, "1", "1.1");
797/// ```
798pub fn start_task(ito_path: &Path, change_id: &str, task_id: &str) -> CoreResult<TaskItem> {
799    let path = checked_tasks_path(ito_path, change_id)?;
800    let file = tracking_file_label(&path);
801    let contents = ito_common::io::read_to_string_std(&path)
802        .map_err(|e| CoreError::io(format!("read {}", path.display()), e))?;
803
804    let outcome = apply_start_task(&contents, change_id, task_id, file)?;
805    ito_common::io::write_std(&path, outcome.updated_content.as_bytes())
806        .map_err(|e| CoreError::io(format!("write {file}"), e))?;
807
808    Ok(outcome.task)
809}
810
811/// Mark a task in a change's tracking file as complete.
812///
813/// Reads and validates the change's tracking file, resolves the provided task identifier
814/// (supports enhanced ids and numeric indexes for checkbox format), updates the file
815/// setting the task's status to `Complete`, and returns the updated task item.
816///
817/// # Returns
818///
819/// `TaskItem` representing the task with its status set to `Complete`.
820///
821/// # Errors
822///
823/// Returns a `CoreError::validation` if the tracking file contains parse errors or the update
824/// operation is rejected; `CoreError::not_found` if the specified task cannot be located;
825/// and `CoreError::io` for filesystem read/write failures.
826///
827/// # Examples
828///
829/// ```
830/// # use std::path::Path;
831/// # use ito_core::tasks::complete_task;
832/// // Attempt to mark task "1.1" complete for change "1" in the repository at "."
833/// let res = complete_task(Path::new("."), "1", "1.1", None);
834/// // `res` will be `Ok(task)` on success or an error describing the failure.
835/// ```
836pub fn complete_task(
837    ito_path: &Path,
838    change_id: &str,
839    task_id: &str,
840    _note: Option<String>,
841) -> CoreResult<TaskItem> {
842    let path = checked_tasks_path(ito_path, change_id)?;
843    let file = tracking_file_label(&path);
844    let contents = ito_common::io::read_to_string_std(&path)
845        .map_err(|e| CoreError::io(format!("read {}", path.display()), e))?;
846
847    let outcome = apply_complete_task(&contents, task_id, file)?;
848    ito_common::io::write_std(&path, outcome.updated_content.as_bytes())
849        .map_err(|e| CoreError::io(format!("write {file}"), e))?;
850
851    Ok(outcome.task)
852}
853
854/// Shelve a task (transition to shelved).
855///
856/// Only supported for enhanced format. Validates preconditions and updates the tracking file.
857pub fn shelve_task(
858    ito_path: &Path,
859    change_id: &str,
860    task_id: &str,
861    _reason: Option<String>,
862) -> CoreResult<TaskItem> {
863    let path = checked_tasks_path(ito_path, change_id)?;
864    let file = tracking_file_label(&path);
865    let contents = ito_common::io::read_to_string_std(&path)
866        .map_err(|e| CoreError::io(format!("read {}", path.display()), e))?;
867
868    let outcome = apply_shelve_task(&contents, task_id, file)?;
869    ito_common::io::write_std(&path, outcome.updated_content.as_bytes())
870        .map_err(|e| CoreError::io(format!("write {file}"), e))?;
871
872    Ok(outcome.task)
873}
874
875/// Unshelve a task (transition back to pending).
876///
877/// Only supported for enhanced format. Validates preconditions and updates the tracking file.
878pub fn unshelve_task(ito_path: &Path, change_id: &str, task_id: &str) -> CoreResult<TaskItem> {
879    let path = checked_tasks_path(ito_path, change_id)?;
880    let file = tracking_file_label(&path);
881    let contents = ito_common::io::read_to_string_std(&path)
882        .map_err(|e| CoreError::io(format!("read {}", path.display()), e))?;
883
884    let outcome = apply_unshelve_task(&contents, task_id, file)?;
885    ito_common::io::write_std(&path, outcome.updated_content.as_bytes())
886        .map_err(|e| CoreError::io(format!("write {file}"), e))?;
887
888    Ok(outcome.task)
889}
890
891/// Add a new task to a change's tracking file.
892///
893/// Only supported for enhanced format. Computes the next task ID and inserts the task.
894pub fn add_task(
895    ito_path: &Path,
896    change_id: &str,
897    title: &str,
898    wave: Option<u32>,
899) -> CoreResult<TaskItem> {
900    let path = checked_tasks_path(ito_path, change_id)?;
901    let file = tracking_file_label(&path);
902    let contents = ito_common::io::read_to_string_std(&path)
903        .map_err(|e| CoreError::io(format!("read {}", path.display()), e))?;
904
905    let outcome = apply_add_task(&contents, title, wave, file)?;
906    ito_common::io::write_std(&path, outcome.updated_content.as_bytes())
907        .map_err(|e| CoreError::io(format!("write {file}"), e))?;
908
909    Ok(outcome.task)
910}
911
912/// Show a specific task by ID.
913///
914/// Returns the full task details.
915pub fn show_task(ito_path: &Path, change_id: &str, task_id: &str) -> CoreResult<TaskItem> {
916    let path = checked_tasks_path(ito_path, change_id)?;
917    let file = tracking_file_label(&path);
918    let contents = ito_common::io::read_to_string_std(&path)
919        .map_err(|e| CoreError::io(format!("read {}", path.display()), e))?;
920
921    let parsed = parse_tasks_tracking_file(&contents);
922
923    // Check for errors
924    if parsed
925        .diagnostics
926        .iter()
927        .any(|d| d.level == DiagnosticLevel::Error)
928    {
929        return Err(CoreError::validation(format!("{file} contains errors")));
930    }
931
932    parsed
933        .tasks
934        .iter()
935        .find(|t| t.id == task_id)
936        .cloned()
937        .ok_or_else(|| CoreError::not_found(format!("Task \"{task_id}\" not found")))
938}
939
940/// Read the raw markdown contents of a change's tracking file.
941pub fn read_tasks_markdown(ito_path: &Path, change_id: &str) -> CoreResult<String> {
942    let path = checked_tasks_path(ito_path, change_id)?;
943    let file = tracking_file_label(&path);
944    ito_common::io::read_to_string(&path).map_err(|e| {
945        CoreError::io(
946            format!("reading {file} for \"{change_id}\""),
947            std::io::Error::other(e),
948        )
949    })
950}
951
952#[cfg(test)]
953mod tests {
954    use std::path::Path;
955
956    use crate::change_repository::FsChangeRepository;
957
958    use super::list_ready_tasks_across_changes;
959
960    fn write(path: impl AsRef<Path>, contents: &str) {
961        let path = path.as_ref();
962        if let Some(parent) = path.parent() {
963            std::fs::create_dir_all(parent).expect("parent dirs should exist");
964        }
965        std::fs::write(path, contents).expect("test fixture should write");
966    }
967
968    fn make_ready_change(root: &Path, id: &str) {
969        write(
970            root.join(".ito/changes").join(id).join("proposal.md"),
971            "## Why\nfixture\n\n## What Changes\n- fixture\n\n## Impact\n- fixture\n",
972        );
973        write(
974            root.join(".ito/changes")
975                .join(id)
976                .join("specs")
977                .join("alpha")
978                .join("spec.md"),
979            "## ADDED Requirements\n\n### Requirement: Fixture\nFixture requirement.\n\n#### Scenario: Works\n- **WHEN** fixture runs\n- **THEN** it is ready\n",
980        );
981        write(
982            root.join(".ito/changes").join(id).join("tasks.md"),
983            "## 1. Implementation\n- [ ] 1.1 pending\n",
984        );
985    }
986
987    fn make_complete_change(root: &Path, id: &str) {
988        write(
989            root.join(".ito/changes").join(id).join("proposal.md"),
990            "## Why\nfixture\n\n## What Changes\n- fixture\n\n## Impact\n- fixture\n",
991        );
992        write(
993            root.join(".ito/changes")
994                .join(id)
995                .join("specs")
996                .join("alpha")
997                .join("spec.md"),
998            "## ADDED Requirements\n\n### Requirement: Fixture\nFixture requirement.\n\n#### Scenario: Works\n- **WHEN** fixture runs\n- **THEN** it is ready\n",
999        );
1000        write(
1001            root.join(".ito/changes").join(id).join("tasks.md"),
1002            "## 1. Implementation\n- [x] 1.1 done\n",
1003        );
1004    }
1005
1006    #[test]
1007    fn returns_ready_tasks_for_ready_changes() {
1008        let repo = tempfile::tempdir().expect("repo tempdir");
1009        let ito_path = repo.path().join(".ito");
1010        make_ready_change(repo.path(), "000-01_alpha");
1011        make_complete_change(repo.path(), "000-02_beta");
1012
1013        let change_repo = FsChangeRepository::new(&ito_path);
1014        let ready =
1015            list_ready_tasks_across_changes(&change_repo, &ito_path).expect("ready task listing");
1016
1017        assert_eq!(ready.len(), 1);
1018        assert_eq!(ready[0].change_id, "000-01_alpha");
1019        assert_eq!(ready[0].ready_tasks.len(), 1);
1020        assert_eq!(ready[0].ready_tasks[0].id, "1.1");
1021    }
1022
1023    #[test]
1024    fn returns_empty_when_no_ready_tasks_exist() {
1025        let repo = tempfile::tempdir().expect("repo tempdir");
1026        let ito_path = repo.path().join(".ito");
1027        make_complete_change(repo.path(), "000-01_alpha");
1028
1029        let change_repo = FsChangeRepository::new(&ito_path);
1030        let ready =
1031            list_ready_tasks_across_changes(&change_repo, &ito_path).expect("ready task listing");
1032
1033        assert!(ready.is_empty());
1034    }
1035
1036    #[test]
1037    fn read_tasks_markdown_returns_contents_for_existing_file() {
1038        let repo = tempfile::tempdir().expect("repo tempdir");
1039        let ito_path = repo.path().join(".ito");
1040        let change_id = "000-01_alpha";
1041        let tasks_content = "## 1. Implementation\n- [ ] 1.1 pending\n";
1042        write(
1043            ito_path.join("changes").join(change_id).join("tasks.md"),
1044            tasks_content,
1045        );
1046
1047        let result =
1048            super::read_tasks_markdown(&ito_path, change_id).expect("should read tasks.md");
1049        assert_eq!(result, tasks_content);
1050    }
1051
1052    #[test]
1053    fn read_tasks_markdown_returns_error_for_missing_file() {
1054        let repo = tempfile::tempdir().expect("repo tempdir");
1055        let ito_path = repo.path().join(".ito");
1056
1057        let result = super::read_tasks_markdown(&ito_path, "nonexistent-change");
1058        assert!(result.is_err(), "should fail for missing tasks.md");
1059        let err = result.unwrap_err();
1060        let msg = err.to_string();
1061        assert!(
1062            msg.contains("tasks.md"),
1063            "error should mention tasks.md, got: {msg}"
1064        );
1065    }
1066
1067    #[test]
1068    fn read_tasks_markdown_rejects_traversal_like_change_id() {
1069        let repo = tempfile::tempdir().expect("repo tempdir");
1070        let ito_path = repo.path().join(".ito");
1071
1072        let result = super::read_tasks_markdown(&ito_path, "../escape");
1073        assert!(result.is_err(), "traversal-like ids should fail");
1074    }
1075}