1use std::path::{Path, PathBuf};
4
5use crate::error_bridge::IntoCoreResult;
6use crate::errors::{CoreError, CoreResult};
7use ito_domain::changes::ChangeRepository as DomainChangeRepository;
8
9pub 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
18fn checked_tasks_path(ito_path: &Path, change_id: &str) -> CoreResult<PathBuf> {
37 let Some(path) = ito_domain::tasks::tasks_path_checked(ito_path, change_id) else {
38 return Err(CoreError::validation(format!(
39 "invalid change id path segment: \"{change_id}\""
40 )));
41 };
42 Ok(path)
43}
44
45fn resolve_task_id<'a>(parsed: &'a TasksParseResult, task_id: &'a str) -> CoreResult<&'a str> {
50 if parsed.format != TasksFormat::Checkbox {
51 return Ok(task_id);
52 }
53
54 if parsed.tasks.iter().any(|t| t.id == task_id) {
55 return Ok(task_id);
56 }
57
58 let not_found_err =
59 || CoreError::not_found(format!("Task \"{task_id}\" not found in tasks.md"));
60
61 let Ok(idx) = task_id.parse::<usize>() else {
62 return Err(not_found_err());
63 };
64 if idx == 0 || idx > parsed.tasks.len() {
65 return Err(not_found_err());
66 }
67
68 Ok(parsed.tasks[idx - 1].id.as_str())
69}
70
71#[derive(Debug, Clone)]
73pub struct ReadyTasksForChange {
74 pub change_id: String,
76 pub ready_tasks: Vec<TaskItem>,
78}
79
80pub fn list_ready_tasks_across_changes(
85 change_repo: &impl DomainChangeRepository,
86 ito_path: &Path,
87) -> CoreResult<Vec<ReadyTasksForChange>> {
88 let summaries = change_repo.list().into_core()?;
89
90 let mut results: Vec<ReadyTasksForChange> = Vec::new();
91 for summary in &summaries {
92 if !summary.is_ready() {
93 continue;
94 }
95
96 let Ok(path) = checked_tasks_path(ito_path, &summary.id) else {
97 continue;
98 };
99 let Ok(contents) = ito_common::io::read_to_string(&path) else {
100 continue;
101 };
102
103 let parsed = parse_tasks_tracking_file(&contents);
104 if parsed
105 .diagnostics
106 .iter()
107 .any(|d| d.level == ito_domain::tasks::DiagnosticLevel::Error)
108 {
109 continue;
110 }
111
112 let (ready, _blocked) = compute_ready_and_blocked(&parsed);
113 if ready.is_empty() {
114 continue;
115 }
116
117 results.push(ReadyTasksForChange {
118 change_id: summary.id.clone(),
119 ready_tasks: ready,
120 });
121 }
122
123 Ok(results)
124}
125
126#[derive(Debug, Clone)]
128pub struct TaskStatusResult {
129 pub format: TasksFormat,
131 pub items: Vec<TaskItem>,
133 pub progress: ProgressInfo,
135 pub diagnostics: Vec<TaskDiagnostic>,
137 pub ready: Vec<TaskItem>,
139 pub blocked: Vec<(TaskItem, Vec<String>)>,
141}
142
143pub fn init_tasks(ito_path: &Path, change_id: &str) -> CoreResult<(PathBuf, bool)> {
147 let path = checked_tasks_path(ito_path, change_id)?;
148
149 if path.exists() {
150 return Ok((path, true));
151 }
152
153 let now = chrono::Local::now();
154 let contents = enhanced_tasks_template(change_id, now);
155
156 if let Some(parent) = path.parent() {
157 ito_common::io::create_dir_all_std(parent)
158 .map_err(|e| CoreError::io("create tasks.md parent directory", e))?;
159 }
160
161 ito_common::io::write_std(&path, contents.as_bytes())
162 .map_err(|e| CoreError::io("write tasks.md", e))?;
163
164 Ok((path, false))
165}
166
167pub fn get_task_status(ito_path: &Path, change_id: &str) -> CoreResult<TaskStatusResult> {
171 let path = checked_tasks_path(ito_path, change_id)?;
172
173 if !path.exists() {
174 return Err(CoreError::not_found(format!(
175 "No tasks.md found for \"{change_id}\". Run \"ito tasks init {change_id}\" first."
176 )));
177 }
178
179 let contents = ito_common::io::read_to_string_std(&path)
180 .map_err(|e| CoreError::io(format!("read {}", path.display()), e))?;
181
182 let parsed = parse_tasks_tracking_file(&contents);
183 let (ready, blocked) = compute_ready_and_blocked(&parsed);
184
185 Ok(TaskStatusResult {
186 format: parsed.format,
187 items: parsed.tasks,
188 progress: parsed.progress,
189 diagnostics: parsed.diagnostics,
190 ready,
191 blocked,
192 })
193}
194
195pub fn get_next_task(ito_path: &Path, change_id: &str) -> CoreResult<Option<TaskItem>> {
199 let status = get_task_status(ito_path, change_id)?;
200
201 if status
203 .diagnostics
204 .iter()
205 .any(|d| d.level == DiagnosticLevel::Error)
206 {
207 return Err(CoreError::validation("tasks.md contains errors"));
208 }
209
210 if status.progress.remaining == 0 {
212 return Ok(None);
213 }
214
215 match status.format {
216 TasksFormat::Checkbox => {
217 if let Some(current) = status
219 .items
220 .iter()
221 .find(|t| t.status == TaskStatus::InProgress)
222 {
223 return Ok(Some(current.clone()));
224 }
225
226 Ok(status
228 .items
229 .iter()
230 .find(|t| t.status == TaskStatus::Pending)
231 .cloned())
232 }
233 TasksFormat::Enhanced => {
234 Ok(status.ready.first().cloned())
236 }
237 }
238}
239
240pub fn start_task(ito_path: &Path, change_id: &str, task_id: &str) -> CoreResult<TaskItem> {
266 let path = checked_tasks_path(ito_path, change_id)?;
267 let contents = ito_common::io::read_to_string_std(&path)
268 .map_err(|e| CoreError::io(format!("read {}", path.display()), e))?;
269
270 let parsed = parse_tasks_tracking_file(&contents);
271
272 if parsed
274 .diagnostics
275 .iter()
276 .any(|d| d.level == DiagnosticLevel::Error)
277 {
278 return Err(CoreError::validation("tasks.md contains errors"));
279 }
280
281 let resolved_task_id = resolve_task_id(&parsed, task_id)?;
282
283 let Some(task) = parsed.tasks.iter().find(|t| t.id == resolved_task_id) else {
285 return Err(CoreError::not_found(format!(
286 "Task \"{task_id}\" not found in tasks.md"
287 )));
288 };
289
290 if parsed.format == TasksFormat::Checkbox
292 && let Some(current) = parsed
293 .tasks
294 .iter()
295 .find(|t| t.status == TaskStatus::InProgress)
296 && current.id != resolved_task_id
297 {
298 return Err(CoreError::validation(format!(
299 "Task \"{}\" is already in-progress (complete it before starting another task)",
300 current.id
301 )));
302 }
303
304 if parsed.format == TasksFormat::Checkbox {
305 match task.status {
307 TaskStatus::Pending => {}
308 TaskStatus::InProgress => {
309 return Err(CoreError::validation(format!(
310 "Task \"{resolved_task_id}\" is already in-progress"
311 )));
312 }
313 TaskStatus::Complete => {
314 return Err(CoreError::validation(format!(
315 "Task \"{resolved_task_id}\" is already complete"
316 )));
317 }
318 TaskStatus::Shelved => {
319 return Err(CoreError::validation(
320 "Checkbox-only tasks.md does not support shelving".to_string(),
321 ));
322 }
323 }
324
325 let updated =
326 update_checkbox_task_status(&contents, resolved_task_id, TaskStatus::InProgress)
327 .map_err(CoreError::validation)?;
328 ito_common::io::write_std(&path, updated.as_bytes())
329 .map_err(|e| CoreError::io("write tasks.md", e))?;
330
331 let mut result = task.clone();
332 result.status = TaskStatus::InProgress;
333 return Ok(result);
334 }
335
336 if task.status == TaskStatus::Shelved {
338 return Err(CoreError::validation(format!(
339 "Task \"{task_id}\" is shelved (run \"ito tasks unshelve {change_id} {task_id}\" first)"
340 )));
341 }
342
343 if task.status != TaskStatus::Pending {
344 return Err(CoreError::validation(format!(
345 "Task \"{task_id}\" is not pending (current: {})",
346 task.status.as_enhanced_label()
347 )));
348 }
349
350 let (ready, blocked) = compute_ready_and_blocked(&parsed);
351 if !ready.iter().any(|t| t.id == task_id) {
352 if let Some((_, blockers)) = blocked.iter().find(|(t, _)| t.id == task_id) {
353 let mut msg = String::from("Task is blocked:");
354 for b in blockers {
355 msg.push_str("\n- ");
356 msg.push_str(b);
357 }
358 return Err(CoreError::validation(msg));
359 }
360 return Err(CoreError::validation("Task is blocked"));
361 }
362
363 let updated = update_enhanced_task_status(
364 &contents,
365 task_id,
366 TaskStatus::InProgress,
367 chrono::Local::now(),
368 );
369 ito_common::io::write_std(&path, updated.as_bytes())
370 .map_err(|e| CoreError::io("write tasks.md", e))?;
371
372 let mut result = task.clone();
373 result.status = TaskStatus::InProgress;
374 Ok(result)
375}
376
377pub fn complete_task(
403 ito_path: &Path,
404 change_id: &str,
405 task_id: &str,
406 _note: Option<String>,
407) -> CoreResult<TaskItem> {
408 let path = checked_tasks_path(ito_path, change_id)?;
409 let contents = ito_common::io::read_to_string_std(&path)
410 .map_err(|e| CoreError::io(format!("read {}", path.display()), e))?;
411
412 let parsed = parse_tasks_tracking_file(&contents);
413
414 if parsed
416 .diagnostics
417 .iter()
418 .any(|d| d.level == DiagnosticLevel::Error)
419 {
420 return Err(CoreError::validation("tasks.md contains errors"));
421 }
422
423 let resolved_task_id = resolve_task_id(&parsed, task_id)?;
424
425 let Some(task) = parsed.tasks.iter().find(|t| t.id == resolved_task_id) else {
427 return Err(CoreError::not_found(format!(
428 "Task \"{task_id}\" not found in tasks.md"
429 )));
430 };
431
432 let updated = if parsed.format == TasksFormat::Checkbox {
433 update_checkbox_task_status(&contents, resolved_task_id, TaskStatus::Complete)
434 .map_err(CoreError::validation)?
435 } else {
436 update_enhanced_task_status(
437 &contents,
438 task_id,
439 TaskStatus::Complete,
440 chrono::Local::now(),
441 )
442 };
443
444 ito_common::io::write_std(&path, updated.as_bytes())
445 .map_err(|e| CoreError::io("write tasks.md", e))?;
446
447 let mut result = task.clone();
448 result.status = TaskStatus::Complete;
449 Ok(result)
450}
451
452pub fn shelve_task(
456 ito_path: &Path,
457 change_id: &str,
458 task_id: &str,
459 _reason: Option<String>,
460) -> CoreResult<TaskItem> {
461 let path = checked_tasks_path(ito_path, change_id)?;
462 let contents = ito_common::io::read_to_string_std(&path)
463 .map_err(|e| CoreError::io(format!("read {}", path.display()), e))?;
464
465 let parsed = parse_tasks_tracking_file(&contents);
466
467 if parsed.format == TasksFormat::Checkbox {
468 return Err(CoreError::validation(
469 "Checkbox-only tasks.md does not support shelving",
470 ));
471 }
472
473 if parsed
475 .diagnostics
476 .iter()
477 .any(|d| d.level == DiagnosticLevel::Error)
478 {
479 return Err(CoreError::validation("tasks.md contains errors"));
480 }
481
482 let Some(task) = parsed.tasks.iter().find(|t| t.id == task_id) else {
484 return Err(CoreError::not_found(format!(
485 "Task \"{task_id}\" not found in tasks.md"
486 )));
487 };
488
489 if task.status == TaskStatus::Complete {
490 return Err(CoreError::validation(format!(
491 "Task \"{task_id}\" is already complete"
492 )));
493 }
494
495 let updated = update_enhanced_task_status(
496 &contents,
497 task_id,
498 TaskStatus::Shelved,
499 chrono::Local::now(),
500 );
501
502 ito_common::io::write_std(&path, updated.as_bytes())
503 .map_err(|e| CoreError::io("write tasks.md", e))?;
504
505 let mut result = task.clone();
506 result.status = TaskStatus::Shelved;
507 Ok(result)
508}
509
510pub fn unshelve_task(ito_path: &Path, change_id: &str, task_id: &str) -> CoreResult<TaskItem> {
514 let path = checked_tasks_path(ito_path, change_id)?;
515 let contents = ito_common::io::read_to_string_std(&path)
516 .map_err(|e| CoreError::io(format!("read {}", path.display()), e))?;
517
518 let parsed = parse_tasks_tracking_file(&contents);
519
520 if parsed.format == TasksFormat::Checkbox {
521 return Err(CoreError::validation(
522 "Checkbox-only tasks.md does not support shelving",
523 ));
524 }
525
526 if parsed
528 .diagnostics
529 .iter()
530 .any(|d| d.level == DiagnosticLevel::Error)
531 {
532 return Err(CoreError::validation("tasks.md contains errors"));
533 }
534
535 let Some(task) = parsed.tasks.iter().find(|t| t.id == task_id) else {
537 return Err(CoreError::not_found(format!(
538 "Task \"{task_id}\" not found in tasks.md"
539 )));
540 };
541
542 if task.status != TaskStatus::Shelved {
543 return Err(CoreError::validation(format!(
544 "Task \"{task_id}\" is not shelved"
545 )));
546 }
547
548 let updated = update_enhanced_task_status(
549 &contents,
550 task_id,
551 TaskStatus::Pending,
552 chrono::Local::now(),
553 );
554
555 ito_common::io::write_std(&path, updated.as_bytes())
556 .map_err(|e| CoreError::io("write tasks.md", e))?;
557
558 let mut result = task.clone();
559 result.status = TaskStatus::Pending;
560 Ok(result)
561}
562
563pub fn add_task(
567 ito_path: &Path,
568 change_id: &str,
569 title: &str,
570 wave: Option<u32>,
571) -> CoreResult<TaskItem> {
572 let wave = wave.unwrap_or(1);
573 let path = checked_tasks_path(ito_path, change_id)?;
574 let contents = ito_common::io::read_to_string_std(&path)
575 .map_err(|e| CoreError::io(format!("read {}", path.display()), e))?;
576
577 let parsed = parse_tasks_tracking_file(&contents);
578
579 if parsed.format != TasksFormat::Enhanced {
580 return Err(CoreError::validation(
581 "Cannot add tasks to checkbox-only tracking file. Convert to enhanced format first.",
582 ));
583 }
584
585 if parsed
587 .diagnostics
588 .iter()
589 .any(|d| d.level == DiagnosticLevel::Error)
590 {
591 return Err(CoreError::validation("tasks.md contains errors"));
592 }
593
594 let mut max_n = 0u32;
596 for t in &parsed.tasks {
597 if let Some((w, n)) = t.id.split_once('.')
598 && let (Ok(w), Ok(n)) = (w.parse::<u32>(), n.parse::<u32>())
599 && w == wave
600 {
601 max_n = max_n.max(n);
602 }
603 }
604 let new_id = format!("{wave}.{}", max_n + 1);
605
606 let date = chrono::Local::now().format("%Y-%m-%d").to_string();
607 let block = format!(
608 "\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"
609 );
610
611 let mut out = contents.clone();
612 if out.contains(&format!("## Wave {wave}")) {
613 if let Some(pos) = out.find("## Checkpoints") {
615 out.insert_str(pos, &block);
616 } else {
617 out.push_str(&block);
618 }
619 } else {
620 if let Some(pos) = out.find("## Checkpoints") {
622 out.insert_str(
623 pos,
624 &format!("\n---\n\n## Wave {wave}\n- **Depends On**: None\n"),
625 );
626 let pos2 = out.find("## Checkpoints").unwrap_or(out.len());
627 out.insert_str(pos2, &block);
628 } else {
629 out.push_str(&format!(
630 "\n---\n\n## Wave {wave}\n- **Depends On**: None\n"
631 ));
632 out.push_str(&block);
633 }
634 }
635
636 ito_common::io::write_std(&path, out.as_bytes())
637 .map_err(|e| CoreError::io("write tasks.md", e))?;
638
639 Ok(TaskItem {
640 id: new_id,
641 name: title.to_string(),
642 wave: Some(wave),
643 status: TaskStatus::Pending,
644 updated_at: Some(date),
645 dependencies: Vec::new(),
646 files: vec!["path/to/file.rs".to_string()],
647 action: "[Describe what needs to be done]".to_string(),
648 verify: Some("cargo test --workspace".to_string()),
649 done_when: Some("[Success criteria]".to_string()),
650 kind: TaskKind::Normal,
651 header_line_index: 0,
652 })
653}
654
655pub fn show_task(ito_path: &Path, change_id: &str, task_id: &str) -> CoreResult<TaskItem> {
659 let path = checked_tasks_path(ito_path, change_id)?;
660 let contents = ito_common::io::read_to_string_std(&path)
661 .map_err(|e| CoreError::io(format!("read {}", path.display()), e))?;
662
663 let parsed = parse_tasks_tracking_file(&contents);
664
665 if parsed
667 .diagnostics
668 .iter()
669 .any(|d| d.level == DiagnosticLevel::Error)
670 {
671 return Err(CoreError::validation("tasks.md contains errors"));
672 }
673
674 parsed
675 .tasks
676 .iter()
677 .find(|t| t.id == task_id)
678 .cloned()
679 .ok_or_else(|| CoreError::not_found(format!("Task \"{task_id}\" not found")))
680}
681
682pub fn read_tasks_markdown(ito_path: &Path, change_id: &str) -> CoreResult<String> {
684 let path = checked_tasks_path(ito_path, change_id)?;
685 ito_common::io::read_to_string(&path).map_err(|e| {
686 CoreError::io(
687 format!("reading tasks.md for \"{change_id}\""),
688 std::io::Error::other(e),
689 )
690 })
691}
692
693#[cfg(test)]
694mod tests {
695 use std::path::Path;
696
697 use crate::change_repository::FsChangeRepository;
698
699 use super::list_ready_tasks_across_changes;
700
701 fn write(path: impl AsRef<Path>, contents: &str) {
702 let path = path.as_ref();
703 if let Some(parent) = path.parent() {
704 std::fs::create_dir_all(parent).expect("parent dirs should exist");
705 }
706 std::fs::write(path, contents).expect("test fixture should write");
707 }
708
709 fn make_ready_change(root: &Path, id: &str) {
710 write(
711 root.join(".ito/changes").join(id).join("proposal.md"),
712 "## Why\nfixture\n\n## What Changes\n- fixture\n\n## Impact\n- fixture\n",
713 );
714 write(
715 root.join(".ito/changes")
716 .join(id)
717 .join("specs")
718 .join("alpha")
719 .join("spec.md"),
720 "## ADDED Requirements\n\n### Requirement: Fixture\nFixture requirement.\n\n#### Scenario: Works\n- **WHEN** fixture runs\n- **THEN** it is ready\n",
721 );
722 write(
723 root.join(".ito/changes").join(id).join("tasks.md"),
724 "## 1. Implementation\n- [ ] 1.1 pending\n",
725 );
726 }
727
728 fn make_complete_change(root: &Path, id: &str) {
729 write(
730 root.join(".ito/changes").join(id).join("proposal.md"),
731 "## Why\nfixture\n\n## What Changes\n- fixture\n\n## Impact\n- fixture\n",
732 );
733 write(
734 root.join(".ito/changes")
735 .join(id)
736 .join("specs")
737 .join("alpha")
738 .join("spec.md"),
739 "## ADDED Requirements\n\n### Requirement: Fixture\nFixture requirement.\n\n#### Scenario: Works\n- **WHEN** fixture runs\n- **THEN** it is ready\n",
740 );
741 write(
742 root.join(".ito/changes").join(id).join("tasks.md"),
743 "## 1. Implementation\n- [x] 1.1 done\n",
744 );
745 }
746
747 #[test]
748 fn returns_ready_tasks_for_ready_changes() {
749 let repo = tempfile::tempdir().expect("repo tempdir");
750 let ito_path = repo.path().join(".ito");
751 make_ready_change(repo.path(), "000-01_alpha");
752 make_complete_change(repo.path(), "000-02_beta");
753
754 let change_repo = FsChangeRepository::new(&ito_path);
755 let ready =
756 list_ready_tasks_across_changes(&change_repo, &ito_path).expect("ready task listing");
757
758 assert_eq!(ready.len(), 1);
759 assert_eq!(ready[0].change_id, "000-01_alpha");
760 assert_eq!(ready[0].ready_tasks.len(), 1);
761 assert_eq!(ready[0].ready_tasks[0].id, "1.1");
762 }
763
764 #[test]
765 fn returns_empty_when_no_ready_tasks_exist() {
766 let repo = tempfile::tempdir().expect("repo tempdir");
767 let ito_path = repo.path().join(".ito");
768 make_complete_change(repo.path(), "000-01_alpha");
769
770 let change_repo = FsChangeRepository::new(&ito_path);
771 let ready =
772 list_ready_tasks_across_changes(&change_repo, &ito_path).expect("ready task listing");
773
774 assert!(ready.is_empty());
775 }
776
777 #[test]
778 fn read_tasks_markdown_returns_contents_for_existing_file() {
779 let repo = tempfile::tempdir().expect("repo tempdir");
780 let ito_path = repo.path().join(".ito");
781 let change_id = "000-01_alpha";
782 let tasks_content = "## 1. Implementation\n- [ ] 1.1 pending\n";
783 write(
784 ito_path.join("changes").join(change_id).join("tasks.md"),
785 tasks_content,
786 );
787
788 let result =
789 super::read_tasks_markdown(&ito_path, change_id).expect("should read tasks.md");
790 assert_eq!(result, tasks_content);
791 }
792
793 #[test]
794 fn read_tasks_markdown_returns_error_for_missing_file() {
795 let repo = tempfile::tempdir().expect("repo tempdir");
796 let ito_path = repo.path().join(".ito");
797
798 let result = super::read_tasks_markdown(&ito_path, "nonexistent-change");
799 assert!(result.is_err(), "should fail for missing tasks.md");
800 let err = result.unwrap_err();
801 let msg = err.to_string();
802 assert!(
803 msg.contains("tasks.md"),
804 "error should mention tasks.md, got: {msg}"
805 );
806 }
807
808 #[test]
809 fn read_tasks_markdown_rejects_traversal_like_change_id() {
810 let repo = tempfile::tempdir().expect("repo tempdir");
811 let ito_path = repo.path().join(".ito");
812
813 let result = super::read_tasks_markdown(&ito_path, "../escape");
814 assert!(result.is_err(), "traversal-like ids should fail");
815 }
816}