use std::collections::{BTreeMap, BTreeSet};
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum NodeKind {
Sheep,
Dog {
boot_first: bool,
},
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct BootNode {
pub name: String,
pub depends_on: Vec<String>,
pub kind: NodeKind,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Unresolved {
pub dependent: String,
pub missing: String,
}
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct BootPlan {
pub stages: Vec<Vec<String>>,
pub unresolved: Vec<Unresolved>,
pub cycles: Vec<Vec<String>>,
pub knots: Vec<BTreeSet<String>>,
}
#[must_use]
pub fn render_cycle(cycle: &[String]) -> String {
let mut path: Vec<&str> = cycle.iter().map(String::as_str).collect();
if let Some(first) = cycle.first() {
path.push(first.as_str());
}
path.join(" -> ")
}
#[must_use]
pub fn plan(nodes: &[BootNode]) -> BootPlan {
let names: BTreeSet<&str> = nodes.iter().map(|n| n.name.as_str()).collect();
let mut unresolved = Vec::new();
let mut edges: BTreeMap<&str, BTreeSet<&str>> = BTreeMap::new();
for node in nodes {
let deps = edges.entry(node.name.as_str()).or_default();
for target in &node.depends_on {
if names.contains(target.as_str()) {
deps.insert(target.as_str());
} else {
unresolved.push(Unresolved {
dependent: node.name.clone(),
missing: target.clone(),
});
}
}
}
let knots = knots(&edges);
let in_a_cycle: BTreeSet<&str> = knots.iter().flatten().copied().collect();
let cycles: Vec<Vec<String>> = knots
.iter()
.map(|members| representative_cycle(&edges, members))
.collect();
let members: Vec<BTreeSet<String>> = knots
.iter()
.map(|knot| knot.iter().map(|name| (*name).to_string()).collect())
.collect();
let after_cycle = depends_on_a_cycle(&edges, &in_a_cycle);
let depended_on: BTreeSet<&str> = edges.values().flatten().copied().collect();
let mut first = Vec::new();
let mut last = Vec::new();
let mut ordered: BTreeSet<&str> = BTreeSet::new();
for node in nodes {
let name = node.name.as_str();
if in_a_cycle.contains(name) || after_cycle.contains(name) {
continue;
}
match node.kind {
NodeKind::Dog { boot_first: true } => first.push(name),
NodeKind::Dog { boot_first: false } if !depended_on.contains(name) => last.push(name),
_ => {
ordered.insert(name);
}
}
}
first.sort_unstable();
last.sort_unstable();
let mut stages: Vec<Vec<String>> = Vec::new();
if !first.is_empty() {
stages.push(first.iter().map(|n| (*n).to_string()).collect());
}
stages.extend(kahn(&ordered, &edges, &first));
if !in_a_cycle.is_empty() {
stages.push(in_a_cycle.iter().map(|n| (*n).to_string()).collect());
}
stages.extend(kahn(&after_cycle, &edges, &[]));
if !last.is_empty() {
stages.push(last.iter().map(|n| (*n).to_string()).collect());
}
BootPlan {
stages,
unresolved,
cycles,
knots: members,
}
}
fn depends_on_a_cycle<'a>(
edges: &BTreeMap<&'a str, BTreeSet<&'a str>>,
in_a_cycle: &BTreeSet<&'a str>,
) -> BTreeSet<&'a str> {
let mut found: BTreeSet<&'a str> = BTreeSet::new();
loop {
let grown: Vec<&'a str> = edges
.iter()
.filter(|(name, _)| !in_a_cycle.contains(*name) && !found.contains(*name))
.filter(|(_, deps)| {
deps.iter()
.any(|dep| in_a_cycle.contains(dep) || found.contains(dep))
})
.map(|(name, _)| *name)
.collect();
if grown.is_empty() {
return found;
}
found.extend(grown);
}
}
fn kahn(
ordered: &BTreeSet<&str>,
edges: &BTreeMap<&str, BTreeSet<&str>>,
already: &[&str],
) -> Vec<Vec<String>> {
let mut placed: BTreeSet<&str> = already.iter().copied().collect();
let mut left: BTreeSet<&str> = ordered.clone();
let mut stages = Vec::new();
while !left.is_empty() {
let ready: Vec<&str> = left
.iter()
.copied()
.filter(|name| {
edges
.get(name)
.is_none_or(|deps| deps.iter().all(|d| placed.contains(d) || !left.contains(d)))
})
.collect();
if ready.is_empty() {
break;
}
for name in &ready {
left.remove(name);
placed.insert(name);
}
stages.push(ready.iter().map(|n| (*n).to_string()).collect());
}
stages
}
fn knots<'a>(edges: &BTreeMap<&'a str, BTreeSet<&'a str>>) -> Vec<BTreeSet<&'a str>> {
let mut tarjan = Tarjan {
index: BTreeMap::new(),
low: BTreeMap::new(),
stack: Vec::new(),
on_stack: BTreeSet::new(),
next: 0,
components: Vec::new(),
};
for name in edges.keys().copied() {
if !tarjan.index.contains_key(name) {
tarjan.connect(name, edges);
}
}
let mut found: Vec<BTreeSet<&str>> = tarjan
.components
.into_iter()
.filter(|members| {
members.len() > 1
|| members
.iter()
.next()
.is_some_and(|only| edges.get(only).is_some_and(|deps| deps.contains(only)))
})
.collect();
found.sort();
found
}
struct Tarjan<'a> {
index: BTreeMap<&'a str, usize>,
low: BTreeMap<&'a str, usize>,
stack: Vec<&'a str>,
on_stack: BTreeSet<&'a str>,
next: usize,
components: Vec<BTreeSet<&'a str>>,
}
impl<'a> Tarjan<'a> {
fn connect(&mut self, name: &'a str, edges: &BTreeMap<&'a str, BTreeSet<&'a str>>) {
self.index.insert(name, self.next);
self.low.insert(name, self.next);
self.next += 1;
self.stack.push(name);
self.on_stack.insert(name);
if let Some(deps) = edges.get(name) {
for dep in deps.iter().copied() {
let reachable = if self.index.contains_key(dep) {
self.on_stack.contains(dep).then(|| self.index[dep])
} else {
self.connect(dep, edges);
Some(self.low[dep])
};
if let Some(reachable) = reachable {
let low = self.low.entry(name).or_insert(reachable);
*low = (*low).min(reachable);
}
}
}
if self.low[name] == self.index[name] {
let mut members = BTreeSet::new();
while let Some(member) = self.stack.pop() {
self.on_stack.remove(member);
members.insert(member);
if member == name {
break;
}
}
self.components.push(members);
}
}
}
fn representative_cycle<'a>(
edges: &BTreeMap<&'a str, BTreeSet<&'a str>>,
members: &BTreeSet<&'a str>,
) -> Vec<String> {
let Some(start) = members.iter().copied().next() else {
return Vec::new();
};
let mut path = vec![start];
let mut seen: BTreeSet<&str> = BTreeSet::from([start]);
if !close_on(start, start, edges, members, &mut path, &mut seen) {
return vec![start.to_string()];
}
path.iter().map(|n| (*n).to_string()).collect()
}
fn close_on<'a>(
at: &'a str,
start: &'a str,
edges: &BTreeMap<&'a str, BTreeSet<&'a str>>,
members: &BTreeSet<&'a str>,
path: &mut Vec<&'a str>,
seen: &mut BTreeSet<&'a str>,
) -> bool {
let Some(deps) = edges.get(at) else {
return false;
};
for dep in deps.iter().copied().filter(|dep| members.contains(dep)) {
if dep == start {
return true;
}
if seen.insert(dep) {
path.push(dep);
if close_on(dep, start, edges, members, path, seen) {
return true;
}
path.pop();
}
}
false
}
#[cfg(test)]
mod tests {
use super::*;
fn sheep(name: &str, deps: &[&str]) -> BootNode {
BootNode {
name: name.to_string(),
depends_on: deps.iter().map(|d| (*d).to_string()).collect(),
kind: NodeKind::Sheep,
}
}
fn dog(name: &str, boot_first: bool) -> BootNode {
BootNode {
name: name.to_string(),
depends_on: Vec::new(),
kind: NodeKind::Dog { boot_first },
}
}
#[test]
fn a_chain_becomes_one_stage_per_link() {
let out = plan(&[
sheep("web", &["api"]),
sheep("api", &["db"]),
sheep("db", &[]),
]);
assert_eq!(out.stages, vec![vec!["db"], vec!["api"], vec!["web"]]);
}
#[test]
fn independent_nodes_share_a_stage_sorted_by_name() {
let out = plan(&[
sheep("cache", &[]),
sheep("db", &[]),
sheep("api", &["db", "cache"]),
]);
assert_eq!(out.stages, vec![vec!["cache", "db"], vec!["api"]]);
}
#[test]
fn a_cycle_is_named_as_a_path_and_its_nodes_run_last() {
let out = plan(&[
sheep("a", &["c"]),
sheep("b", &["a"]),
sheep("c", &["b"]),
sheep("lone", &[]),
]);
assert_eq!(out.cycles.len(), 1, "one cycle expected: {:?}", out.cycles);
let rendered = render_cycle(&out.cycles[0]);
assert!(
rendered.starts_with("a -> ")
|| rendered.starts_with("b -> ")
|| rendered.starts_with("c -> ")
);
assert_eq!(
rendered.matches(" -> ").count(),
3,
"the path must close: {rendered}"
);
assert_eq!(out.stages.last().unwrap(), &vec!["a", "b", "c"]);
}
#[test]
fn an_edge_to_a_name_nobody_has_is_recorded_and_dropped() {
let out = plan(&[sheep("api", &["nope"])]);
assert_eq!(out.stages, vec![vec!["api"]]);
assert_eq!(
out.unresolved,
vec![Unresolved {
dependent: "api".to_string(),
missing: "nope".to_string()
}]
);
}
#[test]
fn a_dog_nobody_depends_on_runs_last() {
let out = plan(&[
dog("metrics", false),
sheep("db", &[]),
sheep("api", &["db"]),
]);
assert_eq!(out.stages, vec![vec!["db"], vec!["api"], vec!["metrics"]]);
}
#[test]
fn a_dog_still_runs_last_when_the_flock_holds_a_cycle() {
let out = plan(&[
dog("metrics", false),
sheep("a", &["b"]),
sheep("b", &["a"]),
sheep("tail", &["a"]),
sheep("plain", &[]),
]);
let metrics = out
.stages
.iter()
.position(|stage| stage.iter().any(|n| n == "metrics"))
.expect("the dog is planned");
assert_eq!(
metrics,
out.stages.len() - 1,
"the dog must be the last stage: {:?}",
out.stages
);
}
#[test]
fn a_boot_first_dog_runs_before_every_sheep() {
let out = plan(&[
dog("log-rotate", true),
sheep("db", &[]),
dog("metrics", false),
]);
assert_eq!(
out.stages,
vec![vec!["log-rotate"], vec!["db"], vec!["metrics"]]
);
}
#[test]
fn a_dog_something_depends_on_takes_its_graph_position() {
let out = plan(&[
dog("sidecar", false),
sheep("db", &[]),
sheep("api", &["db", "sidecar"]),
dog("metrics", false),
]);
assert_eq!(
out.stages,
vec![vec!["db", "sidecar"], vec!["api"], vec!["metrics"]]
);
}
#[test]
fn an_empty_flock_plans_no_stages() {
assert!(plan(&[]).stages.is_empty());
}
#[test]
fn two_cycles_sharing_a_node_put_every_member_in_the_last_stage() {
let out = plan(&[
sheep("a", &["b", "c"]),
sheep("b", &["d"]),
sheep("c", &["d"]),
sheep("d", &["a"]),
]);
assert_eq!(
out.cycles.len(),
1,
"one component expected: {:?}",
out.cycles
);
assert_eq!(out.stages, vec![vec!["a", "b", "c", "d"]]);
}
#[test]
fn a_knot_reports_every_member_even_when_its_path_names_two() {
let out = plan(&[
sheep("a", &["b", "c"]),
sheep("b", &["a"]),
sheep("c", &["a"]),
]);
assert_eq!(out.knots.len(), out.cycles.len(), "one set per path");
assert_eq!(
out.knots[0],
["a", "b", "c"]
.iter()
.map(|n| (*n).to_string())
.collect::<BTreeSet<String>>()
);
assert!(
!out.cycles[0].contains(&"c".to_string()),
"the representative path is still a path: {:?}",
out.cycles[0]
);
}
#[test]
fn a_node_two_cycles_run_through_is_planned_into_one_stage() {
let out = plan(&[
sheep("a", &["b", "c"]),
sheep("b", &["a"]),
sheep("c", &["a"]),
]);
assert_eq!(
out.cycles.len(),
1,
"one component expected: {:?}",
out.cycles
);
assert_eq!(out.stages, vec![vec!["a", "b", "c"]]);
}
#[test]
fn a_node_depending_on_a_cycle_starts_after_it_not_before() {
let out = plan(&[
sheep("x", &["a"]),
sheep("a", &["b"]),
sheep("b", &["a"]),
sheep("y", &["x"]),
]);
assert_eq!(out.stages, vec![vec!["a", "b"], vec!["x"], vec!["y"]]);
}
proptest::proptest! {
#[test]
fn every_edge_is_respected_in_the_planned_order(
edges in proptest::collection::vec((0usize..8, 0usize..8), 0..24)
) {
let mut deps: Vec<Vec<String>> = vec![Vec::new(); 8];
for (from, to) in edges {
if to < from {
deps[from].push(format!("n{to}"));
}
}
let nodes: Vec<BootNode> = (0..8)
.map(|i| BootNode {
name: format!("n{i}"),
depends_on: deps[i].clone(),
kind: NodeKind::Sheep,
})
.collect();
let out = plan(&nodes);
proptest::prop_assert!(out.cycles.is_empty());
for stage in &out.stages {
proptest::prop_assert!(!stage.is_empty(), "an empty stage: {:?}", out.stages);
let mut sorted = stage.clone();
sorted.sort();
proptest::prop_assert_eq!(stage, &sorted, "an unsorted stage: {:?}", out.stages);
}
let mut stage_of = std::collections::BTreeMap::new();
for (index, stage) in out.stages.iter().enumerate() {
for name in stage {
stage_of.insert(name.clone(), index);
}
}
for node in &nodes {
for dep in &node.depends_on {
proptest::prop_assert!(stage_of[dep] < stage_of[&node.name]);
}
}
}
#[test]
fn every_cyclic_node_is_reported_and_no_node_is_planned_twice(
edges in proptest::collection::vec((0usize..6, 0usize..6), 0..18),
shuffle_keys in proptest::collection::vec(0u32.., 6)
) {
const N: usize = 6;
let mut adjacent = [[false; N]; N];
for (from, to) in edges {
adjacent[from][to] = true;
}
let mut reaches = adjacent;
for k in 0..N {
for i in 0..N {
for j in 0..N {
if reaches[i][k] && reaches[k][j] {
reaches[i][j] = true;
}
}
}
}
let nodes: Vec<BootNode> = (0..N)
.map(|i| BootNode {
name: format!("n{i}"),
depends_on: (0..N)
.filter(|j| adjacent[i][*j])
.map(|j| format!("n{j}"))
.collect(),
kind: NodeKind::Sheep,
})
.collect();
let out = plan(&nodes);
for stage in &out.stages {
proptest::prop_assert!(!stage.is_empty(), "an empty stage: {:?}", out.stages);
let mut sorted = stage.clone();
sorted.sort();
proptest::prop_assert_eq!(stage, &sorted, "an unsorted stage: {:?}", out.stages);
}
let mut reordered: Vec<(u32, BootNode)> =
shuffle_keys.into_iter().zip(nodes.iter().cloned()).collect();
reordered.sort_by_key(|(key, _)| *key);
let shuffled_nodes: Vec<BootNode> = reordered.into_iter().map(|(_, n)| n).collect();
let out_shuffled = plan(&shuffled_nodes);
proptest::prop_assert_eq!(
&out_shuffled,
&out,
"the same nodes in a different order planned differently"
);
let planned: Vec<String> = out.stages.iter().flatten().cloned().collect();
let mut once = planned.clone();
once.sort();
once.dedup();
proptest::prop_assert_eq!(
once.len(),
planned.len(),
"a node is planned twice: {:?}",
out.stages
);
proptest::prop_assert_eq!(once.len(), N, "a node is planned nowhere: {:?}", out.stages);
let stage_of = |i: usize| {
out.stages
.iter()
.position(|stage| stage.contains(&format!("n{i}")))
.expect("every node is planned")
};
let cyclic: Vec<usize> = (0..N).filter(|i| reaches[*i][*i]).collect();
for i in (0..N).filter(|i| !reaches[*i][*i]) {
for j in (0..N).filter(|j| adjacent[i][*j]) {
proptest::prop_assert!(
stage_of(j) < stage_of(i),
"n{} depends on n{} but n{} is not strictly earlier: {:?}",
i,
j,
j,
out.stages
);
}
}
if let Some(first) = cyclic.first().copied() {
let knot = stage_of(first);
for i in cyclic.iter().copied() {
proptest::prop_assert_eq!(
stage_of(i),
knot,
"n{} is cyclic and is planned elsewhere: {:?}",
i,
out.stages
);
}
for i in (0..N).filter(|i| !reaches[*i][*i]) {
let is_dependent = cyclic.iter().any(|c| reaches[i][*c]);
proptest::prop_assert!(
stage_of(i) != knot,
"n{} is acyclic but planned into the knot's own stage: {:?}",
i,
out.stages
);
proptest::prop_assert_eq!(
stage_of(i) > knot,
is_dependent,
"n{} depends on the knot: {}, but its stage relative to the knot disagrees: {:?}",
i,
is_dependent,
out.stages
);
}
}
let mut components: Vec<Vec<usize>> = Vec::new();
for (i, reached) in reaches.iter().enumerate() {
if reached[i] && !components.iter().any(|c| c.contains(&i)) {
components.push((0..N).filter(|j| reached[*j] && reaches[*j][i]).collect());
}
}
proptest::prop_assert_eq!(
out.cycles.len(),
components.len(),
"reported {:?} for components {:?}",
out.cycles,
components
);
for cycle in &out.cycles {
for (at, name) in cycle.iter().enumerate() {
let from: usize = name[1..].parse().unwrap();
let to: usize = cycle[(at + 1) % cycle.len()][1..].parse().unwrap();
proptest::prop_assert!(
adjacent[from][to],
"{} is not a path anything can walk",
render_cycle(cycle)
);
}
}
for (i, a) in out.cycles.iter().enumerate() {
for b in &out.cycles[i + 1..] {
let a_names: BTreeSet<&str> = a.iter().map(String::as_str).collect();
let b_names: BTreeSet<&str> = b.iter().map(String::as_str).collect();
proptest::prop_assert!(
a_names.is_disjoint(&b_names),
"two reported cycles share a node: {} and {}",
render_cycle(a),
render_cycle(b)
);
}
}
}
}
}