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;
10
11pub use ito_domain::changes::ChangeTargetResolution;
13pub use ito_domain::tasks::{
14 DiagnosticLevel, ProgressInfo, TaskDiagnostic, TaskItem, TaskKind, TaskStatus, TasksFormat,
15 TasksParseResult, WaveInfo, compute_ready_and_blocked, enhanced_tasks_template,
16 parse_tasks_tracking_file, tasks_path, update_checkbox_task_status,
17 update_enhanced_task_status,
18};
19
20fn checked_tasks_path(ito_path: &Path, change_id: &str) -> CoreResult<PathBuf> {
39 if ito_domain::tasks::tasks_path_checked(ito_path, change_id).is_none() {
40 return Err(CoreError::validation(format!(
41 "invalid change id path segment: \"{change_id}\""
42 )));
43 }
44
45 let schema_name = read_change_schema(ito_path, change_id);
46 let mut ctx = ConfigContext::from_process_env();
47 ctx.project_dir = ito_path.parent().map(|p| p.to_path_buf());
48
49 let resolved = resolve_schema(Some(&schema_name), &ctx).map_err(|e| {
50 CoreError::validation(format!("Failed to resolve schema '{schema_name}': {e}"))
51 })?;
52
53 if let Ok(Some(validation)) = load_schema_validation(&resolved)
56 && let Some(tracking) = validation.tracking.as_ref()
57 && tracking.validate_as != ValidatorId::TasksTrackingV1
58 {
59 return Err(CoreError::validation(format!(
60 "Schema tracking validator '{}' is not supported by `ito tasks`",
61 tracking.validate_as.as_str()
62 )));
63 }
64
65 let tracking_file = resolved
66 .schema
67 .apply
68 .as_ref()
69 .and_then(|a| a.tracks.as_deref())
70 .unwrap_or("tasks.md");
71
72 if !ito_domain::tasks::is_safe_tracking_filename(tracking_file) {
73 return Err(CoreError::validation(format!(
74 "Invalid tracking file path in apply.tracks: '{tracking_file}'"
75 )));
76 }
77
78 Ok(ito_path.join("changes").join(change_id).join(tracking_file))
79}
80
81fn tracking_file_label(path: &Path) -> &str {
82 path.file_name()
83 .and_then(|s| s.to_str())
84 .unwrap_or("tracking file")
85}
86
87pub fn tracking_file_path(ito_path: &Path, change_id: &str) -> CoreResult<PathBuf> {
92 checked_tasks_path(ito_path, change_id)
93}
94
95fn resolve_task_id<'a>(
100 parsed: &'a TasksParseResult,
101 task_id: &'a str,
102 file: &str,
103) -> CoreResult<&'a str> {
104 if parsed.format != TasksFormat::Checkbox {
105 return Ok(task_id);
106 }
107
108 if parsed.tasks.iter().any(|t| t.id == task_id) {
109 return Ok(task_id);
110 }
111
112 let not_found_err = || CoreError::not_found(format!("Task \"{task_id}\" not found in {file}"));
113
114 let Ok(idx) = task_id.parse::<usize>() else {
115 return Err(not_found_err());
116 };
117 if idx == 0 || idx > parsed.tasks.len() {
118 return Err(not_found_err());
119 }
120
121 Ok(parsed.tasks[idx - 1].id.as_str())
122}
123
124fn parse_numeric_task_id(id: &str) -> Option<(u32, u32)> {
125 let (wave, task) = id.split_once('.')?;
126 let wave = wave.parse::<u32>().ok()?;
127 let task = task.parse::<u32>().ok()?;
128 Some((wave, task))
129}
130
131fn compare_task_ids(a: &str, b: &str) -> std::cmp::Ordering {
132 match (parse_numeric_task_id(a), parse_numeric_task_id(b)) {
133 (Some(aa), Some(bb)) => aa.cmp(&bb).then(a.cmp(b)),
134 (Some(_), None) => std::cmp::Ordering::Less,
135 (None, Some(_)) => std::cmp::Ordering::Greater,
136 (None, None) => a.cmp(b),
137 }
138}
139
140fn sort_task_items_by_id(items: &mut [TaskItem]) {
141 items.sort_by(|a, b| compare_task_ids(&a.id, &b.id));
142}
143
144fn sort_blocked_tasks_by_id(items: &mut [(TaskItem, Vec<String>)]) {
145 items.sort_by(|(a, _), (b, _)| compare_task_ids(&a.id, &b.id));
146}
147
148#[derive(Debug, Clone)]
150pub struct ReadyTasksForChange {
151 pub change_id: String,
153 pub ready_tasks: Vec<TaskItem>,
155}
156
157pub fn list_ready_tasks_across_changes(
162 change_repo: &impl DomainChangeRepository,
163 ito_path: &Path,
164) -> CoreResult<Vec<ReadyTasksForChange>> {
165 let summaries = change_repo.list().into_core()?;
166
167 let mut results: Vec<ReadyTasksForChange> = Vec::new();
168 for summary in &summaries {
169 if !summary.is_ready() {
170 continue;
171 }
172
173 let Ok(path) = checked_tasks_path(ito_path, &summary.id) else {
174 continue;
175 };
176 let Ok(contents) = ito_common::io::read_to_string(&path) else {
177 continue;
178 };
179
180 let parsed = parse_tasks_tracking_file(&contents);
181 if parsed
182 .diagnostics
183 .iter()
184 .any(|d| d.level == ito_domain::tasks::DiagnosticLevel::Error)
185 {
186 continue;
187 }
188
189 let (mut ready, _blocked) = compute_ready_and_blocked(&parsed);
190 if ready.is_empty() {
191 continue;
192 }
193
194 sort_task_items_by_id(&mut ready);
195
196 results.push(ReadyTasksForChange {
197 change_id: summary.id.clone(),
198 ready_tasks: ready,
199 });
200 }
201
202 Ok(results)
203}
204
205#[derive(Debug, Clone)]
207pub struct TaskStatusResult {
208 pub path: PathBuf,
210 pub format: TasksFormat,
212 pub items: Vec<TaskItem>,
214 pub progress: ProgressInfo,
216 pub diagnostics: Vec<TaskDiagnostic>,
218 pub ready: Vec<TaskItem>,
220 pub blocked: Vec<(TaskItem, Vec<String>)>,
222}
223
224pub fn init_tasks(ito_path: &Path, change_id: &str) -> CoreResult<(PathBuf, bool)> {
228 let path = checked_tasks_path(ito_path, change_id)?;
229
230 if path.exists() {
231 return Ok((path, true));
232 }
233
234 let now = chrono::Local::now();
235 let contents = enhanced_tasks_template(change_id, now);
236
237 if let Some(parent) = path.parent() {
238 ito_common::io::create_dir_all_std(parent)
239 .map_err(|e| CoreError::io("create tracking file parent directory", e))?;
240 }
241
242 ito_common::io::write_std(&path, contents.as_bytes())
243 .map_err(|e| CoreError::io("write tracking file", e))?;
244
245 Ok((path, false))
246}
247
248pub fn get_task_status(ito_path: &Path, change_id: &str) -> CoreResult<TaskStatusResult> {
252 let path = checked_tasks_path(ito_path, change_id)?;
253
254 if !path.exists() {
255 let file = path
256 .file_name()
257 .and_then(|s| s.to_str())
258 .unwrap_or("tracking file");
259 return Err(CoreError::not_found(format!(
260 "No {file} found for \"{change_id}\". Run \"ito tasks init {change_id}\" first."
261 )));
262 }
263
264 let contents = ito_common::io::read_to_string_std(&path)
265 .map_err(|e| CoreError::io(format!("read {}", path.display()), e))?;
266
267 let parsed = parse_tasks_tracking_file(&contents);
268 let (mut ready, mut blocked) = compute_ready_and_blocked(&parsed);
269 sort_task_items_by_id(&mut ready);
270 sort_blocked_tasks_by_id(&mut blocked);
271 let mut items = parsed.tasks;
272 sort_task_items_by_id(&mut items);
273
274 Ok(TaskStatusResult {
275 path,
276 format: parsed.format,
277 items,
278 progress: parsed.progress,
279 diagnostics: parsed.diagnostics,
280 ready,
281 blocked,
282 })
283}
284
285pub fn get_next_task(ito_path: &Path, change_id: &str) -> CoreResult<Option<TaskItem>> {
289 let status = get_task_status(ito_path, change_id)?;
290 get_next_task_from_status(&status)
291}
292
293pub fn get_next_task_from_status(status: &TaskStatusResult) -> CoreResult<Option<TaskItem>> {
295 let file = tracking_file_label(&status.path);
296
297 if status
298 .diagnostics
299 .iter()
300 .any(|d| d.level == DiagnosticLevel::Error)
301 {
302 return Err(CoreError::validation(format!("{file} contains errors")));
303 }
304
305 if status.progress.remaining == 0 {
306 return Ok(None);
307 }
308
309 match status.format {
310 TasksFormat::Checkbox => {
311 if let Some(current) = status
312 .items
313 .iter()
314 .find(|t| t.status == TaskStatus::InProgress)
315 {
316 return Ok(Some(current.clone()));
317 }
318
319 Ok(status
320 .items
321 .iter()
322 .find(|t| t.status == TaskStatus::Pending)
323 .cloned())
324 }
325 TasksFormat::Enhanced => Ok(status.ready.first().cloned()),
326 }
327}
328
329pub fn start_task(ito_path: &Path, change_id: &str, task_id: &str) -> CoreResult<TaskItem> {
355 let path = checked_tasks_path(ito_path, change_id)?;
356 let file = tracking_file_label(&path);
357 let contents = ito_common::io::read_to_string_std(&path)
358 .map_err(|e| CoreError::io(format!("read {}", path.display()), e))?;
359
360 let parsed = parse_tasks_tracking_file(&contents);
361
362 if parsed
364 .diagnostics
365 .iter()
366 .any(|d| d.level == DiagnosticLevel::Error)
367 {
368 return Err(CoreError::validation(format!("{file} contains errors")));
369 }
370
371 let resolved_task_id = resolve_task_id(&parsed, task_id, file)?;
372
373 let Some(task) = parsed.tasks.iter().find(|t| t.id == resolved_task_id) else {
375 return Err(CoreError::not_found(format!(
376 "Task \"{task_id}\" not found in {file}"
377 )));
378 };
379
380 if parsed.format == TasksFormat::Checkbox
382 && let Some(current) = parsed
383 .tasks
384 .iter()
385 .find(|t| t.status == TaskStatus::InProgress)
386 && current.id != resolved_task_id
387 {
388 return Err(CoreError::validation(format!(
389 "Task \"{}\" is already in-progress (complete it before starting another task)",
390 current.id
391 )));
392 }
393
394 if parsed.format == TasksFormat::Checkbox {
395 match task.status {
397 TaskStatus::Pending => {}
398 TaskStatus::InProgress => {
399 return Err(CoreError::validation(format!(
400 "Task \"{resolved_task_id}\" is already in-progress"
401 )));
402 }
403 TaskStatus::Complete => {
404 return Err(CoreError::validation(format!(
405 "Task \"{resolved_task_id}\" is already complete"
406 )));
407 }
408 TaskStatus::Shelved => {
409 return Err(CoreError::validation(format!(
410 "Checkbox-only {file} does not support shelving"
411 )));
412 }
413 }
414
415 let updated =
416 update_checkbox_task_status(&contents, resolved_task_id, TaskStatus::InProgress)
417 .map_err(CoreError::validation)?;
418 ito_common::io::write_std(&path, updated.as_bytes())
419 .map_err(|e| CoreError::io(format!("write {file}"), e))?;
420
421 let mut result = task.clone();
422 result.status = TaskStatus::InProgress;
423 return Ok(result);
424 }
425
426 if task.status == TaskStatus::Shelved {
428 return Err(CoreError::validation(format!(
429 "Task \"{task_id}\" is shelved (run \"ito tasks unshelve {change_id} {task_id}\" first)"
430 )));
431 }
432
433 if task.status != TaskStatus::Pending {
434 return Err(CoreError::validation(format!(
435 "Task \"{task_id}\" is not pending (current: {})",
436 task.status.as_enhanced_label()
437 )));
438 }
439
440 let (ready, blocked) = compute_ready_and_blocked(&parsed);
441 if !ready.iter().any(|t| t.id == task_id) {
442 if let Some((_, blockers)) = blocked.iter().find(|(t, _)| t.id == task_id) {
443 let mut msg = String::from("Task is blocked:");
444 for b in blockers {
445 msg.push_str("\n- ");
446 msg.push_str(b);
447 }
448 return Err(CoreError::validation(msg));
449 }
450 return Err(CoreError::validation("Task is blocked"));
451 }
452
453 let updated = update_enhanced_task_status(
454 &contents,
455 task_id,
456 TaskStatus::InProgress,
457 chrono::Local::now(),
458 );
459 ito_common::io::write_std(&path, updated.as_bytes())
460 .map_err(|e| CoreError::io(format!("write {file}"), e))?;
461
462 let mut result = task.clone();
463 result.status = TaskStatus::InProgress;
464 Ok(result)
465}
466
467pub fn complete_task(
493 ito_path: &Path,
494 change_id: &str,
495 task_id: &str,
496 _note: Option<String>,
497) -> CoreResult<TaskItem> {
498 let path = checked_tasks_path(ito_path, change_id)?;
499 let file = tracking_file_label(&path);
500 let contents = ito_common::io::read_to_string_std(&path)
501 .map_err(|e| CoreError::io(format!("read {}", path.display()), e))?;
502
503 let parsed = parse_tasks_tracking_file(&contents);
504
505 if parsed
507 .diagnostics
508 .iter()
509 .any(|d| d.level == DiagnosticLevel::Error)
510 {
511 return Err(CoreError::validation(format!("{file} contains errors")));
512 }
513
514 let resolved_task_id = resolve_task_id(&parsed, task_id, file)?;
515
516 let Some(task) = parsed.tasks.iter().find(|t| t.id == resolved_task_id) else {
518 return Err(CoreError::not_found(format!(
519 "Task \"{task_id}\" not found in {file}"
520 )));
521 };
522
523 let updated = if parsed.format == TasksFormat::Checkbox {
524 update_checkbox_task_status(&contents, resolved_task_id, TaskStatus::Complete)
525 .map_err(CoreError::validation)?
526 } else {
527 update_enhanced_task_status(
528 &contents,
529 task_id,
530 TaskStatus::Complete,
531 chrono::Local::now(),
532 )
533 };
534
535 ito_common::io::write_std(&path, updated.as_bytes())
536 .map_err(|e| CoreError::io(format!("write {file}"), e))?;
537
538 let mut result = task.clone();
539 result.status = TaskStatus::Complete;
540 Ok(result)
541}
542
543pub fn shelve_task(
547 ito_path: &Path,
548 change_id: &str,
549 task_id: &str,
550 _reason: Option<String>,
551) -> CoreResult<TaskItem> {
552 let path = checked_tasks_path(ito_path, change_id)?;
553 let file = tracking_file_label(&path);
554 let contents = ito_common::io::read_to_string_std(&path)
555 .map_err(|e| CoreError::io(format!("read {}", path.display()), e))?;
556
557 let parsed = parse_tasks_tracking_file(&contents);
558
559 if parsed.format == TasksFormat::Checkbox {
560 return Err(CoreError::validation(format!(
561 "Checkbox-only {file} does not support shelving"
562 )));
563 }
564
565 if parsed
567 .diagnostics
568 .iter()
569 .any(|d| d.level == DiagnosticLevel::Error)
570 {
571 return Err(CoreError::validation(format!("{file} contains errors")));
572 }
573
574 let Some(task) = parsed.tasks.iter().find(|t| t.id == task_id) else {
576 return Err(CoreError::not_found(format!(
577 "Task \"{task_id}\" not found in {file}"
578 )));
579 };
580
581 if task.status == TaskStatus::Complete {
582 return Err(CoreError::validation(format!(
583 "Task \"{task_id}\" is already complete"
584 )));
585 }
586
587 let updated = update_enhanced_task_status(
588 &contents,
589 task_id,
590 TaskStatus::Shelved,
591 chrono::Local::now(),
592 );
593
594 ito_common::io::write_std(&path, updated.as_bytes())
595 .map_err(|e| CoreError::io(format!("write {file}"), e))?;
596
597 let mut result = task.clone();
598 result.status = TaskStatus::Shelved;
599 Ok(result)
600}
601
602pub fn unshelve_task(ito_path: &Path, change_id: &str, task_id: &str) -> CoreResult<TaskItem> {
606 let path = checked_tasks_path(ito_path, change_id)?;
607 let file = tracking_file_label(&path);
608 let contents = ito_common::io::read_to_string_std(&path)
609 .map_err(|e| CoreError::io(format!("read {}", path.display()), e))?;
610
611 let parsed = parse_tasks_tracking_file(&contents);
612
613 if parsed.format == TasksFormat::Checkbox {
614 return Err(CoreError::validation(format!(
615 "Checkbox-only {file} does not support shelving"
616 )));
617 }
618
619 if parsed
621 .diagnostics
622 .iter()
623 .any(|d| d.level == DiagnosticLevel::Error)
624 {
625 return Err(CoreError::validation(format!("{file} contains errors")));
626 }
627
628 let Some(task) = parsed.tasks.iter().find(|t| t.id == task_id) else {
630 return Err(CoreError::not_found(format!(
631 "Task \"{task_id}\" not found in {file}"
632 )));
633 };
634
635 if task.status != TaskStatus::Shelved {
636 return Err(CoreError::validation(format!(
637 "Task \"{task_id}\" is not shelved"
638 )));
639 }
640
641 let updated = update_enhanced_task_status(
642 &contents,
643 task_id,
644 TaskStatus::Pending,
645 chrono::Local::now(),
646 );
647
648 ito_common::io::write_std(&path, updated.as_bytes())
649 .map_err(|e| CoreError::io(format!("write {file}"), e))?;
650
651 let mut result = task.clone();
652 result.status = TaskStatus::Pending;
653 Ok(result)
654}
655
656pub fn add_task(
660 ito_path: &Path,
661 change_id: &str,
662 title: &str,
663 wave: Option<u32>,
664) -> CoreResult<TaskItem> {
665 let wave = wave.unwrap_or(1);
666 let path = checked_tasks_path(ito_path, change_id)?;
667 let file = tracking_file_label(&path);
668 let contents = ito_common::io::read_to_string_std(&path)
669 .map_err(|e| CoreError::io(format!("read {}", path.display()), e))?;
670
671 let parsed = parse_tasks_tracking_file(&contents);
672
673 if parsed.format != TasksFormat::Enhanced {
674 return Err(CoreError::validation(
675 "Cannot add tasks to checkbox-only tracking file. Convert to enhanced format first.",
676 ));
677 }
678
679 if parsed
681 .diagnostics
682 .iter()
683 .any(|d| d.level == DiagnosticLevel::Error)
684 {
685 return Err(CoreError::validation(format!("{file} contains errors")));
686 }
687
688 let mut max_n = 0u32;
690 for t in &parsed.tasks {
691 if let Some((w, n)) = t.id.split_once('.')
692 && let (Ok(w), Ok(n)) = (w.parse::<u32>(), n.parse::<u32>())
693 && w == wave
694 {
695 max_n = max_n.max(n);
696 }
697 }
698 let new_id = format!("{wave}.{}", max_n + 1);
699
700 let date = chrono::Local::now().format("%Y-%m-%d").to_string();
701 let block = format!(
702 "\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"
703 );
704
705 let mut out = contents.clone();
706 if out.contains(&format!("## Wave {wave}")) {
707 if let Some(pos) = out.find("## Checkpoints") {
709 out.insert_str(pos, &block);
710 } else {
711 out.push_str(&block);
712 }
713 } else {
714 if let Some(pos) = out.find("## Checkpoints") {
716 out.insert_str(
717 pos,
718 &format!("\n---\n\n## Wave {wave}\n- **Depends On**: None\n"),
719 );
720 let pos2 = out.find("## Checkpoints").unwrap_or(out.len());
721 out.insert_str(pos2, &block);
722 } else {
723 out.push_str(&format!(
724 "\n---\n\n## Wave {wave}\n- **Depends On**: None\n"
725 ));
726 out.push_str(&block);
727 }
728 }
729
730 ito_common::io::write_std(&path, out.as_bytes())
731 .map_err(|e| CoreError::io(format!("write {file}"), e))?;
732
733 Ok(TaskItem {
734 id: new_id,
735 name: title.to_string(),
736 wave: Some(wave),
737 status: TaskStatus::Pending,
738 updated_at: Some(date),
739 dependencies: Vec::new(),
740 files: vec!["path/to/file.rs".to_string()],
741 action: "[Describe what needs to be done]".to_string(),
742 verify: Some("cargo test --workspace".to_string()),
743 done_when: Some("[Success criteria]".to_string()),
744 kind: TaskKind::Normal,
745 header_line_index: 0,
746 })
747}
748
749pub fn show_task(ito_path: &Path, change_id: &str, task_id: &str) -> CoreResult<TaskItem> {
753 let path = checked_tasks_path(ito_path, change_id)?;
754 let file = tracking_file_label(&path);
755 let contents = ito_common::io::read_to_string_std(&path)
756 .map_err(|e| CoreError::io(format!("read {}", path.display()), e))?;
757
758 let parsed = parse_tasks_tracking_file(&contents);
759
760 if parsed
762 .diagnostics
763 .iter()
764 .any(|d| d.level == DiagnosticLevel::Error)
765 {
766 return Err(CoreError::validation(format!("{file} contains errors")));
767 }
768
769 parsed
770 .tasks
771 .iter()
772 .find(|t| t.id == task_id)
773 .cloned()
774 .ok_or_else(|| CoreError::not_found(format!("Task \"{task_id}\" not found")))
775}
776
777pub fn read_tasks_markdown(ito_path: &Path, change_id: &str) -> CoreResult<String> {
779 let path = checked_tasks_path(ito_path, change_id)?;
780 let file = tracking_file_label(&path);
781 ito_common::io::read_to_string(&path).map_err(|e| {
782 CoreError::io(
783 format!("reading {file} for \"{change_id}\""),
784 std::io::Error::other(e),
785 )
786 })
787}
788
789#[cfg(test)]
790mod tests {
791 use std::path::Path;
792
793 use crate::change_repository::FsChangeRepository;
794
795 use super::list_ready_tasks_across_changes;
796
797 fn write(path: impl AsRef<Path>, contents: &str) {
798 let path = path.as_ref();
799 if let Some(parent) = path.parent() {
800 std::fs::create_dir_all(parent).expect("parent dirs should exist");
801 }
802 std::fs::write(path, contents).expect("test fixture should write");
803 }
804
805 fn make_ready_change(root: &Path, id: &str) {
806 write(
807 root.join(".ito/changes").join(id).join("proposal.md"),
808 "## Why\nfixture\n\n## What Changes\n- fixture\n\n## Impact\n- fixture\n",
809 );
810 write(
811 root.join(".ito/changes")
812 .join(id)
813 .join("specs")
814 .join("alpha")
815 .join("spec.md"),
816 "## ADDED Requirements\n\n### Requirement: Fixture\nFixture requirement.\n\n#### Scenario: Works\n- **WHEN** fixture runs\n- **THEN** it is ready\n",
817 );
818 write(
819 root.join(".ito/changes").join(id).join("tasks.md"),
820 "## 1. Implementation\n- [ ] 1.1 pending\n",
821 );
822 }
823
824 fn make_complete_change(root: &Path, id: &str) {
825 write(
826 root.join(".ito/changes").join(id).join("proposal.md"),
827 "## Why\nfixture\n\n## What Changes\n- fixture\n\n## Impact\n- fixture\n",
828 );
829 write(
830 root.join(".ito/changes")
831 .join(id)
832 .join("specs")
833 .join("alpha")
834 .join("spec.md"),
835 "## ADDED Requirements\n\n### Requirement: Fixture\nFixture requirement.\n\n#### Scenario: Works\n- **WHEN** fixture runs\n- **THEN** it is ready\n",
836 );
837 write(
838 root.join(".ito/changes").join(id).join("tasks.md"),
839 "## 1. Implementation\n- [x] 1.1 done\n",
840 );
841 }
842
843 #[test]
844 fn returns_ready_tasks_for_ready_changes() {
845 let repo = tempfile::tempdir().expect("repo tempdir");
846 let ito_path = repo.path().join(".ito");
847 make_ready_change(repo.path(), "000-01_alpha");
848 make_complete_change(repo.path(), "000-02_beta");
849
850 let change_repo = FsChangeRepository::new(&ito_path);
851 let ready =
852 list_ready_tasks_across_changes(&change_repo, &ito_path).expect("ready task listing");
853
854 assert_eq!(ready.len(), 1);
855 assert_eq!(ready[0].change_id, "000-01_alpha");
856 assert_eq!(ready[0].ready_tasks.len(), 1);
857 assert_eq!(ready[0].ready_tasks[0].id, "1.1");
858 }
859
860 #[test]
861 fn returns_empty_when_no_ready_tasks_exist() {
862 let repo = tempfile::tempdir().expect("repo tempdir");
863 let ito_path = repo.path().join(".ito");
864 make_complete_change(repo.path(), "000-01_alpha");
865
866 let change_repo = FsChangeRepository::new(&ito_path);
867 let ready =
868 list_ready_tasks_across_changes(&change_repo, &ito_path).expect("ready task listing");
869
870 assert!(ready.is_empty());
871 }
872
873 #[test]
874 fn read_tasks_markdown_returns_contents_for_existing_file() {
875 let repo = tempfile::tempdir().expect("repo tempdir");
876 let ito_path = repo.path().join(".ito");
877 let change_id = "000-01_alpha";
878 let tasks_content = "## 1. Implementation\n- [ ] 1.1 pending\n";
879 write(
880 ito_path.join("changes").join(change_id).join("tasks.md"),
881 tasks_content,
882 );
883
884 let result =
885 super::read_tasks_markdown(&ito_path, change_id).expect("should read tasks.md");
886 assert_eq!(result, tasks_content);
887 }
888
889 #[test]
890 fn read_tasks_markdown_returns_error_for_missing_file() {
891 let repo = tempfile::tempdir().expect("repo tempdir");
892 let ito_path = repo.path().join(".ito");
893
894 let result = super::read_tasks_markdown(&ito_path, "nonexistent-change");
895 assert!(result.is_err(), "should fail for missing tasks.md");
896 let err = result.unwrap_err();
897 let msg = err.to_string();
898 assert!(
899 msg.contains("tasks.md"),
900 "error should mention tasks.md, got: {msg}"
901 );
902 }
903
904 #[test]
905 fn read_tasks_markdown_rejects_traversal_like_change_id() {
906 let repo = tempfile::tempdir().expect("repo tempdir");
907 let ito_path = repo.path().join(".ito");
908
909 let result = super::read_tasks_markdown(&ito_path, "../escape");
910 assert!(result.is_err(), "traversal-like ids should fail");
911 }
912}