use crate::Error;
use crate::git_graph::{Branch, Commit, CommitKind, Event, GitGraph};
use crate::parser::common::strip_inline_comment;
pub fn parse(src: &str) -> Result<GitGraph, Error> {
let mut graph = GitGraph::default();
let mut header_seen = false;
let mut commit_counter: usize = 0;
let mut current_branch = "main".to_string();
graph.branches.push(Branch {
name: "main".to_string(),
created_after_commit: None,
});
for raw_line in src.lines() {
let line = strip_inline_comment(raw_line).trim();
if line.is_empty() {
continue;
}
if !header_seen {
if !line.starts_with("gitGraph") {
return Err(Error::ParseError(format!(
"expected `gitGraph` header, got {line:?}"
)));
}
header_seen = true;
continue;
}
let first = line.split_whitespace().next().unwrap_or("");
match first {
"commit" => {
handle_commit(line, &mut graph, ¤t_branch, &mut commit_counter)?;
}
"branch" => {
let cb = current_branch.clone();
handle_branch(line, &mut graph, &cb, &mut current_branch)?;
}
"checkout" => {
handle_checkout(line, &mut graph, &mut current_branch)?;
}
"merge" => {
handle_merge(line, &mut graph, ¤t_branch, &mut commit_counter)?;
}
"cherry-pick" => {
handle_cherry_pick(line, &mut graph, ¤t_branch, &mut commit_counter)?;
}
_ => {}
}
}
if !header_seen {
return Err(Error::ParseError(
"missing `gitGraph` header line".to_string(),
));
}
Ok(graph)
}
fn handle_commit(
line: &str,
graph: &mut GitGraph,
current_branch: &str,
counter: &mut usize,
) -> Result<(), Error> {
let id = extract_quoted_attr(line, "id").unwrap_or_else(|| {
let auto = format!("c{counter}");
auto
});
let tag = extract_quoted_attr(line, "tag");
*counter += 1;
let parent = graph.head_of(current_branch).or_else(|| {
graph
.branches
.iter()
.find(|b| b.name == current_branch)
.and_then(|b| b.created_after_commit)
});
let commit_idx = graph.commits.len();
graph.commits.push(Commit {
id,
branch: current_branch.to_string(),
tag,
kind: CommitKind::Normal,
parent,
merge_parent: None,
});
graph.events.push(Event::Commit(commit_idx));
Ok(())
}
fn handle_branch(
line: &str,
graph: &mut GitGraph,
current_branch: &str,
new_current: &mut String,
) -> Result<(), Error> {
let name = rest_after_keyword(line, "branch").trim().to_string();
if name.is_empty() {
return Err(Error::ParseError("branch: missing branch name".to_string()));
}
if graph.lane_of(&name).is_some() {
*new_current = name;
return Ok(());
}
let created_after = graph.head_of(current_branch);
let branch_idx = graph.branches.len();
graph.branches.push(Branch {
name: name.clone(),
created_after_commit: created_after,
});
graph.events.push(Event::BranchCreated(branch_idx));
graph.events.push(Event::Checkout(name.clone()));
*new_current = name;
Ok(())
}
fn handle_checkout(
line: &str,
graph: &mut GitGraph,
current_branch: &mut String,
) -> Result<(), Error> {
let name = rest_after_keyword(line, "checkout").trim().to_string();
if name.is_empty() {
return Err(Error::ParseError(
"checkout: missing branch name".to_string(),
));
}
if graph.lane_of(&name).is_none() {
return Err(Error::ParseError(format!(
"checkout: branch {name:?} does not exist"
)));
}
graph.events.push(Event::Checkout(name.clone()));
*current_branch = name;
Ok(())
}
fn handle_merge(
line: &str,
graph: &mut GitGraph,
current_branch: &str,
counter: &mut usize,
) -> Result<(), Error> {
let source_name = rest_after_keyword(line, "merge").trim().to_string();
if source_name.is_empty() {
return Err(Error::ParseError("merge: missing branch name".to_string()));
}
if graph.lane_of(&source_name).is_none() {
return Err(Error::ParseError(format!(
"merge: branch {source_name:?} does not exist"
)));
}
let merge_parent = graph.head_of(&source_name).ok_or_else(|| {
Error::ParseError(format!(
"merge: branch {source_name:?} has no commits to merge"
))
})?;
let id = extract_quoted_attr(line, "id").unwrap_or_else(|| {
let auto = format!("c{counter}");
auto
});
let tag = extract_quoted_attr(line, "tag");
*counter += 1;
let parent = graph.head_of(current_branch).or_else(|| {
graph
.branches
.iter()
.find(|b| b.name == current_branch)
.and_then(|b| b.created_after_commit)
});
let commit_idx = graph.commits.len();
graph.commits.push(Commit {
id,
branch: current_branch.to_string(),
tag,
kind: CommitKind::Merge,
parent,
merge_parent: Some(merge_parent),
});
graph.events.push(Event::Merge(commit_idx));
Ok(())
}
fn handle_cherry_pick(
line: &str,
graph: &mut GitGraph,
current_branch: &str,
counter: &mut usize,
) -> Result<(), Error> {
let source_id = extract_quoted_attr(line, "id")
.ok_or_else(|| Error::ParseError("cherry-pick: missing id attribute".to_string()))?;
if !graph.commits.iter().any(|c| c.id == source_id) {
return Err(Error::ParseError(format!(
"cherry-pick: commit id {source_id:?} not found"
)));
}
let id = format!("c{counter}");
*counter += 1;
let parent = graph.head_of(current_branch).or_else(|| {
graph
.branches
.iter()
.find(|b| b.name == current_branch)
.and_then(|b| b.created_after_commit)
});
let commit_idx = graph.commits.len();
graph.commits.push(Commit {
id,
branch: current_branch.to_string(),
tag: None,
kind: CommitKind::CherryPick,
parent,
merge_parent: None,
});
graph.events.push(Event::CherryPick(commit_idx));
Ok(())
}
fn extract_quoted_attr(line: &str, key: &str) -> Option<String> {
let needle = format!("{key}:");
let start = line.find(needle.as_str())?;
let after_colon = &line[start + needle.len()..];
let open = after_colon.find('"')?;
let rest = &after_colon[open + 1..];
let close = rest.find('"')?;
Some(rest[..close].to_string())
}
fn rest_after_keyword<'a>(line: &'a str, keyword: &str) -> &'a str {
let klen = keyword.len();
if line.len() > klen && line.as_bytes()[klen].is_ascii_whitespace() {
&line[klen + 1..]
} else {
""
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::git_graph::CommitKind;
#[test]
fn minimal_two_commits_on_main() {
let src = "gitGraph\n commit\n commit";
let g = parse(src).unwrap();
assert_eq!(g.commits.len(), 2);
assert_eq!(g.commits[0].branch, "main");
assert_eq!(g.commits[1].branch, "main");
assert_eq!(g.commits[0].parent, None);
assert_eq!(g.commits[1].parent, Some(0));
}
#[test]
fn branch_checkout_commit_on_new_branch() {
let src = "gitGraph\n commit\n branch dev\n checkout dev\n commit";
let g = parse(src).unwrap();
assert_eq!(g.commits[1].branch, "dev");
assert_eq!(g.commits[1].parent, Some(0));
assert_eq!(g.branches.len(), 2);
assert_eq!(g.branches[1].name, "dev");
}
#[test]
fn merge_creates_merge_commit_with_merge_parent() {
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 merge = g.commits.last().unwrap();
assert_eq!(merge.kind, CommitKind::Merge);
assert_eq!(merge.branch, "main");
let feat_idx = g.commits.iter().position(|c| c.id == "feat").unwrap();
assert_eq!(merge.merge_parent, Some(feat_idx));
}
#[test]
fn explicit_commit_id_is_used() {
let src = "gitGraph\n commit id: \"my-commit\"";
let g = parse(src).unwrap();
assert_eq!(g.commits[0].id, "my-commit");
}
#[test]
fn tag_populates_commit_tag() {
let src = "gitGraph\n commit tag: \"v1.0\"";
let g = parse(src).unwrap();
assert_eq!(g.commits[0].tag.as_deref(), Some("v1.0"));
}
#[test]
fn cherry_pick_creates_cherry_pick_commit() {
let src = "gitGraph\n commit id: \"feat\"\n branch dev\n checkout dev\n cherry-pick id: \"feat\"";
let g = parse(src).unwrap();
let cp = g.commits.last().unwrap();
assert_eq!(cp.kind, CommitKind::CherryPick);
assert_eq!(cp.branch, "dev");
}
#[test]
fn branch_off_non_main_branch() {
let src = "gitGraph\n commit\n branch dev\n checkout dev\n commit id: \"d1\"\n branch feature\n checkout feature\n commit id: \"f1\"";
let g = parse(src).unwrap();
let feature = g.branches.iter().find(|b| b.name == "feature").unwrap();
let d1_idx = g.commits.iter().position(|c| c.id == "d1").unwrap();
assert_eq!(feature.created_after_commit, Some(d1_idx));
let f1 = g.commits.iter().find(|c| c.id == "f1").unwrap();
assert_eq!(f1.parent, Some(d1_idx));
}
#[test]
fn comments_stripped() {
let src = "%% header comment\ngitGraph\n %% inline comment line\n commit %% trailing";
let g = parse(src).unwrap();
assert_eq!(g.commits.len(), 1);
}
#[test]
fn multiple_branches_interleaved_commits() {
let src = "gitGraph\n commit id: \"m1\"\n branch dev\n checkout dev\n commit id: \"d1\"\n checkout main\n commit id: \"m2\"\n checkout dev\n commit id: \"d2\"";
let g = parse(src).unwrap();
let ids: Vec<&str> = g.commits.iter().map(|c| c.id.as_str()).collect();
assert_eq!(ids, vec!["m1", "d1", "m2", "d2"]);
assert_eq!(g.commits[3].parent, Some(1)); }
#[test]
fn merge_picks_correct_head_when_branch_has_sub_branches() {
let src = "gitGraph\n commit id: \"m1\"\n branch develop\n checkout develop\n commit id: \"dev1\"\n branch feature\n checkout feature\n commit id: \"feat1\"\n checkout main\n merge develop";
let g = parse(src).unwrap();
let merge = g.commits.last().unwrap();
assert_eq!(merge.kind, CommitKind::Merge);
let dev1_idx = g.commits.iter().position(|c| c.id == "dev1").unwrap();
assert_eq!(merge.merge_parent, Some(dev1_idx));
}
#[test]
fn auto_generated_ids_are_sequential() {
let src = "gitGraph\n commit\n commit\n commit id: \"explicit\"\n commit";
let g = parse(src).unwrap();
assert_eq!(g.commits[0].id, "c0");
assert_eq!(g.commits[1].id, "c1");
assert_eq!(g.commits[2].id, "explicit");
assert_eq!(g.commits[3].id, "c3");
}
#[test]
fn missing_header_returns_error() {
let err = parse("commit").unwrap_err();
assert!(err.to_string().contains("gitGraph"));
}
#[test]
fn checkout_unknown_branch_returns_error() {
let src = "gitGraph\n checkout ghost";
let err = parse(src).unwrap_err();
assert!(err.to_string().contains("ghost"), "unexpected error: {err}");
}
}