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