Skip to main content

diffler_graph/
model.rs

1//! The engine-agnostic graph model. A plain directed graph — cycles are
2//! allowed (CI pipelines are DAGs, but call/reference maps are not), so layout
3//! engines, not the model, decide how to handle back-edges. Front-ends (GitHub
4//! Actions today; DOT/mermaid/LSP later) all build this same shape.
5
6use std::collections::HashSet;
7
8/// Stable node key from the source (a CI job name, a DOT id, a symbol path).
9#[derive(Debug, Clone, PartialEq, Eq, Hash)]
10pub struct NodeId(pub String);
11
12impl NodeId {
13    pub fn new(id: impl Into<String>) -> Self {
14        Self(id.into())
15    }
16}
17
18/// Status of a node, driving its color and glyph. Maps from CI job conclusions
19/// now; generic enough for other producers later.
20#[derive(Debug, Clone, Copy, PartialEq, Eq)]
21pub enum NodeStatus {
22    Ok,
23    Failed,
24    Running,
25    Queued,
26    Skipped,
27    Neutral,
28}
29
30impl NodeStatus {
31    /// A compact status glyph shown beside the label.
32    pub fn glyph(self) -> &'static str {
33        match self {
34            Self::Ok => "✓",
35            Self::Failed => "✗",
36            Self::Running => "●",
37            Self::Queued => "·",
38            Self::Skipped => "–",
39            Self::Neutral => "",
40        }
41    }
42
43    /// The more severe of two statuses, so a failing matrix leg dominates a
44    /// collapsed group's (or aggregate run's) status.
45    #[must_use]
46    pub fn worse(self, other: Self) -> Self {
47        let rank = |s: Self| match s {
48            Self::Failed => 5,
49            Self::Running => 4,
50            Self::Queued => 3,
51            Self::Skipped => 2,
52            Self::Neutral => 1,
53            Self::Ok => 0,
54        };
55        if rank(self) >= rank(other) {
56            self
57        } else {
58            other
59        }
60    }
61}
62
63#[derive(Debug, Clone, Copy, PartialEq, Eq)]
64pub enum RankDir {
65    TopDown,
66    LeftRight,
67}
68
69#[derive(Debug, Clone)]
70pub struct Node {
71    pub id: NodeId,
72    pub label: String,
73    pub status: NodeStatus,
74    /// Set on a member node (a CI matrix leg): the key of the foldable group it
75    /// belongs to. Members hang off their group's root and are hidden when the
76    /// group is collapsed. `None` for ordinary nodes and group roots.
77    pub group: Option<String>,
78    /// Set on the one *root* node of a foldable group: the group key. Only a
79    /// root node is foldable (it takes the collapse shortcut); external edges
80    /// connect to the root, and its members branch off it.
81    pub foldable: Option<String>,
82}
83
84impl Node {
85    /// An ordinary node whose label is its id.
86    pub fn leaf(id: &str, status: NodeStatus) -> Self {
87        Self {
88            id: NodeId::new(id),
89            label: id.to_owned(),
90            status,
91            group: None,
92            foldable: None,
93        }
94    }
95
96    /// Mark this node a member (leg) of `group`.
97    pub fn in_group(mut self, group: &str) -> Self {
98        self.group = Some(group.to_owned());
99        self
100    }
101
102    /// Mark this node the foldable root of `group`.
103    pub fn fold_root(mut self, group: &str) -> Self {
104        self.foldable = Some(group.to_owned());
105        self
106    }
107}
108
109#[derive(Debug, Clone)]
110pub struct Edge {
111    pub from: NodeId,
112    pub to: NodeId,
113    pub label: Option<String>,
114}
115
116#[derive(Debug, Clone)]
117pub struct Model {
118    pub rankdir: RankDir,
119    pub nodes: Vec<Node>,
120    pub edges: Vec<Edge>,
121}
122
123impl Model {
124    pub fn new(rankdir: RankDir) -> Self {
125        Self {
126            rankdir,
127            nodes: Vec::new(),
128            edges: Vec::new(),
129        }
130    }
131
132    /// Index of a node by id, for engines that key on position.
133    pub fn index_of(&self, id: &NodeId) -> Option<usize> {
134        self.nodes.iter().position(|n| &n.id == id)
135    }
136
137    /// The foldable group key of a node, if it is a group root. Only roots are
138    /// foldable; members and ordinary nodes return `None`.
139    pub fn foldable_of(&self, id: &NodeId) -> Option<String> {
140        self.nodes
141            .iter()
142            .find(|n| &n.id == id)
143            .and_then(|n| n.foldable.clone())
144    }
145
146    /// The render view for a set of collapsed groups. Every foldable root gets a
147    /// fold marker (`▾` open / `▸ … (N)` closed); a collapsed group's members and
148    /// the edges touching them are dropped, so only the root stays in the flow.
149    /// The root's status reflects the worst of its members either way.
150    #[must_use]
151    pub fn collapse(&self, collapsed: &HashSet<String>) -> Model {
152        let hidden: HashSet<&NodeId> = self
153            .nodes
154            .iter()
155            .filter(|n| n.group.as_deref().is_some_and(|g| collapsed.contains(g)))
156            .map(|n| &n.id)
157            .collect();
158
159        let mut out = Model::new(self.rankdir);
160        for node in &self.nodes {
161            if hidden.contains(&node.id) {
162                continue;
163            }
164            let mut node = node.clone();
165            if let Some(group) = node.foldable.clone() {
166                let mut worst = node.status;
167                let mut count = 0usize;
168                for member in &self.nodes {
169                    if member.group.as_deref() == Some(group.as_str()) {
170                        worst = worst.worse(member.status);
171                        count += 1;
172                    }
173                }
174                node.status = worst;
175                node.label = if collapsed.contains(&group) {
176                    format!("▸ {} ({count})", node.label)
177                } else {
178                    format!("▾ {}", node.label)
179                };
180            }
181            out.nodes.push(node);
182        }
183        out.edges = self
184            .edges
185            .iter()
186            .filter(|e| !hidden.contains(&e.from) && !hidden.contains(&e.to))
187            .cloned()
188            .collect();
189        out
190    }
191
192    /// A CI-shaped sample used by tests and snapshots: lint/typos/deny fan into
193    /// a foldable test matrix, which fans into the publish jobs.
194    pub fn demo() -> Self {
195        use NodeStatus::{Failed, Neutral, Ok, Queued, Running};
196        let mut model = Self::new(RankDir::TopDown);
197        model.nodes = vec![
198            Node::leaf("lint", Ok),
199            Node::leaf("typos", Ok),
200            Node::leaf("deny", Ok),
201            // the test matrix: one foldable root with three legs branching off it
202            Node::leaf("test", Neutral).fold_root("test"),
203            Node::leaf("test ubuntu", Ok).in_group("test"),
204            Node::leaf("test macos", Ok).in_group("test"),
205            Node::leaf("test windows", Failed).in_group("test"),
206            Node::leaf("build", Running),
207            Node::leaf("publish-crates", Queued),
208            Node::leaf("publish-npm", Queued),
209            Node::leaf("publish-aur", Queued),
210        ];
211        let edge = |from: &str, to: &str| Edge {
212            from: NodeId::new(from),
213            to: NodeId::new(to),
214            label: None,
215        };
216        model.edges = vec![
217            // the flow connects to the group as a whole; the legs live inside
218            // its container (by membership), not via edges
219            edge("lint", "test"),
220            edge("typos", "test"),
221            edge("deny", "test"),
222            edge("test", "build"),
223            edge("build", "publish-crates"),
224            edge("build", "publish-npm"),
225            edge("build", "publish-aur"),
226        ];
227        model
228    }
229}