use std::collections::HashMap;
use crate::graph::types::ChangeGraph;
use crate::jj::types::Signature;
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct LayoutNode {
pub row: usize,
pub col: usize,
pub change_id: String,
pub commit_id: String,
pub summary: String,
pub description: String,
pub bookmark_names: Vec<String>,
pub is_trunk: bool,
pub is_leaf: bool,
pub stack_index: usize,
pub short_change_id: String,
pub author: Signature,
pub files: Vec<String>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct LayoutEdge {
pub from_row: usize,
pub from_col: usize,
pub to_row: usize,
pub to_col: usize,
}
#[derive(Debug, Clone)]
pub struct GraphLayout {
pub nodes: Vec<LayoutNode>,
pub edges: Vec<LayoutEdge>,
pub total_rows: usize,
pub total_cols: usize,
}
impl GraphLayout {
pub fn node_at(&self, row: usize, col: usize) -> Option<&LayoutNode> {
self.nodes.iter().find(|n| n.row == row && n.col == col)
}
#[expect(dead_code, reason = "available for future navigation features")]
pub fn node_index_at(&self, row: usize, col: usize) -> Option<usize> {
self.nodes.iter().position(|n| n.row == row && n.col == col)
}
pub fn leaf_nodes(&self) -> Vec<&LayoutNode> {
let mut leaves: Vec<&LayoutNode> = self.nodes.iter().filter(|n| n.is_leaf).collect();
leaves.sort_by_key(|n| n.col);
leaves
}
}
pub fn build_layout(graph: &ChangeGraph) -> GraphLayout {
if graph.stacks.is_empty() {
return GraphLayout {
nodes: vec![],
edges: vec![],
total_rows: 0,
total_cols: 0,
};
}
let mut placed: HashMap<String, (usize, usize)> = HashMap::new();
let mut nodes: Vec<LayoutNode> = Vec::new();
let mut edges: Vec<LayoutEdge> = Vec::new();
let trunk_row = 0;
nodes.push(LayoutNode {
row: trunk_row,
col: 0,
change_id: String::new(),
commit_id: String::new(),
summary: "trunk".to_string(),
description: String::new(),
bookmark_names: vec![],
is_trunk: true,
is_leaf: false,
stack_index: 0,
short_change_id: String::new(),
author: Signature {
name: String::new(),
email: String::new(),
timestamp: String::new(),
},
files: vec![],
});
let num_stacks = graph.stacks.len();
let mut max_row: usize = 0;
for (stack_idx, stack) in graph.stacks.iter().enumerate() {
let col = stack_idx;
let mut depth: usize = 1;
let mut prev_row = trunk_row;
let mut prev_col: usize = 0;
for (seg_idx, segment) in stack.segments.iter().enumerate() {
let is_last_segment = seg_idx == stack.segments.len() - 1;
let commits: Vec<_> = segment.commits.iter().rev().collect();
for (commit_idx, commit) in commits.iter().enumerate() {
let is_last_commit_in_segment = commit_idx == commits.len() - 1;
if let Some(&(existing_row, existing_col)) = placed.get(&commit.commit_id) {
prev_row = existing_row;
prev_col = existing_col;
depth = existing_row + 1;
continue;
}
let bookmark_names = if is_last_commit_in_segment {
segment.bookmark_names.clone()
} else {
vec![]
};
let summary = commit
.description
.lines()
.next()
.map(str::trim)
.filter(|l| !l.is_empty())
.unwrap_or("(no description)")
.to_string();
let is_leaf = is_last_segment && is_last_commit_in_segment;
let row = depth;
depth += 1;
nodes.push(LayoutNode {
row,
col,
change_id: commit.change_id.clone(),
commit_id: commit.commit_id.clone(),
summary,
description: commit.description.clone(),
bookmark_names,
is_trunk: false,
is_leaf,
stack_index: stack_idx,
short_change_id: commit.short_change_id.clone(),
author: commit.author.clone(),
files: commit.files.clone(),
});
edges.push(LayoutEdge {
from_row: prev_row,
from_col: prev_col,
to_row: row,
to_col: col,
});
placed.insert(commit.commit_id.clone(), (row, col));
max_row = max_row.max(row);
prev_row = row;
prev_col = col;
}
}
}
nodes.sort_by_key(|n| (n.row, n.col));
let total_rows = max_row + 1;
let total_cols = num_stacks.max(1);
GraphLayout {
nodes,
edges,
total_rows,
total_cols,
}
}
pub fn path_to_leaf(layout: &GraphLayout, leaf_row: usize, leaf_col: usize) -> Vec<&LayoutNode> {
let mut path = Vec::new();
let mut current_row = leaf_row;
let mut current_col = leaf_col;
while let Some(node) = layout.node_at(current_row, current_col) {
path.push(node);
if node.is_trunk {
break;
}
let Some(edge) = layout
.edges
.iter()
.find(|e| e.to_row == current_row && e.to_col == current_col)
else {
break;
};
current_row = edge.from_row;
current_col = edge.from_col;
}
path.reverse(); path
}
#[cfg(test)]
mod tests {
use std::collections::HashMap;
use std::collections::HashSet;
use super::*;
use crate::graph::types::BookmarkSegment;
use crate::graph::types::BranchStack;
use crate::graph::types::ChangeGraph;
use crate::graph::types::SegmentCommit;
fn make_graph(stacks: Vec<BranchStack>) -> ChangeGraph {
ChangeGraph {
adjacency_list: HashMap::new(),
stack_leaves: HashSet::new(),
stack_roots: HashSet::new(),
segments: HashMap::new(),
tainted_change_ids: HashSet::new(),
excluded_bookmark_count: 0,
stacks,
}
}
fn make_segment(names: &[&str], change_id: &str, descriptions: &[&str]) -> BookmarkSegment {
BookmarkSegment {
bookmark_names: names.iter().map(ToString::to_string).collect(),
change_id: change_id.to_string(),
commits: descriptions
.iter()
.enumerate()
.map(|(i, desc)| SegmentCommit {
commit_id: format!("c_{change_id}_{i}"),
change_id: change_id.to_string(),
description: desc.to_string(),
author: crate::jj::types::Signature {
name: "Test".to_string(),
email: "test@test.com".to_string(),
timestamp: "T".to_string(),
},
committer: crate::jj::types::Signature {
name: "Test".to_string(),
email: "test@test.com".to_string(),
timestamp: "T".to_string(),
},
files: vec![],
short_change_id: change_id[..4.min(change_id.len())].to_string(),
})
.collect(),
}
}
#[test]
fn empty_graph_layout() {
let graph = make_graph(vec![]);
let layout = build_layout(&graph);
assert!(layout.nodes.is_empty());
assert_eq!(layout.total_rows, 0);
}
#[test]
fn single_linear_stack() {
let graph = make_graph(vec![BranchStack {
segments: vec![
make_segment(&["base"], "ch_a", &["add base"]),
make_segment(&["leaf"], "ch_b", &["add leaf"]),
],
}]);
let layout = build_layout(&graph);
assert_eq!(layout.nodes.len(), 3);
assert_eq!(layout.total_rows, 3);
assert_eq!(layout.total_cols, 1);
assert!(layout.nodes[0].is_trunk);
assert_eq!(layout.nodes[1].row, 1);
assert_eq!(layout.nodes[1].bookmark_names, vec!["base"]);
assert!(!layout.nodes[1].is_leaf);
assert_eq!(layout.nodes[2].row, 2);
assert_eq!(layout.nodes[2].bookmark_names, vec!["leaf"]);
assert!(layout.nodes[2].is_leaf);
assert_eq!(layout.edges.len(), 2);
}
#[test]
fn two_branching_stacks() {
let graph = make_graph(vec![
BranchStack {
segments: vec![make_segment(&["alpha"], "ch_alpha", &["alpha work"])],
},
BranchStack {
segments: vec![make_segment(&["beta"], "ch_beta", &["beta work"])],
},
]);
let layout = build_layout(&graph);
assert_eq!(layout.nodes.len(), 3);
assert_eq!(layout.total_cols, 2);
let alpha = layout
.nodes
.iter()
.find(|n| n.bookmark_names.contains(&"alpha".to_string()))
.unwrap();
assert_eq!(alpha.col, 0);
assert!(alpha.is_leaf);
let beta = layout
.nodes
.iter()
.find(|n| n.bookmark_names.contains(&"beta".to_string()))
.unwrap();
assert_eq!(beta.col, 1);
assert!(beta.is_leaf);
}
#[test]
fn shared_root_segment() {
let graph = make_graph(vec![
BranchStack {
segments: vec![
make_segment(&["base"], "ch_shared", &["shared base"]),
make_segment(&["feat-a"], "ch_a", &["feature a"]),
],
},
BranchStack {
segments: vec![
make_segment(&["base"], "ch_shared", &["shared base"]),
make_segment(&["feat-b"], "ch_b", &["feature b"]),
],
},
]);
let layout = build_layout(&graph);
assert_eq!(layout.nodes.len(), 4);
let shared_nodes: Vec<_> = layout
.nodes
.iter()
.filter(|n| n.change_id == "ch_shared")
.collect();
assert_eq!(shared_nodes.len(), 1);
assert_eq!(shared_nodes[0].col, 0);
let feat_a = layout.nodes.iter().find(|n| n.change_id == "ch_a").unwrap();
let feat_b = layout.nodes.iter().find(|n| n.change_id == "ch_b").unwrap();
assert_eq!(feat_a.col, 0);
assert_eq!(feat_b.col, 1);
assert!(feat_a.is_leaf);
assert!(feat_b.is_leaf);
}
#[test]
fn multi_commit_segment() {
let graph = make_graph(vec![BranchStack {
segments: vec![make_segment(
&["feat"],
"ch_a",
&["second commit", "first commit"],
)],
}]);
let layout = build_layout(&graph);
assert_eq!(layout.nodes.len(), 3);
let first = layout
.nodes
.iter()
.find(|n| n.summary == "first commit")
.unwrap();
let second = layout
.nodes
.iter()
.find(|n| n.summary == "second commit")
.unwrap();
assert!(first.row < second.row);
assert!(first.bookmark_names.is_empty());
assert_eq!(second.bookmark_names, vec!["feat"]);
}
#[test]
fn path_to_leaf_linear() {
let graph = make_graph(vec![BranchStack {
segments: vec![
make_segment(&["base"], "ch_a", &["base work"]),
make_segment(&["leaf"], "ch_b", &["leaf work"]),
],
}]);
let layout = build_layout(&graph);
let leaf = layout.leaf_nodes()[0];
let path = path_to_leaf(&layout, leaf.row, leaf.col);
assert_eq!(path.len(), 3);
assert!(path[0].is_trunk);
assert_eq!(path[1].change_id, "ch_a");
assert_eq!(path[2].change_id, "ch_b");
}
#[test]
fn path_to_leaf_branching() {
let graph = make_graph(vec![
BranchStack {
segments: vec![
make_segment(&["base"], "ch_shared", &["shared"]),
make_segment(&["feat-a"], "ch_a", &["feature a"]),
],
},
BranchStack {
segments: vec![
make_segment(&["base"], "ch_shared", &["shared"]),
make_segment(&["feat-b"], "ch_b", &["feature b"]),
],
},
]);
let layout = build_layout(&graph);
let feat_b = layout.nodes.iter().find(|n| n.change_id == "ch_b").unwrap();
let path = path_to_leaf(&layout, feat_b.row, feat_b.col);
assert_eq!(path.len(), 3);
assert!(path[0].is_trunk);
assert_eq!(path[1].change_id, "ch_shared");
assert_eq!(path[2].change_id, "ch_b");
}
#[test]
fn leaf_nodes_returns_only_leaves() {
let graph = make_graph(vec![
BranchStack {
segments: vec![
make_segment(&["base"], "ch_a", &["base"]),
make_segment(&["leaf-1"], "ch_b", &["leaf 1"]),
],
},
BranchStack {
segments: vec![make_segment(&["leaf-2"], "ch_c", &["leaf 2"])],
},
]);
let layout = build_layout(&graph);
let leaves = layout.leaf_nodes();
assert_eq!(leaves.len(), 2);
assert!(leaves.iter().all(|n| n.is_leaf));
}
}