Skip to main content

gitkraft_core/features/graph/
ops.rs

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