1use std::collections::{BTreeSet, HashMap};
30
31use crate::snapshot::{
32 Dependency, DependencyKind, DependencyMode, Diagnostic, ParsedTasks, Schedule, ScheduleTotals,
33 ScheduledTask, Severity, Task, TaskState,
34};
35
36#[derive(Debug, Clone, Copy, PartialEq, Eq)]
38pub enum PhaseKind {
39 Setup,
41 Foundational,
43 Story,
45 Polish,
47 Convergence,
49 Other,
51}
52
53#[must_use]
55pub fn classify(title: &str, has_story_labels: bool) -> PhaseKind {
56 let t = title.to_lowercase();
57 if t.contains("convergence") {
58 PhaseKind::Convergence
59 } else if t.contains("polish") {
60 PhaseKind::Polish
61 } else if has_story_labels || names_a_user_story(&t) {
62 PhaseKind::Story
63 } else if t.contains("foundational") {
64 PhaseKind::Foundational
65 } else if t.contains("setup") {
66 PhaseKind::Setup
67 } else {
68 PhaseKind::Other
69 }
70}
71
72fn names_a_user_story(lower: &str) -> bool {
73 lower.match_indices("user story").any(|(i, m)| {
74 lower[i + m.len()..]
75 .trim_start()
76 .starts_with(|c: char| c.is_ascii_digit())
77 })
78}
79
80struct Group {
82 kind: PhaseKind,
83 blocks: Vec<Vec<usize>>,
85}
86
87type OpenGroup<'t> = (Option<&'t str>, Vec<Vec<usize>>, Option<&'t str>);
89
90fn groups(tasks: &[Task]) -> Vec<Group> {
91 let mut out: Vec<OpenGroup<'_>> = Vec::new();
92 for (i, t) in tasks.iter().enumerate() {
93 let phase = t.phase.as_deref();
94 let sub = t.subsection.as_deref();
95 match out.last_mut() {
96 Some((p, blocks, last_sub)) if *p == phase => {
97 if *last_sub == sub {
98 if let Some(b) = blocks.last_mut() {
99 b.push(i);
100 }
101 } else {
102 blocks.push(vec![i]);
103 *last_sub = sub;
104 }
105 }
106 _ => out.push((phase, vec![vec![i]], sub)),
107 }
108 }
109 out.into_iter()
110 .map(|(phase, blocks, _)| {
111 let labelled = blocks.iter().flatten().any(|&i| tasks[i].story.is_some());
112 Group {
113 kind: classify(phase.unwrap_or(""), labelled),
114 blocks,
115 }
116 })
117 .collect()
118}
119
120fn peers(a: &Task, b: &Task) -> bool {
123 let parent = |t: &Task| t.id.rsplit_once('.').map(|(p, _)| p.to_owned());
124 parent(a) == parent(b)
125}
126
127#[must_use]
130pub fn side_by_side(parsed: &ParsedTasks) -> Vec<Vec<String>> {
131 if parsed.dependency_mode != DependencyMode::Chain {
132 return Vec::new();
133 }
134 let mut out: Vec<Vec<&Task>> = Vec::new();
135 let mut open = false;
136 for t in &parsed.tasks {
137 match out.last_mut() {
138 Some(run) if open && t.parallel && run.last().is_some_and(|r| peers(r, t)) => {
139 run.push(t);
140 }
141 _ if t.parallel => {
142 out.push(vec![t]);
143 open = true;
144 }
145 _ => open = false,
146 }
147 }
148 out.into_iter()
149 .filter(|r| r.len() > 1)
150 .map(|r| r.into_iter().map(|t| t.id.clone()).collect())
151 .collect()
152}
153
154#[must_use]
156pub fn schedule(parsed: &ParsedTasks) -> (Schedule, Vec<Diagnostic>) {
157 let tasks = &parsed.tasks;
158 let mut diags = Vec::new();
159 let index: HashMap<&str, usize> = tasks
160 .iter()
161 .enumerate()
162 .rev() .map(|(i, t)| (t.id.as_str(), i))
164 .collect();
165
166 let mut structural: Vec<Vec<usize>> = vec![Vec::new(); tasks.len()];
168 let declared = parsed.dependency_mode == DependencyMode::Declared;
169 let chain = parsed.dependency_mode == DependencyMode::Chain;
170 let groups = groups(tasks);
171 let mut run: Vec<usize> = Vec::new();
173 for (g, group) in groups.iter().enumerate() {
174 if chain {
175 let earlier: Vec<usize> = groups[..g]
176 .iter()
177 .flat_map(|e| e.blocks.iter().flatten().copied())
178 .collect();
179 let mut previous: Option<usize> = None;
180 for &i in group.blocks.iter().flatten() {
181 let mut deps = earlier.clone();
182 deps.extend(previous);
183 if tasks[i].parallel && run.last().is_some_and(|&r| peers(&tasks[r], &tasks[i])) {
184 deps.clone_from(&structural[run[0]]);
186 run.push(i);
187 } else {
188 deps.append(&mut run);
190 if tasks[i].parallel {
191 run.push(i);
192 }
193 }
194 structural[i] = deps;
195 previous = Some(i);
196 }
197 continue;
198 }
199 let phase_barrier: Vec<usize> = groups[..g]
200 .iter()
201 .filter(|earlier| group.kind != PhaseKind::Story || earlier.kind != PhaseKind::Story)
202 .flat_map(|earlier| earlier.blocks.iter().flatten().copied())
203 .collect();
204 for (b, block) in group.blocks.iter().enumerate() {
205 let sub_barrier: Vec<usize> = group.blocks[..b].iter().flatten().copied().collect();
206 let mut last_serial: Option<usize> = None;
207 let mut run: Vec<usize> = Vec::new();
208 for &i in block {
209 let mut deps = phase_barrier.clone();
210 if declared {
211 structural[i] = deps;
212 continue;
213 }
214 deps.extend(&sub_barrier);
215 if tasks[i].parallel {
216 deps.extend(last_serial);
217 run.push(i);
218 } else {
219 if run.is_empty() {
220 deps.extend(last_serial);
221 } else {
222 deps.append(&mut run);
223 }
224 last_serial = Some(i);
225 }
226 structural[i] = deps;
227 }
228 }
229 }
230
231 let mut direct: Vec<Vec<Dependency>> = Vec::with_capacity(tasks.len());
233 let mut edges: Vec<Vec<usize>> = Vec::with_capacity(tasks.len());
234 for (i, t) in tasks.iter().enumerate() {
235 let mut seen = BTreeSet::new();
236 let mut deps = Vec::new();
237 let mut resolved = Vec::new();
238 for &d in &structural[i] {
239 if seen.insert(tasks[d].id.as_str()) {
240 deps.push(Dependency {
241 id: tasks[d].id.clone(),
242 kind: DependencyKind::Structural,
243 missing: false,
244 });
245 resolved.push(d);
246 }
247 }
248 for id in &t.depends_on {
249 if !seen.insert(id.as_str()) {
250 continue;
251 }
252 match index.get(id.as_str()) {
253 Some(&d) => resolved.push(d),
254 None => diags.push(
255 Diagnostic::new(
256 Severity::Error,
257 "graph.dangling",
258 format!("{} depends on {id}, which does not exist", t.id),
259 )
260 .at(&parsed.path)
261 .line(t.line)
262 .hint("fix the task ID or remove the reference"),
263 ),
264 }
265 deps.push(Dependency {
266 id: id.clone(),
267 kind: DependencyKind::Explicit,
268 missing: !index.contains_key(id.as_str()),
269 });
270 }
271 direct.push(deps);
272 edges.push(resolved);
273 }
274
275 let component = strongly_connected(&edges);
277 let mut members: HashMap<usize, Vec<usize>> = HashMap::new();
278 for (i, &c) in component.iter().enumerate() {
279 members.entry(c).or_default().push(i);
280 }
281 let in_cycle = |i: usize| members[&component[i]].len() > 1 || edges[i].contains(&i);
282 let mut reported = BTreeSet::new();
283 for i in 0..tasks.len() {
284 if in_cycle(i) && reported.insert(component[i]) {
285 let ids: Vec<&str> = members[&component[i]]
286 .iter()
287 .map(|&m| tasks[m].id.as_str())
288 .collect();
289 diags.push(
290 Diagnostic::new(
291 Severity::Error,
292 "graph.cycle",
293 format!("dependency cycle: {}", ids.join(" → ")),
294 )
295 .at(&parsed.path)
296 .line(tasks[i].line)
297 .hint("remove one of the `depends on` references"),
298 );
299 }
300 }
301
302 let mut out = Vec::with_capacity(tasks.len());
304 let mut totals = ScheduleTotals::default();
305 for (i, t) in tasks.iter().enumerate() {
306 let mut blockers: Vec<(usize, String)> = Vec::new();
307 for (dep, &d) in direct[i]
308 .iter()
309 .zip(resolved_or_missing(&direct[i], &index).iter())
310 {
311 match d {
312 None => blockers.push((usize::MAX, dep.id.clone())),
313 Some(d) => {
314 let cyclic = in_cycle(i) && component[d] == component[i];
315 if !tasks[d].done || cyclic {
316 blockers.push((d, dep.id.clone()));
317 }
318 }
319 }
320 }
321 blockers.sort();
322 blockers.dedup();
323 let state = if t.done {
324 totals.done += 1;
325 TaskState::Done
326 } else if blockers.is_empty() && !in_cycle(i) {
327 totals.ready += 1;
328 TaskState::Ready
329 } else {
330 totals.blocked += 1;
331 TaskState::Blocked
332 };
333 out.push(ScheduledTask {
334 id: t.id.clone(),
335 state,
336 depends_on: direct[i].clone(),
337 blocked_by: if t.done {
338 Vec::new()
339 } else {
340 blockers.into_iter().map(|(_, id)| id).collect()
341 },
342 });
343 }
344 (Schedule { tasks: out, totals }, diags)
345}
346
347fn resolved_or_missing(deps: &[Dependency], index: &HashMap<&str, usize>) -> Vec<Option<usize>> {
348 deps.iter()
349 .map(|d| index.get(d.id.as_str()).copied())
350 .collect()
351}
352
353fn strongly_connected(edges: &[Vec<usize>]) -> Vec<usize> {
355 const UNSEEN: usize = usize::MAX;
356 let n = edges.len();
357 let mut order = vec![UNSEEN; n];
358 let mut low = vec![0; n];
359 let mut on_stack = vec![false; n];
360 let mut component = vec![UNSEEN; n];
361 let mut stack = Vec::new();
362 let mut next_order = 0;
363 let mut next_component = 0;
364
365 for root in 0..n {
366 if order[root] != UNSEEN {
367 continue;
368 }
369 let mut work = vec![(root, 0usize)];
371 while let Some(&mut (v, ref mut e)) = work.last_mut() {
372 if *e == 0 && order[v] == UNSEEN {
373 order[v] = next_order;
374 low[v] = next_order;
375 next_order += 1;
376 stack.push(v);
377 on_stack[v] = true;
378 }
379 if let Some(&w) = edges[v].get(*e) {
380 *e += 1;
381 if order[w] == UNSEEN {
382 work.push((w, 0));
383 } else if on_stack[w] {
384 low[v] = low[v].min(order[w]);
385 }
386 continue;
387 }
388 work.pop();
389 if let Some(&(parent, _)) = work.last() {
390 low[parent] = low[parent].min(low[v]);
391 }
392 if low[v] == order[v] {
393 while let Some(w) = stack.pop() {
394 on_stack[w] = false;
395 component[w] = next_component;
396 if w == v {
397 break;
398 }
399 }
400 next_component += 1;
401 }
402 }
403 }
404 component
405}
406
407#[must_use]
410pub fn dependents(schedule: &Schedule, id: &str) -> Vec<String> {
411 let mut waiting: BTreeSet<&str> = BTreeSet::from([id]);
412 loop {
413 let before = waiting.len();
414 for t in &schedule.tasks {
415 if t.state != TaskState::Done
416 && t.depends_on.iter().any(|d| waiting.contains(d.id.as_str()))
417 {
418 waiting.insert(t.id.as_str());
419 }
420 }
421 if waiting.len() == before {
422 break;
423 }
424 }
425 schedule
426 .tasks
427 .iter()
428 .filter(|t| t.id != id && waiting.contains(t.id.as_str()))
429 .map(|t| t.id.clone())
430 .collect()
431}
432
433#[cfg(test)]
434mod tests {
435 use super::*;
436 use crate::snapshot::Totals;
437
438 type Spec<'a> = (
440 &'a str,
441 &'a str,
442 Option<&'a str>,
443 bool,
444 Option<&'a str>,
445 bool,
446 &'a [&'a str],
447 );
448
449 fn parsed(spec: &[Spec<'_>]) -> ParsedTasks {
450 parsed_in(DependencyMode::Structural, spec)
451 }
452
453 fn parsed_in(dependency_mode: DependencyMode, spec: &[Spec<'_>]) -> ParsedTasks {
454 let tasks: Vec<Task> = spec
455 .iter()
456 .enumerate()
457 .map(|(n, &(id, phase, sub, parallel, story, done, deps))| Task {
458 id: id.to_owned(),
459 done,
460 parallel,
461 story: story.map(str::to_owned),
462 yours: false,
463 title: String::new(),
464 depends_on: deps.iter().map(|d| (*d).to_owned()).collect(),
465 scenarios: Vec::new(),
466 phase: Some(phase.to_owned()),
467 subsection: sub.map(str::to_owned),
468 line: u32::try_from(n + 1).unwrap(),
469 byte_range: [0, 0],
470 line_hash: String::new(),
471 })
472 .collect();
473 ParsedTasks {
474 path: "tasks.md".to_owned(),
475 phases: Vec::new(),
476 totals: Totals::default(),
477 tasks,
478 dependency_mode,
479 }
480 }
481
482 fn states(s: &Schedule) -> Vec<(&str, TaskState, Vec<&str>)> {
483 s.tasks
484 .iter()
485 .map(|t| {
486 (
487 t.id.as_str(),
488 t.state,
489 t.blocked_by.iter().map(String::as_str).collect(),
490 )
491 })
492 .collect()
493 }
494
495 use TaskState::{Blocked, Done, Ready};
496
497 const SETUP: &str = "Phase 1: Setup";
498 const FOUND: &str = "Phase 2: Foundational";
499 const US1: &str = "Phase 3: User Story 1 - A";
500 const US2: &str = "Phase 4: User Story 2 - B";
501 const POLISH: &str = "Phase 5: Polish & Cross-Cutting Concerns";
502
503 fn chain(spec: &[(&'static str, bool, bool, &'static [&'static str])]) -> ParsedTasks {
506 let rows: Vec<Spec<'static>> = spec
507 .iter()
508 .map(|&(id, p, done, deps)| {
509 let major: &'static str = match id.split('.').next() {
510 Some("1") => "1.",
511 Some("2") => "2.",
512 Some("3") => "3.",
513 Some("4") => "4.",
514 _ => "5.",
515 };
516 (id, major, None, p, None, done, deps)
517 })
518 .collect();
519 parsed_in(DependencyMode::Chain, &rows)
520 }
521
522 fn ready(s: &Schedule) -> Vec<&str> {
523 s.tasks
524 .iter()
525 .filter(|t| t.state == Ready)
526 .map(|t| t.id.as_str())
527 .collect()
528 }
529
530 #[test]
531 fn a_chain_without_parallel_tasks_is_one_task_after_another() {
532 let p = chain(&[
533 ("1", false, true, &[]),
534 ("2.1", false, false, &[]),
535 ("2.2", false, false, &[]),
536 ("3", false, false, &[]),
537 ]);
538 let (s, _) = schedule(&p);
539 assert_eq!(ready(&s), ["2.1"]);
540 assert_eq!(s.tasks[2].blocked_by, ["2.1"]);
541 assert_eq!(s.tasks[3].blocked_by, ["2.1", "2.2"]);
542 assert!(side_by_side(&p).is_empty());
543 }
544
545 #[test]
546 fn parallel_peers_run_side_by_side_and_the_next_task_waits_for_all() {
547 let list = [
548 ("1", false, true, &[][..]),
549 ("2.1", true, false, &[][..]),
550 ("2.2", true, false, &[][..]),
551 ("2.3", false, false, &[][..]),
552 ("3", true, false, &[][..]),
553 ("4", true, false, &[][..]),
554 ("5", false, false, &[][..]),
555 ];
556 let (s, _) = schedule(&chain(&list));
557 assert_eq!(ready(&s), ["2.1", "2.2"]);
558 let mut by = s.tasks[3].blocked_by.clone();
559 by.sort();
560 assert_eq!(by, ["2.1", "2.2"]);
561 assert_eq!(
562 side_by_side(&chain(&list)),
563 [vec!["2.1", "2.2"], vec!["3", "4"]]
564 );
565 let mut done = list;
567 for d in &mut done[1..4] {
568 d.2 = true;
569 }
570 let (s, _) = schedule(&chain(&done));
571 assert_eq!(ready(&s), ["3", "4"]);
572 let mut by = s.tasks[6].blocked_by.clone();
573 by.sort();
574 assert_eq!(by, ["3", "4"]);
575 }
576
577 #[test]
578 fn a_run_never_crosses_from_one_parent_to_another() {
579 let p = chain(&[
580 ("1", false, true, &[]),
581 ("2.1", true, false, &[]),
582 ("2.2", true, false, &[]),
583 ("3.1", true, false, &[]),
584 ("3.2", true, false, &[]),
585 ]);
586 let (s, _) = schedule(&p);
587 assert_eq!(ready(&s), ["2.1", "2.2"]);
588 assert_eq!(side_by_side(&p), [vec!["2.1", "2.2"], vec!["3.1", "3.2"]]);
589 let mut by = s.tasks[3].blocked_by.clone();
590 by.sort();
591 assert_eq!(by, ["2.1", "2.2"]);
592 }
593
594 #[test]
595 fn a_written_dependency_is_added_to_the_runs() {
596 let p = chain(&[
598 ("1", false, true, &[]),
599 ("2.1", true, false, &[]),
600 ("2.2", true, false, &[]),
601 ("2.3", true, false, &["2.1"]),
602 ]);
603 let (s, _) = schedule(&p);
604 assert_eq!(ready(&s), ["2.1", "2.2"]);
605 assert_eq!(s.tasks[3].blocked_by, ["2.1"]);
606 }
607
608 #[test]
609 fn classifies_spec_kit_headings() {
610 assert_eq!(
611 classify("Phase 1: Setup (Shared Infrastructure)", false),
612 PhaseKind::Setup
613 );
614 assert_eq!(
615 classify("Phase 2: Foundational (Blocking Prerequisites)", false),
616 PhaseKind::Foundational
617 );
618 assert_eq!(
619 classify("Phase 3: User Story 1 - Login (Priority: P1) 🎯 MVP", false),
620 PhaseKind::Story
621 );
622 assert_eq!(
623 classify("Phase 9: Polish & Cross-Cutting Concerns", false),
624 PhaseKind::Polish
625 );
626 assert_eq!(
627 classify("Phase 10: Convergence", false),
628 PhaseKind::Convergence
629 );
630 assert_eq!(classify("Phase 4: Login", true), PhaseKind::Story);
631 assert_eq!(classify("User stories overview", false), PhaseKind::Other);
632 }
633
634 #[test]
635 fn open_setup_blocks_everything_after_it() {
636 let p = parsed(&[
637 ("T001", SETUP, None, false, None, false, &[]),
638 ("T002", FOUND, None, false, None, false, &[]),
639 ("T003", US1, None, false, Some("US1"), false, &[]),
640 ]);
641 let (s, d) = schedule(&p);
642 assert!(d.is_empty());
643 assert_eq!(
644 states(&s),
645 [
646 ("T001", Ready, vec![]),
647 ("T002", Blocked, vec!["T001"]),
648 ("T003", Blocked, vec!["T001", "T002"]),
649 ]
650 );
651 }
652
653 #[test]
654 fn a_skipped_task_names_what_waits_on_it() {
655 let p = parsed(&[
656 ("T001", SETUP, None, false, None, false, &[]),
657 ("T002", FOUND, None, false, None, false, &[]),
658 ("T003", US1, None, false, Some("US1"), false, &[]),
659 ]);
660 let (s, _) = schedule(&p);
661 assert_eq!(dependents(&s, "T001"), ["T002", "T003"]);
662 assert_eq!(dependents(&s, "T002"), ["T003"]);
663 assert!(dependents(&s, "T003").is_empty());
664 }
665
666 #[test]
667 fn stories_are_independent_and_polish_waits_for_all() {
668 let p = parsed(&[
669 ("T001", SETUP, None, false, None, true, &[]),
670 ("T002", FOUND, None, false, None, true, &[]),
671 ("T003", US1, None, false, Some("US1"), false, &[]),
672 ("T004", US2, None, false, Some("US2"), false, &[]),
673 ("T005", POLISH, None, false, None, false, &[]),
674 ]);
675 let (s, _) = schedule(&p);
676 assert_eq!(
677 states(&s),
678 [
679 ("T001", Done, vec![]),
680 ("T002", Done, vec![]),
681 ("T003", Ready, vec![]),
682 ("T004", Ready, vec![]),
683 ("T005", Blocked, vec!["T003", "T004"]),
684 ]
685 );
686 assert_eq!(
687 s.totals,
688 ScheduleTotals {
689 done: 2,
690 ready: 2,
691 blocked: 1
692 }
693 );
694 }
695
696 #[test]
697 fn parallel_runs_fork_and_join() {
698 let p = parsed(&[
699 ("T001", SETUP, None, false, None, true, &[]),
700 ("T002", SETUP, None, true, None, false, &[]),
701 ("T003", SETUP, None, true, None, false, &[]),
702 ("T004", SETUP, None, false, None, false, &[]),
703 ]);
704 let (s, _) = schedule(&p);
705 assert_eq!(
706 states(&s),
707 [
708 ("T001", Done, vec![]),
709 ("T002", Ready, vec![]),
710 ("T003", Ready, vec![]),
711 ("T004", Blocked, vec!["T002", "T003"]),
712 ]
713 );
714 }
715
716 #[test]
717 fn tests_subsection_comes_before_implementation() {
718 let p = parsed(&[
719 (
720 "T001",
721 US1,
722 Some("Tests for User Story 1"),
723 true,
724 Some("US1"),
725 false,
726 &[],
727 ),
728 (
729 "T002",
730 US1,
731 Some("Implementation for User Story 1"),
732 true,
733 Some("US1"),
734 false,
735 &[],
736 ),
737 ]);
738 let (s, _) = schedule(&p);
739 assert_eq!(states(&s)[1], ("T002", Blocked, vec!["T001"]));
740 }
741
742 #[test]
743 fn explicit_dependency_crosses_stories() {
744 let p = parsed(&[
745 ("T001", US1, None, false, Some("US1"), false, &[]),
746 ("T002", US2, None, false, Some("US2"), false, &["T001"]),
747 ]);
748 let (s, _) = schedule(&p);
749 assert_eq!(states(&s)[1], ("T002", Blocked, vec!["T001"]));
750 assert_eq!(s.tasks[1].depends_on[0].kind, DependencyKind::Explicit);
751 }
752
753 #[test]
754 fn declared_mode_keeps_phase_order_but_drops_structure_within_a_phase() {
755 let p = parsed_in(
756 DependencyMode::Declared,
757 &[
758 ("T001", SETUP, None, false, None, true, &[]),
759 ("T002", SETUP, None, false, None, false, &["T001"]),
760 ("T003", SETUP, None, true, None, false, &["T001"]),
761 (
762 "T004",
763 US1,
764 Some("Tests"),
765 true,
766 Some("US1"),
767 false,
768 &["T002"],
769 ),
770 (
771 "T005",
772 US1,
773 Some("Implementation"),
774 true,
775 Some("US1"),
776 false,
777 &[],
778 ),
779 ],
780 );
781 let (s, d) = schedule(&p);
782 assert!(d.is_empty());
783 assert_eq!(
784 states(&s),
785 [
786 ("T001", Done, vec![]),
787 ("T002", Ready, vec![]),
788 ("T003", Ready, vec![]),
789 ("T004", Blocked, vec!["T002", "T003"]),
790 ("T005", Blocked, vec!["T002", "T003"]),
791 ]
792 );
793 }
794
795 #[test]
796 fn dangling_reference_is_an_error_and_blocks() {
797 let p = parsed(&[("T001", SETUP, None, false, None, false, &["T099"])]);
798 let (s, d) = schedule(&p);
799 assert_eq!(d[0].code, "graph.dangling");
800 assert_eq!(d[0].line, Some(1));
801 assert_eq!(states(&s)[0], ("T001", Blocked, vec!["T099"]));
802 assert!(s.tasks[0].depends_on[0].missing);
803 }
804
805 #[test]
806 fn cycles_and_self_references_are_errors_and_never_ready() {
807 let p = parsed(&[
809 ("T001", SETUP, None, false, None, false, &["T002"]),
810 ("T002", SETUP, None, false, None, true, &[]),
811 ("T003", POLISH, None, false, None, false, &["T003"]),
812 ]);
813 let (s, d) = schedule(&p);
814 let codes: Vec<_> = d
815 .iter()
816 .map(|d| (d.code.as_str(), d.message.as_str()))
817 .collect();
818 assert_eq!(
819 codes,
820 [
821 ("graph.cycle", "dependency cycle: T001 → T002"),
822 ("graph.cycle", "dependency cycle: T003"),
823 ]
824 );
825 assert_eq!(s.tasks[0].state, Blocked);
826 assert_eq!(s.tasks[2].state, Blocked);
827 }
828
829 mod properties {
830 use super::*;
831 use proptest::prelude::*;
832
833 type OwnedSpec = (
835 String,
836 &'static str,
837 Option<String>,
838 bool,
839 Option<&'static str>,
840 bool,
841 Vec<String>,
842 );
843
844 const PHASES: [&str; 6] = [SETUP, FOUND, US1, US2, "Phase 6: Story notes", POLISH];
845
846 fn task_lists() -> impl Strategy<Value = ParsedTasks> {
849 prop::collection::vec(
850 (
851 0..PHASES.len(),
852 0..3usize,
853 any::<bool>(),
854 any::<bool>(),
855 prop::option::of(0..64usize),
856 ),
857 1..40,
858 )
859 .prop_map(|mut rows| {
860 rows.sort_by_key(|r| (r.0, r.1));
861 let ids: Vec<String> = (1..=rows.len()).map(|n| format!("T{n:03}")).collect();
862 let spec: Vec<OwnedSpec> = rows
863 .iter()
864 .enumerate()
865 .map(|(i, &(ph, sub, par, done, back))| {
866 let story = match ph {
867 2 => Some("US1"),
868 3 => Some("US2"),
869 _ => None,
870 };
871 let deps = back
872 .filter(|_| i > 0)
873 .map(|b| vec![ids[b % i].clone()])
874 .unwrap_or_default();
875 (
876 ids[i].clone(),
877 PHASES[ph],
878 Some(format!("S{sub}")),
879 par,
880 story,
881 done,
882 deps,
883 )
884 })
885 .collect();
886 let owned: Vec<Vec<&str>> = spec
887 .iter()
888 .map(|r| r.6.iter().map(String::as_str).collect())
889 .collect();
890 let rows: Vec<Spec<'_>> = spec
891 .iter()
892 .zip(&owned)
893 .map(|(r, d)| {
894 (
895 r.0.as_str(),
896 r.1,
897 r.2.as_deref(),
898 r.3,
899 r.4,
900 r.5,
901 d.as_slice(),
902 )
903 })
904 .collect();
905 parsed(&rows)
906 })
907 }
908
909 proptest! {
910 #[test]
911 fn ready_means_every_dependency_is_done(p in task_lists()) {
912 let (s, _) = schedule(&p);
913 let done: HashMap<&str, bool> = p.tasks.iter().map(|t| (t.id.as_str(), t.done)).collect();
914 for t in &s.tasks {
915 if t.state == Ready {
916 prop_assert!(t.depends_on.iter().all(|d| done[d.id.as_str()]));
917 }
918 }
919 }
920
921 #[test]
922 fn open_tasks_with_done_dependencies_are_ready(p in task_lists()) {
923 let (s, _) = schedule(&p);
924 let done: HashMap<&str, bool> = p.tasks.iter().map(|t| (t.id.as_str(), t.done)).collect();
925 for (t, task) in s.tasks.iter().zip(&p.tasks) {
926 if !task.done && t.depends_on.iter().all(|d| done[d.id.as_str()]) {
927 prop_assert_eq!(t.state, Ready);
928 }
929 }
930 }
931
932 #[test]
933 fn stories_never_depend_structurally_on_each_other(p in task_lists()) {
934 let (s, _) = schedule(&p);
935 let story: HashMap<&str, Option<&str>> =
936 p.tasks.iter().map(|t| (t.id.as_str(), t.story.as_deref())).collect();
937 for t in &s.tasks {
938 for d in t.depends_on.iter().filter(|d| d.kind == DependencyKind::Structural) {
939 if let (Some(a), Some(b)) = (story[t.id.as_str()], story[d.id.as_str()]) {
940 prop_assert_eq!(a, b);
941 }
942 }
943 }
944 }
945
946 #[test]
947 fn backward_references_never_create_cycles(p in task_lists()) {
948 let (s, d) = schedule(&p);
949 prop_assert!(d.is_empty(), "{:?}", d);
950 prop_assert_eq!(s.totals.done + s.totals.ready + s.totals.blocked, p.tasks.len());
951 }
952 }
953 }
954}