gitkraft_core/features/graph/
ops.rs1use std::collections::HashMap;
8
9use super::types::{GraphEdge, GraphRow};
10use crate::features::commits::CommitInfo;
11
12pub fn build_graph(commits: &[CommitInfo]) -> Vec<GraphRow> {
24 if commits.is_empty() {
25 return Vec::new();
26 }
27
28 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 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 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 let node_color = lanes[node_col].as_ref().map(|l| l.color).unwrap_or(0);
69
70 let mut edges: Vec<GraphEdge> = Vec::new();
72
73 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 let parents = &commit.parent_ids;
89
90 if parents.is_empty() {
91 lanes[node_col] = None;
93 } else {
94 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 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 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 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 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}