Skip to main content

ito_core/
tasks.rs

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