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