mermaid_text/render/git_graph.rs
1//! Renderer for [`GitGraph`]. Produces a Unicode lane-diagram string.
2//!
3//! **Layout.**
4//!
5//! Each branch occupies a vertical "lane" (a column). Lanes are assigned in
6//! branch-creation order: `main` is lane 0, the first `branch X` is lane 1,
7//! etc. Time flows top-to-bottom; each commit occupies one row.
8//!
9//! **Glyph alphabet** (geometric line-drawing characters — not emoji):
10//!
11//! | Glyph | Meaning |
12//! |-------|--------------------------------------|
13//! | `*` | Normal commit |
14//! | `M` | Merge commit |
15//! | `C` | Cherry-pick commit |
16//! | `│` | Vertical lane continuation |
17//! | `╭` | Branch fork connector (parent lane) |
18//! | `╮` | Branch fork connector (child lane) |
19//! | `╯` | Merge incoming connector (src lane) |
20//! | `╰` | Merge incoming connector (dst lane) |
21//! | `─` | Horizontal connector segment |
22//!
23//! **Row anatomy.** Each output row consists of lane columns separated by a
24//! single space. The lane column width is 1 character (`*`, `M`, `C`, `│`, or
25//! space for an inactive lane). After all lane columns a label field appears:
26//! `id [tag]` where `[tag]` is omitted when the commit has no tag. A
27//! connector row (branch fork or merge) emits glyphs across the two lanes
28//! involved and a horizontal `─` fill between them.
29//!
30//! **Bottom labels.** After all commit rows, a label row prints each branch
31//! name centred under its lane column.
32//!
33//! **`max_width` handling.** When `max_width` is `Some(n)`, commit ids that
34//! would push the label column past the budget are truncated with `…`.
35
36use crate::git_graph::{CommitKind, Event, GitGraph};
37
38/// Glyph for a normal commit.
39const GLYPH_NORMAL: char = '*';
40/// Glyph for a merge commit.
41const GLYPH_MERGE: char = 'M';
42/// Glyph for a cherry-pick commit.
43const GLYPH_CHERRY: char = 'C';
44/// Vertical lane continuation character.
45const LANE_VERT: char = '│';
46/// Horizontal connector fill.
47const CONN_HORIZ: char = '─';
48/// Top-left fork: the lane that forks INTO the new branch.
49const CONN_FORK_LEFT: char = '╭';
50/// Top-right fork: position on the new (right) branch.
51const CONN_FORK_RIGHT: char = '╮';
52/// Bottom-right merge source: the lane being merged away FROM.
53const CONN_MERGE_SRC: char = '╯';
54/// Bottom-left merge destination: the lane receiving the merge.
55const CONN_MERGE_DST: char = '╰';
56
57/// Render a [`GitGraph`] to a Unicode string.
58///
59/// # Arguments
60///
61/// * `diag` — the parsed git graph
62/// * `max_width` — optional column budget; when `Some(N)` commit ids are
63/// truncated with `…` so the label column stays within the budget.
64///
65/// # Returns
66///
67/// A multi-line string ready for printing. Branch names appear at the bottom,
68/// one per lane column.
69pub fn render(diag: &GitGraph, max_width: Option<usize>) -> String {
70 if diag.branches.is_empty() {
71 return String::new();
72 }
73
74 let lane_count = diag.branches.len();
75 // Each lane column is 1 char wide; columns are separated by a single space.
76 // Total lane section width: lane_count + (lane_count - 1) spaces.
77 let lane_section_width = lane_count + (lane_count.saturating_sub(1));
78
79 // Label column starts after the lane section + 2-space gap.
80 let label_offset = lane_section_width + 2;
81
82 // The label budget: chars available for the "id [tag]" field.
83 let label_budget = max_width.map(|w| w.saturating_sub(label_offset));
84
85 let mut out = String::new();
86
87 // Track which lanes are "alive" (have been created). A lane becomes alive
88 // when its branch is created and stays alive until end-of-output.
89 // Lane 0 (main) is always alive from the start.
90 let mut alive: Vec<bool> = vec![false; lane_count];
91 alive[0] = true;
92
93 for event in &diag.events {
94 match event {
95 Event::Commit(idx) | Event::Merge(idx) | Event::CherryPick(idx) => {
96 let commit = &diag.commits[*idx];
97 let lane = diag.lane_of(&commit.branch).unwrap_or(0);
98
99 // If this is a merge commit, emit a connector row first showing
100 // where the merge source lane joins this lane.
101 if commit.kind == CommitKind::Merge
102 && let Some(mp_idx) = commit.merge_parent
103 {
104 let src_lane = diag.lane_of(&diag.commits[mp_idx].branch).unwrap_or(0);
105 if src_lane != lane {
106 // Emit a merge-arc row connecting src_lane → lane.
107 // The merge destination is lane (left), source is src_lane (right),
108 // assuming source lanes come from branches to the right.
109 out.push_str(&render_arc_row(
110 lane_count,
111 &alive,
112 lane,
113 src_lane,
114 CONN_MERGE_DST,
115 CONN_MERGE_SRC,
116 ));
117 out.push('\n');
118 }
119 }
120
121 // Emit the commit row.
122 let glyph = commit_glyph(commit.kind);
123 let row = render_commit_row(
124 lane_count,
125 &alive,
126 lane,
127 glyph,
128 &commit.id,
129 commit.tag.as_deref(),
130 label_budget,
131 );
132 out.push_str(&row);
133 out.push('\n');
134 }
135
136 Event::BranchCreated(branch_idx) => {
137 let branch = &diag.branches[*branch_idx];
138 let new_lane = *branch_idx; // branches are in creation order
139
140 // The parent lane is the lane of the branch from which this
141 // branch was forked. We find it by looking at created_after_commit.
142 // WHY: a branch always forks from wherever the current branch HEAD
143 // was at the time `branch X` was issued; that commit's branch
144 // gives us the parent lane.
145 let parent_lane = branch
146 .created_after_commit
147 .and_then(|ci| diag.lane_of(&diag.commits[ci].branch))
148 .unwrap_or(0);
149
150 // Mark this lane as alive before emitting the fork row.
151 if new_lane < alive.len() {
152 alive[new_lane] = true;
153 }
154
155 // Emit the fork arc row: parent lane forks into the new lane.
156 out.push_str(&render_arc_row(
157 lane_count,
158 &alive,
159 parent_lane,
160 new_lane,
161 CONN_FORK_LEFT,
162 CONN_FORK_RIGHT,
163 ));
164 out.push('\n');
165 }
166
167 Event::Checkout(_) => {
168 // Checkout events carry no visible row — they only change state.
169 // (State is tracked implicitly via the current_branch in the parser;
170 // the renderer uses commit.branch directly.)
171 }
172 }
173 }
174
175 // Branch label row — one label per lane, space-separated to align under lanes.
176 out.push_str(&render_label_row(diag));
177
178 // Trim trailing newline if present (the label row does not add one).
179 out
180}
181
182// ---------------------------------------------------------------------------
183// Row builders
184// ---------------------------------------------------------------------------
185
186/// Build a commit row for a single commit at `lane`.
187///
188/// Layout per row: one char per lane, separated by spaces, then 2 spaces,
189/// then the label (`id [tag]`).
190///
191/// Lanes that are alive but not the commit's lane show `│`; the commit's
192/// lane shows `glyph`; dead lanes show ` `.
193fn render_commit_row(
194 lane_count: usize,
195 alive: &[bool],
196 commit_lane: usize,
197 glyph: char,
198 id: &str,
199 tag: Option<&str>,
200 label_budget: Option<usize>,
201) -> String {
202 let lane_part = build_lane_part(lane_count, alive, |lane| {
203 if lane == commit_lane {
204 glyph
205 } else if alive[lane] {
206 LANE_VERT
207 } else {
208 ' '
209 }
210 });
211
212 let label = build_label(id, tag, label_budget);
213 format!("{lane_part} {label}")
214}
215
216/// Build an arc connector row (fork or merge) between two lanes.
217///
218/// `left_lane` receives `left_glyph`; `right_lane` receives `right_glyph`.
219/// Lanes between them show `─`; all other alive lanes show `│`.
220///
221/// WHY: the arc row visually connects two adjacent (or distant) lanes with a
222/// horizontal bridge. For fork rows the parent lane goes on the left and the
223/// new branch on the right (since branches are added to the right). For merge
224/// rows the destination is on the left and the source on the right.
225///
226/// NOTE: this function builds the row character-by-character including the
227/// inter-lane separator positions. Within the horizontal span `[lo, hi]` the
228/// separators between lane columns are `─` (continuing the bridge); outside
229/// the span they remain spaces, matching the rest of the diagram.
230fn render_arc_row(
231 lane_count: usize,
232 alive: &[bool],
233 left_lane: usize,
234 right_lane: usize,
235 left_glyph: char,
236 right_glyph: char,
237) -> String {
238 let lo = left_lane.min(right_lane);
239 let hi = left_lane.max(right_lane);
240
241 // Build the row manually so we can emit `─` for the inter-lane separator
242 // cells that fall inside the horizontal arc span [lo, hi]. The standard
243 // `build_lane_part` always uses a space separator, which leaves a visible
244 // gap between the corner glyphs even for immediately adjacent lanes.
245 let mut s = String::with_capacity(lane_count * 2);
246 for lane in 0..lane_count {
247 if lane > 0 {
248 // Separator cell: `─` inside the arc span, space outside.
249 if lane > lo && lane <= hi {
250 s.push(CONN_HORIZ);
251 } else {
252 s.push(' ');
253 }
254 }
255 let ch = if lane < alive.len() {
256 if lane == lo {
257 if lo == left_lane {
258 left_glyph
259 } else {
260 right_glyph
261 }
262 } else if lane == hi {
263 if hi == right_lane {
264 right_glyph
265 } else {
266 left_glyph
267 }
268 } else if lane > lo && lane < hi {
269 // Interior lane: horizontal fill.
270 CONN_HORIZ
271 } else if alive[lane] {
272 LANE_VERT
273 } else {
274 ' '
275 }
276 } else {
277 ' '
278 };
279 s.push(ch);
280 }
281 s
282}
283
284/// Build the bottom label row listing branch names under their lanes.
285///
286/// Each lane column is 1 char wide with a single space between lanes. Branch
287/// names are placed directly at each lane's starting position. When names
288/// overlap (because adjacent branch names are wider than the inter-lane gap)
289/// they are separated by a single space instead, keeping the output readable.
290///
291/// WHY: a simple "place at lane position" approach causes shorter names to be
292/// overwritten by wider neighbors. Instead we build the label row incrementally
293/// left-to-right, tracking the write cursor and advancing past the previous
294/// label before appending the next.
295fn render_label_row(diag: &GitGraph) -> String {
296 if diag.branches.is_empty() {
297 return String::new();
298 }
299
300 let mut out = String::new();
301 // `cursor` tracks the number of display characters written so far.
302 let mut cursor: usize = 0;
303
304 for (i, branch) in diag.branches.iter().enumerate() {
305 // The column position for this lane in the lane-section layout is i * 2
306 // (1 char per lane + 1 space separator).
307 let lane_pos = i * 2;
308
309 if cursor < lane_pos {
310 // Pad with spaces to reach the lane position.
311 let pad = lane_pos - cursor;
312 for _ in 0..pad {
313 out.push(' ');
314 }
315 cursor = lane_pos;
316 } else if cursor > lane_pos && i > 0 {
317 // Previous label spilled past this lane's position; add a single
318 // space separator to visually distinguish the names.
319 out.push(' ');
320 cursor += 1;
321 }
322
323 out.push_str(&branch.name);
324 cursor += branch.name.len();
325 }
326
327 out
328}
329
330// ---------------------------------------------------------------------------
331// Lane rendering helpers
332// ---------------------------------------------------------------------------
333
334/// Build the lane section of a row using a per-lane mapping function.
335///
336/// Returns a string of `lane_count` lane characters separated by single
337/// spaces. The mapping function receives the lane index and returns the
338/// character to place at that position.
339fn build_lane_part(lane_count: usize, alive: &[bool], mut f: impl FnMut(usize) -> char) -> String {
340 let mut s = String::with_capacity(lane_count * 2);
341 for i in 0..lane_count {
342 if i > 0 {
343 // Separator: if both the current lane and the previous are in the
344 // horizontal span of an arc, the separator itself becomes `─`.
345 // In this function we simply use space; the arc builder handles
346 // the `─` between endpoints differently (it applies to lane slots,
347 // not separators). This keeps the separator logic simple and the
348 // ASCII/Unicode rendering consistent.
349 s.push(' ');
350 }
351 let ch = if i < alive.len() { f(i) } else { ' ' };
352 s.push(ch);
353 }
354 s
355}
356
357/// Build the label string for a commit row: `"id"` or `"id [tag]"`.
358///
359/// If `label_budget` is `Some(n)` and the label would exceed `n` chars, the
360/// id is truncated with `…` to make it fit.
361fn build_label(id: &str, tag: Option<&str>, budget: Option<usize>) -> String {
362 let full = match tag {
363 Some(t) => format!("{id} [{t}]"),
364 None => id.to_string(),
365 };
366 match budget {
367 None => full,
368 Some(b) => {
369 if full.len() <= b {
370 full
371 } else if b == 0 {
372 String::new()
373 } else {
374 // Truncate: take at most b-1 chars from the id plus `…`.
375 let chars: Vec<char> = full.chars().collect();
376 let take = b.saturating_sub(1);
377 let truncated: String = chars.into_iter().take(take).collect();
378 format!("{truncated}\u{2026}") // …
379 }
380 }
381 }
382}
383
384/// Map a [`CommitKind`] to its display glyph.
385fn commit_glyph(kind: CommitKind) -> char {
386 match kind {
387 CommitKind::Normal => GLYPH_NORMAL,
388 CommitKind::Merge => GLYPH_MERGE,
389 CommitKind::CherryPick => GLYPH_CHERRY,
390 }
391}
392
393// ---------------------------------------------------------------------------
394// Tests
395// ---------------------------------------------------------------------------
396
397#[cfg(test)]
398mod tests {
399 use super::*;
400 use crate::parser::git_graph::parse;
401
402 // ---- (1) single-branch linear history renders as a vertical chain -----
403
404 #[test]
405 fn single_branch_linear_history() {
406 let src = "gitGraph\n commit id: \"a\"\n commit id: \"b\"\n commit id: \"c\"";
407 let g = parse(src).unwrap();
408 let out = render(&g, None);
409
410 // Every commit glyph must appear.
411 let commit_count = out
412 .lines()
413 .filter(|l| l.trim_start().starts_with('*'))
414 .count();
415 assert_eq!(commit_count, 3, "expected 3 commit rows:\n{out}");
416
417 // All ids must appear.
418 assert!(out.contains("a"), "id 'a' missing:\n{out}");
419 assert!(out.contains("b"), "id 'b' missing:\n{out}");
420 assert!(out.contains("c"), "id 'c' missing:\n{out}");
421 }
422
423 // ---- (2) two-branch fork shows lane separation -----------------------
424
425 #[test]
426 fn two_branch_fork_shows_lanes() {
427 let src = "gitGraph\n commit\n branch dev\n checkout dev\n commit id: \"d1\"";
428 let g = parse(src).unwrap();
429 let out = render(&g, None);
430
431 // There must be a fork connector row (contains CONN_FORK_LEFT or similar).
432 let has_fork = out.contains(CONN_FORK_LEFT) || out.contains(CONN_FORK_RIGHT);
433 assert!(has_fork, "no fork connector found:\n{out}");
434
435 // Both main and dev should appear in the label row.
436 assert!(out.contains("main"), "main label missing:\n{out}");
437 assert!(out.contains("dev"), "dev label missing:\n{out}");
438
439 // The dev commit must appear somewhere.
440 assert!(out.contains("d1"), "dev commit id missing:\n{out}");
441 }
442
443 // ---- (3) merge renders both incoming arrows --------------------------
444
445 #[test]
446 fn merge_renders_merge_glyph_and_arc() {
447 let src = "gitGraph\n commit\n branch dev\n checkout dev\n commit id: \"feat\"\n checkout main\n merge dev";
448 let g = parse(src).unwrap();
449 let out = render(&g, None);
450
451 // The merge glyph must appear.
452 assert!(
453 out.lines().any(|l| l.contains(GLYPH_MERGE)),
454 "no merge glyph 'M' found:\n{out}"
455 );
456 // The merge arc must contain the merge-incoming connectors.
457 let has_arc = out.contains(CONN_MERGE_SRC) || out.contains(CONN_MERGE_DST);
458 assert!(has_arc, "no merge arc connector found:\n{out}");
459 }
460
461 // ---- (4) tag appears in output ---------------------------------------
462
463 #[test]
464 fn tag_appears_in_output() {
465 let src = "gitGraph\n commit tag: \"v1.0\"";
466 let g = parse(src).unwrap();
467 let out = render(&g, None);
468 assert!(out.contains("v1.0"), "tag 'v1.0' not found:\n{out}");
469 assert!(out.contains('['), "tag bracket missing:\n{out}");
470 }
471
472 // ---- (5) empty diagram returns empty string -------------------------
473
474 #[test]
475 fn empty_graph_returns_empty_string() {
476 let g = GitGraph::default();
477 let out = render(&g, None);
478 assert!(out.is_empty());
479 }
480
481 // ---- (6) max_width truncates long ids --------------------------------
482
483 #[test]
484 fn max_width_truncates_long_id() {
485 let src = "gitGraph\n commit id: \"very-long-commit-identifier-here\"";
486 let g = parse(src).unwrap();
487 // Use a very narrow budget so truncation must happen.
488 let out = render(&g, Some(12));
489 // The output must not contain the full id.
490 assert!(
491 !out.contains("very-long-commit-identifier-here"),
492 "full id not truncated:\n{out}"
493 );
494 // But it must contain the truncation ellipsis.
495 assert!(out.contains('\u{2026}'), "ellipsis not found:\n{out}");
496 }
497
498 // ---- (7) fork arc has horizontal connector between corners -----------
499
500 #[test]
501 fn fork_arc_has_horizontal_connector() {
502 // A fork arc between two immediately adjacent lanes (main=0, dev=1)
503 // must have a `─` between the corner glyphs — no gap allowed.
504 let src = "gitGraph
505 commit id: \"first\"
506 branch dev
507 checkout dev
508 commit id: \"d1\"
509 checkout main
510 merge dev";
511 let g = parse(src).unwrap();
512 let out = render(&g, None);
513
514 // Find the fork row: contains CONN_FORK_LEFT.
515 let fork_row = out
516 .lines()
517 .find(|l| l.contains(CONN_FORK_LEFT))
518 .expect("no fork-arc row found in output");
519
520 // The fork row must contain a `─` connector (not be a bare `╰ ╮`).
521 assert!(
522 fork_row.contains(CONN_HORIZ),
523 "fork-arc row has no horizontal connector `─`; got: {fork_row:?}"
524 );
525
526 // Also verify the merge arc has a connector.
527 let merge_row = out
528 .lines()
529 .find(|l| l.contains(CONN_MERGE_DST))
530 .expect("no merge-arc row found in output");
531 assert!(
532 merge_row.contains(CONN_HORIZ),
533 "merge-arc row has no horizontal connector `─`; got: {merge_row:?}"
534 );
535 }
536}