use std::collections::{HashMap, HashSet};
use super::graph::GraphTransitionInfo;
#[derive(Debug, Clone, PartialEq, Eq)]
pub(super) struct NodeSlot {
pub(super) name: String,
pub(super) layer: usize,
pub(super) slot: usize,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub(super) struct EdgeLine {
pub(super) from: String,
pub(super) to: String,
pub(super) back_edge: bool,
pub(super) condition: String,
pub(super) hint: Option<String>,
}
#[derive(Debug, Clone, Default)]
pub(super) struct GraphLayout {
pub(super) nodes: Vec<NodeSlot>,
pub(super) edges: Vec<EdgeLine>,
pub(super) max_layer: usize,
}
impl GraphLayout {
#[cfg(test)]
pub(super) fn slot_of(&self, name: &str) -> Option<&NodeSlot> {
self.nodes.iter().find(|n| n.name == name)
}
pub(super) fn layer_nodes(&self, layer: usize) -> Vec<&NodeSlot> {
let mut nodes: Vec<&NodeSlot> = self.nodes.iter().filter(|n| n.layer == layer).collect();
nodes.sort_by_key(|n| n.slot);
nodes
}
}
pub(super) fn layout(graph: &GraphTransitionInfo) -> GraphLayout {
let mut back: HashSet<(String, String)> = HashSet::new();
let mut visited: HashSet<&str> = HashSet::new();
let mut on_stack: HashSet<&str> = HashSet::new();
let mut stack: Vec<(&str, usize)> = Vec::new();
let neighbors = |name: &str| -> Vec<&str> {
graph
.edges
.get(name)
.map(|edges| edges.iter().map(|e| e.target.as_str()).collect())
.unwrap_or_default()
};
if graph.stage_names.iter().any(|s| s == &graph.entry_stage) {
stack.push((graph.entry_stage.as_str(), 0));
visited.insert(graph.entry_stage.as_str());
on_stack.insert(graph.entry_stage.as_str());
while let Some((node, child_idx)) = stack.pop() {
let kids = neighbors(node);
if child_idx < kids.len() {
stack.push((node, child_idx + 1));
let child = kids[child_idx];
if on_stack.contains(child) {
back.insert((node.to_string(), child.to_string()));
} else if !visited.contains(child) && graph.stage_names.iter().any(|s| s == child) {
visited.insert(child);
on_stack.insert(child);
stack.push((child, 0));
}
} else {
on_stack.remove(node);
}
}
}
let mut layer: HashMap<&str, usize> = HashMap::new();
if graph.stage_names.iter().any(|s| s == &graph.entry_stage) {
layer.insert(graph.entry_stage.as_str(), 0);
for _ in 0..graph.stage_names.len() {
let mut changed = false;
for name in &graph.stage_names {
let Some(&from_layer) = layer.get(name.as_str()) else {
continue;
};
for target in neighbors(name) {
if back.contains(&(name.clone(), target.to_string())) {
continue;
}
if !graph.stage_names.iter().any(|s| s == target) {
continue;
}
let entry = layer.entry(target).or_insert(0);
if *entry < from_layer + 1 {
*entry = from_layer + 1;
changed = true;
}
}
}
if !changed {
break;
}
}
}
let mut max_layer = layer.values().copied().max().unwrap_or(0);
for name in &graph.stage_names {
if !layer.contains_key(name.as_str()) {
max_layer += 1;
layer.insert(name.as_str(), max_layer);
}
}
let mut slots: HashMap<&str, usize> = HashMap::new();
for l in 0..=max_layer {
let mut names: Vec<&str> = graph
.stage_names
.iter()
.map(|s| s.as_str())
.filter(|s| layer.get(s) == Some(&l))
.collect();
if l > 0 {
let median_of_preds = |name: &str| -> usize {
let mut preds: Vec<usize> = graph
.edges
.iter()
.filter(|(src, edges)| {
layer.get(src.as_str()) == Some(&(l - 1))
&& edges.iter().any(|e| e.target == name)
})
.filter_map(|(src, _)| slots.get(src.as_str()).copied())
.collect();
preds.sort_unstable();
preds.get(preds.len() / 2).copied().unwrap_or(usize::MAX)
};
names.sort_by_key(|n| median_of_preds(n));
}
for (i, name) in names.iter().enumerate() {
slots.insert(name, i);
}
}
let nodes: Vec<NodeSlot> = graph
.stage_names
.iter()
.map(|name| NodeSlot {
name: name.clone(),
layer: layer.get(name.as_str()).copied().unwrap_or(0),
slot: slots.get(name.as_str()).copied().unwrap_or(0),
})
.collect();
let edges: Vec<EdgeLine> = graph
.stage_names
.iter()
.flat_map(|src| {
graph
.edges
.get(src)
.map(|list| {
list.iter()
.filter(|e| graph.stage_names.iter().any(|s| s == &e.target))
.map(|e| EdgeLine {
from: src.clone(),
to: e.target.clone(),
back_edge: back.contains(&(src.clone(), e.target.clone())),
condition: e.condition.clone(),
hint: e.hint.clone(),
})
.collect::<Vec<_>>()
})
.unwrap_or_default()
})
.collect();
GraphLayout {
nodes,
edges,
max_layer,
}
}
#[cfg(test)]
mod tests {
use super::super::graph::{GraphEdge, GraphTransitionInfo};
use super::*;
fn edge(target: &str) -> GraphEdge {
GraphEdge {
target: target.to_string(),
hint: Some("go".to_string()),
condition: "always".to_string(),
transform: "direct".to_string(),
}
}
fn graph(entry: &str, stages: &[&str], edges: &[(&str, &[&str])]) -> GraphTransitionInfo {
GraphTransitionInfo {
entry_stage: entry.to_string(),
stage_names: stages.iter().map(|s| s.to_string()).collect(),
edges: edges
.iter()
.map(|(src, targets)| {
(
src.to_string(),
targets.iter().map(|t| edge(t)).collect::<Vec<_>>(),
)
})
.collect(),
}
}
#[test]
fn a_linear_chain_gets_one_node_per_layer() {
let g = graph("a", &["a", "b", "c"], &[("a", &["b"]), ("b", &["c"])]);
let l = layout(&g);
assert_eq!(l.max_layer, 2);
assert_eq!(l.slot_of("a").unwrap().layer, 0);
assert_eq!(l.slot_of("b").unwrap().layer, 1);
assert_eq!(l.slot_of("c").unwrap().layer, 2);
assert!(l.edges.iter().all(|e| !e.back_edge));
}
#[test]
fn a_diamond_shares_a_layer_and_the_join_lands_below() {
let g = graph(
"a",
&["a", "b", "c", "d"],
&[("a", &["b", "c"]), ("b", &["d"]), ("c", &["d"])],
);
let l = layout(&g);
assert_eq!(l.slot_of("b").unwrap().layer, 1);
assert_eq!(l.slot_of("c").unwrap().layer, 1);
assert_eq!(l.slot_of("d").unwrap().layer, 2);
assert_eq!(l.layer_nodes(1).len(), 2);
}
#[test]
fn a_revisit_cycle_is_classified_as_a_back_edge_and_still_terminates() {
let g = graph(
"plan",
&["plan", "implement", "review"],
&[
("plan", &["implement"]),
("implement", &["review"]),
("review", &["implement"]),
],
);
let l = layout(&g);
let back: Vec<&EdgeLine> = l.edges.iter().filter(|e| e.back_edge).collect();
assert_eq!(back.len(), 1);
assert_eq!(back[0].from, "review");
assert_eq!(back[0].to, "implement");
assert_eq!(l.slot_of("implement").unwrap().layer, 1);
assert_eq!(l.slot_of("review").unwrap().layer, 2);
}
#[test]
fn longest_path_wins_when_a_shortcut_exists() {
let g = graph("a", &["a", "b", "c"], &[("a", &["b", "c"]), ("b", &["c"])]);
let l = layout(&g);
assert_eq!(l.slot_of("c").unwrap().layer, 2);
}
#[test]
fn unreachable_stages_get_trailing_layers_not_dropped() {
let g = graph("a", &["a", "b", "island"], &[("a", &["b"])]);
let l = layout(&g);
assert_eq!(l.nodes.len(), 3);
let island = l.slot_of("island").unwrap();
assert!(island.layer > l.slot_of("b").unwrap().layer);
}
#[test]
fn an_entry_missing_from_the_stage_list_produces_a_flat_fallback() {
let g = graph("ghost", &["a", "b"], &[("a", &["b"])]);
let l = layout(&g);
assert_eq!(l.nodes.len(), 2);
assert_ne!(l.slot_of("a").unwrap().layer, l.slot_of("b").unwrap().layer);
}
#[test]
fn edges_to_unknown_stages_are_dropped_and_layout_is_deterministic() {
let g = graph("a", &["a", "b"], &[("a", &["b", "phantom"])]);
let l1 = layout(&g);
let l2 = layout(&g);
assert!(l1.edges.iter().all(|e| e.to != "phantom"));
assert_eq!(l1.nodes, l2.nodes);
assert_eq!(l1.edges, l2.edges);
}
#[test]
fn median_sweep_orders_a_layer_under_its_predecessors() {
let g = graph(
"a",
&["a", "x", "y", "x2", "y2"],
&[("a", &["x", "y"]), ("x", &["x2"]), ("y", &["y2"])],
);
let l = layout(&g);
assert_eq!(l.slot_of("x2").unwrap().slot, l.slot_of("x").unwrap().slot);
assert_eq!(l.slot_of("y2").unwrap().slot, l.slot_of("y").unwrap().slot);
}
}