1use 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
12pub 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
21fn 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 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
88pub fn tracking_file_path(ito_path: &Path, change_id: &str) -> CoreResult<PathBuf> {
93 checked_tasks_path(ito_path, change_id)
94}
95
96fn 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#[derive(Debug, Clone)]
151pub struct TaskStatusSummary {
152 pub format: TasksFormat,
154 pub items: Vec<TaskItem>,
156 pub progress: ProgressInfo,
158 pub diagnostics: Vec<TaskDiagnostic>,
160 pub ready: Vec<TaskItem>,
162 pub blocked: Vec<(TaskItem, Vec<String>)>,
164}
165
166#[derive(Debug, Clone)]
168pub struct ReadyTasksForChange {
169 pub change_id: String,
171 pub ready_tasks: Vec<TaskItem>,
173}
174
175pub 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
223pub 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#[derive(Debug, Clone)]
263pub struct TaskStatusResult {
264 pub path: PathBuf,
266 pub format: TasksFormat,
268 pub items: Vec<TaskItem>,
270 pub progress: ProgressInfo,
272 pub diagnostics: Vec<TaskDiagnostic>,
274 pub ready: Vec<TaskItem>,
276 pub blocked: Vec<(TaskItem, Vec<String>)>,
278}
279
280impl TaskStatusResult {
281 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
311pub 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
335pub 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
368pub 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
377pub 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
385pub 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
398pub 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
753pub 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
791pub 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
834pub 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
855pub 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
871pub 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
892pub 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 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
920pub 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}