1use crate::Error;
41use crate::git_graph::{Branch, Commit, CommitKind, Event, GitGraph};
42use crate::parser::common::strip_inline_comment;
43
44pub fn parse(src: &str) -> Result<GitGraph, Error> {
56 let mut graph = GitGraph::default();
57 let mut header_seen = false;
58 let mut commit_counter: usize = 0;
60 let mut current_branch = "main".to_string();
62
63 graph.branches.push(Branch {
65 name: "main".to_string(),
66 created_after_commit: None,
67 });
68
69 for raw_line in src.lines() {
70 let line = strip_inline_comment(raw_line).trim();
71 if line.is_empty() {
72 continue;
73 }
74
75 if !header_seen {
76 if !line.starts_with("gitGraph") {
79 return Err(Error::ParseError(format!(
80 "expected `gitGraph` header, got {line:?}"
81 )));
82 }
83 header_seen = true;
84 continue;
85 }
86
87 let first = line.split_whitespace().next().unwrap_or("");
89 match first {
90 "commit" => {
91 handle_commit(line, &mut graph, ¤t_branch, &mut commit_counter)?;
92 }
93 "branch" => {
94 let cb = current_branch.clone();
98 handle_branch(line, &mut graph, &cb, &mut current_branch)?;
99 }
100 "checkout" => {
101 handle_checkout(line, &mut graph, &mut current_branch)?;
102 }
103 "merge" => {
104 handle_merge(line, &mut graph, ¤t_branch, &mut commit_counter)?;
105 }
106 "cherry-pick" => {
107 handle_cherry_pick(line, &mut graph, ¤t_branch, &mut commit_counter)?;
108 }
109 _ => {}
112 }
113 }
114
115 if !header_seen {
116 return Err(Error::ParseError(
117 "missing `gitGraph` header line".to_string(),
118 ));
119 }
120
121 Ok(graph)
122}
123
124fn handle_commit(
136 line: &str,
137 graph: &mut GitGraph,
138 current_branch: &str,
139 counter: &mut usize,
140) -> Result<(), Error> {
141 let id = extract_quoted_attr(line, "id").unwrap_or_else(|| {
143 let auto = format!("c{counter}");
145 auto
146 });
147 let tag = extract_quoted_attr(line, "tag");
148
149 *counter += 1;
152
153 let parent = graph.head_of(current_branch).or_else(|| {
159 graph
160 .branches
161 .iter()
162 .find(|b| b.name == current_branch)
163 .and_then(|b| b.created_after_commit)
164 });
165 let commit_idx = graph.commits.len();
166
167 graph.commits.push(Commit {
168 id,
169 branch: current_branch.to_string(),
170 tag,
171 kind: CommitKind::Normal,
172 parent,
173 merge_parent: None,
174 });
175 graph.events.push(Event::Commit(commit_idx));
176 Ok(())
177}
178
179fn handle_branch(
185 line: &str,
186 graph: &mut GitGraph,
187 current_branch: &str,
188 new_current: &mut String,
189) -> Result<(), Error> {
190 let name = rest_after_keyword(line, "branch").trim().to_string();
191 if name.is_empty() {
192 return Err(Error::ParseError("branch: missing branch name".to_string()));
193 }
194
195 if graph.lane_of(&name).is_some() {
197 *new_current = name;
198 return Ok(());
199 }
200
201 let created_after = graph.head_of(current_branch);
202 let branch_idx = graph.branches.len();
203 graph.branches.push(Branch {
204 name: name.clone(),
205 created_after_commit: created_after,
206 });
207 graph.events.push(Event::BranchCreated(branch_idx));
208 graph.events.push(Event::Checkout(name.clone()));
210 *new_current = name;
211 Ok(())
212}
213
214fn handle_checkout(
218 line: &str,
219 graph: &mut GitGraph,
220 current_branch: &mut String,
221) -> Result<(), Error> {
222 let name = rest_after_keyword(line, "checkout").trim().to_string();
223 if name.is_empty() {
224 return Err(Error::ParseError(
225 "checkout: missing branch name".to_string(),
226 ));
227 }
228 if graph.lane_of(&name).is_none() {
229 return Err(Error::ParseError(format!(
230 "checkout: branch {name:?} does not exist"
231 )));
232 }
233 graph.events.push(Event::Checkout(name.clone()));
234 *current_branch = name;
235 Ok(())
236}
237
238fn handle_merge(
244 line: &str,
245 graph: &mut GitGraph,
246 current_branch: &str,
247 counter: &mut usize,
248) -> Result<(), Error> {
249 let source_name = rest_after_keyword(line, "merge").trim().to_string();
250 if source_name.is_empty() {
251 return Err(Error::ParseError("merge: missing branch name".to_string()));
252 }
253 if graph.lane_of(&source_name).is_none() {
254 return Err(Error::ParseError(format!(
255 "merge: branch {source_name:?} does not exist"
256 )));
257 }
258
259 let merge_parent = graph.head_of(&source_name).ok_or_else(|| {
261 Error::ParseError(format!(
262 "merge: branch {source_name:?} has no commits to merge"
263 ))
264 })?;
265
266 let id = extract_quoted_attr(line, "id").unwrap_or_else(|| {
267 let auto = format!("c{counter}");
268 auto
269 });
270 let tag = extract_quoted_attr(line, "tag");
271 *counter += 1;
272
273 let parent = graph.head_of(current_branch).or_else(|| {
274 graph
275 .branches
276 .iter()
277 .find(|b| b.name == current_branch)
278 .and_then(|b| b.created_after_commit)
279 });
280 let commit_idx = graph.commits.len();
281
282 graph.commits.push(Commit {
283 id,
284 branch: current_branch.to_string(),
285 tag,
286 kind: CommitKind::Merge,
287 parent,
288 merge_parent: Some(merge_parent),
289 });
290 graph.events.push(Event::Merge(commit_idx));
291 Ok(())
292}
293
294fn handle_cherry_pick(
299 line: &str,
300 graph: &mut GitGraph,
301 current_branch: &str,
302 counter: &mut usize,
303) -> Result<(), Error> {
304 let source_id = extract_quoted_attr(line, "id")
305 .ok_or_else(|| Error::ParseError("cherry-pick: missing id attribute".to_string()))?;
306
307 if !graph.commits.iter().any(|c| c.id == source_id) {
309 return Err(Error::ParseError(format!(
310 "cherry-pick: commit id {source_id:?} not found"
311 )));
312 }
313
314 let id = format!("c{counter}");
315 *counter += 1;
316
317 let parent = graph.head_of(current_branch).or_else(|| {
318 graph
319 .branches
320 .iter()
321 .find(|b| b.name == current_branch)
322 .and_then(|b| b.created_after_commit)
323 });
324 let commit_idx = graph.commits.len();
325
326 graph.commits.push(Commit {
327 id,
328 branch: current_branch.to_string(),
329 tag: None,
330 kind: CommitKind::CherryPick,
331 parent,
332 merge_parent: None,
333 });
334 graph.events.push(Event::CherryPick(commit_idx));
335 Ok(())
336}
337
338fn extract_quoted_attr(line: &str, key: &str) -> Option<String> {
349 let needle = format!("{key}:");
351 let start = line.find(needle.as_str())?;
352 let after_colon = &line[start + needle.len()..];
353 let open = after_colon.find('"')?;
355 let rest = &after_colon[open + 1..];
356 let close = rest.find('"')?;
358 Some(rest[..close].to_string())
359}
360
361fn rest_after_keyword<'a>(line: &'a str, keyword: &str) -> &'a str {
366 let klen = keyword.len();
367 if line.len() > klen && line.as_bytes()[klen].is_ascii_whitespace() {
368 &line[klen + 1..]
369 } else {
370 ""
371 }
372}
373
374#[cfg(test)]
379mod tests {
380 use super::*;
381 use crate::git_graph::CommitKind;
382
383 #[test]
386 fn minimal_two_commits_on_main() {
387 let src = "gitGraph\n commit\n commit";
388 let g = parse(src).unwrap();
389 assert_eq!(g.commits.len(), 2);
390 assert_eq!(g.commits[0].branch, "main");
391 assert_eq!(g.commits[1].branch, "main");
392 assert_eq!(g.commits[0].parent, None);
393 assert_eq!(g.commits[1].parent, Some(0));
394 }
395
396 #[test]
399 fn branch_checkout_commit_on_new_branch() {
400 let src = "gitGraph\n commit\n branch dev\n checkout dev\n commit";
401 let g = parse(src).unwrap();
402 assert_eq!(g.commits[1].branch, "dev");
404 assert_eq!(g.commits[1].parent, Some(0));
406 assert_eq!(g.branches.len(), 2);
407 assert_eq!(g.branches[1].name, "dev");
408 }
409
410 #[test]
413 fn merge_creates_merge_commit_with_merge_parent() {
414 let src = "gitGraph\n commit\n branch dev\n checkout dev\n commit id: \"feat\"\n checkout main\n merge dev";
415 let g = parse(src).unwrap();
416 let merge = g.commits.last().unwrap();
417 assert_eq!(merge.kind, CommitKind::Merge);
418 assert_eq!(merge.branch, "main");
419 let feat_idx = g.commits.iter().position(|c| c.id == "feat").unwrap();
421 assert_eq!(merge.merge_parent, Some(feat_idx));
422 }
423
424 #[test]
427 fn explicit_commit_id_is_used() {
428 let src = "gitGraph\n commit id: \"my-commit\"";
429 let g = parse(src).unwrap();
430 assert_eq!(g.commits[0].id, "my-commit");
431 }
432
433 #[test]
436 fn tag_populates_commit_tag() {
437 let src = "gitGraph\n commit tag: \"v1.0\"";
438 let g = parse(src).unwrap();
439 assert_eq!(g.commits[0].tag.as_deref(), Some("v1.0"));
440 }
441
442 #[test]
445 fn cherry_pick_creates_cherry_pick_commit() {
446 let src = "gitGraph\n commit id: \"feat\"\n branch dev\n checkout dev\n cherry-pick id: \"feat\"";
447 let g = parse(src).unwrap();
448 let cp = g.commits.last().unwrap();
449 assert_eq!(cp.kind, CommitKind::CherryPick);
450 assert_eq!(cp.branch, "dev");
451 }
452
453 #[test]
456 fn branch_off_non_main_branch() {
457 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\"";
458 let g = parse(src).unwrap();
459 let feature = g.branches.iter().find(|b| b.name == "feature").unwrap();
461 let d1_idx = g.commits.iter().position(|c| c.id == "d1").unwrap();
462 assert_eq!(feature.created_after_commit, Some(d1_idx));
463 let f1 = g.commits.iter().find(|c| c.id == "f1").unwrap();
465 assert_eq!(f1.parent, Some(d1_idx));
466 }
467
468 #[test]
471 fn comments_stripped() {
472 let src = "%% header comment\ngitGraph\n %% inline comment line\n commit %% trailing";
473 let g = parse(src).unwrap();
474 assert_eq!(g.commits.len(), 1);
475 }
476
477 #[test]
480 fn multiple_branches_interleaved_commits() {
481 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\"";
482 let g = parse(src).unwrap();
483 let ids: Vec<&str> = g.commits.iter().map(|c| c.id.as_str()).collect();
485 assert_eq!(ids, vec!["m1", "d1", "m2", "d2"]);
486 assert_eq!(g.commits[3].parent, Some(1)); }
488
489 #[test]
492 fn merge_picks_correct_head_when_branch_has_sub_branches() {
493 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";
496 let g = parse(src).unwrap();
497 let merge = g.commits.last().unwrap();
498 assert_eq!(merge.kind, CommitKind::Merge);
499 let dev1_idx = g.commits.iter().position(|c| c.id == "dev1").unwrap();
501 assert_eq!(merge.merge_parent, Some(dev1_idx));
502 }
503
504 #[test]
507 fn auto_generated_ids_are_sequential() {
508 let src = "gitGraph\n commit\n commit\n commit id: \"explicit\"\n commit";
509 let g = parse(src).unwrap();
510 assert_eq!(g.commits[0].id, "c0");
513 assert_eq!(g.commits[1].id, "c1");
514 assert_eq!(g.commits[2].id, "explicit");
515 assert_eq!(g.commits[3].id, "c3");
516 }
517
518 #[test]
521 fn missing_header_returns_error() {
522 let err = parse("commit").unwrap_err();
523 assert!(err.to_string().contains("gitGraph"));
524 }
525
526 #[test]
529 fn checkout_unknown_branch_returns_error() {
530 let src = "gitGraph\n checkout ghost";
531 let err = parse(src).unwrap_err();
532 assert!(err.to_string().contains("ghost"), "unexpected error: {err}");
533 }
534}