Skip to main content

torii_lib/util/
graph.rs

1//! Commit graph rendering — lane-based ASCII like `git log --graph --all`.
2//!
3//! Pure logic, no TUI / git2 dependency. Take a topologically-sorted slice of
4//! `GraphCommit` (id + parent ids), produce a `Vec<GraphRow>` whose
5//! `lane_glyphs` field is the prefix to render before the commit subject.
6//!
7//! Rendering vocabulary (single-cell glyphs + spaces between lanes):
8//!   `*`  the active commit on its lane
9//!   `|`  a continuing lane
10//!   `\\` lane joining to the right (merge incoming or fork)
11//!   `/`  lane joining to the left (merge incoming from right or fork)
12//!   ` `  empty lane
13//!
14//! Each row produces TWO lines:
15//!   1. "commit line"   — one column per active lane: `*` for the commit's
16//!      lane, `|` for others.
17//!   2. "transition line" (optional) — only present when lanes split or merge
18//!      between this commit and the next. Shows `\` / `/` / `|` joins.
19//!
20//! For simplicity:
21//! - commit's first parent inherits its lane;
22//! - extra parents (merge) open new lanes to the right;
23//! - when a lane's tip is no longer referenced by any later commit, it closes
24//!   on the next transition line as `/` joining toward its first-parent lane.
25
26/// Visual glyph set for the graph. Pure data, no rendering — `render` reads
27/// the glyphs from the supplied set when emitting commit / transition lines.
28#[derive(Debug, Clone, Copy, PartialEq, Eq)]
29pub enum GraphStyle {
30    /// Plain ASCII (`* | / \`). Maximum portability.
31    Ascii,
32    /// Unicode box-drawing curves (`● │ ╮ ╰`). Recommended default.
33    Curves,
34    /// Heavy box-drawing (`⬢ ┃ ┓ ┗`). Bold, attention-grabbing.
35    Heavy,
36    /// Wide spacing + filled circles. Compact one-line per commit.
37    Bubbles,
38    /// Same glyphs as Bubbles but each commit spans 2 lines: a node row
39    /// followed by an info-padding row. Use with `expanded_extra_lines()`.
40    BubblesX,
41}
42
43impl GraphStyle {
44    pub fn from_str(s: &str) -> Self {
45        match s {
46            "ascii" => Self::Ascii,
47            "heavy" => Self::Heavy,
48            "bubbles" => Self::Bubbles,
49            "bubbles-x" | "bubbles_x" | "bubblesx" => Self::BubblesX,
50            _ => Self::Curves,
51        }
52    }
53
54    pub fn as_str(self) -> &'static str {
55        match self {
56            Self::Ascii => "ascii",
57            Self::Curves => "curves",
58            Self::Heavy => "heavy",
59            Self::Bubbles => "bubbles",
60            Self::BubblesX => "bubbles-x",
61        }
62    }
63
64    /// Glyph for the active commit on its lane, varying by parent count:
65    /// 0 = root, 1 = normal, ≥2 = merge.
66    ///
67    /// Curves / Bubbles / BubblesX share the bullseye family (〇 ⦿ ◉) for a
68    /// "node-on-a-line" look that reads like a graph editor (≈ kraken).
69    pub fn commit_glyph(self, parent_count: usize) -> char {
70        match (self, parent_count) {
71            (Self::Ascii, _) => '*',
72            (Self::Heavy, 0) => '□',
73            (Self::Heavy, 1) => '◉',
74            (Self::Heavy, _) => '◆',
75            (Self::Curves | Self::Bubbles | Self::BubblesX, 0) => '〇',
76            (Self::Curves | Self::Bubbles | Self::BubblesX, 1) => '⦿',
77            (Self::Curves | Self::Bubbles | Self::BubblesX, _) => '◉',
78        }
79    }
80
81    /// Vertical lane-continues glyph.
82    pub fn lane_glyph(self) -> char {
83        match self {
84            Self::Ascii => '|',
85            Self::Heavy => '┃',
86            Self::Curves | Self::Bubbles | Self::BubblesX => '│',
87        }
88    }
89
90    /// Glyph for a lane closing toward the left (fork-end / merge-target).
91    /// Curves / Bubbles use ◟ (lower-left half-circle) to suggest a smooth
92    /// arc from the closing lane into its target.
93    pub fn close_left_glyph(self) -> char {
94        match self {
95            Self::Ascii => '/',
96            Self::Heavy => '┛',
97            Self::Curves | Self::Bubbles | Self::BubblesX => '◟',
98        }
99    }
100
101    /// Glyph for a lane opening toward the right (new merge parent).
102    /// Curves / Bubbles use ◝ (upper-right half-circle) — mirror of ◟.
103    pub fn open_right_glyph(self) -> char {
104        match self {
105            Self::Ascii => '\\',
106            Self::Heavy => '┓',
107            Self::Curves | Self::Bubbles | Self::BubblesX => '◝',
108        }
109    }
110
111    /// Cells of horizontal padding between lanes. Bubbles styles use wider
112    /// spacing so commit nodes have room to breathe.
113    pub fn lane_spacing(self) -> usize {
114        match self {
115            Self::Ascii | Self::Curves | Self::Heavy => 1,
116            Self::Bubbles | Self::BubblesX => 3,
117        }
118    }
119
120    /// Number of *extra* padding lines to insert below each commit row.
121    /// 0 for compact styles. BubblesX uses 1 (commit row + breather row).
122    pub fn expanded_extra_lines(self) -> usize {
123        match self {
124            Self::BubblesX => 1,
125            _ => 0,
126        }
127    }
128}
129
130impl Default for GraphStyle {
131    fn default() -> Self {
132        Self::Curves
133    }
134}
135
136#[derive(Debug, Clone, PartialEq, Eq)]
137pub struct GraphCommit {
138    pub id: String,
139    pub parents: Vec<String>,
140}
141
142#[derive(Debug, Clone, PartialEq, Eq)]
143pub struct GraphRow {
144    /// Prefix line: lane glyphs at the moment of this commit (e.g. "* | |").
145    pub commit_line: String,
146    /// Optional transition line shown BEFORE the next commit_line. Empty if
147    /// no lane changes happen between this commit and the next.
148    pub transition_line: String,
149    /// Lane index where the commit sits (0-based, useful for coloring).
150    pub lane: usize,
151    /// Number of parents (1 = normal, 2+ = merge, 0 = root).
152    pub parent_count: usize,
153}
154
155/// Render the commit graph using the default ASCII style. Kept for callers
156/// that don't need to choose a style. New code should prefer `render_with`.
157#[allow(dead_code)]
158pub fn render(commits: &[GraphCommit]) -> Vec<GraphRow> {
159    render_with(commits, GraphStyle::Ascii)
160}
161
162/// Render the commit graph for an ordered list of commits with a chosen
163/// glyph style.
164///
165/// Input order MUST be topological (children before parents). Use git2's
166/// `Sort::TOPOLOGICAL | Sort::TIME` for live data.
167pub fn render_with(commits: &[GraphCommit], style: GraphStyle) -> Vec<GraphRow> {
168    let mut rows = Vec::with_capacity(commits.len());
169    // Each lane holds the OID it currently expects to render next, or None.
170    let mut lanes: Vec<Option<String>> = Vec::new();
171
172    for (idx, commit) in commits.iter().enumerate() {
173        // 1. Find or assign this commit's lane.
174        let lane = match lanes.iter().position(|l| l.as_deref() == Some(&commit.id)) {
175            Some(i) => i,
176            None => {
177                // First time seen — append a new lane on the right.
178                lanes.push(Some(commit.id.clone()));
179                lanes.len() - 1
180            }
181        };
182
183        // 2. Build the commit line. Use the styled glyphs.
184        let parent_count = commit.parents.len();
185        let pre_width = active_width(&lanes);
186        let lane_g = style.lane_glyph();
187        let commit_g = style.commit_glyph(parent_count);
188        let spacing = style.lane_spacing();
189        let pad: String = std::iter::repeat(' ').take(spacing).collect();
190        let mut commit_line = String::with_capacity(pre_width * (1 + spacing));
191        for i in 0..pre_width {
192            if i > 0 {
193                commit_line.push_str(&pad);
194            }
195            if i == lane {
196                commit_line.push(commit_g);
197            } else if lanes[i].is_some() {
198                commit_line.push(lane_g);
199            } else {
200                commit_line.push(' ');
201            }
202        }
203
204        // 3. Replace this commit's lane with its first parent (if any), open
205        //    additional lanes for extra parents.
206        if parent_count == 0 {
207            lanes[lane] = None;
208        } else {
209            // First parent stays on this lane (or merges into existing lane
210            // already holding this parent).
211            let first = commit.parents[0].clone();
212            // If another lane already expects this OID, close ours and the
213            // transition will join our lane to that one.
214            let already = lanes
215                .iter()
216                .enumerate()
217                .find(|(i, l)| *i != lane && l.as_deref() == Some(&first));
218            if already.is_some() {
219                lanes[lane] = None;
220            } else {
221                lanes[lane] = Some(first);
222            }
223
224            // Extra parents (merge): each opens (or joins) a lane.
225            for p in &commit.parents[1..] {
226                if !lanes.iter().any(|l| l.as_deref() == Some(p.as_str())) {
227                    // Reuse a free slot if any, else append.
228                    let slot = lanes.iter().position(|l| l.is_none());
229                    match slot {
230                        Some(i) => lanes[i] = Some(p.clone()),
231                        None => lanes.push(Some(p.clone())),
232                    }
233                }
234            }
235        }
236
237        // 4. Compute transition line vs the previous lanes snapshot.
238        //    For now we emit a simple straight-down or `/` close transition.
239        //    Full merge zigzags would need pre/post diff with `\` cells.
240        let post_width = active_width(&lanes);
241        let width = pre_width.max(post_width);
242        let close_g = style.close_left_glyph();
243        let open_g = style.open_right_glyph();
244        let mut transition = String::with_capacity(width * (1 + spacing));
245        let mut any_change = false;
246        for i in 0..width {
247            if i > 0 {
248                transition.push_str(&pad);
249            }
250            let was_active = i < pre_width
251                && (i == lane || lanes_at_commit_active(&lanes, i, lane, commit, idx));
252            let now_active = i < lanes.len() && lanes[i].is_some();
253            if i == lane && parent_count >= 2 {
254                transition.push(lane_g);
255            } else if !now_active && was_active {
256                transition.push(close_g);
257                any_change = true;
258            } else if now_active {
259                transition.push(lane_g);
260            } else {
261                transition.push(' ');
262            }
263        }
264        // Mark newly-opened merge parent lanes (right of `lane`) with open_g.
265        // Replace by lane index — char-aware so it works with multi-byte
266        // Unicode glyphs and variable spacing.
267        if parent_count >= 2 {
268            let new_parent_ids: Vec<&String> = commit.parents[1..].iter().collect();
269            for npid in new_parent_ids {
270                if let Some(i) = lanes.iter().position(|l| l.as_deref() == Some(npid.as_str())) {
271                    if i > lane {
272                        let chars: Vec<char> = transition.chars().collect();
273                        let cell = i * (1 + spacing);
274                        if cell < chars.len() {
275                            let cur = chars[cell];
276                            if cur == lane_g || cur == ' ' {
277                                let mut new_chars = chars;
278                                new_chars[cell] = open_g;
279                                transition = new_chars.into_iter().collect();
280                                any_change = true;
281                            }
282                        }
283                    }
284                }
285            }
286        }
287
288        // Trim trailing whitespace from transition so empty stays empty.
289        let trimmed = transition.trim_end().to_string();
290        let transition_final = if any_change && !trimmed.is_empty() {
291            trimmed
292        } else {
293            String::new()
294        };
295
296        // 5. Trim trailing tail of None lanes for compact rendering.
297        while lanes.last().map(|l| l.is_none()).unwrap_or(false) {
298            lanes.pop();
299        }
300
301        rows.push(GraphRow {
302            commit_line: commit_line.trim_end().to_string(),
303            transition_line: transition_final,
304            lane,
305            parent_count,
306        });
307    }
308
309    rows
310}
311
312fn active_width(lanes: &[Option<String>]) -> usize {
313    lanes
314        .iter()
315        .rposition(|l| l.is_some())
316        .map(|i| i + 1)
317        .unwrap_or(0)
318}
319
320/// Build a "breather" row that mirrors the current lanes but only shows
321/// vertical glyphs — useful for expanded styles (BubblesX) which insert one
322/// or more padding rows between commits to leave breathing room.
323///
324/// Pass the commit_line of the commit we're padding under; this function
325/// derives lane positions from it (any non-space char becomes a lane glyph
326/// of `style.lane_glyph()`).
327pub fn padding_row(commit_line: &str, style: GraphStyle) -> String {
328    let lane_g = style.lane_glyph();
329    commit_line
330        .chars()
331        .map(|c| if c == ' ' { ' ' } else { lane_g })
332        .collect()
333}
334
335/// Stub used only inside `render` to keep pre-mutation reasoning explicit.
336/// Always returns true — kept for future expansion when we need to compare
337/// pre/post lane snapshots properly.
338fn lanes_at_commit_active(
339    _lanes_after: &[Option<String>],
340    _i: usize,
341    _commit_lane: usize,
342    _commit: &GraphCommit,
343    _idx: usize,
344) -> bool {
345    true
346}
347
348/// Map an arbitrary lane index to a stable ANSI 256 color. Useful for TUI
349/// callers that want consistent colour-per-lane across renders.
350pub fn lane_color(lane: usize) -> u8 {
351    // Hand-picked vivid hues, well-spaced in HSL. Stable per-lane between
352    // renders so a branch keeps the same colour as you scroll.
353    const PALETTE: &[u8] = &[
354        39,   // bright cyan
355        208,  // orange
356        207,  // pink/magenta
357        226,  // yellow
358        46,   // green
359        99,   // purple
360        202,  // red-orange
361        51,   // turquoise
362        220,  // gold
363        129,  // violet
364    ];
365    PALETTE[lane % PALETTE.len()]
366}
367
368/// Format a single ref label with a leading icon for visual scanning.
369/// Used by both CLI graph output and TUI graph view.
370///
371///   `HEAD -> main`     → `★ HEAD -> main`
372///   `HEAD`             → `★ HEAD (detached)`
373///   `tag: v0.6.0`      → `◆ v0.6.0`
374///   `main` (branch)    → `▸ main`
375pub fn format_ref_badge(raw: &str) -> String {
376    if raw.starts_with("HEAD -> ") {
377        format!("★ {}", raw)
378    } else if raw == "HEAD" {
379        "★ HEAD (detached)".to_string()
380    } else if let Some(name) = raw.strip_prefix("tag: ") {
381        format!("◆ {}", name)
382    } else {
383        format!("▸ {}", raw)
384    }
385}
386
387/// Color hint for a ref badge (ANSI 256). Brand-consistent: HEAD bright,
388/// tags gold, branches cyan. Currently used only by the TUI badge renderer
389/// (planned); CLI prints unstyled badges.
390#[allow(dead_code)]
391pub fn ref_badge_color(raw: &str) -> u8 {
392    if raw.starts_with("HEAD") {
393        199 // hot pink
394    } else if raw.starts_with("tag: ") {
395        220 // gold
396    } else {
397        51 // turquoise
398    }
399}
400
401// ============================================================================
402// git2 integration
403// ============================================================================
404
405/// One commit with everything a TUI row needs: graph topology + decorations.
406#[derive(Debug, Clone)]
407#[allow(dead_code)] // author + timestamp consumed by callers we'll add later
408pub struct DecoratedCommit {
409    pub id: String,
410    pub short_id: String,
411    pub summary: String,
412    pub author: String,
413    pub timestamp: i64,
414    pub parents: Vec<String>,
415    /// Refs that point at this commit, formatted: ["HEAD -> main", "tag: v0.6.0"].
416    pub refs: Vec<String>,
417}
418
419/// Walk repository commits topologically and decorate with refs/tags.
420///
421/// `include_all` = true → include every local branch + tag tip as a starting
422/// point (mimics `git log --all`). False → only HEAD.
423pub fn walk_repo(
424    repo: &git2::Repository,
425    limit: usize,
426    include_all: bool,
427) -> Result<Vec<DecoratedCommit>, git2::Error> {
428    use std::collections::HashMap;
429
430    // Build oid → labels map by scanning refs once.
431    let mut labels: HashMap<git2::Oid, Vec<String>> = HashMap::new();
432    let head_oid = repo.head().ok().and_then(|h| h.target());
433    let head_name = repo
434        .head()
435        .ok()
436        .and_then(|h| h.shorthand().map(|s| s.to_string()));
437
438    for r in repo.references()?.flatten() {
439        let Some(oid) = r.target() else { continue };
440        let Some(name) = r.name() else { continue };
441        let label = if let Some(short) = name.strip_prefix("refs/heads/") {
442            if Some(oid) == head_oid && head_name.as_deref() == Some(short) {
443                format!("HEAD -> {}", short)
444            } else {
445                short.to_string()
446            }
447        } else if let Some(short) = name.strip_prefix("refs/tags/") {
448            format!("tag: {}", short)
449        } else if let Some(short) = name.strip_prefix("refs/remotes/") {
450            // Skip remotes for now (saturate). Caller can extend later.
451            let _ = short;
452            continue;
453        } else {
454            continue;
455        };
456        labels.entry(oid).or_default().push(label);
457    }
458
459    // Detached HEAD case: ensure HEAD label appears.
460    if let (Some(oid), Some(_)) = (head_oid, head_name.as_ref()) {
461        let entry = labels.entry(oid).or_default();
462        if !entry.iter().any(|s| s.starts_with("HEAD")) {
463            entry.insert(0, "HEAD".to_string());
464        }
465    }
466
467    let mut walk = repo.revwalk()?;
468    walk.set_sorting(git2::Sort::TOPOLOGICAL | git2::Sort::TIME)?;
469
470    if include_all {
471        for r in repo.references()?.flatten() {
472            let Some(name) = r.name() else { continue };
473            if name.starts_with("refs/heads/") || name.starts_with("refs/tags/") {
474                if let Some(oid) = r.target() {
475                    let _ = walk.push(oid);
476                }
477            }
478        }
479    } else {
480        walk.push_head()?;
481    }
482
483    let mut out = Vec::with_capacity(limit);
484    for oid_res in walk.take(limit) {
485        let oid = oid_res?;
486        let commit = repo.find_commit(oid)?;
487        let id = oid.to_string();
488        let short_id = id.chars().take(7).collect();
489        let summary = commit.summary().unwrap_or("").to_string();
490        let author = commit
491            .author()
492            .name()
493            .unwrap_or("")
494            .to_string();
495        let timestamp = commit.time().seconds();
496        let parents: Vec<String> = commit.parent_ids().map(|p| p.to_string()).collect();
497        let refs = labels.remove(&oid).unwrap_or_default();
498        out.push(DecoratedCommit {
499            id,
500            short_id,
501            summary,
502            author,
503            timestamp,
504            parents,
505            refs,
506        });
507    }
508    Ok(out)
509}
510
511/// Convenience: walk + render with default ASCII style.
512#[allow(dead_code)]
513pub fn render_repo(
514    repo: &git2::Repository,
515    limit: usize,
516    include_all: bool,
517) -> Result<Vec<(DecoratedCommit, GraphRow)>, git2::Error> {
518    render_repo_with(repo, limit, include_all, GraphStyle::Ascii)
519}
520
521/// Walk + render with a chosen glyph style.
522pub fn render_repo_with(
523    repo: &git2::Repository,
524    limit: usize,
525    include_all: bool,
526    style: GraphStyle,
527) -> Result<Vec<(DecoratedCommit, GraphRow)>, git2::Error> {
528    let commits = walk_repo(repo, limit, include_all)?;
529    let graph_input: Vec<GraphCommit> = commits
530        .iter()
531        .map(|c| GraphCommit {
532            id: c.id.clone(),
533            parents: c.parents.clone(),
534        })
535        .collect();
536    let rows = render_with(&graph_input, style);
537    Ok(commits.into_iter().zip(rows.into_iter()).collect())
538}
539
540#[cfg(test)]
541mod tests {
542    use super::*;
543
544    fn c(id: &str, parents: &[&str]) -> GraphCommit {
545        GraphCommit {
546            id: id.to_string(),
547            parents: parents.iter().map(|s| s.to_string()).collect(),
548        }
549    }
550
551    #[test]
552    fn linear_history() {
553        let commits = vec![c("c", &["b"]), c("b", &["a"]), c("a", &[])];
554        let rows = render(&commits);
555        assert_eq!(rows.len(), 3);
556        assert_eq!(rows[0].commit_line, "*");
557        assert_eq!(rows[1].commit_line, "*");
558        assert_eq!(rows[2].commit_line, "*");
559        assert_eq!(rows[0].lane, 0);
560        assert_eq!(rows[2].parent_count, 0);
561    }
562
563    #[test]
564    fn simple_merge() {
565        // d is a merge of b and c; both have parent a.
566        //   *   d (b, c)
567        //   |\
568        //   | * c
569        //   * | b
570        //   |/
571        //   * a
572        let commits = vec![
573            c("d", &["b", "c"]),
574            c("b", &["a"]),
575            c("c", &["a"]),
576            c("a", &[]),
577        ];
578        let rows = render(&commits);
579        assert_eq!(rows.len(), 4);
580        assert_eq!(rows[0].parent_count, 2);
581        // d sits on lane 0, opens lane 1 for parent c.
582        assert_eq!(rows[0].lane, 0);
583        assert!(rows[0].transition_line.contains('\\'));
584        // a closes both lanes — last commit, no parents.
585        assert_eq!(rows[3].parent_count, 0);
586    }
587
588    #[test]
589    fn fork_then_close() {
590        // c branches off from a:
591        //   * c (a)
592        //   | * b (a)
593        //   |/
594        //   * a
595        let commits = vec![c("c", &["a"]), c("b", &["a"]), c("a", &[])];
596        let rows = render(&commits);
597        assert_eq!(rows.len(), 3);
598        // After c, lane 0 expects a. b appears, gets new lane (1).
599        // When a appears, it occupies lane 0; lane 1 closes with '/'.
600        assert_eq!(rows[0].commit_line, "*");
601        assert!(rows[1].commit_line.contains('*'));
602    }
603
604    #[test]
605    fn lane_color_stable() {
606        assert_eq!(lane_color(0), lane_color(0));
607        assert_ne!(lane_color(0), lane_color(1));
608    }
609
610    #[test]
611    fn empty_input() {
612        assert_eq!(render(&[]), vec![]);
613    }
614}