Skip to main content

speccoder_core/
graph.rs

1//! Task dependencies from what `tasks.md` documents, and which tasks are ready.
2//!
3//! Edges come only from structure and from IDs written in task text; nothing
4//! is inferred:
5//!
6//! - **Phases.** Foundational waits on all earlier phases; each user-story
7//!   phase waits on the earlier *non-story* phases only, so stories are
8//!   independent of each other; polish, convergence and unrecognised phases
9//!   wait on all earlier phases.
10//! - **Subsections.** A `###` subsection waits on the earlier subsections of
11//!   its phase (tests before implementation).
12//! - **`[P]` runs.** A `[P]` task waits on the previous non-`[P]` task; a
13//!   non-`[P]` task waits on the whole preceding `[P]` run, or on the previous
14//!   task when there is no run.
15//! - **Explicit.** `depends on T012` / `after T012` in the task text.
16//!
17//! In **declared** mode (the Format line documents `(depends on …)`) the
18//! written references are the complete order within a phase:
19//! subsection and `[P]` edges are dropped, phase order is kept.
20//!
21//! In **chain** mode (OpenSpec, Kiro) a group waits on every earlier group
22//! and a task on the one before it in its group; written references are
23//! added. Consecutive parallel peers (cc-sdd's `(P)`: siblings of one parent,
24//! or top-level tasks) form a run: each waits on what the first waits on,
25//! and the task after the run waits on all of it.
26//!
27//! Dangling references and cycles are errors; the tasks involved are blocked.
28
29use std::collections::{BTreeSet, HashMap};
30
31use crate::snapshot::{
32    Dependency, DependencyKind, DependencyMode, Diagnostic, ParsedTasks, Schedule, ScheduleTotals,
33    ScheduledTask, Severity, Task, TaskState,
34};
35
36/// What a phase heading says the phase is.
37#[derive(Debug, Clone, Copy, PartialEq, Eq)]
38pub enum PhaseKind {
39    /// Shared infrastructure.
40    Setup,
41    /// Blocking prerequisites of every story.
42    Foundational,
43    /// One user story; independent of the other stories.
44    Story,
45    /// Cross-cutting work after the stories.
46    Polish,
47    /// Tasks appended by `/speckit-converge`.
48    Convergence,
49    /// Anything else; waits on everything before it.
50    Other,
51}
52
53/// Classifies a phase by its heading and whether its tasks carry story labels.
54#[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
80/// A run of consecutive tasks sharing a phase heading.
81struct Group {
82    kind: PhaseKind,
83    /// Task indices, split into consecutive subsection blocks.
84    blocks: Vec<Vec<usize>>,
85}
86
87/// While grouping: (phase title, subsection blocks, current subsection).
88type 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
120/// Whether `a` and `b` are siblings in a chain's outline (`2.1` and `2.2`,
121/// or top-level `3` and `4`).
122fn 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/// A chain's runs of two or more parallel peers, as task IDs: the tasks
128/// that run side by side.
129#[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/// Builds the schedule of `parsed`, with `graph.*` diagnostics.
155#[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() // the first occurrence of a duplicate wins
163        .map(|(i, t)| (t.id.as_str(), i))
164        .collect();
165
166    // Structural dependencies, as task indices.
167    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    // Chain mode: the open run of parallel peers.
172    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                    // Beside its peers: waits on what the run waits on.
185                    deps.clone_from(&structural[run[0]]);
186                    run.push(i);
187                } else {
188                    // After a run: waits on all of it.
189                    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    // Direct dependencies with kinds; explicit ones resolved by ID.
232    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    // Cycles: strongly connected components with more than one task, or a self-loop.
276    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    // States.
303    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
353/// Iterative Tarjan: the component number of every node.
354fn 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        // (node, index of the next edge to visit)
370        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/// The open tasks that wait on `id`, directly or through another, in
408/// document order: what stays blocked when it is skipped.
409#[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    /// `spec`: (id, phase, subsection, parallel, story, done, explicit deps)
439    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    /// A cc-sdd list: (id, parallel, done, explicit deps), phases by
504    /// major task as the Kiro adapter names them.
505    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        // Once 2.x is done, the top-level peers 3 and 4 are ready together.
566        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        // 2.3 runs beside 2.1 and 2.2, but its `_Depends: 2.1_` holds.
597        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        // T002 waits on T001 structurally; T001 explicitly waits on T002.
808        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        /// Owned form of [`Spec`] while generating.
834        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        /// Random task lists: phases in template order, subsections, `[P]`,
847        /// done flags, and explicit references only to earlier tasks.
848        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}