gitkraft_core/features/graph/
ops.rs1use std::collections::HashSet;
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: HashSet<&str> = commits.iter().map(|c| c.oid.as_str()).collect();
41
42 for commit in commits {
43 let oid = &commit.oid;
44
45 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 let node_color = lanes[node_col].as_ref().map(|l| l.color).unwrap_or(0);
65
66 let mut edges: Vec<GraphEdge> = Vec::new();
68
69 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 let parents = &commit.parent_ids;
85
86 if parents.is_empty() {
87 lanes[node_col] = None;
89 } else {
90 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 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 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 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 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}