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::HashMap;
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: HashMap<&str, usize> = commits
41        .iter()
42        .enumerate()
43        .map(|(i, c)| (c.oid.as_str(), i))
44        .collect();
45
46    for commit in commits {
47        let oid = &commit.oid;
48
49        // Find the lane expecting this commit, or allocate a new one.
50        let node_col = lanes
51            .iter()
52            .position(|l| l.as_ref().is_some_and(|ln| ln.target_oid == *oid))
53            .unwrap_or_else(|| {
54                let color = next_color;
55                next_color += 1;
56                let idx = lanes.iter().position(|l| l.is_none()).unwrap_or_else(|| {
57                    lanes.push(None);
58                    lanes.len() - 1
59                });
60                lanes[idx] = Some(Lane {
61                    target_oid: oid.clone(),
62                    color,
63                });
64                idx
65            });
66
67        // The node inherits its lane's colour.
68        let node_color = lanes[node_col].as_ref().map(|l| l.color).unwrap_or(0);
69
70        // Build edges.
71        let mut edges: Vec<GraphEdge> = Vec::new();
72
73        // Pass-through edges for lanes that are not the node lane.
74        for (col, lane_opt) in lanes.iter().enumerate() {
75            if col == node_col {
76                continue;
77            }
78            if let Some(lane) = lane_opt {
79                edges.push(GraphEdge {
80                    from_column: col,
81                    to_column: col,
82                    color_index: lane.color,
83                });
84            }
85        }
86
87        // Handle the commit's parents.
88        let parents = &commit.parent_ids;
89
90        if parents.is_empty() {
91            // Root commit — the lane ends.
92            lanes[node_col] = None;
93        } else {
94            // First parent takes over the node's lane (same colour).
95            let first_parent = &parents[0];
96            let lane_color = node_color;
97
98            lanes[node_col] = Some(Lane {
99                target_oid: first_parent.clone(),
100                color: lane_color,
101            });
102
103            edges.push(GraphEdge {
104                from_column: node_col,
105                to_column: node_col,
106                color_index: lane_color,
107            });
108
109            // Additional parents get new lanes (or reuse existing ones).
110            for parent_oid in &parents[1..] {
111                let existing = lanes
112                    .iter()
113                    .position(|l| l.as_ref().is_some_and(|ln| ln.target_oid == *parent_oid));
114
115                let (target_col, target_color) = if let Some(col) = existing {
116                    let color = lanes[col].as_ref().unwrap().color;
117                    (col, color)
118                } else {
119                    let color = next_color;
120                    next_color += 1;
121                    let idx = lanes.iter().position(|l| l.is_none()).unwrap_or_else(|| {
122                        lanes.push(None);
123                        lanes.len() - 1
124                    });
125                    lanes[idx] = Some(Lane {
126                        target_oid: parent_oid.clone(),
127                        color,
128                    });
129                    (idx, color)
130                };
131
132                edges.push(GraphEdge {
133                    from_column: node_col,
134                    to_column: target_col,
135                    color_index: target_color,
136                });
137            }
138        }
139
140        // Clean up lanes whose target is NOT in our commit list — they'll
141        // never resolve, so keeping them wastes columns.  But only clean
142        // lanes that are NOT the node's lane (we just set that above).
143        for (col, lane_opt) in lanes.iter_mut().enumerate() {
144            if col == node_col {
145                continue;
146            }
147            if let Some(lane) = lane_opt {
148                if !oid_set.contains_key(lane.target_oid.as_str()) {
149                    *lane_opt = None;
150                }
151            }
152        }
153
154        // Compact: remove trailing empty lanes.
155        while lanes.last().is_some_and(|l| l.is_none()) {
156            lanes.pop();
157        }
158
159        let width = lanes.len().max(1);
160
161        rows.push(GraphRow {
162            width,
163            node_column: node_col,
164            node_color,
165            edges,
166        });
167    }
168
169    rows
170}
171
172#[cfg(test)]
173mod tests {
174    use super::*;
175    use chrono::Utc;
176
177    fn make_commit(oid: &str, parents: &[&str]) -> CommitInfo {
178        CommitInfo {
179            oid: oid.to_string(),
180            short_oid: oid[..7.min(oid.len())].to_string(),
181            summary: format!("commit {oid}"),
182            message: format!("commit {oid}"),
183            author_name: "Test".to_string(),
184            author_email: "test@test.com".to_string(),
185            time: Utc::now(),
186            parent_ids: parents.iter().map(|s| s.to_string()).collect(),
187            refs: Vec::new(),
188        }
189    }
190
191    #[test]
192    fn empty_commits_returns_empty_graph() {
193        let rows = build_graph(&[]);
194        assert!(rows.is_empty());
195    }
196
197    #[test]
198    fn single_commit_no_parents() {
199        let commits = vec![make_commit("aaa0000", &[])];
200        let rows = build_graph(&commits);
201        assert_eq!(rows.len(), 1);
202        assert_eq!(rows[0].node_column, 0);
203        assert!(rows[0].edges.is_empty());
204    }
205
206    #[test]
207    fn linear_history_same_colour() {
208        let commits = vec![
209            make_commit("ccc0000", &["bbb0000"]),
210            make_commit("bbb0000", &["aaa0000"]),
211            make_commit("aaa0000", &[]),
212        ];
213        let rows = build_graph(&commits);
214        assert_eq!(rows.len(), 3);
215        for row in &rows {
216            assert_eq!(row.node_column, 0);
217        }
218        // All nodes should share the same colour (lane-based colouring).
219        assert_eq!(rows[0].node_color, rows[1].node_color);
220        assert_eq!(rows[1].node_color, rows[2].node_color);
221    }
222
223    #[test]
224    fn branch_uses_separate_lane() {
225        let commits = vec![
226            make_commit("ddd0000", &["bbb0000", "ccc0000"]),
227            make_commit("ccc0000", &["aaa0000"]),
228            make_commit("bbb0000", &["aaa0000"]),
229            make_commit("aaa0000", &[]),
230        ];
231        let rows = build_graph(&commits);
232        assert_eq!(rows.len(), 4);
233        let merge_row = &rows[0];
234        assert!(merge_row.edges.len() >= 2);
235    }
236
237    #[test]
238    fn merge_creates_different_colour_for_second_parent() {
239        let commits = vec![
240            make_commit("merge00", &["parent1", "parent2"]),
241            make_commit("parent2", &["base000"]),
242            make_commit("parent1", &["base000"]),
243            make_commit("base000", &[]),
244        ];
245        let rows = build_graph(&commits);
246        let merge_edges = &rows[0].edges;
247        let cross_edges: Vec<_> = merge_edges
248            .iter()
249            .filter(|e| e.from_column != e.to_column)
250            .collect();
251        assert!(!cross_edges.is_empty(), "merge must have a cross-edge");
252        assert_ne!(cross_edges[0].color_index, rows[0].node_color);
253    }
254
255    #[test]
256    fn graph_width_is_at_least_node_column_plus_one() {
257        let commits = vec![
258            make_commit("ccc0000", &["bbb0000"]),
259            make_commit("bbb0000", &["aaa0000"]),
260            make_commit("aaa0000", &[]),
261        ];
262        let rows = build_graph(&commits);
263        for row in &rows {
264            assert!(row.width > row.node_column);
265        }
266    }
267}