Skip to main content

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}