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