Skip to main content

gitkraft_core/features/graph/
ops.rs

1//! Graph layout computation — lane-based algorithm for commit graph visualisation.
2//!
3//! The algorithm assigns colours **per lane** (not per OID) so that a linear
4//! branch keeps a single consistent colour.  New colours are only introduced
5//! when a new lane is created for a branch or merge.
6
7use std::collections::HashSet;
8
9use super::types::{GraphEdge, GraphRow};
10use crate::features::commits::CommitInfo;
11
12/// Build a graph layout for a slice of commits (newest-first).
13///
14/// The algorithm maintains a set of "active lanes" — columns that carry a
15/// branch line downward through the graph.  Each lane tracks the OID of the
16/// next commit it expects to encounter and its colour.
17///
18/// Colours are assigned per-lane:
19/// - When a lane is created (new branch / new root), it gets a fresh colour.
20/// - When a commit takes over a lane, it inherits that lane's colour.
21/// - First-parent edges continue the lane colour.
22/// - Additional-parent edges get the colour of their target lane.
23pub fn build_graph(commits: &[CommitInfo]) -> Vec<GraphRow> {
24    if commits.is_empty() {
25        return Vec::new();
26    }
27
28    /// An active lane: the OID it's waiting for, and its colour index.
29    struct Lane {
30        target_oid: String,
31        color: usize,
32    }
33
34    let mut lanes: Vec<Option<Lane>> = Vec::new();
35    let mut next_color: usize = 0;
36    let mut rows: Vec<GraphRow> = Vec::with_capacity(commits.len());
37
38    // Pre-build a set of OIDs in this commit list so we can detect when a
39    // parent is NOT in the loaded window (and thus its lane will never resolve).
40    let oid_set: HashSet<&str> = commits.iter().map(|c| c.oid.as_str()).collect();
41
42    for commit in commits {
43        let oid = &commit.oid;
44
45        // Find the lane expecting this commit, or allocate a new one.
46        let node_col = lanes
47            .iter()
48            .position(|l| l.as_ref().is_some_and(|ln| ln.target_oid == *oid))
49            .unwrap_or_else(|| {
50                let color = next_color;
51                next_color += 1;
52                let idx = lanes.iter().position(|l| l.is_none()).unwrap_or_else(|| {
53                    lanes.push(None);
54                    lanes.len() - 1
55                });
56                lanes[idx] = Some(Lane {
57                    target_oid: oid.clone(),
58                    color,
59                });
60                idx
61            });
62
63        // The node inherits its lane's colour.
64        let node_color = lanes[node_col].as_ref().map(|l| l.color).unwrap_or(0);
65
66        // Build edges.
67        let mut edges: Vec<GraphEdge> = Vec::new();
68
69        // Pass-through edges for lanes that are not the node lane.
70        for (col, lane_opt) in lanes.iter().enumerate() {
71            if col == node_col {
72                continue;
73            }
74            if let Some(lane) = lane_opt {
75                edges.push(GraphEdge {
76                    from_column: col,
77                    to_column: col,
78                    color_index: lane.color,
79                });
80            }
81        }
82
83        // Handle the commit's parents.
84        let parents = &commit.parent_ids;
85
86        if parents.is_empty() {
87            // Root commit — the lane ends.
88            lanes[node_col] = None;
89        } else {
90            // First parent takes over the node's lane (same colour).
91            let first_parent = &parents[0];
92            let lane_color = node_color;
93
94            lanes[node_col] = Some(Lane {
95                target_oid: first_parent.clone(),
96                color: lane_color,
97            });
98
99            edges.push(GraphEdge {
100                from_column: node_col,
101                to_column: node_col,
102                color_index: lane_color,
103            });
104
105            // Additional parents get new lanes (or reuse existing ones).
106            for parent_oid in &parents[1..] {
107                let existing = lanes
108                    .iter()
109                    .position(|l| l.as_ref().is_some_and(|ln| ln.target_oid == *parent_oid));
110
111                let (target_col, target_color) = if let Some(col) = existing {
112                    let color = lanes[col].as_ref().unwrap().color;
113                    (col, color)
114                } else {
115                    let color = next_color;
116                    next_color += 1;
117                    let idx = lanes.iter().position(|l| l.is_none()).unwrap_or_else(|| {
118                        lanes.push(None);
119                        lanes.len() - 1
120                    });
121                    lanes[idx] = Some(Lane {
122                        target_oid: parent_oid.clone(),
123                        color,
124                    });
125                    (idx, color)
126                };
127
128                edges.push(GraphEdge {
129                    from_column: node_col,
130                    to_column: target_col,
131                    color_index: target_color,
132                });
133            }
134        }
135
136        // Clean up lanes whose target is NOT in our commit list — they'll
137        // never resolve, so keeping them wastes columns.  But only clean
138        // lanes that are NOT the node's lane (we just set that above).
139        for (col, lane_opt) in lanes.iter_mut().enumerate() {
140            if col == node_col {
141                continue;
142            }
143            if let Some(lane) = lane_opt {
144                if !oid_set.contains(lane.target_oid.as_str()) {
145                    *lane_opt = None;
146                }
147            }
148        }
149
150        // Compact: remove trailing empty lanes.
151        while lanes.last().is_some_and(|l| l.is_none()) {
152            lanes.pop();
153        }
154
155        let width = lanes.len().max(1);
156
157        rows.push(GraphRow {
158            width,
159            node_column: node_col,
160            node_color,
161            edges,
162        });
163    }
164
165    rows
166}
167
168#[cfg(test)]
169mod tests {
170    use super::*;
171    use chrono::Utc;
172
173    fn make_commit(oid: &str, parents: &[&str]) -> CommitInfo {
174        CommitInfo {
175            oid: oid.to_string(),
176            summary: format!("commit {oid}"),
177            message: format!("commit {oid}"),
178            author_name: "Test".to_string(),
179            author_email: "test@test.com".to_string(),
180            time: Utc::now(),
181            parent_ids: parents.iter().map(|s| s.to_string()).collect(),
182            refs: Vec::new(),
183        }
184    }
185
186    #[test]
187    fn empty_commits_returns_empty_graph() {
188        let rows = build_graph(&[]);
189        assert!(rows.is_empty());
190    }
191
192    #[test]
193    fn single_commit_no_parents() {
194        let commits = vec![make_commit("aaa0000", &[])];
195        let rows = build_graph(&commits);
196        assert_eq!(rows.len(), 1);
197        assert_eq!(rows[0].node_column, 0);
198        assert!(rows[0].edges.is_empty());
199    }
200
201    #[test]
202    fn linear_history_same_colour() {
203        let commits = vec![
204            make_commit("ccc0000", &["bbb0000"]),
205            make_commit("bbb0000", &["aaa0000"]),
206            make_commit("aaa0000", &[]),
207        ];
208        let rows = build_graph(&commits);
209        assert_eq!(rows.len(), 3);
210        for row in &rows {
211            assert_eq!(row.node_column, 0);
212        }
213        // All nodes should share the same colour (lane-based colouring).
214        assert_eq!(rows[0].node_color, rows[1].node_color);
215        assert_eq!(rows[1].node_color, rows[2].node_color);
216    }
217
218    #[test]
219    fn branch_uses_separate_lane() {
220        let commits = vec![
221            make_commit("ddd0000", &["bbb0000", "ccc0000"]),
222            make_commit("ccc0000", &["aaa0000"]),
223            make_commit("bbb0000", &["aaa0000"]),
224            make_commit("aaa0000", &[]),
225        ];
226        let rows = build_graph(&commits);
227        assert_eq!(rows.len(), 4);
228        let merge_row = &rows[0];
229        assert!(merge_row.edges.len() >= 2);
230    }
231
232    #[test]
233    fn merge_creates_different_colour_for_second_parent() {
234        let commits = vec![
235            make_commit("merge00", &["parent1", "parent2"]),
236            make_commit("parent2", &["base000"]),
237            make_commit("parent1", &["base000"]),
238            make_commit("base000", &[]),
239        ];
240        let rows = build_graph(&commits);
241        let merge_edges = &rows[0].edges;
242        let cross_edges: Vec<_> = merge_edges
243            .iter()
244            .filter(|e| e.from_column != e.to_column)
245            .collect();
246        assert!(!cross_edges.is_empty(), "merge must have a cross-edge");
247        assert_ne!(cross_edges[0].color_index, rows[0].node_color);
248    }
249
250    #[test]
251    fn graph_width_is_at_least_node_column_plus_one() {
252        let commits = vec![
253            make_commit("ccc0000", &["bbb0000"]),
254            make_commit("bbb0000", &["aaa0000"]),
255            make_commit("aaa0000", &[]),
256        ];
257        let rows = build_graph(&commits);
258        for row in &rows {
259            assert!(row.width > row.node_column);
260        }
261    }
262}