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