use crate::git_graph::{CommitKind, Event, GitGraph};
const GLYPH_NORMAL: char = '*';
const GLYPH_MERGE: char = 'M';
const GLYPH_CHERRY: char = 'C';
const LANE_VERT: char = '│';
const CONN_HORIZ: char = '─';
const CONN_FORK_LEFT: char = '╭';
const CONN_FORK_RIGHT: char = '╮';
const CONN_MERGE_SRC: char = '╯';
const CONN_MERGE_DST: char = '╰';
pub fn render(diag: &GitGraph, max_width: Option<usize>) -> String {
if diag.branches.is_empty() {
return String::new();
}
let lane_count = diag.branches.len();
let lane_section_width = lane_count + (lane_count.saturating_sub(1));
let label_offset = lane_section_width + 2;
let label_budget = max_width.map(|w| w.saturating_sub(label_offset));
let mut out = String::new();
let mut alive: Vec<bool> = vec![false; lane_count];
alive[0] = true;
for event in &diag.events {
match event {
Event::Commit(idx) | Event::Merge(idx) | Event::CherryPick(idx) => {
let commit = &diag.commits[*idx];
let lane = diag.lane_of(&commit.branch).unwrap_or(0);
if commit.kind == CommitKind::Merge
&& let Some(mp_idx) = commit.merge_parent
{
let src_lane = diag.lane_of(&diag.commits[mp_idx].branch).unwrap_or(0);
if src_lane != lane {
out.push_str(&render_arc_row(
lane_count,
&alive,
lane,
src_lane,
CONN_MERGE_DST,
CONN_MERGE_SRC,
));
out.push('\n');
}
}
let glyph = commit_glyph(commit.kind);
let row = render_commit_row(
lane_count,
&alive,
lane,
glyph,
&commit.id,
commit.tag.as_deref(),
label_budget,
);
out.push_str(&row);
out.push('\n');
}
Event::BranchCreated(branch_idx) => {
let branch = &diag.branches[*branch_idx];
let new_lane = *branch_idx;
let parent_lane = branch
.created_after_commit
.and_then(|ci| diag.lane_of(&diag.commits[ci].branch))
.unwrap_or(0);
if new_lane < alive.len() {
alive[new_lane] = true;
}
out.push_str(&render_arc_row(
lane_count,
&alive,
parent_lane,
new_lane,
CONN_FORK_LEFT,
CONN_FORK_RIGHT,
));
out.push('\n');
}
Event::Checkout(_) => {
}
}
}
out.push_str(&render_label_row(diag));
out
}
fn render_commit_row(
lane_count: usize,
alive: &[bool],
commit_lane: usize,
glyph: char,
id: &str,
tag: Option<&str>,
label_budget: Option<usize>,
) -> String {
let lane_part = build_lane_part(lane_count, alive, |lane| {
if lane == commit_lane {
glyph
} else if alive[lane] {
LANE_VERT
} else {
' '
}
});
let label = build_label(id, tag, label_budget);
format!("{lane_part} {label}")
}
fn render_arc_row(
lane_count: usize,
alive: &[bool],
left_lane: usize,
right_lane: usize,
left_glyph: char,
right_glyph: char,
) -> String {
let lo = left_lane.min(right_lane);
let hi = left_lane.max(right_lane);
let mut s = String::with_capacity(lane_count * 2);
for lane in 0..lane_count {
if lane > 0 {
if lane > lo && lane <= hi {
s.push(CONN_HORIZ);
} else {
s.push(' ');
}
}
let ch = if lane < alive.len() {
if lane == lo {
if lo == left_lane {
left_glyph
} else {
right_glyph
}
} else if lane == hi {
if hi == right_lane {
right_glyph
} else {
left_glyph
}
} else if lane > lo && lane < hi {
CONN_HORIZ
} else if alive[lane] {
LANE_VERT
} else {
' '
}
} else {
' '
};
s.push(ch);
}
s
}
fn render_label_row(diag: &GitGraph) -> String {
if diag.branches.is_empty() {
return String::new();
}
let mut out = String::new();
let mut cursor: usize = 0;
for (i, branch) in diag.branches.iter().enumerate() {
let lane_pos = i * 2;
if cursor < lane_pos {
let pad = lane_pos - cursor;
for _ in 0..pad {
out.push(' ');
}
cursor = lane_pos;
} else if cursor > lane_pos && i > 0 {
out.push(' ');
cursor += 1;
}
out.push_str(&branch.name);
cursor += branch.name.len();
}
out
}
fn build_lane_part(lane_count: usize, alive: &[bool], mut f: impl FnMut(usize) -> char) -> String {
let mut s = String::with_capacity(lane_count * 2);
for i in 0..lane_count {
if i > 0 {
s.push(' ');
}
let ch = if i < alive.len() { f(i) } else { ' ' };
s.push(ch);
}
s
}
fn build_label(id: &str, tag: Option<&str>, budget: Option<usize>) -> String {
let full = match tag {
Some(t) => format!("{id} [{t}]"),
None => id.to_string(),
};
match budget {
None => full,
Some(b) => {
if full.len() <= b {
full
} else if b == 0 {
String::new()
} else {
let chars: Vec<char> = full.chars().collect();
let take = b.saturating_sub(1);
let truncated: String = chars.into_iter().take(take).collect();
format!("{truncated}\u{2026}") }
}
}
}
fn commit_glyph(kind: CommitKind) -> char {
match kind {
CommitKind::Normal => GLYPH_NORMAL,
CommitKind::Merge => GLYPH_MERGE,
CommitKind::CherryPick => GLYPH_CHERRY,
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::parser::git_graph::parse;
#[test]
fn single_branch_linear_history() {
let src = "gitGraph\n commit id: \"a\"\n commit id: \"b\"\n commit id: \"c\"";
let g = parse(src).unwrap();
let out = render(&g, None);
let commit_count = out
.lines()
.filter(|l| l.trim_start().starts_with('*'))
.count();
assert_eq!(commit_count, 3, "expected 3 commit rows:\n{out}");
assert!(out.contains("a"), "id 'a' missing:\n{out}");
assert!(out.contains("b"), "id 'b' missing:\n{out}");
assert!(out.contains("c"), "id 'c' missing:\n{out}");
}
#[test]
fn two_branch_fork_shows_lanes() {
let src = "gitGraph\n commit\n branch dev\n checkout dev\n commit id: \"d1\"";
let g = parse(src).unwrap();
let out = render(&g, None);
let has_fork = out.contains(CONN_FORK_LEFT) || out.contains(CONN_FORK_RIGHT);
assert!(has_fork, "no fork connector found:\n{out}");
assert!(out.contains("main"), "main label missing:\n{out}");
assert!(out.contains("dev"), "dev label missing:\n{out}");
assert!(out.contains("d1"), "dev commit id missing:\n{out}");
}
#[test]
fn merge_renders_merge_glyph_and_arc() {
let src = "gitGraph\n commit\n branch dev\n checkout dev\n commit id: \"feat\"\n checkout main\n merge dev";
let g = parse(src).unwrap();
let out = render(&g, None);
assert!(
out.lines().any(|l| l.contains(GLYPH_MERGE)),
"no merge glyph 'M' found:\n{out}"
);
let has_arc = out.contains(CONN_MERGE_SRC) || out.contains(CONN_MERGE_DST);
assert!(has_arc, "no merge arc connector found:\n{out}");
}
#[test]
fn tag_appears_in_output() {
let src = "gitGraph\n commit tag: \"v1.0\"";
let g = parse(src).unwrap();
let out = render(&g, None);
assert!(out.contains("v1.0"), "tag 'v1.0' not found:\n{out}");
assert!(out.contains('['), "tag bracket missing:\n{out}");
}
#[test]
fn empty_graph_returns_empty_string() {
let g = GitGraph::default();
let out = render(&g, None);
assert!(out.is_empty());
}
#[test]
fn max_width_truncates_long_id() {
let src = "gitGraph\n commit id: \"very-long-commit-identifier-here\"";
let g = parse(src).unwrap();
let out = render(&g, Some(12));
assert!(
!out.contains("very-long-commit-identifier-here"),
"full id not truncated:\n{out}"
);
assert!(out.contains('\u{2026}'), "ellipsis not found:\n{out}");
}
#[test]
fn fork_arc_has_horizontal_connector() {
let src = "gitGraph
commit id: \"first\"
branch dev
checkout dev
commit id: \"d1\"
checkout main
merge dev";
let g = parse(src).unwrap();
let out = render(&g, None);
let fork_row = out
.lines()
.find(|l| l.contains(CONN_FORK_LEFT))
.expect("no fork-arc row found in output");
assert!(
fork_row.contains(CONN_HORIZ),
"fork-arc row has no horizontal connector `─`; got: {fork_row:?}"
);
let merge_row = out
.lines()
.find(|l| l.contains(CONN_MERGE_DST))
.expect("no merge-arc row found in output");
assert!(
merge_row.contains(CONN_HORIZ),
"merge-arc row has no horizontal connector `─`; got: {merge_row:?}"
);
}
}