use std::collections::HashMap;
use icechunk_format::SnapshotId;
use icechunk_format::snapshot::SnapshotInfo;
#[derive(Debug, Clone)]
pub struct AncestryNode {
pub info: SnapshotInfo,
pub branches: Vec<String>,
pub tags: Vec<String>,
pub column: usize,
}
const MAX_DISPLAY_NODES: usize = 100;
#[derive(Debug, Clone)]
pub struct AncestryGraph {
pub nodes: Vec<AncestryNode>,
pub num_columns: usize,
pub all_branches: Vec<String>,
pub total_snapshots: usize,
pub plain: bool,
}
fn lookup_labels(map: &HashMap<SnapshotId, Vec<String>>, id: &SnapshotId) -> Vec<String> {
map.get(id).cloned().unwrap_or_default()
}
fn main_first_cmp(a: &str, b: &str) -> std::cmp::Ordering {
let a_is_main = a == "main";
let b_is_main = b == "main";
b_is_main.cmp(&a_is_main).then(a.cmp(b))
}
impl AncestryGraph {
pub fn new(
mut branch_ancestries: Vec<(String, Vec<SnapshotInfo>)>,
tag_map: &HashMap<SnapshotId, Vec<String>>,
mut all_branches: Vec<String>,
plain: bool,
) -> Self {
branch_ancestries.sort_by(|a, b| main_first_cmp(&a.0, &b.0));
all_branches.sort_by(|a, b| main_first_cmp(a, b));
if branch_ancestries.is_empty() {
return Self {
nodes: Vec::new(),
num_columns: 0,
all_branches,
total_snapshots: 0,
plain,
};
}
let mut branch_labels: HashMap<SnapshotId, Vec<String>> = HashMap::new();
for (name, snapshots) in &branch_ancestries {
if let Some(tip) = snapshots.first() {
branch_labels.entry(tip.id.clone()).or_default().push(name.clone());
}
}
let mut seen: HashMap<SnapshotId, usize> = HashMap::new();
let mut node_map: HashMap<SnapshotId, AncestryNode> = HashMap::new();
let num_columns = branch_ancestries.len();
for (col, (_name, snapshots)) in branch_ancestries.iter().enumerate() {
for info in snapshots {
if seen.contains_key(&info.id) {
break; }
seen.insert(info.id.clone(), col);
node_map.insert(
info.id.clone(),
AncestryNode {
branches: lookup_labels(&branch_labels, &info.id),
tags: lookup_labels(tag_map, &info.id),
column: col,
info: info.clone(),
},
);
}
}
let mut nodes: Vec<AncestryNode> = node_map.into_values().collect();
nodes.sort_by(|a, b| b.info.flushed_at.cmp(&a.info.flushed_at));
let total_snapshots = nodes.len();
if total_snapshots > MAX_DISPLAY_NODES {
nodes.truncate(MAX_DISPLAY_NODES);
}
Self { nodes, num_columns, all_branches, total_snapshots, plain }
}
fn color_index_for_branch(&self, name: &str) -> usize {
self.all_branches.iter().position(|b| b == name).unwrap_or(0)
}
}
#[derive(Debug, Clone)]
pub enum LayoutElement {
Node { row: usize, col: usize, node_idx: usize },
Line { from_row: usize, to_row: usize, col: usize },
Fork { from_row: usize, from_col: usize, to_row: usize, to_col: usize },
}
impl AncestryGraph {
pub fn column_colors(&self) -> Vec<usize> {
(0..self.num_columns)
.map(|col| {
self.nodes
.iter()
.find(|n| n.column == col)
.and_then(|n| n.branches.first())
.map(|name| self.color_index_for_branch(name))
.unwrap_or(col)
})
.collect()
}
pub fn layout(&self) -> Vec<LayoutElement> {
let mut elements = Vec::new();
let mut active: Vec<bool> = vec![false; self.num_columns];
let mut fork_targets: HashMap<usize, SnapshotId> = HashMap::new();
for col in 1..self.num_columns {
if let Some(pid) = self
.nodes
.iter()
.rev()
.find(|n| n.column == col)
.and_then(|last| last.info.parent_id.as_ref())
{
fork_targets.insert(col, pid.clone());
}
}
for (row, node) in self.nodes.iter().enumerate() {
let col = node.column;
active[col] = true;
elements.push(LayoutElement::Node { row, col, node_idx: row });
let mut merging: Vec<usize> = fork_targets
.iter()
.filter(|(c, id)| **id == node.info.id && active[**c])
.map(|(c, _)| *c)
.collect();
merging.sort();
if !merging.is_empty() {
let fork_row = row.saturating_sub(1);
for &mc in &merging {
elements.push(LayoutElement::Fork {
from_row: fork_row,
from_col: mc,
to_row: row,
to_col: col,
});
active[mc] = false;
}
}
if row >= self.nodes.len() - 1 {
continue;
}
let next_row = row + 1;
let has_more = self.nodes[next_row..].iter().any(|n| n.column == col);
if !has_more && !fork_targets.contains_key(&col) {
active[col] = false;
}
for (c, is_active) in active.iter().enumerate() {
if *is_active {
elements.push(LayoutElement::Line {
from_row: row,
to_row: next_row,
col: c,
});
}
}
}
elements
}
}
struct BranchColor {
ansi: &'static str,
hex: &'static str,
}
const BRANCH_PALETTE: &[BranchColor] = &[
BranchColor { ansi: "\x1b[38;2;183;228;0m", hex: "#B7E400" }, BranchColor { ansi: "\x1b[38;2;166;83;255m", hex: "#A653FF" }, BranchColor { ansi: "\x1b[38;2;255;101;84m", hex: "#FF6554" }, BranchColor { ansi: "\x1b[38;2;255;158;13m", hex: "#FF9E0D" }, BranchColor { ansi: "\x1b[38;2;248;129;209m", hex: "#F881D1" }, ];
pub const TAG_COLOR_ANSI: &str = "\x1b[38;2;94;196;247m";
pub const TAG_COLOR_HEX: &str = "#5EC4F7";
pub fn palette_ansi(idx: usize) -> &'static str {
BRANCH_PALETTE[idx % BRANCH_PALETTE.len()].ansi
}
pub fn palette_hex(idx: usize) -> &'static str {
BRANCH_PALETTE[idx % BRANCH_PALETTE.len()].hex
}
#[cfg(test)]
mod tests {
use super::*;
use chrono::{Duration, Utc};
use std::collections::BTreeMap;
fn make_snapshot(id_byte: u8, parent_id_byte: Option<u8>) -> SnapshotInfo {
let mut id_bytes = [0u8; 12];
id_bytes[0] = id_byte;
let parent_id = parent_id_byte.map(|b| {
let mut p = [0u8; 12];
p[0] = b;
SnapshotId::new(p)
});
let base = Utc::now() - Duration::hours(100);
SnapshotInfo {
id: SnapshotId::new(id_bytes),
parent_id,
flushed_at: base + Duration::minutes(id_byte as i64),
message: format!("Commit {id_byte}"),
metadata: BTreeMap::new(),
}
}
#[test]
fn test_empty() {
let graph = AncestryGraph::new(vec![], &HashMap::new(), vec![], false);
assert_eq!(graph.nodes.len(), 0);
assert_eq!(graph.num_columns, 0);
}
#[test]
fn test_single_branch_with_labels() {
let s1 = make_snapshot(1, None);
let s2 = make_snapshot(2, Some(1));
let s3 = make_snapshot(3, Some(2));
let mut tag_map = HashMap::new();
tag_map.insert(s1.id.clone(), vec!["v1.0".to_string()]);
let graph = AncestryGraph::new(
vec![("main".to_string(), vec![s3.clone(), s2.clone(), s1.clone()])],
&tag_map,
vec!["main".to_string()],
false,
);
assert_eq!(graph.nodes.len(), 3);
assert_eq!(graph.num_columns, 1);
assert_eq!(graph.nodes[0].branches, vec!["main"]);
assert!(graph.nodes[1].branches.is_empty());
assert_eq!(graph.nodes[2].tags, vec!["v1.0"]);
assert!(graph.nodes.iter().all(|n| n.column == 0));
}
#[test]
fn test_from_tree_deduplicates() {
let s1 = make_snapshot(1, None);
let s2 = make_snapshot(2, Some(1));
let s3 = make_snapshot(3, Some(2));
let s4 = make_snapshot(4, Some(2));
let branch_ancestries = vec![
("main".to_string(), vec![s3.clone(), s2.clone(), s1.clone()]),
("feat".to_string(), vec![s4.clone(), s2.clone(), s1.clone()]),
];
let all = vec!["feat".to_string(), "main".to_string()];
let graph = AncestryGraph::new(branch_ancestries, &HashMap::new(), all, false);
assert_eq!(graph.nodes.len(), 4);
assert_eq!(graph.num_columns, 2);
let s3_node = graph.nodes.iter().find(|n| n.info.id == s3.id);
let s4_node = graph.nodes.iter().find(|n| n.info.id == s4.id);
assert!(s3_node.is_some());
assert!(s4_node.is_some());
assert_eq!(s3_node.map(|n| n.column), Some(0));
assert_eq!(s4_node.map(|n| n.column), Some(1));
}
#[test]
fn test_minimal_fork_display() {
let s1 = make_snapshot(1, None);
let s2 = make_snapshot(2, Some(1));
let s3 = make_snapshot(3, Some(1));
let branch_ancestries = vec![
("main".to_string(), vec![s2.clone(), s1.clone()]),
("feat".to_string(), vec![s3.clone(), s1.clone()]),
];
let all = vec!["feat".to_string(), "main".to_string()];
let graph = AncestryGraph::new(branch_ancestries, &HashMap::new(), all, true);
let output = graph.to_string();
let lines: Vec<&str> = output.lines().collect();
eprintln!("=== minimal fork display ===");
for line in &lines {
eprintln!(" {line:?}");
}
assert!(lines[0].contains("feat"), "first line should have feat label");
assert!(lines[0].contains("Commit 3"), "first line should be s3");
assert!(
!lines[0].starts_with('│'),
"trunk | should not appear above trunk's first node: {:?}",
lines[0]
);
let has_fork = lines.iter().any(|l| l.contains('╯'));
assert!(has_fork, "should have a ╯ fork connector: {output}");
assert!(
lines.iter().any(|l| l.contains("main") && l.contains("Commit 2")),
"should have main Commit 2: {output}"
);
}
#[test]
fn test_from_tree_three_branches_trunk_priority() {
let s1 = make_snapshot(1, None); let s2 = make_snapshot(2, Some(1)); let s3 = make_snapshot(3, Some(2)); let s4 = make_snapshot(4, Some(2)); let s5 = make_snapshot(5, Some(4)); let s6 = make_snapshot(6, Some(3));
let branch_ancestries = vec![
("main".to_string(), vec![s3.clone(), s2.clone(), s1.clone()]),
(
"experiment".to_string(),
vec![s5.clone(), s4.clone(), s2.clone(), s1.clone()],
),
("hotfix".to_string(), vec![s6.clone(), s3.clone(), s2.clone(), s1.clone()]),
];
let mut tag_map = HashMap::new();
tag_map.insert(s2.id.clone(), vec!["v1.0".to_string()]);
let all =
vec!["experiment".to_string(), "hotfix".to_string(), "main".to_string()];
let graph = AncestryGraph::new(branch_ancestries, &tag_map, all, false);
assert_eq!(graph.nodes.len(), 6);
let find = |id: &SnapshotId| graph.nodes.iter().find(|n| &n.info.id == id);
let s1_node = find(&s1.id).expect("s1 missing");
let s2_node = find(&s2.id).expect("s2 missing");
let s3_node = find(&s3.id).expect("s3 missing");
let s4_node = find(&s4.id).expect("s4 missing");
let s5_node = find(&s5.id).expect("s5 missing");
let s6_node = find(&s6.id).expect("s6 missing");
assert_eq!(s1_node.column, 0, "s1 (shared root) should be trunk");
assert_eq!(s2_node.column, 0, "s2 (shared ancestor) should be trunk");
assert_eq!(s3_node.column, 0, "s3 (main tip) should be trunk");
assert_eq!(s5_node.column, 1, "s5 (experiment tip) should be side branch");
assert_eq!(s4_node.column, 1, "s4 (experiment commit) should be side branch");
assert_eq!(s6_node.column, 2, "s6 (hotfix tip) should be side branch");
assert!(s3_node.branches.contains(&"main".to_string()));
assert!(s5_node.branches.contains(&"experiment".to_string()));
assert!(s6_node.branches.contains(&"hotfix".to_string()));
assert!(s2_node.tags.contains(&"v1.0".to_string()));
let output = graph.to_string();
assert!(output.contains("main"), "output should mention main");
assert!(output.contains("experiment"), "output should mention experiment");
assert!(output.contains("hotfix"), "output should mention hotfix");
assert!(output.contains("v1.0"), "output should mention v1.0 tag");
assert!(
output.contains('╯'),
"tree output should contain fork connectors: {output}"
);
}
#[test]
fn test_svg_output_structure() {
let s1 = make_snapshot(1, None);
let s2 = make_snapshot(2, Some(1));
let s3 = make_snapshot(3, Some(1));
let branch_ancestries = vec![
("main".to_string(), vec![s2.clone(), s1.clone()]),
("feat".to_string(), vec![s3.clone(), s1.clone()]),
];
let all = vec!["feat".to_string(), "main".to_string()];
let graph = AncestryGraph::new(branch_ancestries, &HashMap::new(), all, false);
let svg = graph.to_svg();
assert!(svg.contains("<svg"), "should contain SVG element");
assert!(svg.contains("</svg>"), "should close SVG element");
assert!(svg.contains("<circle"), "should have circle elements for nodes");
assert!(
svg.contains("<line") || svg.contains("<path"),
"should have line or path elements for connections"
);
assert!(svg.contains("main"), "should mention main branch");
assert!(svg.contains("feat"), "should mention feat branch");
assert!(svg.contains("Commit 1"), "should contain commit message");
assert!(svg.contains("Commit 2"), "should contain commit message");
assert!(
svg.contains("#B7E400") || svg.contains("#A653FF"),
"should use hex colors from palette"
);
}
#[test]
fn test_truncation() {
let count = MAX_DISPLAY_NODES + 50;
let snapshots: Vec<SnapshotInfo> = (0..count as u8)
.map(|i| {
let parent = if i == 0 { None } else { Some(i - 1) };
make_snapshot(i, parent)
})
.collect();
let reversed: Vec<SnapshotInfo> = snapshots.into_iter().rev().collect();
let graph = AncestryGraph::new(
vec![("main".to_string(), reversed)],
&HashMap::new(),
vec!["main".to_string()],
false,
);
assert_eq!(graph.nodes.len(), MAX_DISPLAY_NODES);
assert_eq!(graph.total_snapshots, count);
let output = graph.to_string();
assert!(
output.contains("showing 100 of 150 snapshots"),
"should show truncation message: {output}"
);
}
#[test]
fn test_multiple_branches_from_same_commit() {
let s1 = make_snapshot(1, None);
let s2 = make_snapshot(2, Some(1)); let s3 = make_snapshot(3, Some(1)); let s4 = make_snapshot(4, Some(1));
let branch_ancestries = vec![
("main".to_string(), vec![s2.clone(), s1.clone()]),
("feature-a".to_string(), vec![s3.clone(), s1.clone()]),
("feature-b".to_string(), vec![s4.clone(), s1.clone()]),
];
let all =
vec!["feature-a".to_string(), "feature-b".to_string(), "main".to_string()];
let graph = AncestryGraph::new(branch_ancestries, &HashMap::new(), all, true);
let output = graph.to_string();
let lines: Vec<&str> = output.lines().collect();
eprintln!("=== multiple branches from same commit ===");
for line in &lines {
eprintln!(" {line:?}");
}
assert!(output.contains("feature-a"), "should mention feature-a");
assert!(output.contains("feature-b"), "should mention feature-b");
let fork_point_line =
lines.iter().find(|l| l.contains("Commit 1")).expect("should have Commit 1");
let fork_count = fork_point_line.matches('╯').count();
assert_eq!(
fork_count, 2,
"both branches should have ╯ on the fork point row: {fork_point_line:?}"
);
let main_row =
lines.iter().position(|l| l.contains("main")).expect("should have main");
for line in &lines[..main_row] {
assert!(
!line.starts_with('│'),
"no trunk │ should appear above trunk's first node: {line:?}"
);
}
}
#[test]
fn test_horizontal_fill_between_node_and_fork() {
let s1 = make_snapshot(1, None);
let s2 = make_snapshot(2, Some(1));
let s3 = make_snapshot(3, Some(2));
let s4 = make_snapshot(4, Some(3)); let s5 = make_snapshot(5, Some(4)); let s6 = make_snapshot(6, Some(2));
let branch_ancestries = vec![
("main".to_string(), vec![s4.clone(), s3.clone(), s2.clone(), s1.clone()]),
("alpha".to_string(), vec![s5.clone(), s4.clone()]),
("beta".to_string(), vec![s6.clone(), s2.clone()]),
];
let all = vec!["alpha".to_string(), "beta".to_string(), "main".to_string()];
let graph = AncestryGraph::new(branch_ancestries, &HashMap::new(), all, true);
let output = graph.to_string();
let lines: Vec<&str> = output.lines().collect();
eprintln!("=== horizontal fill test ===");
for line in &lines {
eprintln!(" {line:?}");
}
let merge_line =
lines.iter().find(|l| l.contains("Commit 2")).expect("should have Commit 2");
assert!(
merge_line.contains('─'),
"blank columns between ● and ╯ should be filled with ─: {merge_line:?}"
);
assert!(
!merge_line.contains("● ─") && !merge_line.contains("─ ╯"),
"horizontal line should be continuous (no spaces between ─): {merge_line:?}"
);
}
}