Skip to main content

gitkraft_core/features/graph/
ops.rs

1//! Graph layout computation — lane-based algorithm for commit graph visualisation.
2
3use std::collections::HashMap;
4
5use super::types::{GraphEdge, GraphRow};
6use crate::features::commits::CommitInfo;
7
8/// Build a graph layout for a slice of commits (newest-first).
9///
10/// The algorithm maintains a set of "active lanes" — columns that carry a
11/// branch line downward through the graph.  Each lane tracks the OID of the
12/// next commit it expects to encounter.  When a commit is found, its lane
13/// becomes the *node column*, and the commit's parents are assigned to lanes
14/// (using the current lane for the first parent and allocating new lanes for
15/// any additional parents).
16pub fn build_graph(commits: &[CommitInfo]) -> Vec<GraphRow> {
17    if commits.is_empty() {
18        return Vec::new();
19    }
20
21    let mut lanes: Vec<Option<String>> = Vec::new();
22    let mut color_map: HashMap<String, usize> = HashMap::new();
23    let mut next_color: usize = 0;
24    let mut rows: Vec<GraphRow> = Vec::with_capacity(commits.len());
25
26    for commit in commits {
27        let oid = &commit.oid;
28
29        // Find or allocate the lane for this commit
30        let node_col = lanes
31            .iter()
32            .position(|l| l.as_deref() == Some(oid))
33            .unwrap_or_else(|| {
34                lanes.push(Some(oid.clone()));
35                lanes.len() - 1
36            });
37
38        // Assign a colour
39        let node_color = *color_map.entry(oid.clone()).or_insert_with(|| {
40            let c = next_color;
41            next_color += 1;
42            c
43        });
44
45        // Build edges
46        let mut edges: Vec<GraphEdge> = Vec::new();
47
48        // Pass-through edges for lanes that are not the node lane
49        for (col, lane) in lanes.iter().enumerate() {
50            if col == node_col {
51                continue;
52            }
53            if let Some(ref target_oid) = lane {
54                let lane_color = *color_map.entry(target_oid.clone()).or_insert_with(|| {
55                    let c = next_color;
56                    next_color += 1;
57                    c
58                });
59                edges.push(GraphEdge {
60                    from_column: col,
61                    to_column: col,
62                    color_index: lane_color,
63                });
64            }
65        }
66
67        // Handle the commit's parents
68        let parents = &commit.parent_ids;
69
70        if parents.is_empty() {
71            // Root commit — the lane simply ends.
72            lanes[node_col] = None;
73        } else {
74            // First parent takes over the node's lane.
75            let first_parent = &parents[0];
76            lanes[node_col] = Some(first_parent.clone());
77
78            let first_parent_color = *color_map.entry(first_parent.clone()).or_insert_with(|| {
79                let c = next_color;
80                next_color += 1;
81                c
82            });
83
84            edges.push(GraphEdge {
85                from_column: node_col,
86                to_column: node_col,
87                color_index: first_parent_color,
88            });
89
90            // Additional parents get new lanes (or reuse existing ones).
91            for parent_oid in &parents[1..] {
92                let existing = lanes
93                    .iter()
94                    .position(|l| l.as_deref() == Some(parent_oid.as_str()));
95
96                let target_col = if let Some(col) = existing {
97                    col
98                } else if let Some(free) = lanes.iter().position(|l| l.is_none()) {
99                    lanes[free] = Some(parent_oid.clone());
100                    free
101                } else {
102                    lanes.push(Some(parent_oid.clone()));
103                    lanes.len() - 1
104                };
105
106                let parent_color = *color_map.entry(parent_oid.clone()).or_insert_with(|| {
107                    let c = next_color;
108                    next_color += 1;
109                    c
110                });
111
112                edges.push(GraphEdge {
113                    from_column: node_col,
114                    to_column: target_col,
115                    color_index: parent_color,
116                });
117            }
118        }
119
120        // Compact: remove trailing None lanes
121        while lanes.last() == Some(&None) {
122            lanes.pop();
123        }
124
125        let width = lanes.len().max(1);
126
127        rows.push(GraphRow {
128            width,
129            node_column: node_col,
130            node_color,
131            edges,
132        });
133    }
134
135    rows
136}
137
138#[cfg(test)]
139mod tests {
140    use super::*;
141    use chrono::Utc;
142
143    fn make_commit(oid: &str, parents: &[&str]) -> CommitInfo {
144        CommitInfo {
145            oid: oid.to_string(),
146            short_oid: oid[..7.min(oid.len())].to_string(),
147            summary: format!("commit {oid}"),
148            message: format!("commit {oid}"),
149            author_name: "Test".to_string(),
150            author_email: "test@test.com".to_string(),
151            time: Utc::now(),
152            parent_ids: parents.iter().map(|s| s.to_string()).collect(),
153            refs: Vec::new(),
154        }
155    }
156
157    #[test]
158    fn empty_commits_returns_empty_graph() {
159        let rows = build_graph(&[]);
160        assert!(rows.is_empty());
161    }
162
163    #[test]
164    fn single_commit_no_parents() {
165        let commits = vec![make_commit("aaa0000", &[])];
166        let rows = build_graph(&commits);
167        assert_eq!(rows.len(), 1);
168        assert_eq!(rows[0].node_column, 0);
169        assert!(rows[0].edges.is_empty());
170    }
171
172    #[test]
173    fn linear_history() {
174        let commits = vec![
175            make_commit("ccc0000", &["bbb0000"]),
176            make_commit("bbb0000", &["aaa0000"]),
177            make_commit("aaa0000", &[]),
178        ];
179        let rows = build_graph(&commits);
180        assert_eq!(rows.len(), 3);
181        for row in &rows {
182            assert_eq!(row.node_column, 0);
183        }
184    }
185
186    #[test]
187    fn branch_uses_separate_lane() {
188        let commits = vec![
189            make_commit("ddd0000", &["bbb0000", "ccc0000"]),
190            make_commit("ccc0000", &["aaa0000"]),
191            make_commit("bbb0000", &["aaa0000"]),
192            make_commit("aaa0000", &[]),
193        ];
194        let rows = build_graph(&commits);
195        assert_eq!(rows.len(), 4);
196        let merge_row = &rows[0];
197        assert!(merge_row.edges.len() >= 2);
198    }
199
200    #[test]
201    fn graph_width_is_at_least_node_column_plus_one() {
202        let commits = vec![
203            make_commit("ccc0000", &["bbb0000"]),
204            make_commit("bbb0000", &["aaa0000"]),
205            make_commit("aaa0000", &[]),
206        ];
207        let rows = build_graph(&commits);
208        for row in &rows {
209            assert!(row.width > row.node_column);
210        }
211    }
212}