Skip to main content

shep_core/config/
graph.rs

1//! Boot ordering: names and edges in, stages out.
2//!
3//! Pure. No I/O and no runtime, so the dog positioning rule below is
4//! testable without a daemon. The daemon's driver runs what this produces;
5//! it decides nothing about order itself.
6
7use std::collections::{BTreeMap, BTreeSet};
8
9/// What a node is, which decides its default position.
10#[derive(Debug, Clone, Copy, PartialEq, Eq)]
11pub enum NodeKind {
12    /// A managed user process. Sorted purely by its edges.
13    Sheep,
14    /// A plugin process the daemon supervises.
15    Dog {
16        /// Named in `[daemon] boot_first_dogs`, so it runs before every
17        /// sheep.
18        boot_first: bool,
19    },
20}
21
22/// One node of the boot graph.
23#[derive(Debug, Clone, PartialEq, Eq)]
24pub struct BootNode {
25    /// The sheep or dog name.
26    pub name: String,
27    /// Names this node waits for. Always empty for a dog, since `dog_app`
28    /// builds a dog's config from `AppConfig::minimal`.
29    pub depends_on: Vec<String>,
30    /// Sheep or dog.
31    pub kind: NodeKind,
32}
33
34/// An edge that pointed at a name the flock does not have.
35#[derive(Debug, Clone, PartialEq, Eq)]
36pub struct Unresolved {
37    /// The node whose list holds the edge.
38    pub dependent: String,
39    /// The name nothing answers to.
40    pub missing: String,
41}
42
43/// The order to start a flock in, and what was wrong with the graph.
44#[derive(Debug, Clone, Default, PartialEq, Eq)]
45pub struct BootPlan {
46    /// Stages in start order. Every stage is non-empty and its names are
47    /// sorted, so one flock always plans the same way.
48    pub stages: Vec<Vec<String>>,
49    /// Edges dropped because nothing answers to the name.
50    pub unresolved: Vec<Unresolved>,
51    /// One cycle for every knot in the graph, as a path with the first name
52    /// not repeated at the end. [`render_cycle`] closes it for display.
53    /// Several cycles can run through the same knot; the one named here is
54    /// a representative, and breaking it is what an operator does about it.
55    pub cycles: Vec<Vec<String>>,
56    /// Every member of each knot, index-aligned with [`BootPlan::cycles`].
57    ///
58    /// The representative path names only the nodes one cycle runs through,
59    /// so a knot of three reached by two edges leaves one of them off it. A
60    /// caller asking whether a name is stuck asks this; a caller printing
61    /// what to break prints the path.
62    pub knots: Vec<BTreeSet<String>>,
63}
64
65/// Borrowed names, owned. The call sites below all convert one of this
66/// module's `&str` sets or paths into an owned `Vec<String>` or
67/// `BTreeSet<String>` for a `BootPlan` field; this is the one place that
68/// spells the conversion.
69fn owned<'a, C: FromIterator<String>>(names: impl IntoIterator<Item = &'a str>) -> C {
70    names.into_iter().map(str::to_string).collect()
71}
72
73/// Renders one cycle as `a -> b -> c -> a`.
74///
75/// The closing repeat is what makes it readable as a cycle rather than as a
76/// list of names that happen to be involved in one.
77#[must_use]
78pub fn render_cycle(cycle: &[String]) -> String {
79    let mut path: Vec<&str> = cycle.iter().map(String::as_str).collect();
80    if let Some(first) = cycle.first() {
81        path.push(first.as_str());
82    }
83    path.join(" -> ")
84}
85
86/// Sorts `nodes` into stages.
87///
88/// Edges to a name outside `nodes` are dropped and recorded in
89/// [`BootPlan::unresolved`], because a dependency on an app whose Flockfile
90/// lives in another repository is legitimate.
91///
92/// Nodes in a cycle are lifted out of the sort into one unordered stage, and
93/// one cycle through each knot they form is recorded in
94/// [`BootPlan::cycles`]. Refusing here would strand an unattended boot; the
95/// caller decides whether to refuse, and only the operator-facing callers
96/// do. Anything depending on a knot, directly or through a chain, follows
97/// that stage rather than preceding it, for the reason argued on
98/// `depends_on_a_cycle`.
99///
100/// Dogs default to a stage after every sheep, so an existing install keeps
101/// the order `boot.rs` argues for. Two things move one: `boot_first`, which
102/// puts it in the first stage unless it depends on a knot, in which case it
103/// plans after the cyclic stage like anything else that does; and anything
104/// depending on it, which gives it an ordinary graph position. A dog runs
105/// at the earliest stage anything asks for.
106///
107/// The stages therefore run: `boot_first` dogs, the ordinary sort, the
108/// cyclic stage, the nodes that depend on the cycle in their own edge order,
109/// then dogs nothing depends on. Those come last unconditionally, since a
110/// dog placed before a knot would answer for a flock still coming up.
111///
112/// That paragraph describes the plan this function returns, and not even
113/// shep's boot honours all of it. `shep-daemon`'s `boot` spawns dogs in two
114/// groups,
115/// the promoted ones before the restore and every other one after the last
116/// stage, so a dog's plan position between those two points is not read: a
117/// sheep depending on a dog is warned about and started anyway. The driver
118/// decides, not this plan.
119#[must_use]
120pub fn plan(nodes: &[BootNode]) -> BootPlan {
121    let names: BTreeSet<&str> = nodes.iter().map(|n| n.name.as_str()).collect();
122
123    let mut unresolved = Vec::new();
124    let mut edges: BTreeMap<&str, BTreeSet<&str>> = BTreeMap::new();
125    for node in nodes {
126        let deps = edges.entry(node.name.as_str()).or_default();
127        for target in &node.depends_on {
128            if names.contains(target.as_str()) {
129                deps.insert(target.as_str());
130            } else {
131                unresolved.push(Unresolved {
132                    dependent: node.name.clone(),
133                    missing: target.clone(),
134                });
135            }
136        }
137    }
138
139    let knots = knots(&edges);
140    let in_a_cycle: BTreeSet<&str> = knots.iter().flatten().copied().collect();
141    let cycles: Vec<Vec<String>> = knots
142        .iter()
143        .map(|members| representative_cycle(&edges, members))
144        .collect();
145    let members: Vec<BTreeSet<String>> = knots
146        .iter()
147        .map(|knot| owned(knot.iter().copied()))
148        .collect();
149
150    let after_cycle = depends_on_a_cycle(&edges, &in_a_cycle);
151
152    let depended_on: BTreeSet<&str> = edges.values().flatten().copied().collect();
153    let mut first = Vec::new();
154    let mut last = Vec::new();
155    let mut ordered: BTreeSet<&str> = BTreeSet::new();
156    for node in nodes {
157        let name = node.name.as_str();
158        if in_a_cycle.contains(name) || after_cycle.contains(name) {
159            continue;
160        }
161        match node.kind {
162            NodeKind::Dog { boot_first: true } => first.push(name),
163            NodeKind::Dog { boot_first: false } if !depended_on.contains(name) => last.push(name),
164            _ => {
165                ordered.insert(name);
166            }
167        }
168    }
169    first.sort_unstable();
170    last.sort_unstable();
171
172    let mut stages: Vec<Vec<String>> = Vec::new();
173    if !first.is_empty() {
174        stages.push(owned(first.iter().copied()));
175    }
176    stages.extend(kahn(&ordered, &edges, &first));
177    if !in_a_cycle.is_empty() {
178        // One stage for every cyclic node, never one per reported cycle: a
179        // node several cycles run through is still started once.
180        stages.push(owned(in_a_cycle.iter().copied()));
181    }
182    // Whatever hangs off the knot, in its own edge order. Every dependency
183    // outside this set is already placed, which is what `kahn` reads a
184    // missing name as.
185    stages.extend(kahn(&after_cycle, &edges, &[]));
186    // Dead last, after the knot and everything hanging off it. Placing these
187    // before the cyclic stage would leave a metrics dog answering for a flock
188    // whose knot has not started, which is the case the dogs-last default
189    // exists to prevent, so "last" has to mean last even when the graph is
190    // degraded.
191    if !last.is_empty() {
192        stages.push(owned(last.iter().copied()));
193    }
194
195    BootPlan {
196        stages,
197        unresolved,
198        cycles,
199        knots: members,
200    }
201}
202
203/// Every node outside `in_a_cycle` with a path to one of its members.
204///
205/// The surprising part: shep does not refuse a boot over a cycle, it warns
206/// and brings the flock up with the knot last. A dependent of the knot can
207/// never have its dependency satisfied, so it starts anyway, and the only
208/// question left is where. A plain topological sort answers "first", because
209/// the edge points at a name the sort no longer holds and an absent
210/// dependency reads as a met one. That is the worst of the available orders,
211/// so these nodes are held out of the sort too and replanted after the
212/// cyclic stage.
213fn depends_on_a_cycle<'a>(
214    edges: &BTreeMap<&'a str, BTreeSet<&'a str>>,
215    in_a_cycle: &BTreeSet<&'a str>,
216) -> BTreeSet<&'a str> {
217    let mut found: BTreeSet<&'a str> = BTreeSet::new();
218    loop {
219        let grown: Vec<&'a str> = edges
220            .iter()
221            .filter(|(name, _)| !in_a_cycle.contains(*name) && !found.contains(*name))
222            .filter(|(_, deps)| {
223                deps.iter()
224                    .any(|dep| in_a_cycle.contains(dep) || found.contains(dep))
225            })
226            .map(|(name, _)| *name)
227            .collect();
228        if grown.is_empty() {
229            return found;
230        }
231        found.extend(grown);
232    }
233}
234
235/// Kahn's algorithm over `ordered`, taking every node whose remaining edges
236/// are satisfied as one stage. `already` names nodes placed in an earlier
237/// stage, whose edges are therefore met.
238fn kahn(
239    ordered: &BTreeSet<&str>,
240    edges: &BTreeMap<&str, BTreeSet<&str>>,
241    already: &[&str],
242) -> Vec<Vec<String>> {
243    let mut placed: BTreeSet<&str> = already.iter().copied().collect();
244    let mut left: BTreeSet<&str> = ordered.clone();
245    let mut stages = Vec::new();
246    while !left.is_empty() {
247        let ready: Vec<&str> = left
248            .iter()
249            .copied()
250            .filter(|name| {
251                edges
252                    .get(name)
253                    .is_none_or(|deps| deps.iter().all(|d| placed.contains(d) || !left.contains(d)))
254            })
255            .collect();
256        // Cycles were lifted out before this ran, so a stall is impossible.
257        // Breaking rather than looping forever is the safe arm regardless.
258        if ready.is_empty() {
259            break;
260        }
261        for name in &ready {
262            left.remove(name);
263            placed.insert(name);
264        }
265        stages.push(owned(ready.iter().copied()));
266    }
267    stages
268}
269
270/// Every knot in `edges`: a strongly connected component of two or more
271/// nodes, or a lone node that depends on itself. Each component's members,
272/// sorted, and the components themselves ordered by their first member.
273///
274/// Tarjan's algorithm rather than a search for a back edge. A back-edge walk
275/// answers "does this one walk close on itself", and marking a node explored
276/// the first time it is reached is what makes that walk finite: a second path
277/// arriving at the same node turns back without retraversing it, so of two
278/// cycles sharing a node only one is ever seen. The question this module asks
279/// is which nodes sit in a component larger than themselves, and only a
280/// component algorithm answers it.
281///
282/// Recursive, since the depth is bounded by the size of one flock.
283fn knots<'a>(edges: &BTreeMap<&'a str, BTreeSet<&'a str>>) -> Vec<BTreeSet<&'a str>> {
284    let mut tarjan = Tarjan {
285        index: BTreeMap::new(),
286        low: BTreeMap::new(),
287        stack: Vec::new(),
288        on_stack: BTreeSet::new(),
289        next: 0,
290        components: Vec::new(),
291    };
292    for name in edges.keys().copied() {
293        if !tarjan.index.contains_key(name) {
294            tarjan.connect(name, edges);
295        }
296    }
297    let mut found: Vec<BTreeSet<&str>> = tarjan
298        .components
299        .into_iter()
300        .filter(|members| {
301            members.len() > 1
302                || members
303                    .iter()
304                    .next()
305                    .is_some_and(|only| edges.get(only).is_some_and(|deps| deps.contains(only)))
306        })
307        .collect();
308    found.sort();
309    found
310}
311
312/// Tarjan's bookkeeping: the visit number each node was reached at, the
313/// lowest number it can reach, and the nodes whose component is still open.
314struct Tarjan<'a> {
315    index: BTreeMap<&'a str, usize>,
316    low: BTreeMap<&'a str, usize>,
317    stack: Vec<&'a str>,
318    on_stack: BTreeSet<&'a str>,
319    next: usize,
320    components: Vec<BTreeSet<&'a str>>,
321}
322
323impl<'a> Tarjan<'a> {
324    fn connect(&mut self, name: &'a str, edges: &BTreeMap<&'a str, BTreeSet<&'a str>>) {
325        self.index.insert(name, self.next);
326        self.low.insert(name, self.next);
327        self.next += 1;
328        self.stack.push(name);
329        self.on_stack.insert(name);
330
331        if let Some(deps) = edges.get(name) {
332            for dep in deps.iter().copied() {
333                let reachable = if self.index.contains_key(dep) {
334                    // An edge into a closed component says nothing about
335                    // this one, so only a node still on the stack counts.
336                    self.on_stack.contains(dep).then(|| self.index[dep])
337                } else {
338                    self.connect(dep, edges);
339                    Some(self.low[dep])
340                };
341                if let Some(reachable) = reachable {
342                    let low = self.low.entry(name).or_insert(reachable);
343                    *low = (*low).min(reachable);
344                }
345            }
346        }
347
348        if self.low[name] == self.index[name] {
349            let mut members = BTreeSet::new();
350            while let Some(member) = self.stack.pop() {
351                self.on_stack.remove(member);
352                members.insert(member);
353                if member == name {
354                    break;
355                }
356            }
357            self.components.push(members);
358        }
359    }
360}
361
362/// One cycle through `members`, as the path a walk closed on.
363///
364/// [`BootPlan::cycles`] names a path rather than a set because
365/// [`render_cycle`] prints `a -> b -> c -> a`, and a bare set of the names
366/// involved is not something an operator can act on.
367fn representative_cycle<'a>(
368    edges: &BTreeMap<&'a str, BTreeSet<&'a str>>,
369    members: &BTreeSet<&'a str>,
370) -> Vec<String> {
371    let Some(start) = members.iter().copied().next() else {
372        return Vec::new();
373    };
374    let mut path = vec![start];
375    let mut seen: BTreeSet<&str> = BTreeSet::from([start]);
376    if !close_on(start, start, edges, members, &mut path, &mut seen) {
377        // Every member of a knot has a path back to every other, so the
378        // walk closes. Naming the node alone is the honest fallback.
379        return vec![start.to_string()];
380    }
381    owned(path.iter().copied())
382}
383
384/// Walks from `at`, inside `members` only, until it finds an edge back to
385/// `start`. `path` holds the walk so far and is the cycle when this answers
386/// `true`.
387fn close_on<'a>(
388    at: &'a str,
389    start: &'a str,
390    edges: &BTreeMap<&'a str, BTreeSet<&'a str>>,
391    members: &BTreeSet<&'a str>,
392    path: &mut Vec<&'a str>,
393    seen: &mut BTreeSet<&'a str>,
394) -> bool {
395    let Some(deps) = edges.get(at) else {
396        return false;
397    };
398    for dep in deps.iter().copied().filter(|dep| members.contains(dep)) {
399        if dep == start {
400            return true;
401        }
402        if seen.insert(dep) {
403            path.push(dep);
404            if close_on(dep, start, edges, members, path, seen) {
405                return true;
406            }
407            path.pop();
408        }
409    }
410    false
411}
412
413#[cfg(test)]
414mod tests {
415    use super::*;
416
417    fn sheep(name: &str, deps: &[&str]) -> BootNode {
418        BootNode {
419            name: name.to_string(),
420            depends_on: deps.iter().map(|d| (*d).to_string()).collect(),
421            kind: NodeKind::Sheep,
422        }
423    }
424
425    fn dog(name: &str, boot_first: bool) -> BootNode {
426        BootNode {
427            name: name.to_string(),
428            depends_on: Vec::new(),
429            kind: NodeKind::Dog { boot_first },
430        }
431    }
432
433    #[test]
434    fn a_chain_becomes_one_stage_per_link() {
435        // fails if the sort collapses or reorders the chain
436        let out = plan(&[
437            sheep("web", &["api"]),
438            sheep("api", &["db"]),
439            sheep("db", &[]),
440        ]);
441        assert_eq!(out.stages, vec![vec!["db"], vec!["api"], vec!["web"]]);
442    }
443
444    #[test]
445    fn independent_nodes_share_a_stage_sorted_by_name() {
446        // fails if a stage's order depends on input order, which would make
447        // the boot plan nondeterministic
448        let out = plan(&[
449            sheep("cache", &[]),
450            sheep("db", &[]),
451            sheep("api", &["db", "cache"]),
452        ]);
453        assert_eq!(out.stages, vec![vec!["cache", "db"], vec!["api"]]);
454    }
455
456    #[test]
457    fn a_cycle_is_named_as_a_path_and_its_nodes_run_last() {
458        // fails if the cycle is merely detected, or if a cycle sinks the
459        // whole plan rather than being isolated into a final stage
460        let out = plan(&[
461            sheep("a", &["c"]),
462            sheep("b", &["a"]),
463            sheep("c", &["b"]),
464            sheep("lone", &[]),
465        ]);
466        assert_eq!(out.cycles.len(), 1, "one cycle expected: {:?}", out.cycles);
467        let rendered = render_cycle(&out.cycles[0]);
468        assert!(
469            rendered.starts_with("a -> ")
470                || rendered.starts_with("b -> ")
471                || rendered.starts_with("c -> ")
472        );
473        assert_eq!(
474            rendered.matches(" -> ").count(),
475            3,
476            "the path must close: {rendered}"
477        );
478        assert_eq!(out.stages.last().unwrap(), &vec!["a", "b", "c"]);
479    }
480
481    #[test]
482    fn an_edge_to_a_name_nobody_has_is_recorded_and_dropped() {
483        // fails if an unknown name refuses the plan or silently vanishes
484        let out = plan(&[sheep("api", &["nope"])]);
485        assert_eq!(out.stages, vec![vec!["api"]]);
486        assert_eq!(
487            out.unresolved,
488            vec![Unresolved {
489                dependent: "api".to_string(),
490                missing: "nope".to_string()
491            }]
492        );
493    }
494
495    #[test]
496    fn a_dog_nobody_depends_on_runs_last() {
497        // fails if dogs join the ordinary sort, which would move every
498        // existing install's boot order
499        let out = plan(&[
500            dog("metrics", false),
501            sheep("db", &[]),
502            sheep("api", &["db"]),
503        ]);
504        assert_eq!(out.stages, vec![vec!["db"], vec!["api"], vec!["metrics"]]);
505    }
506
507    #[test]
508    fn a_dog_still_runs_last_when_the_flock_holds_a_cycle() {
509        // fails if the undepended-on dogs are placed before the cyclic stage
510        // rather than after everything: a metrics dog would then answer for a
511        // flock whose knot has not started, which is the exact case the
512        // dogs-last default exists to prevent.
513        let out = plan(&[
514            dog("metrics", false),
515            sheep("a", &["b"]),
516            sheep("b", &["a"]),
517            sheep("tail", &["a"]),
518            sheep("plain", &[]),
519        ]);
520        let metrics = out
521            .stages
522            .iter()
523            .position(|stage| stage.iter().any(|n| n == "metrics"))
524            .expect("the dog is planned");
525        assert_eq!(
526            metrics,
527            out.stages.len() - 1,
528            "the dog must be the last stage: {:?}",
529            out.stages
530        );
531    }
532
533    #[test]
534    fn a_boot_first_dog_runs_before_every_sheep() {
535        // fails if boot_first is ignored, which is the log-rotate case
536        let out = plan(&[
537            dog("log-rotate", true),
538            sheep("db", &[]),
539            dog("metrics", false),
540        ]);
541        assert_eq!(
542            out.stages,
543            vec![vec!["log-rotate"], vec!["db"], vec!["metrics"]]
544        );
545    }
546
547    #[test]
548    fn a_dog_something_depends_on_takes_its_graph_position() {
549        // fails if the dogs-last default outranks an explicit edge
550        let out = plan(&[
551            dog("sidecar", false),
552            sheep("db", &[]),
553            sheep("api", &["db", "sidecar"]),
554            dog("metrics", false),
555        ]);
556        assert_eq!(
557            out.stages,
558            vec![vec!["db", "sidecar"], vec!["api"], vec!["metrics"]]
559        );
560    }
561
562    #[test]
563    fn an_empty_flock_plans_no_stages() {
564        // fails if the sort emits an empty stage, which the driver would
565        // then wait on
566        assert!(plan(&[]).stages.is_empty());
567    }
568
569    #[test]
570    fn two_cycles_sharing_a_node_put_every_member_in_the_last_stage() {
571        // fails if the cycle search marks a node done the first time it is
572        // reached, which leaves the second path through it unwalked: "c" is
573        // as cyclic as "b" and used to plan into the first stage, ahead of
574        // the "d" it depends on
575        let out = plan(&[
576            sheep("a", &["b", "c"]),
577            sheep("b", &["d"]),
578            sheep("c", &["d"]),
579            sheep("d", &["a"]),
580        ]);
581        assert_eq!(
582            out.cycles.len(),
583            1,
584            "one component expected: {:?}",
585            out.cycles
586        );
587        assert_eq!(out.stages, vec![vec!["a", "b", "c", "d"]]);
588    }
589
590    #[test]
591    fn a_knot_reports_every_member_even_when_its_path_names_two() {
592        // fails if `knots` is derived from the reported path: the path runs
593        // through one cycle and "c" is off it, so a caller asking whether a
594        // name is stuck would read "c" as free
595        let out = plan(&[
596            sheep("a", &["b", "c"]),
597            sheep("b", &["a"]),
598            sheep("c", &["a"]),
599        ]);
600        assert_eq!(out.knots.len(), out.cycles.len(), "one set per path");
601        assert_eq!(
602            out.knots[0],
603            ["a", "b", "c"]
604                .iter()
605                .map(|n| (*n).to_string())
606                .collect::<BTreeSet<String>>()
607        );
608        assert!(
609            !out.cycles[0].contains(&"c".to_string()),
610            "the representative path is still a path: {:?}",
611            out.cycles[0]
612        );
613    }
614
615    #[test]
616    fn a_node_two_cycles_run_through_is_planned_into_one_stage() {
617        // fails if the final stage is built once per reported cycle rather
618        // than once from the set of cyclic nodes, which starts the shared
619        // node twice
620        let out = plan(&[
621            sheep("a", &["b", "c"]),
622            sheep("b", &["a"]),
623            sheep("c", &["a"]),
624        ]);
625        assert_eq!(
626            out.cycles.len(),
627            1,
628            "one component expected: {:?}",
629            out.cycles
630        );
631        assert_eq!(out.stages, vec![vec!["a", "b", "c"]]);
632    }
633
634    #[test]
635    fn a_node_depending_on_a_cycle_starts_after_it_not_before() {
636        // fails if an edge into a knot reads as satisfied because the knot
637        // was lifted out of the sort, which put "x" in the first stage,
638        // ahead of the "a" it depends on
639        let out = plan(&[
640            sheep("x", &["a"]),
641            sheep("a", &["b"]),
642            sheep("b", &["a"]),
643            sheep("y", &["x"]),
644        ]);
645        assert_eq!(out.stages, vec![vec!["a", "b"], vec!["x"], vec!["y"]]);
646    }
647
648    /// Every stage is non-empty and internally sorted, the shape both
649    /// proptests below check before going on to their own invariant.
650    ///
651    /// Returns rather than asserts: `prop_assert!` expands to an early
652    /// return of a `TestCaseError`, which is what lets a failure shrink.
653    fn stages_are_well_formed(
654        stages: &[Vec<String>],
655    ) -> Result<(), proptest::test_runner::TestCaseError> {
656        for stage in stages {
657            proptest::prop_assert!(!stage.is_empty(), "an empty stage: {:?}", stages);
658            let mut sorted = stage.clone();
659            sorted.sort();
660            proptest::prop_assert_eq!(stage, &sorted, "an unsorted stage: {:?}", stages);
661        }
662        Ok(())
663    }
664
665    proptest::proptest! {
666        #[test]
667        fn every_edge_is_respected_in_the_planned_order(
668            edges in proptest::collection::vec((0usize..8, 0usize..8), 0..24)
669        ) {
670            // fails if the sort violates edge order anywhere on an acyclic
671            // graph. An edge only ever points at a lower index by
672            // construction, so no input here can be cyclic.
673            let mut deps: Vec<Vec<String>> = vec![Vec::new(); 8];
674            for (from, to) in edges {
675                if to < from {
676                    deps[from].push(format!("n{to}"));
677                }
678            }
679            let nodes: Vec<BootNode> = (0..8)
680                .map(|i| BootNode {
681                    name: format!("n{i}"),
682                    depends_on: deps[i].clone(),
683                    kind: NodeKind::Sheep,
684                })
685                .collect();
686            let out = plan(&nodes);
687            proptest::prop_assert!(out.cycles.is_empty());
688            stages_are_well_formed(&out.stages)?;
689            let mut stage_of = std::collections::BTreeMap::new();
690            for (index, stage) in out.stages.iter().enumerate() {
691                for name in stage {
692                    stage_of.insert(name.clone(), index);
693                }
694            }
695            for node in &nodes {
696                for dep in &node.depends_on {
697                    proptest::prop_assert!(stage_of[dep] < stage_of[&node.name]);
698                }
699            }
700        }
701
702        #[test]
703        fn every_cyclic_node_is_reported_and_no_node_is_planned_twice(
704            edges in proptest::collection::vec((0usize..6, 0usize..6), 0..18),
705            shuffle_keys in proptest::collection::vec(0u32.., 6)
706        ) {
707            // fails if a node is dropped or duplicated, a cyclic node is
708            // missed or misplaced, an edge out of an acyclic node is not
709            // respected, a stage is empty or unsorted, two reported cycles
710            // share a node, or the plan changes when the same nodes are
711            // planned in a different order. Arbitrary edges, so most draws
712            // are cyclic: the DAG-by-construction case above gives the
713            // cyclic path no coverage at all. Ground truth is a transitive
714            // closure computed here, not anything the module does, so the
715            // test cannot agree with a bug by sharing its logic.
716            const N: usize = 6;
717            let mut adjacent = [[false; N]; N];
718            for (from, to) in edges {
719                adjacent[from][to] = true;
720            }
721            let mut reaches = adjacent;
722            for k in 0..N {
723                for i in 0..N {
724                    for j in 0..N {
725                        if reaches[i][k] && reaches[k][j] {
726                            reaches[i][j] = true;
727                        }
728                    }
729                }
730            }
731            let nodes: Vec<BootNode> = (0..N)
732                .map(|i| BootNode {
733                    name: format!("n{i}"),
734                    depends_on: (0..N)
735                        .filter(|j| adjacent[i][*j])
736                        .map(|j| format!("n{j}"))
737                        .collect(),
738                    kind: NodeKind::Sheep,
739                })
740                .collect();
741            let out = plan(&nodes);
742
743            stages_are_well_formed(&out.stages)?;
744
745            // Determinism against input order: planning the same nodes in a
746            // different order must produce the identical plan. The reorder
747            // is a sort key drawn independently of the edges, not a
748            // transform derived from the module under test.
749            let mut reordered: Vec<(u32, BootNode)> =
750                shuffle_keys.into_iter().zip(nodes.iter().cloned()).collect();
751            reordered.sort_by_key(|(key, _)| *key);
752            let shuffled_nodes: Vec<BootNode> = reordered.into_iter().map(|(_, n)| n).collect();
753            let out_shuffled = plan(&shuffled_nodes);
754            proptest::prop_assert_eq!(
755                &out_shuffled,
756                &out,
757                "the same nodes in a different order planned differently"
758            );
759
760            let planned: Vec<String> = out.stages.iter().flatten().cloned().collect();
761            let mut once = planned.clone();
762            once.sort();
763            once.dedup();
764            proptest::prop_assert_eq!(
765                once.len(),
766                planned.len(),
767                "a node is planned twice: {:?}",
768                out.stages
769            );
770            proptest::prop_assert_eq!(once.len(), N, "a node is planned nowhere: {:?}", out.stages);
771
772            // A node is truly cyclic when it reaches itself, and every one
773            // of them belongs in one stage together. That stage is not the
774            // last one: whatever depends on the knot follows it.
775            let stage_of = |i: usize| {
776                out.stages
777                    .iter()
778                    .position(|stage| stage.contains(&format!("n{i}")))
779                    .expect("every node is planned")
780            };
781            let cyclic: Vec<usize> = (0..N).filter(|i| reaches[*i][*i]).collect();
782
783            // An edge out of an acyclic node has to land strictly earlier,
784            // the same claim the DAG test above makes, just without the
785            // guarantee that every node here qualifies: a node inside a
786            // knot has no such promise, since its own dependency can sit in
787            // the same stage or after it.
788            for i in (0..N).filter(|i| !reaches[*i][*i]) {
789                for j in (0..N).filter(|j| adjacent[i][*j]) {
790                    proptest::prop_assert!(
791                        stage_of(j) < stage_of(i),
792                        "n{} depends on n{} but n{} is not strictly earlier: {:?}",
793                        i,
794                        j,
795                        j,
796                        out.stages
797                    );
798                }
799            }
800
801            if let Some(first) = cyclic.first().copied() {
802                let knot = stage_of(first);
803                for i in cyclic.iter().copied() {
804                    proptest::prop_assert_eq!(
805                        stage_of(i),
806                        knot,
807                        "n{} is cyclic and is planned elsewhere: {:?}",
808                        i,
809                        out.stages
810                    );
811                }
812                // A dependent of the knot can never have its dependency
813                // satisfied, so it starts anyway, but never first; an
814                // acyclic node that is NOT a dependent has no business in
815                // the knot's stage or after it.
816                for i in (0..N).filter(|i| !reaches[*i][*i]) {
817                    let is_dependent = cyclic.iter().any(|c| reaches[i][*c]);
818                    // `prop_assert!` with a comparison rather than
819                    // `prop_assert_ne!`: that macro expands to an unqualified
820                    // `prop_assert!` in proptest 1.0.0, so a path-qualified
821                    // call to it fails to compile under the minimal-versions
822                    // job even though it is fine on a current proptest.
823                    proptest::prop_assert!(
824                        stage_of(i) != knot,
825                        "n{} is acyclic but planned into the knot's own stage: {:?}",
826                        i,
827                        out.stages
828                    );
829                    proptest::prop_assert_eq!(
830                        stage_of(i) > knot,
831                        is_dependent,
832                        "n{} depends on the knot: {}, but its stage relative to the knot disagrees: {:?}",
833                        i,
834                        is_dependent,
835                        out.stages
836                    );
837                }
838            }
839
840            // One reported path per cyclic component, and every reported
841            // path is a real cycle rather than a set of involved names.
842            let mut components: Vec<Vec<usize>> = Vec::new();
843            for (i, reached) in reaches.iter().enumerate() {
844                if reached[i] && !components.iter().any(|c| c.contains(&i)) {
845                    components.push((0..N).filter(|j| reached[*j] && reaches[*j][i]).collect());
846                }
847            }
848            proptest::prop_assert_eq!(
849                out.cycles.len(),
850                components.len(),
851                "reported {:?} for components {:?}",
852                out.cycles,
853                components
854            );
855            for cycle in &out.cycles {
856                for (at, name) in cycle.iter().enumerate() {
857                    let from: usize = name[1..].parse().unwrap();
858                    let to: usize = cycle[(at + 1) % cycle.len()][1..].parse().unwrap();
859                    proptest::prop_assert!(
860                        adjacent[from][to],
861                        "{} is not a path anything can walk",
862                        render_cycle(cycle)
863                    );
864                }
865            }
866
867            // Two reported cycles never share a node: a component is a set
868            // of nodes, so two distinct components are disjoint, and this
869            // checks the reported paths against each other rather than
870            // against `components` above, since a bug that miscomputed both
871            // the same way would slip past a check that used one to verify
872            // the other.
873            for (i, a) in out.cycles.iter().enumerate() {
874                for b in &out.cycles[i + 1..] {
875                    let a_names: BTreeSet<&str> = a.iter().map(String::as_str).collect();
876                    let b_names: BTreeSet<&str> = b.iter().map(String::as_str).collect();
877                    proptest::prop_assert!(
878                        a_names.is_disjoint(&b_names),
879                        "two reported cycles share a node: {} and {}",
880                        render_cycle(a),
881                        render_cycle(b)
882                    );
883                }
884            }
885        }
886    }
887}