1use std::collections::HashMap;
4
5use crate::objects::ObjectId;
6
7#[derive(Clone, Copy, Debug, PartialEq, Eq)]
9pub struct GraphCharset {
10 pub commit: char,
12 pub pipe: char,
14 pub merge_right: char,
16 pub merge_left: char,
18 pub merge_middle: char,
20}
21
22impl GraphCharset {
23 pub const ASCII: Self = Self {
25 commit: '*',
26 pipe: '|',
27 merge_right: '\\',
28 merge_left: '/',
29 merge_middle: '.',
30 };
31
32 pub const UNICODE: Self = Self {
34 commit: '●',
35 pipe: '│',
36 merge_right: '╲',
37 merge_left: '╱',
38 merge_middle: '┼',
39 };
40}
41
42#[derive(Clone, Debug, PartialEq, Eq)]
44pub struct GraphCommitInput {
45 pub id: ObjectId,
47 pub parents: Vec<ObjectId>,
49}
50
51#[derive(Clone, Copy, Debug, PartialEq, Eq)]
53pub enum GraphLineKind {
54 Commit,
56 Padding,
58}
59
60#[derive(Clone, Debug, PartialEq, Eq)]
62pub struct GraphRenderedLine {
63 pub prefix: String,
65 pub kind: GraphLineKind,
67}
68
69#[derive(Clone, Debug, PartialEq, Eq)]
71pub struct GraphRowTopology {
72 pub commit_column: usize,
74 pub active_lanes: Vec<usize>,
76 pub edges: Vec<(usize, usize)>,
78}
79
80#[derive(Clone, Debug)]
82pub struct GraphLayout {
83 charset: GraphCharset,
84 lanes: Vec<Option<ObjectId>>,
86 width: usize,
87 topologies: Vec<GraphRowTopology>,
88}
89
90impl Default for GraphLayout {
91 fn default() -> Self {
92 Self::new(GraphCharset::ASCII)
93 }
94}
95
96impl GraphLayout {
97 #[must_use]
99 pub fn new(charset: GraphCharset) -> Self {
100 Self {
101 charset,
102 lanes: Vec::new(),
103 width: 0,
104 topologies: Vec::new(),
105 }
106 }
107
108 #[must_use]
110 pub fn topologies(&self) -> &[GraphRowTopology] {
111 &self.topologies
112 }
113
114 #[must_use]
116 pub fn width(&self) -> usize {
117 self.width
118 }
119
120 #[must_use]
122 pub fn render(&mut self, commits: &[GraphCommitInput]) -> Vec<Vec<GraphRenderedLine>> {
123 let mut blocks = Vec::with_capacity(commits.len());
124 for commit in commits {
125 blocks.push(self.render_one(commit));
126 }
127 blocks
128 }
129
130 fn render_one(&mut self, commit: &GraphCommitInput) -> Vec<GraphRenderedLine> {
131 let parents = &commit.parents;
132 let mut lines = Vec::new();
133
134 if self.lanes_matching(commit.id) > 1 {
135 let pad = self.format_convergence();
136 self.width = self.width.max(pad.len());
137 lines.push(GraphRenderedLine {
138 prefix: pad,
139 kind: GraphLineKind::Padding,
140 });
141 self.collapse_lanes_to(commit.id);
142 }
143
144 let commit_lane = self.find_or_open_lane(commit.id);
145 let prefix = self.format_commit_row(commit_lane, parents);
146 self.width = self.width.max(prefix.len());
147 self.topologies
148 .push(self.record_topology(commit_lane, parents));
149 lines.push(GraphRenderedLine {
150 prefix,
151 kind: GraphLineKind::Commit,
152 });
153
154 if parents.len() > 1 {
155 let pad = self.format_post_merge(commit_lane);
156 self.width = self.width.max(pad.len());
157 lines.push(GraphRenderedLine {
158 prefix: pad,
159 kind: GraphLineKind::Padding,
160 });
161 }
162
163 self.advance_lanes(parents, commit_lane);
164 lines
165 }
166
167 fn lanes_matching(&self, id: ObjectId) -> usize {
168 self.lanes.iter().filter(|slot| **slot == Some(id)).count()
169 }
170
171 fn collapse_lanes_to(&mut self, id: ObjectId) {
172 let mut kept = false;
173 self.lanes.retain(|slot| {
174 if *slot == Some(id) {
175 if kept {
176 return false;
177 }
178 kept = true;
179 return true;
180 }
181 slot.is_some()
182 });
183 if !kept {
184 self.lanes.push(Some(id));
185 }
186 }
187
188 fn find_or_open_lane(&mut self, id: ObjectId) -> usize {
189 if let Some(idx) = self.lanes.iter().position(|slot| *slot == Some(id)) {
190 return idx;
191 }
192 self.lanes.push(Some(id));
193 self.lanes.len() - 1
194 }
195
196 fn format_commit_row(&self, commit_lane: usize, parents: &[ObjectId]) -> String {
197 let cols = self.lane_count().max(commit_lane + 1);
198 let mut parts = Vec::with_capacity(cols);
199 for lane in 0..cols {
200 let cell = if lane == commit_lane {
201 if parents.len() > 1 {
202 format!("{} ", self.charset.commit)
203 } else {
204 format!("{} ", self.charset.commit)
205 }
206 } else if self.lane_active(lane) {
207 format!("{} ", self.charset.pipe)
208 } else {
209 " ".to_owned()
210 };
211 parts.push(cell);
212 }
213 parts.join("")
214 }
215
216 fn format_post_merge(&self, commit_lane: usize) -> String {
217 let cols = self.lane_count().max(commit_lane + 2);
218 let mut parts = Vec::with_capacity(cols);
219 for lane in 0..cols {
220 let cell = if lane == commit_lane {
221 format!("{} ", self.charset.pipe)
222 } else if lane == commit_lane + 1 {
223 format!("{} ", self.charset.merge_right)
224 } else if self.lane_active(lane) {
225 format!("{} ", self.charset.pipe)
226 } else {
227 " ".to_owned()
228 };
229 parts.push(cell);
230 }
231 parts.join("")
232 }
233
234 fn format_convergence(&self) -> String {
235 let cols = self.lane_count();
236 let mut parts = Vec::with_capacity(cols);
237 for lane in 0..cols {
238 let cell = if lane == 0 {
239 format!("{}{} ", self.charset.pipe, self.charset.merge_left)
240 } else {
241 " ".to_owned()
242 };
243 parts.push(cell);
244 }
245 parts.join("")
246 }
247
248 fn lane_count(&self) -> usize {
249 self.lanes.len()
250 }
251
252 fn lane_active(&self, lane: usize) -> bool {
253 self.lanes.get(lane).is_some_and(|s| s.is_some())
254 }
255
256 fn advance_lanes(&mut self, parents: &[ObjectId], commit_lane: usize) {
257 if parents.is_empty() {
258 if commit_lane < self.lanes.len() {
259 self.lanes[commit_lane] = None;
260 }
261 self.compact_lanes();
262 return;
263 }
264
265 let first = parents[0];
266 if commit_lane < self.lanes.len() {
267 self.lanes[commit_lane] = Some(first);
268 }
269
270 for &parent in parents.iter().skip(1) {
271 if self.lanes.contains(&Some(parent)) {
272 continue;
273 }
274 if let Some(empty) = self.lanes.iter().position(|slot| slot.is_none()) {
275 self.lanes[empty] = Some(parent);
276 } else {
277 self.lanes.push(Some(parent));
278 }
279 }
280 self.compact_lanes();
281 }
282
283 fn compact_lanes(&mut self) {
284 self.lanes.retain(|slot| slot.is_some());
285 }
286
287 fn record_topology(&self, commit_lane: usize, parents: &[ObjectId]) -> GraphRowTopology {
288 let mut edges = Vec::new();
289 let parent_to_lane: HashMap<ObjectId, usize> = self
290 .lanes
291 .iter()
292 .enumerate()
293 .filter_map(|(i, slot)| slot.map(|oid| (oid, i)))
294 .collect();
295 for parent in parents {
296 if let Some(&parent_lane) = parent_to_lane.get(parent) {
297 edges.push((commit_lane, parent_lane));
298 }
299 }
300 GraphRowTopology {
301 commit_column: commit_lane,
302 active_lanes: (0..self.lanes.len()).collect(),
303 edges,
304 }
305 }
306}
307
308#[cfg(test)]
309mod tests {
310 use super::*;
311 use crate::objects::ObjectId;
312
313 fn oid(n: u8) -> ObjectId {
314 ObjectId::from_bytes(&[n; 20]).expect("test oid")
315 }
316
317 fn flat_prefixes(blocks: &[Vec<GraphRenderedLine>]) -> String {
318 blocks
319 .iter()
320 .flat_map(|b| b.iter().map(|l| l.prefix.clone()))
321 .collect::<Vec<_>>()
322 .join("\n")
323 }
324
325 #[test]
326 fn graph_layout_linear() {
327 let c2 = oid(2);
328 let c1 = oid(1);
329 let c0 = oid(0);
330 let inputs = [
331 GraphCommitInput {
332 id: c2,
333 parents: vec![c1],
334 },
335 GraphCommitInput {
336 id: c1,
337 parents: vec![c0],
338 },
339 GraphCommitInput {
340 id: c0,
341 parents: vec![],
342 },
343 ];
344 let mut layout = GraphLayout::new(GraphCharset::ASCII);
345 let blocks = layout.render(&inputs);
346 assert!(flat_prefixes(&blocks).starts_with("* "));
347 assert_eq!(layout.topologies().len(), 3);
348 }
349
350 #[test]
351 fn graph_layout_merge_fork() {
352 let root = oid(0);
353 let main = oid(1);
354 let topic = oid(2);
355 let merge = oid(3);
356 let inputs = [
357 GraphCommitInput {
358 id: merge,
359 parents: vec![main, topic],
360 },
361 GraphCommitInput {
362 id: topic,
363 parents: vec![root],
364 },
365 GraphCommitInput {
366 id: main,
367 parents: vec![root],
368 },
369 GraphCommitInput {
370 id: root,
371 parents: vec![],
372 },
373 ];
374 let mut layout = GraphLayout::new(GraphCharset::ASCII);
375 let blocks = layout.render(&inputs);
376 let art = flat_prefixes(&blocks);
377 assert!(
378 art.contains('\\'),
379 "merge should draw a backslash rail: {art}"
380 );
381 assert!(art.contains('/'), "convergence should draw slash: {art}");
382 assert!(
383 art.contains("| *"),
384 "secondary branch should use pipe column: {art}"
385 );
386 }
387}