use crate::sanitize::sanitize_text;
use crate::{Error, MermaidConfig, ParseMetadata, Result};
use serde_json::{Map, Value, json};
use std::collections::HashMap;
use uuid::Uuid;
const COMMIT_TYPE_NORMAL: i64 = 0;
const COMMIT_TYPE_REVERSE: i64 = 1;
const COMMIT_TYPE_HIGHLIGHT: i64 = 2;
const COMMIT_TYPE_MERGE: i64 = 3;
const COMMIT_TYPE_CHERRY_PICK: i64 = 4;
#[derive(Debug, Clone)]
struct Commit {
id: String,
message: String,
seq: i64,
commit_type: i64,
tags: Vec<String>,
parents: Vec<String>,
branch: String,
custom_type: Option<i64>,
custom_id: Option<bool>,
}
#[derive(Debug, Clone)]
struct BranchConfig {
order: i64,
}
#[derive(Debug, Clone)]
struct CommitDb {
id: String,
msg: String,
commit_type: i64,
tags: Vec<String>,
}
#[derive(Debug, Clone)]
struct BranchDb {
name: String,
order: i64,
}
#[derive(Debug, Clone)]
struct MergeDb {
branch: String,
id: Option<String>,
commit_type: Option<i64>,
tags: Vec<String>,
}
#[derive(Debug, Clone)]
struct CherryPickDb {
id: String,
target_id: String,
parent: String,
tags: Option<Vec<String>>,
}
#[derive(Debug)]
struct GitGraphDb {
commits: HashMap<String, Commit>,
commit_order: Vec<String>,
branches: HashMap<String, Option<String>>,
branch_config: HashMap<String, BranchConfig>,
branch_config_order: Vec<String>,
head: Option<String>,
curr_branch: String,
direction: String,
seq: i64,
warnings: Vec<String>,
acc_title: String,
acc_descr: String,
prng: Option<XorShift64Star>,
}
#[derive(Debug, Clone, Copy)]
struct XorShift64Star {
state: u64,
}
impl XorShift64Star {
fn new(seed: u64) -> Self {
let mut state = seed;
if state == 0 {
state = 1;
}
Self { state }
}
fn next_u64(&mut self) -> u64 {
let mut x = self.state;
x ^= x >> 12;
x ^= x << 25;
x ^= x >> 27;
self.state = x;
x.wrapping_mul(0x2545F4914F6CDD1D)
}
fn next_hex_digit(&mut self) -> u8 {
((self.next_u64() >> 60) & 0xF) as u8
}
fn make_random_hex(&mut self, len: usize) -> String {
const HEX: &[u8; 16] = b"0123456789abcdef";
let mut out = String::with_capacity(len);
for _ in 0..len {
let idx = self.next_hex_digit() as usize;
out.push(HEX[idx] as char);
}
out
}
}
impl GitGraphDb {
fn clear(&mut self, config: &MermaidConfig) {
self.commits.clear();
self.commit_order.clear();
self.branches.clear();
self.branch_config.clear();
self.branch_config_order.clear();
self.head = None;
self.direction = "LR".to_string();
self.seq = 0;
self.warnings.clear();
self.acc_title.clear();
self.acc_descr.clear();
let seed = config_i64(config, "gitGraph.seed")
.and_then(|v| u64::try_from(v).ok())
.filter(|v| *v != 0);
self.prng = seed.map(XorShift64Star::new);
if let Some(prng) = self.prng.as_mut() {
let _ = prng.next_u64();
}
let main = config
.get_str("gitGraph.mainBranchName")
.unwrap_or("main")
.to_string();
let main_order = config_i64(config, "gitGraph.mainBranchOrder").unwrap_or(0);
self.curr_branch = main.clone();
self.branches.insert(main.clone(), None);
self.branch_config
.insert(main.clone(), BranchConfig { order: main_order });
self.branch_config_order.push(main);
}
fn set_direction(&mut self, dir: &str) {
self.direction = dir.to_string();
}
fn next_id(&mut self) -> String {
if let Some(prng) = self.prng.as_mut() {
prng.make_random_hex(7)
} else {
let hex = Uuid::new_v4().simple().to_string();
hex.chars().take(7).collect()
}
}
fn commit(&mut self, mut commit_db: CommitDb, config: &MermaidConfig) {
let id_raw = std::mem::take(&mut commit_db.id);
let msg_raw = std::mem::take(&mut commit_db.msg);
let tags_raw = std::mem::take(&mut commit_db.tags);
let id = sanitize_text(&id_raw, config);
let msg = sanitize_text(&msg_raw, config);
let tags: Vec<String> = tags_raw
.into_iter()
.map(|t| sanitize_text(&t, config))
.collect();
let commit_id = if id.is_empty() {
let seq = self.seq;
format!("{seq}-{}", self.next_id())
} else {
id
};
let parents = self
.head
.as_ref()
.map(|h| vec![h.clone()])
.unwrap_or_default();
let new_commit = Commit {
id: commit_id.clone(),
message: msg,
seq: self.seq,
commit_type: commit_db.commit_type,
tags,
parents,
branch: self.curr_branch.clone(),
custom_type: None,
custom_id: None,
};
self.seq += 1;
self.head = Some(new_commit.id.clone());
if self.commits.contains_key(&new_commit.id) {
self.warnings
.push(format!("Commit ID {} already exists", new_commit.id));
}
let existed = self.commits.contains_key(&new_commit.id);
self.commits.insert(new_commit.id.clone(), new_commit);
if !existed {
self.commit_order.push(commit_id.clone());
}
self.branches
.insert(self.curr_branch.clone(), Some(commit_id));
}
fn branch(&mut self, mut branch_db: BranchDb, config: &MermaidConfig) -> Result<()> {
branch_db.name = sanitize_text(&branch_db.name, config);
if self.branches.contains_key(&branch_db.name) {
return Err(Error::DiagramParse {
diagram_type: "gitGraph".to_string(),
message: format!(
"Trying to create an existing branch. (Help: Either use a new name if you want create a new branch or try using \"checkout {}\")",
branch_db.name
),
});
}
let head_id = self.head.clone();
self.branches.insert(branch_db.name.clone(), head_id);
self.branch_config.insert(
branch_db.name.clone(),
BranchConfig {
order: branch_db.order,
},
);
self.branch_config_order.push(branch_db.name.clone());
self.checkout(&branch_db.name, config)?;
Ok(())
}
fn checkout(&mut self, branch: &str, config: &MermaidConfig) -> Result<()> {
let branch = sanitize_text(branch, config);
if !self.branches.contains_key(&branch) {
return Err(Error::DiagramParse {
diagram_type: "gitGraph".to_string(),
message: format!(
"Trying to checkout branch which is not yet created. (Help try using \"branch {}\")",
branch
),
});
}
self.curr_branch = branch.clone();
let id = self.branches.get(&branch).cloned().unwrap_or_default();
self.head = id;
Ok(())
}
fn merge(&mut self, mut merge_db: MergeDb, config: &MermaidConfig) -> Result<()> {
merge_db.branch = sanitize_text(&merge_db.branch, config);
if let Some(custom_id) = merge_db.id.as_mut() {
*custom_id = sanitize_text(custom_id, config);
if custom_id.is_empty() {
merge_db.id = None;
}
}
let current_branch = self.curr_branch.clone();
let other_branch = merge_db.branch.clone();
if current_branch == other_branch {
return Err(Error::DiagramParse {
diagram_type: "gitGraph".to_string(),
message: "Incorrect usage of \"merge\". Cannot merge a branch to itself"
.to_string(),
});
}
let current_head_id = self.branches.get(¤t_branch).cloned().unwrap_or(None);
let current_commit = current_head_id
.as_ref()
.and_then(|id| self.commits.get(id))
.cloned();
if current_commit.is_none() {
return Err(Error::DiagramParse {
diagram_type: "gitGraph".to_string(),
message: format!(
"Incorrect usage of \"merge\". Current branch ({})has no commits",
current_branch
),
});
}
if !self.branches.contains_key(&other_branch) {
return Err(Error::DiagramParse {
diagram_type: "gitGraph".to_string(),
message: format!(
"Incorrect usage of \"merge\". Branch to be merged ({}) does not exist",
other_branch
),
});
}
let other_head_id = self.branches.get(&other_branch).cloned().unwrap_or(None);
let other_commit = other_head_id
.as_ref()
.and_then(|id| self.commits.get(id))
.cloned();
if other_commit.is_none() {
return Err(Error::DiagramParse {
diagram_type: "gitGraph".to_string(),
message: format!(
"Incorrect usage of \"merge\". Branch to be merged ({}) has no commits",
other_branch
),
});
}
let current_commit = current_commit.unwrap();
let other_commit = other_commit.unwrap();
if current_commit.branch == other_branch {
return Err(Error::DiagramParse {
diagram_type: "gitGraph".to_string(),
message: format!("Cannot merge branch '{}' into itself.", other_branch),
});
}
if current_commit.id == other_commit.id {
return Err(Error::DiagramParse {
diagram_type: "gitGraph".to_string(),
message: "Incorrect usage of \"merge\". Both branches have same head".to_string(),
});
}
if let Some(custom_id) = merge_db.id.as_ref() {
if self.commits.contains_key(custom_id) {
return Err(Error::DiagramParse {
diagram_type: "gitGraph".to_string(),
message: format!(
"Incorrect usage of \"merge\". Commit with id:{} already exists, use different custom id",
custom_id
),
});
}
}
let verified_branch = other_head_id.unwrap_or_default();
let merge_commit_id = match merge_db.id.clone() {
Some(id) => id,
None => {
let seq = self.seq;
format!("{seq}-{}", self.next_id())
}
};
let custom_id_flag = merge_db.id.is_some();
let tags: Vec<String> = merge_db
.tags
.into_iter()
.map(|t| sanitize_text(&t, config))
.collect();
let new_commit = Commit {
id: merge_commit_id.clone(),
message: format!("merged branch {} into {}", other_branch, current_branch),
seq: self.seq,
commit_type: COMMIT_TYPE_MERGE,
tags,
parents: vec![current_commit.id, verified_branch],
branch: current_branch.clone(),
custom_type: merge_db.commit_type,
custom_id: Some(custom_id_flag),
};
self.seq += 1;
self.head = Some(new_commit.id.clone());
self.commits.insert(new_commit.id.clone(), new_commit);
self.commit_order.push(merge_commit_id.clone());
self.branches
.insert(current_branch.clone(), Some(merge_commit_id));
Ok(())
}
fn cherry_pick(&mut self, mut cp: CherryPickDb, config: &MermaidConfig) -> Result<()> {
cp.id = sanitize_text(&cp.id, config);
cp.target_id = sanitize_text(&cp.target_id, config);
cp.parent = sanitize_text(&cp.parent, config);
if cp.id.is_empty() || !self.commits.contains_key(&cp.id) {
return Err(Error::DiagramParse {
diagram_type: "gitGraph".to_string(),
message:
"Incorrect usage of \"cherryPick\". Source commit id should exist and provided"
.to_string(),
});
}
let source_commit = self.commits.get(&cp.id).cloned().unwrap();
if !cp.parent.is_empty() && !(source_commit.parents.iter().any(|p| p == &cp.parent)) {
return Err(Error::DiagramParse {
diagram_type: "gitGraph".to_string(),
message: "Invalid operation: The specified parent commit is not an immediate parent of the cherry-picked commit.".to_string(),
});
}
if source_commit.commit_type == COMMIT_TYPE_MERGE && cp.parent.is_empty() {
return Err(Error::DiagramParse {
diagram_type: "gitGraph".to_string(),
message: "Incorrect usage of cherry-pick: If the source commit is a merge commit, an immediate parent commit must be specified.".to_string(),
});
}
if cp.target_id.is_empty() || !self.commits.contains_key(&cp.target_id) {
if source_commit.branch == self.curr_branch {
return Err(Error::DiagramParse {
diagram_type: "gitGraph".to_string(),
message:
"Incorrect usage of \"cherryPick\". Source commit is already on current branch"
.to_string(),
});
}
let current_commit_id = self
.branches
.get(&self.curr_branch)
.cloned()
.unwrap_or(None);
if current_commit_id.is_none() {
return Err(Error::DiagramParse {
diagram_type: "gitGraph".to_string(),
message: format!(
"Incorrect usage of \"cherry-pick\". Current branch ({})has no commits",
self.curr_branch
),
});
}
let tags = match cp.tags {
Some(mut t) => {
t.retain(|s| !s.is_empty());
t.into_iter()
.map(|v| sanitize_text(&v, config))
.collect::<Vec<_>>()
}
None => {
let mut tag = format!("cherry-pick:{}", source_commit.id);
if source_commit.commit_type == COMMIT_TYPE_MERGE {
tag.push_str(&format!("|parent:{}", cp.parent));
}
vec![tag]
}
};
let seq = self.seq;
let new_id = format!("{seq}-{}", self.next_id());
let parents = self
.head
.as_ref()
.map(|h| vec![h.clone(), source_commit.id.clone()])
.unwrap_or_default();
let commit = Commit {
id: new_id.clone(),
message: format!(
"cherry-picked {} into {}",
source_commit.message, self.curr_branch
),
seq: self.seq,
commit_type: COMMIT_TYPE_CHERRY_PICK,
tags,
parents,
branch: self.curr_branch.clone(),
custom_type: None,
custom_id: None,
};
self.seq += 1;
self.head = Some(commit.id.clone());
self.commits.insert(commit.id.clone(), commit);
self.commit_order.push(new_id.clone());
self.branches.insert(self.curr_branch.clone(), Some(new_id));
}
Ok(())
}
fn commits_in_seq_order(&self) -> Vec<Commit> {
let mut out: Vec<Commit> = self.commits.values().cloned().collect();
out.sort_by_key(|c| c.seq);
out
}
fn branches_as_obj_array(&self) -> Vec<Value> {
let mut entries: Vec<(String, f64)> = Vec::new();
for (i, name) in self.branch_config_order.iter().enumerate() {
let cfg = self.branch_config.get(name);
let order = cfg.map(|c| c.order);
let order_f = match order {
Some(v) => v as f64,
None => format!("0.{i}").parse::<f64>().unwrap_or(0.0),
};
entries.push((name.clone(), order_f));
}
entries.sort_by(|a, b| a.1.partial_cmp(&b.1).unwrap_or(std::cmp::Ordering::Equal));
entries
.into_iter()
.map(|(name, _)| json!({ "name": name }))
.collect()
}
}
fn config_i64(config: &MermaidConfig, dotted_path: &str) -> Option<i64> {
let mut cur = config.as_value();
for seg in dotted_path.split('.') {
cur = cur.as_object()?.get(seg)?;
}
cur.as_i64()
}
fn commit_to_value(c: &Commit) -> Value {
let mut obj = Map::new();
obj.insert("id".to_string(), json!(c.id));
obj.insert("message".to_string(), json!(c.message));
obj.insert("seq".to_string(), json!(c.seq));
obj.insert("type".to_string(), json!(c.commit_type));
obj.insert("tags".to_string(), json!(c.tags));
obj.insert("parents".to_string(), json!(c.parents));
obj.insert("branch".to_string(), json!(c.branch));
if let Some(v) = c.custom_type {
obj.insert("customType".to_string(), json!(v));
}
if let Some(v) = c.custom_id {
obj.insert("customId".to_string(), json!(v));
}
Value::Object(obj)
}
fn parse_commit_type(raw: &str) -> Result<i64> {
match raw.trim() {
"NORMAL" => Ok(COMMIT_TYPE_NORMAL),
"REVERSE" => Ok(COMMIT_TYPE_REVERSE),
"HIGHLIGHT" => Ok(COMMIT_TYPE_HIGHLIGHT),
other => Err(Error::DiagramParse {
diagram_type: "gitGraph".to_string(),
message: format!("Unknown commit type: {other}"),
}),
}
}
struct LineParser<'a> {
input: &'a str,
pos: usize,
}
impl<'a> LineParser<'a> {
fn new(input: &'a str) -> Self {
Self { input, pos: 0 }
}
fn is_eof(&self) -> bool {
self.pos >= self.input.len()
}
fn peek_char(&self) -> Option<char> {
self.input[self.pos..].chars().next()
}
fn bump(&mut self) -> Option<char> {
let ch = self.peek_char()?;
self.pos += ch.len_utf8();
Some(ch)
}
fn skip_ws(&mut self) {
while self.peek_char().is_some_and(|c| c.is_whitespace()) {
self.bump();
}
}
fn parse_word_until_ws_or_colon(&mut self) -> Option<String> {
self.skip_ws();
let start = self.pos;
while let Some(c) = self.peek_char() {
if c.is_whitespace() || c == ':' {
break;
}
self.bump();
}
if self.pos == start {
return None;
}
Some(self.input[start..self.pos].to_string())
}
fn consume_char(&mut self, ch: char) -> bool {
self.skip_ws();
if self.peek_char() == Some(ch) {
self.bump();
return true;
}
false
}
fn parse_quoted(&mut self) -> Result<String> {
self.skip_ws();
if self.peek_char() != Some('"') {
return Err(Error::DiagramParse {
diagram_type: "gitGraph".to_string(),
message: "expected quoted string".to_string(),
});
}
self.bump();
let start = self.pos;
while let Some(c) = self.peek_char() {
if c == '"' {
break;
}
self.bump();
}
if self.peek_char() != Some('"') {
return Err(Error::DiagramParse {
diagram_type: "gitGraph".to_string(),
message: "unterminated quoted string".to_string(),
});
}
let s = self.input[start..self.pos].to_string();
self.bump();
Ok(s)
}
fn parse_name_token(&mut self) -> Result<String> {
self.skip_ws();
if self.peek_char() == Some('"') {
return self.parse_quoted();
}
let start = self.pos;
while let Some(c) = self.peek_char() {
if c.is_whitespace() {
break;
}
self.bump();
}
if self.pos == start {
return Err(Error::DiagramParse {
diagram_type: "gitGraph".to_string(),
message: "expected name".to_string(),
});
}
Ok(self.input[start..self.pos].to_string())
}
fn parse_kv_pairs(&mut self) -> Result<Vec<(String, String)>> {
let mut out = Vec::new();
while !self.is_eof() {
self.skip_ws();
if self.is_eof() {
break;
}
let Some(key) = self.parse_word_until_ws_or_colon() else {
break;
};
self.skip_ws();
if !self.consume_char(':') {
return Err(Error::DiagramParse {
diagram_type: "gitGraph".to_string(),
message: format!("expected ':' after {key}"),
});
}
self.skip_ws();
let value = if self.peek_char() == Some('"') {
self.parse_quoted()?
} else {
self.parse_word_until_ws_or_colon().unwrap_or_default()
};
out.push((key, value));
}
Ok(out)
}
}
fn parse_header_line(line: &str) -> Result<Option<String>> {
let t = line.trim_start();
if !t.starts_with("gitGraph") {
return Err(Error::DiagramParse {
diagram_type: "gitGraph".to_string(),
message: "expected gitGraph header".to_string(),
});
}
let rest = t["gitGraph".len()..].trim();
if rest.is_empty() || rest == ":" {
return Ok(None);
}
let rest = rest.trim_end_matches(':').trim();
if rest.is_empty() {
return Ok(None);
}
let dir = rest.split_whitespace().next().unwrap_or("");
match dir {
"LR" | "TB" | "BT" => Ok(Some(dir.to_string())),
_ => Ok(None),
}
}
fn parse_acc_title(line: &str) -> Option<String> {
let t = line.trim_start();
if !t.starts_with("accTitle") {
return None;
}
let rest = &t["accTitle".len()..];
let rest = rest.trim_start();
if !rest.starts_with(':') {
return None;
}
Some(rest[1..].trim().to_string())
}
fn parse_acc_descr_inline(line: &str) -> Option<String> {
let t = line.trim_start();
if !t.starts_with("accDescr") {
return None;
}
let rest = &t["accDescr".len()..];
let rest = rest.trim_start();
if !rest.starts_with(':') {
return None;
}
Some(rest[1..].trim().to_string())
}
fn parse_acc_descr_block_start(line: &str) -> bool {
let t = line.trim_start();
if !t.starts_with("accDescr") {
return false;
}
let rest = t["accDescr".len()..].trim_start();
rest.starts_with('{')
}
pub fn parse_git_graph(code: &str, meta: &ParseMetadata) -> Result<Value> {
let mut lines = code.lines();
let Some(header) = lines.next() else {
return Err(Error::DiagramParse {
diagram_type: "gitGraph".to_string(),
message: "empty input".to_string(),
});
};
let direction = parse_header_line(header)?;
let mut db = GitGraphDb {
commits: HashMap::new(),
commit_order: Vec::new(),
branches: HashMap::new(),
branch_config: HashMap::new(),
branch_config_order: Vec::new(),
head: None,
curr_branch: "main".to_string(),
direction: "LR".to_string(),
seq: 0,
warnings: Vec::new(),
acc_title: String::new(),
acc_descr: String::new(),
prng: None,
};
db.clear(&meta.effective_config);
if let Some(d) = direction {
db.set_direction(&d);
}
let mut pending_acc_descr_block = false;
let mut acc_descr_lines: Vec<String> = Vec::new();
for raw in lines {
let line = raw.trim_end_matches('\r');
let trimmed = line.trim();
if pending_acc_descr_block {
if trimmed.starts_with('}') {
pending_acc_descr_block = false;
db.acc_descr = acc_descr_lines.join("\n");
acc_descr_lines.clear();
continue;
}
let t = trimmed.trim();
if !t.is_empty() {
acc_descr_lines.push(t.to_string());
}
continue;
}
if trimmed.is_empty() {
continue;
}
if trimmed.starts_with("%%") {
continue;
}
if let Some(v) = parse_acc_title(trimmed) {
db.acc_title = v;
continue;
}
if parse_acc_descr_block_start(trimmed) {
pending_acc_descr_block = true;
continue;
}
if let Some(v) = parse_acc_descr_inline(trimmed) {
db.acc_descr = v;
continue;
}
let mut lp = LineParser::new(trimmed);
let Some(cmd) = lp.parse_word_until_ws_or_colon() else {
continue;
};
match cmd.as_str() {
"commit" => {
lp.skip_ws();
let mut commit_db = CommitDb {
id: String::new(),
msg: String::new(),
commit_type: COMMIT_TYPE_NORMAL,
tags: Vec::new(),
};
if lp.peek_char() == Some('"') {
commit_db.msg = lp.parse_quoted()?;
} else {
let kv = lp.parse_kv_pairs()?;
for (k, v) in kv {
match k.as_str() {
"id" => commit_db.id = v,
"msg" => commit_db.msg = v,
"tag" => commit_db.tags.push(v),
"type" => commit_db.commit_type = parse_commit_type(&v)?,
other => {
return Err(Error::DiagramParse {
diagram_type: "gitGraph".to_string(),
message: format!("unexpected commit field: {other}"),
});
}
}
}
}
db.commit(commit_db, &meta.effective_config);
}
"branch" => {
let name = lp.parse_name_token()?;
let kv = lp.parse_kv_pairs()?;
let mut order = 0i64;
for (k, v) in kv {
if k == "order" {
order = v.trim().parse::<i64>().map_err(|e| Error::DiagramParse {
diagram_type: "gitGraph".to_string(),
message: e.to_string(),
})?;
}
}
db.branch(BranchDb { name, order }, &meta.effective_config)?;
}
"checkout" | "switch" => {
let name = lp.parse_name_token()?;
db.checkout(&name, &meta.effective_config)?;
}
"merge" => {
let branch = lp.parse_name_token()?;
let kv = lp.parse_kv_pairs()?;
let mut merge_db = MergeDb {
branch,
id: None,
commit_type: None,
tags: Vec::new(),
};
for (k, v) in kv {
match k.as_str() {
"id" => merge_db.id = Some(v),
"tag" => merge_db.tags.push(v),
"type" => merge_db.commit_type = Some(parse_commit_type(&v)?),
_ => {}
}
}
db.merge(merge_db, &meta.effective_config)?;
}
"cherry-pick" | "cherryPick" => {
let kv = lp.parse_kv_pairs()?;
let mut id = String::new();
let mut parent = String::new();
let mut tags: Option<Vec<String>> = None;
for (k, v) in kv {
match k.as_str() {
"id" => id = v,
"parent" => parent = v,
"tag" => tags.get_or_insert_with(Vec::new).push(v),
_ => {}
}
}
db.cherry_pick(
CherryPickDb {
id,
target_id: String::new(),
parent,
tags,
},
&meta.effective_config,
)?;
}
_ => {
return Err(Error::DiagramParse {
diagram_type: "gitGraph".to_string(),
message: format!("Unknown statement: {cmd}"),
});
}
}
}
let commits = db
.commits_in_seq_order()
.into_iter()
.map(|c| commit_to_value(&c))
.collect::<Vec<_>>();
Ok(json!({
"type": meta.diagram_type,
"commits": commits,
"branches": db.branches_as_obj_array(),
"currentBranch": db.curr_branch,
"direction": db.direction,
"accTitle": if db.acc_title.is_empty() { None::<String> } else { Some(db.acc_title) },
"accDescr": if db.acc_descr.is_empty() { None::<String> } else { Some(db.acc_descr) },
"warnings": db.warnings,
"config": meta.effective_config.as_value().clone(),
}))
}
#[cfg(test)]
mod tests {
use super::*;
use crate::{Engine, ParseOptions};
use futures::executor::block_on;
fn parse(text: &str) -> Value {
let engine = Engine::new();
block_on(engine.parse_diagram(text, ParseOptions::default()))
.unwrap()
.unwrap()
.model
}
fn parse_err(text: &str) -> String {
let engine = Engine::new();
match block_on(engine.parse_diagram(text, ParseOptions::default())).unwrap_err() {
Error::DiagramParse { message, .. } => message,
other => other.to_string(),
}
}
fn parse_with_seed(text: &str, seed: i64) -> Value {
let engine = Engine::new().with_site_config(MermaidConfig::from_value(
json!({ "gitGraph": { "seed": seed } }),
));
block_on(engine.parse_diagram(text, ParseOptions::default()))
.unwrap()
.unwrap()
.model
}
fn commit_ids(model: &Value) -> Vec<String> {
model["commits"]
.as_array()
.cloned()
.unwrap_or_default()
.iter()
.filter_map(|c| c["id"].as_str().map(|s| s.to_string()))
.collect()
}
#[test]
fn should_handle_gitgraph_definition_and_defaults() {
let model = parse("gitGraph:\n commit\n");
assert_eq!(model["commits"].as_array().unwrap().len(), 1);
assert_eq!(model["currentBranch"].as_str().unwrap(), "main");
assert_eq!(model["direction"].as_str().unwrap(), "LR");
assert_eq!(model["branches"].as_array().unwrap().len(), 1);
}
#[test]
fn seeded_auto_commit_ids_match_upstream_seeded_baselines() {
let model = parse_with_seed("gitGraph:\ncommit\n", 1);
let ids = commit_ids(&model);
assert_eq!(ids, vec!["0-ab40cda".to_string()]);
}
#[test]
fn seeded_auto_commit_ids_are_direction_invariant() {
let base = commit_ids(&parse_with_seed("gitGraph:\ncommit\n", 1));
let tb = commit_ids(&parse_with_seed("gitGraph TB:\ncommit\n", 1));
let bt = commit_ids(&parse_with_seed("gitGraph BT:\ncommit\n", 1));
assert_eq!(base, tb);
assert_eq!(base, bt);
assert_eq!(base, vec!["0-ab40cda".to_string()]);
}
#[test]
fn should_handle_set_direction_tb_and_bt() {
let model = parse("gitGraph TB:\ncommit\n");
assert_eq!(model["direction"].as_str().unwrap(), "TB");
let model = parse("gitGraph BT:\ncommit\n");
assert_eq!(model["direction"].as_str().unwrap(), "BT");
}
#[test]
fn should_checkout_and_switch_branch() {
let model = parse("gitGraph:\nbranch new\ncheckout new\n");
assert_eq!(model["commits"].as_array().unwrap().len(), 0);
assert_eq!(model["currentBranch"].as_str().unwrap(), "new");
let model = parse("gitGraph:\nbranch new\nswitch new\n");
assert_eq!(model["commits"].as_array().unwrap().len(), 0);
assert_eq!(model["currentBranch"].as_str().unwrap(), "new");
}
#[test]
fn should_add_commits_to_checked_out_branch() {
let model = parse("gitGraph:\nbranch new\ncheckout new\ncommit\ncommit\n");
let commits = model["commits"].as_array().unwrap();
assert_eq!(commits.len(), 2);
assert_eq!(model["currentBranch"].as_str().unwrap(), "new");
assert_eq!(commits[0]["branch"].as_str().unwrap(), "new");
assert_eq!(commits[1]["branch"].as_str().unwrap(), "new");
assert_eq!(commits[1]["parents"].as_array().unwrap().len(), 1);
}
#[test]
fn should_handle_commit_with_args_and_message_variants() {
let model = parse("gitGraph:\ncommit \"a commit\"\n");
let commits = model["commits"].as_array().unwrap();
assert_eq!(commits.len(), 1);
assert_eq!(commits[0]["message"].as_str().unwrap(), "a commit");
let model = parse("gitGraph:\ncommit msg: \"test commit\"\n");
let commits = model["commits"].as_array().unwrap();
assert_eq!(commits[0]["message"].as_str().unwrap(), "test commit");
let model = parse("gitGraph:\ncommit id:\"1111\"\n");
let commits = model["commits"].as_array().unwrap();
assert_eq!(commits[0]["id"].as_str().unwrap(), "1111");
let model = parse("gitGraph:\ncommit tag:\"test\"\n");
let commits = model["commits"].as_array().unwrap();
assert_eq!(
commits[0]["tags"].as_array().unwrap()[0].as_str().unwrap(),
"test"
);
let model = parse("gitGraph:\ncommit tag:\"a\" tag:\"b\"\n");
let commits = model["commits"].as_array().unwrap();
let tags = commits[0]["tags"].as_array().unwrap();
assert_eq!(tags.len(), 2);
assert_eq!(tags[0].as_str().unwrap(), "a");
assert_eq!(tags[1].as_str().unwrap(), "b");
let model = parse("gitGraph:\ncommit type: HIGHLIGHT\n");
let commits = model["commits"].as_array().unwrap();
assert_eq!(commits[0]["type"].as_i64().unwrap(), 2);
let model = parse("gitGraph:\ncommit id:\"1111\" tag: \"test tag\"\n");
let commits = model["commits"].as_array().unwrap();
assert_eq!(commits[0]["id"].as_str().unwrap(), "1111");
assert_eq!(
commits[0]["tags"].as_array().unwrap()[0].as_str().unwrap(),
"test tag"
);
let model = parse(
"gitGraph:\ncommit id:\"1111\" type:REVERSE tag: \"test tag\" msg:\"test msg\"\n",
);
let commits = model["commits"].as_array().unwrap();
assert_eq!(commits[0]["id"].as_str().unwrap(), "1111");
assert_eq!(commits[0]["type"].as_i64().unwrap(), 1);
assert_eq!(commits[0]["message"].as_str().unwrap(), "test msg");
assert_eq!(
commits[0]["tags"].as_array().unwrap()[0].as_str().unwrap(),
"test tag"
);
}
#[test]
fn commit_errors_on_unknown_fields() {
let err =
parse_err("gitGraph\ncommit id:\"2\" msg:\"Malformed commit\" oops:\"ignored\"\n");
assert_eq!(err, "unexpected commit field: oops");
}
#[test]
fn should_handle_three_straight_commits() {
let model = parse("gitGraph:\ncommit\ncommit\ncommit\n");
assert_eq!(model["commits"].as_array().unwrap().len(), 3);
assert_eq!(model["branches"].as_array().unwrap().len(), 1);
}
#[test]
fn should_handle_new_branch_creation_and_names() {
let model = parse("gitGraph:\ncommit\nbranch testBranch\n");
assert_eq!(model["commits"].as_array().unwrap().len(), 1);
assert_eq!(model["currentBranch"].as_str().unwrap(), "testBranch");
assert_eq!(model["branches"].as_array().unwrap().len(), 2);
let model = parse("gitGraph:\ncommit\nbranch azAZ_-./test\n");
assert_eq!(model["currentBranch"].as_str().unwrap(), "azAZ_-./test");
assert_eq!(model["branches"].as_array().unwrap().len(), 2);
let model = parse("gitGraph:\ncommit\nbranch 1.0.1\n");
assert_eq!(model["currentBranch"].as_str().unwrap(), "1.0.1");
assert_eq!(model["branches"].as_array().unwrap().len(), 2);
}
#[test]
fn should_allow_quoted_branch_names_and_merge() {
let model = parse(
"gitGraph:\ncommit\nbranch \"branch\"\ncheckout \"branch\"\ncommit\ncheckout main\nmerge \"branch\"\n",
);
assert_eq!(model["commits"].as_array().unwrap().len(), 3);
assert_eq!(model["currentBranch"].as_str().unwrap(), "main");
assert_eq!(model["branches"].as_array().unwrap().len(), 2);
assert_eq!(
model["branches"]
.as_array()
.unwrap()
.iter()
.filter_map(|b| b["name"].as_str())
.collect::<Vec<_>>(),
vec!["main", "branch"]
);
}
#[test]
fn should_handle_branch_order_sorting() {
let model = parse(
"gitGraph:\ncommit\nbranch test1 order: 3\nbranch test2 order: 2\nbranch test3 order: 1\n",
);
assert_eq!(
model["branches"]
.as_array()
.unwrap()
.iter()
.filter_map(|b| b["name"].as_str())
.collect::<Vec<_>>(),
vec!["main", "test3", "test2", "test1"]
);
let model = parse("gitGraph:\ncommit\nbranch test1 order: 1\nbranch test2\nbranch test3\n");
assert_eq!(
model["branches"]
.as_array()
.unwrap()
.iter()
.filter_map(|b| b["name"].as_str())
.collect::<Vec<_>>(),
vec!["main", "test2", "test3", "test1"]
);
}
#[test]
fn should_handle_merge_with_two_parents() {
let model = parse(
"gitGraph:\ncommit\nbranch testBranch\ncheckout testBranch\ncommit\ncheckout main\ncommit\nmerge testBranch\n",
);
let commits = model["commits"].as_array().unwrap();
assert_eq!(commits.len(), 4);
let merge = &commits[3];
assert_eq!(merge["branch"].as_str().unwrap(), "main");
assert_eq!(merge["parents"].as_array().unwrap().len(), 2);
}
#[test]
fn should_support_cherry_picking_commits() {
let model = parse(
"gitGraph\ncommit id: \"ZERO\"\nbranch develop\ncommit id:\"A\"\ncheckout main\ncherry-pick id:\"A\"\n",
);
let commits = model["commits"].as_array().unwrap();
assert_eq!(commits.len(), 3);
assert_eq!(commits[2]["branch"].as_str().unwrap(), "main");
assert_eq!(
commits[2]["tags"].as_array().unwrap()[0].as_str().unwrap(),
"cherry-pick:A"
);
let model = parse(
"gitGraph\ncommit id: \"ZERO\"\nbranch develop\ncommit id:\"A\"\ncheckout main\ncherry-pick id:\"A\" tag:\"MyTag\"\n",
);
let commits = model["commits"].as_array().unwrap();
assert_eq!(
commits[2]["tags"].as_array().unwrap()[0].as_str().unwrap(),
"MyTag"
);
let model = parse(
"gitGraph\ncommit id: \"ZERO\"\nbranch develop\ncommit id:\"A\"\ncheckout main\ncherry-pick id:\"A\" tag:\"\"\n",
);
let commits = model["commits"].as_array().unwrap();
assert!(commits[2]["tags"].as_array().unwrap().is_empty());
}
#[test]
fn should_support_cherry_picking_merge_commits_and_validate_parent() {
let err = parse_err(
"gitGraph\ncommit id: \"ZERO\"\nbranch feature\nbranch release\ncheckout feature\ncommit id: \"A\"\ncommit id: \"B\"\ncheckout main\nmerge feature id: \"M\"\ncheckout release\ncommit id: \"C\"\ncherry-pick id:\"M\"\n",
);
assert!(err.contains("Incorrect usage of cherry-pick: If the source commit is a merge commit, an immediate parent commit must be specified."));
let err = parse_err(
"gitGraph\ncommit id: \"ZERO\"\nbranch feature\nbranch release\ncheckout feature\ncommit id: \"A\"\ncommit id: \"B\"\ncheckout main\nmerge feature id: \"M\"\ncheckout release\ncommit id: \"C\"\ncherry-pick id:\"M\" parent: \"A\"\n",
);
assert!(err.contains("Invalid operation: The specified parent commit is not an immediate parent of the cherry-picked commit."));
}
#[test]
fn should_throw_error_when_try_to_branch_existing_branch() {
let err = parse_err("gitGraph\ncommit\nbranch testBranch\ncommit\nbranch main\n");
assert!(err.contains("Trying to create an existing branch."));
let err = parse_err("gitGraph\ncommit\nbranch testBranch\ncommit\nbranch testBranch\n");
assert!(err.contains("Trying to create an existing branch."));
}
#[test]
fn should_throw_error_when_try_to_checkout_unknown_branch() {
let err = parse_err("gitGraph\ncommit\ncheckout testBranch\n");
assert_eq!(
err,
"Trying to checkout branch which is not yet created. (Help try using \"branch testBranch\")"
);
}
#[test]
fn should_throw_error_when_trying_to_merge_without_commits_or_unknown_branch() {
let err = parse_err("gitGraph\nmerge testBranch\n");
assert_eq!(
err,
"Incorrect usage of \"merge\". Current branch (main)has no commits"
);
let err = parse_err("gitGraph\ncommit\nmerge testBranch\n");
assert_eq!(
err,
"Incorrect usage of \"merge\". Branch to be merged (testBranch) does not exist"
);
let err = parse_err("gitGraph\nbranch test1\ncheckout main\ncommit\nmerge test1\n");
assert_eq!(
err,
"Incorrect usage of \"merge\". Branch to be merged (test1) has no commits"
);
}
#[test]
fn should_throw_error_when_trying_to_merge_branch_to_itself() {
let err = parse_err("gitGraph\ncommit\nbranch testBranch\nmerge testBranch\n");
assert_eq!(
err,
"Incorrect usage of \"merge\". Cannot merge a branch to itself"
);
}
#[test]
fn should_throw_error_when_using_existing_id_as_merge_id() {
let err = parse_err(
"gitGraph\ncommit id: \"1-111\"\nbranch testBranch\ncommit id: \"2-222\"\ncheckout main\nmerge testBranch id: \"1-111\"\n",
);
assert!(err.contains("Incorrect usage of \"merge\". Commit with id:1-111 already exists, use different custom id"));
}
#[test]
fn should_throw_error_when_trying_to_merge_branches_having_same_heads() {
let err =
parse_err("gitGraph\ncommit\nbranch testBranch\ncheckout main\nmerge testBranch\n");
assert_eq!(
err,
"Incorrect usage of \"merge\". Both branches have same head"
);
}
#[test]
fn should_handle_accessibility_title_and_description() {
let model = parse(
"gitGraph:\naccTitle: This is a title\naccDescr: This is a description\ncommit\n",
);
assert_eq!(model["accTitle"].as_str().unwrap(), "This is a title");
assert_eq!(model["accDescr"].as_str().unwrap(), "This is a description");
let model = parse(
"gitGraph:\naccTitle: This is a title\naccDescr {\n This is a description\n using multiple lines\n}\ncommit\n",
);
assert_eq!(model["accTitle"].as_str().unwrap(), "This is a title");
assert_eq!(
model["accDescr"].as_str().unwrap(),
"This is a description\nusing multiple lines"
);
}
#[test]
fn should_work_with_unsafe_properties_as_ids_and_branch_names() {
for prop in ["__proto__", "constructor"] {
let model = parse(&format!(
"gitGraph\ncommit id:\"{prop}\"\nbranch {prop}\ncheckout {prop}\ncommit\ncheckout main\nmerge {prop}\n"
));
assert_eq!(model["commits"].as_array().unwrap().len(), 3);
assert_eq!(commit_ids(&model)[0], prop);
assert_eq!(model["currentBranch"].as_str().unwrap(), "main");
assert_eq!(model["branches"].as_array().unwrap().len(), 2);
}
}
#[test]
fn should_log_warning_when_two_commits_have_same_id() {
let model = parse("gitGraph\ncommit id:\"working on MDR\"\ncommit id:\"working on MDR\"\n");
let warnings = model["warnings"]
.as_array()
.unwrap()
.iter()
.filter_map(|v| v.as_str())
.collect::<Vec<_>>();
assert!(warnings.contains(&"Commit ID working on MDR already exists"));
}
}