1use std::collections::{BTreeSet, HashMap};
27
28use crate::snapshot::{
29 Dependency, DependencyKind, DependencyMode, Diagnostic, ParsedTasks, Schedule, ScheduleTotals,
30 ScheduledTask, Severity, Task, TaskState,
31};
32
33#[derive(Debug, Clone, Copy, PartialEq, Eq)]
35pub enum PhaseKind {
36 Setup,
38 Foundational,
40 Story,
42 Polish,
44 Convergence,
46 Other,
48}
49
50#[must_use]
52pub fn classify(title: &str, has_story_labels: bool) -> PhaseKind {
53 let t = title.to_lowercase();
54 if t.contains("convergence") {
55 PhaseKind::Convergence
56 } else if t.contains("polish") {
57 PhaseKind::Polish
58 } else if has_story_labels || names_a_user_story(&t) {
59 PhaseKind::Story
60 } else if t.contains("foundational") {
61 PhaseKind::Foundational
62 } else if t.contains("setup") {
63 PhaseKind::Setup
64 } else {
65 PhaseKind::Other
66 }
67}
68
69fn names_a_user_story(lower: &str) -> bool {
70 lower.match_indices("user story").any(|(i, m)| {
71 lower[i + m.len()..]
72 .trim_start()
73 .starts_with(|c: char| c.is_ascii_digit())
74 })
75}
76
77struct Group {
79 kind: PhaseKind,
80 blocks: Vec<Vec<usize>>,
82}
83
84type OpenGroup<'t> = (Option<&'t str>, Vec<Vec<usize>>, Option<&'t str>);
86
87fn groups(tasks: &[Task]) -> Vec<Group> {
88 let mut out: Vec<OpenGroup<'_>> = Vec::new();
89 for (i, t) in tasks.iter().enumerate() {
90 let phase = t.phase.as_deref();
91 let sub = t.subsection.as_deref();
92 match out.last_mut() {
93 Some((p, blocks, last_sub)) if *p == phase => {
94 if *last_sub == sub {
95 if let Some(b) = blocks.last_mut() {
96 b.push(i);
97 }
98 } else {
99 blocks.push(vec![i]);
100 *last_sub = sub;
101 }
102 }
103 _ => out.push((phase, vec![vec![i]], sub)),
104 }
105 }
106 out.into_iter()
107 .map(|(phase, blocks, _)| {
108 let labelled = blocks.iter().flatten().any(|&i| tasks[i].story.is_some());
109 Group {
110 kind: classify(phase.unwrap_or(""), labelled),
111 blocks,
112 }
113 })
114 .collect()
115}
116
117#[must_use]
119pub fn schedule(parsed: &ParsedTasks) -> (Schedule, Vec<Diagnostic>) {
120 let tasks = &parsed.tasks;
121 let mut diags = Vec::new();
122 let index: HashMap<&str, usize> = tasks
123 .iter()
124 .enumerate()
125 .rev() .map(|(i, t)| (t.id.as_str(), i))
127 .collect();
128
129 let mut structural: Vec<Vec<usize>> = vec![Vec::new(); tasks.len()];
131 let declared = parsed.dependency_mode == DependencyMode::Declared;
132 let chain = parsed.dependency_mode == DependencyMode::Chain;
133 let groups = groups(tasks);
134 for (g, group) in groups.iter().enumerate() {
135 if chain {
136 let earlier: Vec<usize> = groups[..g]
137 .iter()
138 .flat_map(|e| e.blocks.iter().flatten().copied())
139 .collect();
140 let mut previous: Option<usize> = None;
141 for &i in group.blocks.iter().flatten() {
142 let mut deps = earlier.clone();
143 deps.extend(previous);
144 structural[i] = deps;
145 previous = Some(i);
146 }
147 continue;
148 }
149 let phase_barrier: Vec<usize> = groups[..g]
150 .iter()
151 .filter(|earlier| group.kind != PhaseKind::Story || earlier.kind != PhaseKind::Story)
152 .flat_map(|earlier| earlier.blocks.iter().flatten().copied())
153 .collect();
154 for (b, block) in group.blocks.iter().enumerate() {
155 let sub_barrier: Vec<usize> = group.blocks[..b].iter().flatten().copied().collect();
156 let mut last_serial: Option<usize> = None;
157 let mut run: Vec<usize> = Vec::new();
158 for &i in block {
159 let mut deps = phase_barrier.clone();
160 if declared {
161 structural[i] = deps;
162 continue;
163 }
164 deps.extend(&sub_barrier);
165 if tasks[i].parallel {
166 deps.extend(last_serial);
167 run.push(i);
168 } else {
169 if run.is_empty() {
170 deps.extend(last_serial);
171 } else {
172 deps.append(&mut run);
173 }
174 last_serial = Some(i);
175 }
176 structural[i] = deps;
177 }
178 }
179 }
180
181 let mut direct: Vec<Vec<Dependency>> = Vec::with_capacity(tasks.len());
183 let mut edges: Vec<Vec<usize>> = Vec::with_capacity(tasks.len());
184 for (i, t) in tasks.iter().enumerate() {
185 let mut seen = BTreeSet::new();
186 let mut deps = Vec::new();
187 let mut resolved = Vec::new();
188 for &d in &structural[i] {
189 if seen.insert(tasks[d].id.as_str()) {
190 deps.push(Dependency {
191 id: tasks[d].id.clone(),
192 kind: DependencyKind::Structural,
193 missing: false,
194 });
195 resolved.push(d);
196 }
197 }
198 for id in &t.depends_on {
199 if !seen.insert(id.as_str()) {
200 continue;
201 }
202 match index.get(id.as_str()) {
203 Some(&d) => resolved.push(d),
204 None => diags.push(
205 Diagnostic::new(
206 Severity::Error,
207 "graph.dangling",
208 format!("{} depends on {id}, which does not exist", t.id),
209 )
210 .at(&parsed.path)
211 .line(t.line)
212 .hint("fix the task ID or remove the reference"),
213 ),
214 }
215 deps.push(Dependency {
216 id: id.clone(),
217 kind: DependencyKind::Explicit,
218 missing: !index.contains_key(id.as_str()),
219 });
220 }
221 direct.push(deps);
222 edges.push(resolved);
223 }
224
225 let component = strongly_connected(&edges);
227 let mut members: HashMap<usize, Vec<usize>> = HashMap::new();
228 for (i, &c) in component.iter().enumerate() {
229 members.entry(c).or_default().push(i);
230 }
231 let in_cycle = |i: usize| members[&component[i]].len() > 1 || edges[i].contains(&i);
232 let mut reported = BTreeSet::new();
233 for i in 0..tasks.len() {
234 if in_cycle(i) && reported.insert(component[i]) {
235 let ids: Vec<&str> = members[&component[i]]
236 .iter()
237 .map(|&m| tasks[m].id.as_str())
238 .collect();
239 diags.push(
240 Diagnostic::new(
241 Severity::Error,
242 "graph.cycle",
243 format!("dependency cycle: {}", ids.join(" → ")),
244 )
245 .at(&parsed.path)
246 .line(tasks[i].line)
247 .hint("remove one of the `depends on` references"),
248 );
249 }
250 }
251
252 let mut out = Vec::with_capacity(tasks.len());
254 let mut totals = ScheduleTotals::default();
255 for (i, t) in tasks.iter().enumerate() {
256 let mut blockers: Vec<(usize, String)> = Vec::new();
257 for (dep, &d) in direct[i]
258 .iter()
259 .zip(resolved_or_missing(&direct[i], &index).iter())
260 {
261 match d {
262 None => blockers.push((usize::MAX, dep.id.clone())),
263 Some(d) => {
264 let cyclic = in_cycle(i) && component[d] == component[i];
265 if !tasks[d].done || cyclic {
266 blockers.push((d, dep.id.clone()));
267 }
268 }
269 }
270 }
271 blockers.sort();
272 blockers.dedup();
273 let state = if t.done {
274 totals.done += 1;
275 TaskState::Done
276 } else if blockers.is_empty() && !in_cycle(i) {
277 totals.ready += 1;
278 TaskState::Ready
279 } else {
280 totals.blocked += 1;
281 TaskState::Blocked
282 };
283 out.push(ScheduledTask {
284 id: t.id.clone(),
285 state,
286 depends_on: direct[i].clone(),
287 blocked_by: if t.done {
288 Vec::new()
289 } else {
290 blockers.into_iter().map(|(_, id)| id).collect()
291 },
292 });
293 }
294 (Schedule { tasks: out, totals }, diags)
295}
296
297fn resolved_or_missing(deps: &[Dependency], index: &HashMap<&str, usize>) -> Vec<Option<usize>> {
298 deps.iter()
299 .map(|d| index.get(d.id.as_str()).copied())
300 .collect()
301}
302
303fn strongly_connected(edges: &[Vec<usize>]) -> Vec<usize> {
305 const UNSEEN: usize = usize::MAX;
306 let n = edges.len();
307 let mut order = vec![UNSEEN; n];
308 let mut low = vec![0; n];
309 let mut on_stack = vec![false; n];
310 let mut component = vec![UNSEEN; n];
311 let mut stack = Vec::new();
312 let mut next_order = 0;
313 let mut next_component = 0;
314
315 for root in 0..n {
316 if order[root] != UNSEEN {
317 continue;
318 }
319 let mut work = vec![(root, 0usize)];
321 while let Some(&mut (v, ref mut e)) = work.last_mut() {
322 if *e == 0 && order[v] == UNSEEN {
323 order[v] = next_order;
324 low[v] = next_order;
325 next_order += 1;
326 stack.push(v);
327 on_stack[v] = true;
328 }
329 if let Some(&w) = edges[v].get(*e) {
330 *e += 1;
331 if order[w] == UNSEEN {
332 work.push((w, 0));
333 } else if on_stack[w] {
334 low[v] = low[v].min(order[w]);
335 }
336 continue;
337 }
338 work.pop();
339 if let Some(&(parent, _)) = work.last() {
340 low[parent] = low[parent].min(low[v]);
341 }
342 if low[v] == order[v] {
343 while let Some(w) = stack.pop() {
344 on_stack[w] = false;
345 component[w] = next_component;
346 if w == v {
347 break;
348 }
349 }
350 next_component += 1;
351 }
352 }
353 }
354 component
355}
356
357#[cfg(test)]
358mod tests {
359 use super::*;
360 use crate::snapshot::Totals;
361
362 type Spec<'a> = (
364 &'a str,
365 &'a str,
366 Option<&'a str>,
367 bool,
368 Option<&'a str>,
369 bool,
370 &'a [&'a str],
371 );
372
373 fn parsed(spec: &[Spec<'_>]) -> ParsedTasks {
374 parsed_in(DependencyMode::Structural, spec)
375 }
376
377 fn parsed_in(dependency_mode: DependencyMode, spec: &[Spec<'_>]) -> ParsedTasks {
378 let tasks: Vec<Task> = spec
379 .iter()
380 .enumerate()
381 .map(|(n, &(id, phase, sub, parallel, story, done, deps))| Task {
382 id: id.to_owned(),
383 done,
384 parallel,
385 story: story.map(str::to_owned),
386 title: String::new(),
387 depends_on: deps.iter().map(|d| (*d).to_owned()).collect(),
388 phase: Some(phase.to_owned()),
389 subsection: sub.map(str::to_owned),
390 line: u32::try_from(n + 1).unwrap(),
391 byte_range: [0, 0],
392 line_hash: String::new(),
393 })
394 .collect();
395 ParsedTasks {
396 path: "tasks.md".to_owned(),
397 phases: Vec::new(),
398 totals: Totals::default(),
399 tasks,
400 dependency_mode,
401 }
402 }
403
404 fn states(s: &Schedule) -> Vec<(&str, TaskState, Vec<&str>)> {
405 s.tasks
406 .iter()
407 .map(|t| {
408 (
409 t.id.as_str(),
410 t.state,
411 t.blocked_by.iter().map(String::as_str).collect(),
412 )
413 })
414 .collect()
415 }
416
417 use TaskState::{Blocked, Done, Ready};
418
419 const SETUP: &str = "Phase 1: Setup";
420 const FOUND: &str = "Phase 2: Foundational";
421 const US1: &str = "Phase 3: User Story 1 - A";
422 const US2: &str = "Phase 4: User Story 2 - B";
423 const POLISH: &str = "Phase 5: Polish & Cross-Cutting Concerns";
424
425 #[test]
426 fn classifies_spec_kit_headings() {
427 assert_eq!(
428 classify("Phase 1: Setup (Shared Infrastructure)", false),
429 PhaseKind::Setup
430 );
431 assert_eq!(
432 classify("Phase 2: Foundational (Blocking Prerequisites)", false),
433 PhaseKind::Foundational
434 );
435 assert_eq!(
436 classify("Phase 3: User Story 1 - Login (Priority: P1) 🎯 MVP", false),
437 PhaseKind::Story
438 );
439 assert_eq!(
440 classify("Phase 9: Polish & Cross-Cutting Concerns", false),
441 PhaseKind::Polish
442 );
443 assert_eq!(
444 classify("Phase 10: Convergence", false),
445 PhaseKind::Convergence
446 );
447 assert_eq!(classify("Phase 4: Login", true), PhaseKind::Story);
448 assert_eq!(classify("User stories overview", false), PhaseKind::Other);
449 }
450
451 #[test]
452 fn open_setup_blocks_everything_after_it() {
453 let p = parsed(&[
454 ("T001", SETUP, None, false, None, false, &[]),
455 ("T002", FOUND, None, false, None, false, &[]),
456 ("T003", US1, None, false, Some("US1"), false, &[]),
457 ]);
458 let (s, d) = schedule(&p);
459 assert!(d.is_empty());
460 assert_eq!(
461 states(&s),
462 [
463 ("T001", Ready, vec![]),
464 ("T002", Blocked, vec!["T001"]),
465 ("T003", Blocked, vec!["T001", "T002"]),
466 ]
467 );
468 }
469
470 #[test]
471 fn stories_are_independent_and_polish_waits_for_all() {
472 let p = parsed(&[
473 ("T001", SETUP, None, false, None, true, &[]),
474 ("T002", FOUND, None, false, None, true, &[]),
475 ("T003", US1, None, false, Some("US1"), false, &[]),
476 ("T004", US2, None, false, Some("US2"), false, &[]),
477 ("T005", POLISH, None, false, None, false, &[]),
478 ]);
479 let (s, _) = schedule(&p);
480 assert_eq!(
481 states(&s),
482 [
483 ("T001", Done, vec![]),
484 ("T002", Done, vec![]),
485 ("T003", Ready, vec![]),
486 ("T004", Ready, vec![]),
487 ("T005", Blocked, vec!["T003", "T004"]),
488 ]
489 );
490 assert_eq!(
491 s.totals,
492 ScheduleTotals {
493 done: 2,
494 ready: 2,
495 blocked: 1
496 }
497 );
498 }
499
500 #[test]
501 fn parallel_runs_fork_and_join() {
502 let p = parsed(&[
503 ("T001", SETUP, None, false, None, true, &[]),
504 ("T002", SETUP, None, true, None, false, &[]),
505 ("T003", SETUP, None, true, None, false, &[]),
506 ("T004", SETUP, None, false, None, false, &[]),
507 ]);
508 let (s, _) = schedule(&p);
509 assert_eq!(
510 states(&s),
511 [
512 ("T001", Done, vec![]),
513 ("T002", Ready, vec![]),
514 ("T003", Ready, vec![]),
515 ("T004", Blocked, vec!["T002", "T003"]),
516 ]
517 );
518 }
519
520 #[test]
521 fn tests_subsection_comes_before_implementation() {
522 let p = parsed(&[
523 (
524 "T001",
525 US1,
526 Some("Tests for User Story 1"),
527 true,
528 Some("US1"),
529 false,
530 &[],
531 ),
532 (
533 "T002",
534 US1,
535 Some("Implementation for User Story 1"),
536 true,
537 Some("US1"),
538 false,
539 &[],
540 ),
541 ]);
542 let (s, _) = schedule(&p);
543 assert_eq!(states(&s)[1], ("T002", Blocked, vec!["T001"]));
544 }
545
546 #[test]
547 fn explicit_dependency_crosses_stories() {
548 let p = parsed(&[
549 ("T001", US1, None, false, Some("US1"), false, &[]),
550 ("T002", US2, None, false, Some("US2"), false, &["T001"]),
551 ]);
552 let (s, _) = schedule(&p);
553 assert_eq!(states(&s)[1], ("T002", Blocked, vec!["T001"]));
554 assert_eq!(s.tasks[1].depends_on[0].kind, DependencyKind::Explicit);
555 }
556
557 #[test]
558 fn declared_mode_keeps_phase_order_but_drops_structure_within_a_phase() {
559 let p = parsed_in(
560 DependencyMode::Declared,
561 &[
562 ("T001", SETUP, None, false, None, true, &[]),
563 ("T002", SETUP, None, false, None, false, &["T001"]),
564 ("T003", SETUP, None, true, None, false, &["T001"]),
565 (
566 "T004",
567 US1,
568 Some("Tests"),
569 true,
570 Some("US1"),
571 false,
572 &["T002"],
573 ),
574 (
575 "T005",
576 US1,
577 Some("Implementation"),
578 true,
579 Some("US1"),
580 false,
581 &[],
582 ),
583 ],
584 );
585 let (s, d) = schedule(&p);
586 assert!(d.is_empty());
587 assert_eq!(
588 states(&s),
589 [
590 ("T001", Done, vec![]),
591 ("T002", Ready, vec![]),
592 ("T003", Ready, vec![]),
593 ("T004", Blocked, vec!["T002", "T003"]),
594 ("T005", Blocked, vec!["T002", "T003"]),
595 ]
596 );
597 }
598
599 #[test]
600 fn dangling_reference_is_an_error_and_blocks() {
601 let p = parsed(&[("T001", SETUP, None, false, None, false, &["T099"])]);
602 let (s, d) = schedule(&p);
603 assert_eq!(d[0].code, "graph.dangling");
604 assert_eq!(d[0].line, Some(1));
605 assert_eq!(states(&s)[0], ("T001", Blocked, vec!["T099"]));
606 assert!(s.tasks[0].depends_on[0].missing);
607 }
608
609 #[test]
610 fn cycles_and_self_references_are_errors_and_never_ready() {
611 let p = parsed(&[
613 ("T001", SETUP, None, false, None, false, &["T002"]),
614 ("T002", SETUP, None, false, None, true, &[]),
615 ("T003", POLISH, None, false, None, false, &["T003"]),
616 ]);
617 let (s, d) = schedule(&p);
618 let codes: Vec<_> = d
619 .iter()
620 .map(|d| (d.code.as_str(), d.message.as_str()))
621 .collect();
622 assert_eq!(
623 codes,
624 [
625 ("graph.cycle", "dependency cycle: T001 → T002"),
626 ("graph.cycle", "dependency cycle: T003"),
627 ]
628 );
629 assert_eq!(s.tasks[0].state, Blocked);
630 assert_eq!(s.tasks[2].state, Blocked);
631 }
632
633 mod properties {
634 use super::*;
635 use proptest::prelude::*;
636
637 type OwnedSpec = (
639 String,
640 &'static str,
641 Option<String>,
642 bool,
643 Option<&'static str>,
644 bool,
645 Vec<String>,
646 );
647
648 const PHASES: [&str; 6] = [SETUP, FOUND, US1, US2, "Phase 6: Story notes", POLISH];
649
650 fn task_lists() -> impl Strategy<Value = ParsedTasks> {
653 prop::collection::vec(
654 (
655 0..PHASES.len(),
656 0..3usize,
657 any::<bool>(),
658 any::<bool>(),
659 prop::option::of(0..64usize),
660 ),
661 1..40,
662 )
663 .prop_map(|mut rows| {
664 rows.sort_by_key(|r| (r.0, r.1));
665 let ids: Vec<String> = (1..=rows.len()).map(|n| format!("T{n:03}")).collect();
666 let spec: Vec<OwnedSpec> = rows
667 .iter()
668 .enumerate()
669 .map(|(i, &(ph, sub, par, done, back))| {
670 let story = match ph {
671 2 => Some("US1"),
672 3 => Some("US2"),
673 _ => None,
674 };
675 let deps = back
676 .filter(|_| i > 0)
677 .map(|b| vec![ids[b % i].clone()])
678 .unwrap_or_default();
679 (
680 ids[i].clone(),
681 PHASES[ph],
682 Some(format!("S{sub}")),
683 par,
684 story,
685 done,
686 deps,
687 )
688 })
689 .collect();
690 let owned: Vec<Vec<&str>> = spec
691 .iter()
692 .map(|r| r.6.iter().map(String::as_str).collect())
693 .collect();
694 let rows: Vec<Spec<'_>> = spec
695 .iter()
696 .zip(&owned)
697 .map(|(r, d)| {
698 (
699 r.0.as_str(),
700 r.1,
701 r.2.as_deref(),
702 r.3,
703 r.4,
704 r.5,
705 d.as_slice(),
706 )
707 })
708 .collect();
709 parsed(&rows)
710 })
711 }
712
713 proptest! {
714 #[test]
715 fn ready_means_every_dependency_is_done(p in task_lists()) {
716 let (s, _) = schedule(&p);
717 let done: HashMap<&str, bool> = p.tasks.iter().map(|t| (t.id.as_str(), t.done)).collect();
718 for t in &s.tasks {
719 if t.state == Ready {
720 prop_assert!(t.depends_on.iter().all(|d| done[d.id.as_str()]));
721 }
722 }
723 }
724
725 #[test]
726 fn open_tasks_with_done_dependencies_are_ready(p in task_lists()) {
727 let (s, _) = schedule(&p);
728 let done: HashMap<&str, bool> = p.tasks.iter().map(|t| (t.id.as_str(), t.done)).collect();
729 for (t, task) in s.tasks.iter().zip(&p.tasks) {
730 if !task.done && t.depends_on.iter().all(|d| done[d.id.as_str()]) {
731 prop_assert_eq!(t.state, Ready);
732 }
733 }
734 }
735
736 #[test]
737 fn stories_never_depend_structurally_on_each_other(p in task_lists()) {
738 let (s, _) = schedule(&p);
739 let story: HashMap<&str, Option<&str>> =
740 p.tasks.iter().map(|t| (t.id.as_str(), t.story.as_deref())).collect();
741 for t in &s.tasks {
742 for d in t.depends_on.iter().filter(|d| d.kind == DependencyKind::Structural) {
743 if let (Some(a), Some(b)) = (story[t.id.as_str()], story[d.id.as_str()]) {
744 prop_assert_eq!(a, b);
745 }
746 }
747 }
748 }
749
750 #[test]
751 fn backward_references_never_create_cycles(p in task_lists()) {
752 let (s, d) = schedule(&p);
753 prop_assert!(d.is_empty(), "{:?}", d);
754 prop_assert_eq!(s.totals.done + s.totals.ready + s.totals.blocked, p.tasks.len());
755 }
756 }
757 }
758}