1use std::collections::{HashMap, HashSet};
4
5use super::cell::GraphCell;
6use super::colors::{ColorContext, PenaltyBasedColorAssigner};
7use super::layout::{GraphLayout, GraphRow};
8use crate::event::{GitEvent, GitEventKind};
9
10struct ColumnTracker {
12 columns: Vec<Option<String>>,
14 hash_colors: HashMap<String, usize>,
16 column_colors: HashMap<usize, usize>,
18 color_assigner: PenaltyBasedColorAssigner,
20 fork_points: HashMap<String, String>,
22}
23
24impl ColumnTracker {
25 fn new() -> Self {
26 Self {
27 columns: Vec::new(),
28 hash_colors: HashMap::new(),
29 column_colors: HashMap::new(),
30 color_assigner: PenaltyBasedColorAssigner::new(),
31 fork_points: HashMap::new(),
32 }
33 }
34
35 fn find_column(&self, hash: &str) -> Option<usize> {
37 self.columns
38 .iter()
39 .position(|h| h.as_ref().is_some_and(|h| h == hash))
40 }
41
42 fn find_or_create_column(&mut self) -> usize {
44 if let Some(pos) = self.columns.iter().position(|h| h.is_none()) {
45 pos
46 } else {
47 self.columns.push(None);
48 self.columns.len() - 1
49 }
50 }
51
52 fn release_column(&mut self, col: usize) {
54 if col < self.columns.len() {
55 self.columns[col] = None;
56 self.column_colors.remove(&col);
57 self.color_assigner.release_lane(col);
58 }
59 }
60
61 fn set_column(&mut self, col: usize, hash: String, color_idx: usize) {
63 while self.columns.len() <= col {
64 self.columns.push(None);
65 }
66 self.columns[col] = Some(hash.clone());
67 self.hash_colors.insert(hash, color_idx);
68 self.column_colors.insert(col, color_idx);
69 }
70
71 fn get_column_color(&self, col: usize) -> Option<usize> {
73 self.column_colors.get(&col).copied()
74 }
75
76 fn get_hash_color(&self, hash: &str) -> Option<usize> {
78 self.hash_colors.get(hash).copied()
79 }
80
81 fn assign_main_color(&mut self, col: usize) -> usize {
83 let ctx = ColorContext::new(col).with_main_branch(true);
84 self.color_assigner.assign_with_context(&ctx)
85 }
86
87 fn assign_color_with_context(
89 &mut self,
90 col: usize,
91 fork_point: Option<String>,
92 parent_color: Option<usize>,
93 ) -> usize {
94 let ctx = ColorContext::new(col)
95 .with_fork_point(fork_point)
96 .with_parent_color(parent_color);
97 self.color_assigner.assign_with_context(&ctx)
98 }
99
100 fn set_fork_point(&mut self, hash: &str, fork_point: &str) {
102 self.fork_points
103 .insert(hash.to_string(), fork_point.to_string());
104 }
105
106 fn get_fork_point(&self, hash: &str) -> Option<String> {
108 self.fork_points.get(hash).cloned()
109 }
110
111 fn max_active_column(&self) -> usize {
113 self.columns
114 .iter()
115 .enumerate()
116 .filter(|(_, h)| h.is_some())
117 .map(|(i, _)| i)
118 .max()
119 .unwrap_or(0)
120 }
121}
122
123pub fn build_graph(events: &[GitEvent], head_hash: Option<&str>) -> GraphLayout {
125 if events.is_empty() {
126 return GraphLayout::empty();
127 }
128
129 let mut tracker = ColumnTracker::new();
130 let mut rows: Vec<GraphRow> = Vec::new();
131 let mut max_column: usize = 0;
132
133 let hash_to_idx: HashMap<&str, usize> = events
135 .iter()
136 .enumerate()
137 .map(|(i, e)| (e.short_hash.as_str(), i))
138 .collect();
139
140 let mut shown_commits: HashSet<String> = HashSet::new();
142
143 for (idx, event) in events.iter().enumerate() {
144 let converging_columns: Vec<usize> = tracker
147 .columns
148 .iter()
149 .enumerate()
150 .filter(|(_, h)| h.as_ref().is_some_and(|h| h == &event.short_hash))
151 .map(|(i, _)| i)
152 .collect();
153
154 if converging_columns.len() >= 2 {
155 let main_col = *converging_columns.iter().min().unwrap();
157 let merging_cols: Vec<usize> = converging_columns
158 .iter()
159 .filter(|&&c| c != main_col)
160 .copied()
161 .collect();
162
163 for &col in &merging_cols {
165 max_column = max_column.max(col);
166 }
167 max_column = max_column.max(main_col);
168
169 let connector_cells = build_connector_cells(
171 main_col,
172 &merging_cols,
173 &tracker.columns,
174 &tracker.column_colors,
175 max_column,
176 );
177
178 let main_color = tracker.get_column_color(main_col).unwrap_or(0);
180 rows.push(GraphRow::new(None, main_col, main_color, connector_cells));
181
182 for col in merging_cols {
184 tracker.release_column(col);
185 }
186 }
187
188 let commit_col = tracker
190 .find_column(&event.short_hash)
191 .unwrap_or_else(|| tracker.find_or_create_column());
192
193 if let Some(first_parent) = event.parent_hashes.first() {
195 tracker.set_fork_point(&event.short_hash, first_parent);
196 }
197
198 let commit_color = if idx == 0 {
200 let color = tracker.assign_main_color(commit_col);
202 tracker.set_column(commit_col, event.short_hash.clone(), color);
203 color
204 } else if let Some(color) = tracker.get_column_color(commit_col) {
205 color
207 } else {
208 let fork_point = tracker.get_fork_point(&event.short_hash);
210 let parent_color = if let Some(first_parent) = event.parent_hashes.first() {
211 tracker.get_hash_color(first_parent)
212 } else {
213 None
214 };
215 let color = tracker.assign_color_with_context(commit_col, fork_point, parent_color);
216 tracker.set_column(commit_col, event.short_hash.clone(), color);
217 color
218 };
219
220 if commit_col < tracker.columns.len() {
222 tracker.columns[commit_col] = None;
223 }
224
225 let mut parent_info: Vec<(usize, usize, bool, bool)> = Vec::new();
228
229 for (parent_idx, parent_hash) in event.parent_hashes.iter().enumerate() {
230 let in_display_range = hash_to_idx.contains_key(parent_hash.as_str());
232
233 if !in_display_range {
234 if parent_idx == 0 {
236 tracker.set_column(commit_col, parent_hash.clone(), commit_color);
237 }
238 continue;
239 }
240
241 let existing_col = tracker.find_column(parent_hash);
243
244 let already_shown = shown_commits.contains(parent_hash);
246
247 let (parent_col, parent_color, is_existing) = if let Some(col) = existing_col {
248 let color = tracker
250 .get_column_color(col)
251 .or_else(|| tracker.get_hash_color(parent_hash))
252 .unwrap_or(col);
253
254 if parent_idx == 0 && col != commit_col {
255 tracker.set_column(commit_col, parent_hash.clone(), commit_color);
258 (commit_col, commit_color, true)
261 } else {
262 (col, color, true)
265 }
266 } else if parent_idx == 0 {
267 tracker.set_column(commit_col, parent_hash.clone(), commit_color);
269 (commit_col, commit_color, false)
270 } else {
271 let new_col = tracker.find_or_create_column();
273 let fork_point = Some(event.short_hash.clone());
275 let new_color = tracker.assign_color_with_context(new_col, fork_point, None);
276 tracker.set_column(new_col, parent_hash.clone(), new_color);
277 (new_col, new_color, false)
278 };
279
280 parent_info.push((parent_col, parent_color, is_existing, already_shown));
281 }
282
283 max_column = max_column.max(commit_col).max(tracker.max_active_column());
285
286 let cells = build_cells(
288 commit_col,
289 commit_color,
290 &parent_info,
291 &tracker.columns,
292 &tracker.column_colors,
293 max_column,
294 event.kind == GitEventKind::Merge,
295 head_hash == Some(&event.short_hash),
296 );
297
298 let is_head = head_hash == Some(&event.short_hash);
300 let row =
301 GraphRow::new(Some(event.clone()), commit_col, commit_color, cells).with_head(is_head);
302 rows.push(row);
303
304 shown_commits.insert(event.short_hash.clone());
306
307 }
311
312 GraphLayout { rows, max_column }
313}
314
315#[allow(clippy::too_many_arguments)]
317fn build_cells(
318 commit_col: usize,
319 commit_color: usize,
320 parent_info: &[(usize, usize, bool, bool)], active_columns: &[Option<String>],
322 column_colors: &HashMap<usize, usize>,
323 max_col: usize,
324 is_merge: bool,
325 is_head: bool,
326) -> Vec<GraphCell> {
327 let cell_count = (max_col + 1) * 2;
329 let mut cells = vec![GraphCell::Empty; cell_count];
330
331 for (col, hash) in active_columns.iter().enumerate() {
333 if hash.is_some() && col != commit_col {
334 let cell_idx = col * 2;
335 if cell_idx < cells.len() {
336 let color_idx = column_colors.get(&col).copied().unwrap_or(col % 8);
338 cells[cell_idx] = GraphCell::Vertical { color_idx };
339 }
340 }
341 }
342
343 let commit_cell_idx = commit_col * 2;
345 if commit_cell_idx < cells.len() {
346 cells[commit_cell_idx] = if is_head {
347 GraphCell::HeadNode {
348 color_idx: commit_color,
349 }
350 } else if is_merge {
351 GraphCell::MergeNode {
352 color_idx: commit_color,
353 }
354 } else {
355 GraphCell::Node {
356 color_idx: commit_color,
357 }
358 };
359 }
360
361 for &(parent_col, parent_color, is_existing, already_shown) in parent_info {
363 if parent_col == commit_col {
364 continue;
366 }
367
368 if parent_col > commit_col {
369 draw_horizontal_connection(
371 &mut cells,
372 commit_col,
373 parent_col,
374 parent_color,
375 is_existing,
376 already_shown,
377 true, );
379 } else {
380 draw_horizontal_connection(
382 &mut cells,
383 parent_col,
384 commit_col,
385 parent_color,
386 is_existing,
387 already_shown,
388 false, );
390 }
391 }
392
393 cells
394}
395
396fn draw_horizontal_connection(
398 cells: &mut [GraphCell],
399 left_col: usize,
400 right_col: usize,
401 color_idx: usize,
402 is_existing: bool,
403 already_shown: bool,
404 is_right_direction: bool, ) {
406 let start_idx = left_col * 2 + 1;
407 let end_idx = right_col * 2;
408
409 for idx in start_idx..end_idx {
411 if idx < cells.len() {
412 match cells[idx] {
413 GraphCell::Vertical { color_idx: v_color } => {
414 cells[idx] = GraphCell::Cross {
416 h_color: color_idx,
417 v_color,
418 };
419 }
420 GraphCell::Empty => {
421 cells[idx] = GraphCell::Horizontal { color_idx };
422 }
423 _ => {}
424 }
425 }
426 }
427
428 if is_right_direction {
434 if end_idx < cells.len() {
436 cells[end_idx] = if is_existing && already_shown {
437 GraphCell::CurveDownLeft { color_idx } } else if is_existing {
439 GraphCell::TeeLeft { color_idx } } else {
441 GraphCell::CurveUpLeft { color_idx } };
443 }
444 } else {
445 let left_idx = left_col * 2;
447 if left_idx < cells.len() {
448 cells[left_idx] = if is_existing && already_shown {
449 GraphCell::CurveDownRight { color_idx } } else if is_existing {
451 GraphCell::TeeRight { color_idx } } else {
453 GraphCell::CurveUpRight { color_idx } };
455 }
456 }
457}
458
459fn build_connector_cells(
463 main_col: usize,
464 merging_cols: &[usize],
465 active_columns: &[Option<String>],
466 column_colors: &HashMap<usize, usize>,
467 max_col: usize,
468) -> Vec<GraphCell> {
469 let cell_count = (max_col + 1) * 2;
470 let mut cells = vec![GraphCell::Empty; cell_count];
471
472 let mut sorted_merging: Vec<usize> = merging_cols.to_vec();
474 sorted_merging.sort();
475
476 let rightmost_col = sorted_merging.last().copied().unwrap_or(main_col);
478
479 for (col, hash) in active_columns.iter().enumerate() {
481 if hash.is_some() && col != main_col && !sorted_merging.contains(&col) {
482 let cell_idx = col * 2;
483 if cell_idx < cells.len() {
484 let color_idx = column_colors.get(&col).copied().unwrap_or(col % 8);
485 cells[cell_idx] = GraphCell::Vertical { color_idx };
486 }
487 }
488 }
489
490 let main_color = column_colors.get(&main_col).copied().unwrap_or(0);
492 let main_cell_idx = main_col * 2;
493 if main_cell_idx < cells.len() {
494 cells[main_cell_idx] = GraphCell::TeeRight {
495 color_idx: main_color,
496 };
497 }
498
499 for &merge_col in &sorted_merging {
501 let merge_color = column_colors
502 .get(&merge_col)
503 .copied()
504 .unwrap_or(merge_col % 8);
505
506 let start_idx = main_col * 2 + 1;
508 let end_idx = merge_col * 2;
509
510 for idx in start_idx..end_idx {
511 if idx < cells.len() {
512 match cells[idx] {
513 GraphCell::Vertical { color_idx: v_color } => {
514 cells[idx] = GraphCell::Cross {
516 h_color: merge_color,
517 v_color,
518 };
519 }
520 GraphCell::Empty => {
521 cells[idx] = GraphCell::Horizontal {
522 color_idx: merge_color,
523 };
524 }
525 GraphCell::Horizontal { .. } => {
526 }
528 _ => {}
529 }
530 }
531 }
532
533 if end_idx < cells.len() {
535 if merge_col == rightmost_col {
536 cells[end_idx] = GraphCell::CurveDownLeft {
538 color_idx: merge_color,
539 };
540 } else {
541 cells[end_idx] = GraphCell::TeeUp {
543 color_idx: merge_color,
544 };
545 }
546 }
547 }
548
549 cells
550}
551
552#[cfg(test)]
553mod tests {
554 use super::*;
555 use chrono::Local;
556
557 fn create_event(hash: &str, parents: Vec<&str>) -> GitEvent {
558 let mut event = GitEvent::commit(
559 hash.to_string(),
560 format!("commit {}", hash),
561 "author".to_string(),
562 Local::now(),
563 0,
564 0,
565 );
566 event.parent_hashes = parents.into_iter().map(|s| s.to_string()).collect();
567 event
568 }
569
570 #[test]
571 fn test_build_graph_empty() {
572 let layout = build_graph(&[], None);
573 assert!(layout.is_empty());
574 }
575
576 #[test]
577 fn test_build_graph_single_commit() {
578 let events = vec![create_event("abc1234", vec![])];
579 let layout = build_graph(&events, None);
580 assert_eq!(layout.len(), 1);
581 assert_eq!(layout.rows[0].column, 0);
582 }
583
584 #[test]
585 fn test_build_graph_linear_history() {
586 let events = vec![
587 create_event("commit3", vec!["commit2"]),
588 create_event("commit2", vec!["commit1"]),
589 create_event("commit1", vec![]),
590 ];
591 let layout = build_graph(&events, None);
592 assert_eq!(layout.len(), 3);
593 assert_eq!(layout.rows[0].column, 0);
595 assert_eq!(layout.rows[1].column, 0);
596 assert_eq!(layout.rows[2].column, 0);
597 }
598
599 #[test]
600 fn test_build_graph_with_merge() {
601 let mut merge_event = create_event("merge", vec!["main2", "feat1"]);
603 merge_event.kind = GitEventKind::Merge;
604
605 let events = vec![
606 merge_event,
607 create_event("feat1", vec!["main1"]),
608 create_event("main2", vec!["main1"]),
609 create_event("main1", vec![]),
610 ];
611 let layout = build_graph(&events, None);
612 let event_rows: Vec<_> = layout.rows.iter().filter(|r| r.event.is_some()).collect();
614 assert_eq!(event_rows.len(), 4);
615 assert!(event_rows[0]
617 .cells
618 .iter()
619 .any(|c| matches!(c, GraphCell::MergeNode { .. })));
620 }
621
622 #[test]
623 fn test_build_graph_head_marker() {
624 let events = vec![create_event("abc1234", vec![])];
625 let layout = build_graph(&events, Some("abc1234"));
626 assert!(layout.rows[0].is_head);
627 assert!(layout.rows[0]
628 .cells
629 .iter()
630 .any(|c| matches!(c, GraphCell::HeadNode { .. })));
631 }
632
633 #[test]
634 fn test_column_tracker_find_or_create() {
635 let mut tracker = ColumnTracker::new();
636 let col1 = tracker.find_or_create_column();
637 tracker.set_column(col1, "hash1".to_string(), 0);
638 let col2 = tracker.find_or_create_column();
639 assert_ne!(col1, col2);
640 }
641
642 #[test]
643 fn test_column_tracker_release() {
644 let mut tracker = ColumnTracker::new();
645 let col = tracker.find_or_create_column();
646 tracker.set_column(col, "hash1".to_string(), 0);
647 tracker.release_column(col);
648 assert!(tracker.find_column("hash1").is_none());
649 }
650
651 #[test]
659 fn test_parallel_branches() {
660 let mut merge_event = create_event("merge", vec!["main2", "feat1"]);
661 merge_event.kind = GitEventKind::Merge;
662
663 let events = vec![
664 merge_event,
665 create_event("main2", vec!["base"]),
666 create_event("feat1", vec!["base"]),
667 create_event("base", vec![]),
668 ];
669 let layout = build_graph(&events, None);
670 let event_rows: Vec<_> = layout.rows.iter().filter(|r| r.event.is_some()).collect();
672 assert_eq!(event_rows.len(), 4);
673
674 let main2_row = event_rows
676 .iter()
677 .find(|r| r.event.as_ref().is_some_and(|e| e.short_hash == "main2"))
678 .unwrap();
679 let feat1_row = event_rows
680 .iter()
681 .find(|r| r.event.as_ref().is_some_and(|e| e.short_hash == "feat1"))
682 .unwrap();
683 assert_ne!(main2_row.column, feat1_row.column);
684 }
685
686 #[test]
695 fn test_fork_point() {
696 let events = vec![
697 create_event("feat2", vec!["base"]),
698 create_event("feat1", vec!["base"]),
699 create_event("main", vec!["base"]),
700 create_event("base", vec![]),
701 ];
702 let layout = build_graph(&events, None);
703 let event_rows: Vec<_> = layout.rows.iter().filter(|r| r.event.is_some()).collect();
705 assert_eq!(event_rows.len(), 4);
706
707 let base_row = event_rows
710 .iter()
711 .find(|r| r.event.as_ref().is_some_and(|e| e.short_hash == "base"))
712 .unwrap();
713 assert_eq!(base_row.column, 0);
714 }
715
716 #[test]
718 fn test_color_assignment_unique() {
719 let mut merge_event = create_event("merge", vec!["main2", "feat1"]);
720 merge_event.kind = GitEventKind::Merge;
721
722 let events = vec![
723 merge_event,
724 create_event("main2", vec!["base"]),
725 create_event("feat1", vec!["base"]),
726 create_event("base", vec![]),
727 ];
728 let layout = build_graph(&events, None);
729
730 let event_rows: Vec<_> = layout.rows.iter().filter(|r| r.event.is_some()).collect();
732
733 let main2_row = event_rows
735 .iter()
736 .find(|r| r.event.as_ref().is_some_and(|e| e.short_hash == "main2"))
737 .unwrap();
738 let feat1_row = event_rows
739 .iter()
740 .find(|r| r.event.as_ref().is_some_and(|e| e.short_hash == "feat1"))
741 .unwrap();
742
743 if main2_row.column != feat1_row.column {
746 assert_ne!(main2_row.color_idx, feat1_row.color_idx);
747 }
748 }
749
750 #[test]
752 fn test_long_merge_chain() {
753 let mut merge1 = create_event("merge1", vec!["main3", "feat1"]);
754 merge1.kind = GitEventKind::Merge;
755
756 let events = vec![
757 merge1,
758 create_event("main3", vec!["main2"]),
759 create_event("feat1", vec!["main1"]),
760 create_event("main2", vec!["main1"]),
761 create_event("main1", vec![]),
762 ];
763 let layout = build_graph(&events, Some("merge1"));
764 let event_rows: Vec<_> = layout.rows.iter().filter(|r| r.event.is_some()).collect();
766 assert_eq!(event_rows.len(), 5);
767
768 assert!(event_rows[0]
770 .cells
771 .iter()
772 .any(|c| matches!(c, GraphCell::HeadNode { .. })));
773 }
774
775 #[test]
777 fn test_horizontal_connection() {
778 let mut merge_event = create_event("merge", vec!["main", "feat"]);
779 merge_event.kind = GitEventKind::Merge;
780
781 let events = vec![
782 merge_event,
783 create_event("main", vec!["base"]),
784 create_event("feat", vec!["base"]),
785 create_event("base", vec![]),
786 ];
787 let layout = build_graph(&events, None);
788
789 let has_connection = layout.rows[0].cells.iter().any(|c| {
791 matches!(
792 c,
793 GraphCell::Horizontal { .. }
794 | GraphCell::CurveUpRight { .. }
795 | GraphCell::CurveUpLeft { .. }
796 | GraphCell::CurveDownRight { .. }
797 | GraphCell::CurveDownLeft { .. }
798 )
799 });
800 assert!(has_connection);
801 }
802
803 #[test]
812 fn test_convergence_connector_row() {
813 let events = vec![
815 create_event("feat2", vec!["base"]),
816 create_event("feat1", vec!["base"]),
817 create_event("base", vec![]),
818 ];
819 let layout = build_graph(&events, None);
820
821 assert_eq!(
824 layout.len(),
825 4,
826 "Expected 4 rows (feat2, feat1, connector, base)"
827 );
828
829 let connector_row = layout.rows.iter().find(|row| row.event.is_none());
831 assert!(
832 connector_row.is_some(),
833 "Expected a connector row (event=None)"
834 );
835
836 let connector_row = connector_row.unwrap();
837 let has_tee_right = connector_row
838 .cells
839 .iter()
840 .any(|c| matches!(c, GraphCell::TeeRight { .. }));
841 assert!(has_tee_right, "Connector row should have TeeRight symbol");
842
843 let has_base = layout
845 .rows
846 .iter()
847 .any(|row| row.event.as_ref().is_some_and(|e| e.short_hash == "base"));
848 assert!(has_base);
849 }
850
851 #[test]
854 fn test_connector_row_cell_layout() {
855 let events = vec![
856 create_event("feat2", vec!["base"]),
857 create_event("feat1", vec!["base"]),
858 create_event("base", vec![]),
859 ];
860 let layout = build_graph(&events, None);
861
862 let connector_row = layout
864 .rows
865 .iter()
866 .find(|row| row.event.is_none())
867 .expect("Connector row should exist");
868
869 let cells_str: String = connector_row.cells.iter().map(|c| c.to_char()).collect();
871
872 assert!(
874 cells_str.contains('├'),
875 "Connector row should contain TeeRight (├): got '{}'",
876 cells_str
877 );
878
879 assert!(
881 cells_str.contains('╯'),
882 "Connector row should contain CurveDownLeft (╯): got '{}'",
883 cells_str
884 );
885 }
886
887 #[test]
890 fn test_merge_commit_horizontal_line() {
891 let mut merge_event = create_event("merge", vec!["main", "feat"]);
892 merge_event.kind = GitEventKind::Merge;
893
894 let events = vec![
895 merge_event,
896 create_event("main", vec!["base"]),
897 create_event("feat", vec!["base"]),
898 create_event("base", vec![]),
899 ];
900 let layout = build_graph(&events, None);
901
902 let merge_row = layout
904 .rows
905 .iter()
906 .find(|r| r.event.as_ref().is_some_and(|e| e.short_hash == "merge"))
907 .expect("Merge row should exist");
908
909 let has_merge_or_head_node = merge_row
911 .cells
912 .iter()
913 .any(|c| matches!(c, GraphCell::MergeNode { .. } | GraphCell::HeadNode { .. }));
914 assert!(has_merge_or_head_node, "Merge row should have MergeNode");
915
916 let has_horizontal = merge_row
918 .cells
919 .iter()
920 .any(|c| matches!(c, GraphCell::Horizontal { .. }));
921 assert!(
922 has_horizontal,
923 "Merge row should have Horizontal line for second parent"
924 );
925
926 let has_curve = merge_row
928 .cells
929 .iter()
930 .any(|c| matches!(c, GraphCell::CurveUpLeft { .. }));
931 assert!(
932 has_curve,
933 "Merge row should have CurveUpLeft for branch start"
934 );
935 }
936
937 #[test]
940 fn test_branch_commit_no_horizontal_line() {
941 let events = vec![
945 create_event("feat1", vec!["base"]),
946 create_event("main1", vec!["base"]),
947 create_event("base", vec![]),
948 ];
949 let layout = build_graph(&events, None);
950
951 let feat1_row = layout
953 .rows
954 .iter()
955 .find(|r| r.event.as_ref().is_some_and(|e| e.short_hash == "feat1"))
956 .expect("feat1 row should exist");
957
958 let has_horizontal = feat1_row
961 .cells
962 .iter()
963 .any(|c| matches!(c, GraphCell::Horizontal { .. }));
964 assert!(
965 !has_horizontal,
966 "Branch commit should NOT have Horizontal line"
967 );
968 }
969
970 #[test]
972 fn test_parent_outside_display_range() {
973 let events = vec![
975 create_event("commit2", vec!["commit1"]),
976 create_event("commit1", vec!["outside_parent"]), ];
978 let layout = build_graph(&events, None);
979
980 assert_eq!(layout.len(), 2);
981
982 let commit1_row = layout
985 .rows
986 .iter()
987 .find(|r| r.event.as_ref().is_some_and(|e| e.short_hash == "commit1"))
988 .expect("commit1 row should exist");
989
990 assert_eq!(commit1_row.column, 0);
992 }
993
994 #[test]
996 fn test_three_parallel_branches() {
997 let mut merge = create_event("merge", vec!["main3", "feat1", "feat2"]);
998 merge.kind = GitEventKind::Merge;
999
1000 let events = vec![
1001 merge,
1002 create_event("main3", vec!["main2"]),
1003 create_event("feat1", vec!["main1"]),
1004 create_event("feat2", vec!["main1"]),
1005 create_event("main2", vec!["main1"]),
1006 create_event("main1", vec![]),
1007 ];
1008 let layout = build_graph(&events, None);
1009
1010 let event_rows: Vec<_> = layout.rows.iter().filter(|r| r.event.is_some()).collect();
1012 assert_eq!(event_rows.len(), 6);
1013
1014 assert!(
1016 layout.max_column >= 2,
1017 "Should have at least 3 columns for 3 parallel branches"
1018 );
1019 }
1020
1021 #[test]
1023 fn test_linear_history_same_column() {
1024 let events = vec![
1025 create_event("c5", vec!["c4"]),
1026 create_event("c4", vec!["c3"]),
1027 create_event("c3", vec!["c2"]),
1028 create_event("c2", vec!["c1"]),
1029 create_event("c1", vec![]),
1030 ];
1031 let layout = build_graph(&events, None);
1032
1033 for row in &layout.rows {
1035 assert_eq!(
1036 row.column, 0,
1037 "All commits in linear history should be in column 0"
1038 );
1039 }
1040
1041 let connector_count = layout.rows.iter().filter(|r| r.event.is_none()).count();
1043 assert_eq!(
1044 connector_count, 0,
1045 "Linear history should have no connector rows"
1046 );
1047 }
1048
1049 #[test]
1051 fn test_vertical_line_colors() {
1052 let mut merge = create_event("merge", vec!["main", "feat"]);
1053 merge.kind = GitEventKind::Merge;
1054
1055 let events = vec![
1056 merge,
1057 create_event("feat", vec!["base"]),
1058 create_event("main", vec!["base"]),
1059 create_event("base", vec![]),
1060 ];
1061 let layout = build_graph(&events, None);
1062
1063 for row in layout.rows.iter().filter(|r| r.event.is_some()) {
1065 for cell in &row.cells {
1066 if let GraphCell::Vertical { color_idx } = cell {
1067 assert!(*color_idx < 8, "Color index should be < 8");
1069 }
1070 }
1071 }
1072 }
1073
1074 #[test]
1076 fn test_cross_generation() {
1077 let mut merge = create_event("merge", vec!["main2", "feat1", "feat2"]);
1083 merge.kind = GitEventKind::Merge;
1084
1085 let events = vec![
1086 merge,
1087 create_event("main2", vec!["main1"]),
1088 create_event("feat1", vec!["base"]),
1089 create_event("feat2", vec!["base"]),
1090 create_event("main1", vec!["base"]),
1091 create_event("base", vec![]),
1092 ];
1093 let layout = build_graph(&events, None);
1094
1095 let has_cross = layout
1098 .rows
1099 .iter()
1100 .any(|r| r.cells.iter().any(|c| matches!(c, GraphCell::Cross { .. })));
1101
1102 if has_cross {
1104 for row in &layout.rows {
1105 for cell in &row.cells {
1106 if let GraphCell::Cross { h_color, v_color } = cell {
1107 assert!(*h_color < 8, "h_color should be valid");
1108 assert!(*v_color < 8, "v_color should be valid");
1109 }
1110 }
1111 }
1112 }
1113 }
1114
1115 #[test]
1117 fn test_column_release_after_merge() {
1118 let mut merge1 = create_event("merge1", vec!["main2", "feat1"]);
1119 merge1.kind = GitEventKind::Merge;
1120
1121 let events = vec![
1122 merge1,
1123 create_event("feat1", vec!["main1"]),
1124 create_event("main2", vec!["main1"]),
1125 create_event("main1", vec![]),
1126 create_event("after_merge", vec!["main1"]), ];
1128 let layout = build_graph(&events, None);
1129
1130 assert!(
1133 layout.max_column <= 2,
1134 "Columns should be reused after merge"
1135 );
1136 }
1137
1138 #[test]
1140 fn test_node_types() {
1141 let mut merge = create_event("merge", vec!["main", "feat"]);
1142 merge.kind = GitEventKind::Merge;
1143
1144 let events = vec![
1145 merge,
1146 create_event("main", vec!["base"]),
1147 create_event("base", vec![]),
1148 ];
1149
1150 let layout_no_head = build_graph(&events, None);
1152 let merge_row = layout_no_head
1153 .rows
1154 .iter()
1155 .find(|r| r.event.as_ref().is_some_and(|e| e.short_hash == "merge"))
1156 .unwrap();
1157 assert!(
1158 merge_row
1159 .cells
1160 .iter()
1161 .any(|c| matches!(c, GraphCell::MergeNode { .. })),
1162 "Merge commit without HEAD should have MergeNode"
1163 );
1164
1165 let layout_with_head = build_graph(&events, Some("merge"));
1167 let merge_row_head = layout_with_head
1168 .rows
1169 .iter()
1170 .find(|r| r.event.as_ref().is_some_and(|e| e.short_hash == "merge"))
1171 .unwrap();
1172 assert!(
1173 merge_row_head
1174 .cells
1175 .iter()
1176 .any(|c| matches!(c, GraphCell::HeadNode { .. })),
1177 "Merge commit with HEAD should have HeadNode"
1178 );
1179
1180 let normal_row = layout_no_head
1182 .rows
1183 .iter()
1184 .find(|r| r.event.as_ref().is_some_and(|e| e.short_hash == "main"))
1185 .unwrap();
1186 assert!(
1187 normal_row
1188 .cells
1189 .iter()
1190 .any(|c| matches!(c, GraphCell::Node { .. })),
1191 "Normal commit should have Node"
1192 );
1193 }
1194
1195 #[test]
1197 fn test_tee_left_generation() {
1198 let mut merge = create_event("merge", vec!["main", "feat"]);
1199 merge.kind = GitEventKind::Merge;
1200
1201 let events = vec![
1202 merge,
1203 create_event("feat", vec!["base"]),
1205 create_event("main", vec!["base"]),
1206 create_event("base", vec![]),
1207 ];
1208 let layout = build_graph(&events, None);
1209
1210 let has_tee_or_curve = layout.rows.iter().any(|r| {
1212 r.cells
1213 .iter()
1214 .any(|c| matches!(c, GraphCell::TeeLeft { .. } | GraphCell::CurveUpLeft { .. }))
1215 });
1216 assert!(
1217 has_tee_or_curve,
1218 "Should have TeeLeft or CurveUpLeft for rightward connection"
1219 );
1220 }
1221
1222 #[test]
1224 fn test_empty_events_no_panic() {
1225 let layout = build_graph(&[], None);
1226 assert!(layout.is_empty());
1227 assert_eq!(layout.max_column, 0);
1228 }
1229
1230 #[test]
1232 fn test_single_commit_no_panic() {
1233 let events = vec![create_event("only", vec![])];
1234 let layout = build_graph(&events, Some("only"));
1235
1236 assert_eq!(layout.len(), 1);
1237 assert!(layout.rows[0].is_head);
1238 assert!(layout.rows[0]
1239 .cells
1240 .iter()
1241 .any(|c| matches!(c, GraphCell::HeadNode { .. })));
1242 }
1243
1244 #[test]
1247 fn test_consecutive_merges_vertical_lines_preserved() {
1248 let mut merge2 = create_event("merge2", vec!["main2", "feat2"]);
1254 merge2.kind = GitEventKind::Merge;
1255 let mut merge1 = create_event("merge1", vec!["base", "feat1"]);
1256 merge1.kind = GitEventKind::Merge;
1257
1258 let events = vec![
1259 merge2,
1260 create_event("feat2", vec!["merge1"]),
1261 create_event("main2", vec!["merge1"]),
1262 merge1,
1263 create_event("feat1", vec!["base"]),
1264 create_event("base", vec![]),
1265 ];
1266 let layout = build_graph(&events, None);
1267
1268 let mut vertical_count = 0;
1271 for row in &layout.rows {
1272 for cell in &row.cells {
1273 if matches!(cell, GraphCell::Vertical { .. }) {
1274 vertical_count += 1;
1275 }
1276 }
1277 }
1278
1279 assert!(
1281 vertical_count > 0,
1282 "Vertical lines should be preserved between consecutive merges"
1283 );
1284
1285 assert!(
1287 layout.max_column <= 3,
1288 "Columns should be reused efficiently, got max_column={}",
1289 layout.max_column
1290 );
1291 }
1292
1293 #[test]
1295 fn test_many_parallel_branches() {
1296 let mut events = vec![];
1312
1313 let mut final_merge = create_event(
1315 "final",
1316 vec![
1317 "branch01", "branch02", "branch03", "branch04", "branch05", "branch06", "branch07",
1318 "branch08", "branch09", "branch10", "branch11", "branch12",
1319 ],
1320 );
1321 final_merge.kind = GitEventKind::Merge;
1322 events.push(final_merge);
1323
1324 for i in 1..=12 {
1326 events.push(create_event(&format!("branch{:02}", i), vec!["base"]));
1327 }
1328
1329 events.push(create_event("base", vec![]));
1331
1332 let layout = build_graph(&events, None);
1333
1334 assert!(!layout.rows.is_empty());
1336
1337 let mut branch_columns: Vec<usize> = vec![];
1339 for row in &layout.rows {
1340 if let Some(ref event) = row.event {
1341 if event.short_hash.starts_with("branch") {
1342 branch_columns.push(row.column);
1343 }
1344 }
1345 }
1346 assert_eq!(branch_columns.len(), 12);
1348
1349 assert!(
1351 layout.max_column >= 11,
1352 "Expected at least 12 columns for 12 parallel branches, got max_column={}",
1353 layout.max_column
1354 );
1355 }
1356
1357 #[test]
1359 fn test_complex_cross_pattern() {
1360 let mut merge_left = create_event("merge_left", vec!["left_branch", "middle"]);
1369 merge_left.kind = GitEventKind::Merge;
1370
1371 let mut middle = create_event("middle", vec!["right_branch", "right2"]);
1372 middle.kind = GitEventKind::Merge;
1373
1374 let events = vec![
1375 merge_left,
1376 create_event("left_branch", vec!["base"]),
1377 middle,
1378 create_event("right_branch", vec!["base"]),
1379 create_event("right2", vec!["base"]),
1380 create_event("base", vec![]),
1381 ];
1382
1383 let layout = build_graph(&events, None);
1384
1385 assert!(!layout.rows.is_empty());
1387
1388 let has_any_horizontal = layout.rows.iter().any(|row| {
1390 row.cells.iter().any(|c| {
1391 matches!(
1392 c,
1393 GraphCell::Horizontal { .. }
1394 | GraphCell::Cross { .. }
1395 | GraphCell::TeeLeft { .. }
1396 | GraphCell::TeeRight { .. }
1397 )
1398 })
1399 });
1400 assert!(
1402 has_any_horizontal,
1403 "Complex merge pattern should have horizontal connections"
1404 );
1405 }
1406
1407 #[test]
1409 fn test_color_wraparound_with_many_branches() {
1410 let events: Vec<GitEvent> = (1..=20)
1412 .map(|i| {
1413 let hash = format!("commit{:02}", i);
1414 let parent = if i == 1 {
1415 vec![]
1416 } else {
1417 vec![format!("commit{:02}", i - 1)]
1418 };
1419 let mut event = GitEvent::commit(
1420 hash.clone(),
1421 format!("commit {}", hash),
1422 "author".to_string(),
1423 Local::now(),
1424 0,
1425 0,
1426 );
1427 event.parent_hashes = parent;
1428 event
1429 })
1430 .collect();
1431
1432 let layout = build_graph(&events, None);
1433
1434 assert_eq!(layout.rows.len(), 20);
1436
1437 for row in &layout.rows {
1439 assert!(
1440 row.color_idx < 16,
1441 "Color index {} should be within palette size",
1442 row.color_idx
1443 );
1444 }
1445 }
1446
1447 #[test]
1449 fn test_deeply_nested_merges() {
1450 let mut events = vec![];
1460
1461 for i in (1..=5).rev() {
1462 let parent1 = if i == 1 {
1463 "main".to_string()
1464 } else {
1465 format!("merge{}", i - 1)
1466 };
1467 let parent2 = format!("branch{}", i);
1468 let mut merge = create_event(&format!("merge{}", i), vec![]);
1469 merge.parent_hashes = vec![parent1, parent2];
1470 merge.kind = GitEventKind::Merge;
1471 events.push(merge);
1472 }
1473
1474 for i in (1..=5).rev() {
1475 events.push(create_event(&format!("branch{}", i), vec!["base"]));
1476 }
1477 events.push(create_event("main", vec!["base"]));
1478 events.push(create_event("base", vec![]));
1479
1480 let layout = build_graph(&events, None);
1481
1482 assert!(!layout.rows.is_empty());
1484
1485 let merge_count = layout
1487 .rows
1488 .iter()
1489 .filter(|row| {
1490 row.cells
1491 .iter()
1492 .any(|c| matches!(c, GraphCell::MergeNode { .. }))
1493 })
1494 .count();
1495 assert_eq!(merge_count, 5, "Should have 5 merge nodes");
1496 }
1497
1498 #[test]
1500 fn test_diamond_pattern_chain() {
1501 let mut events = vec![];
1509
1510 for i in (1..=3).rev() {
1512 let parent = if i == 3 {
1513 "base".to_string()
1514 } else {
1515 format!("merge{}", i + 1)
1516 };
1517
1518 let mut merge = create_event(&format!("merge{}", i), vec![]);
1520 merge.parent_hashes = vec![format!("feat{}", i), parent.clone()];
1521 merge.kind = GitEventKind::Merge;
1522 events.push(merge);
1523
1524 events.push(create_event(&format!("feat{}", i), vec![&parent]));
1526 }
1527
1528 events.push(create_event("base", vec![]));
1529
1530 let layout = build_graph(&events, None);
1531
1532 assert!(!layout.rows.is_empty());
1534
1535 let merge_count = layout
1537 .rows
1538 .iter()
1539 .filter(|row| {
1540 row.cells
1541 .iter()
1542 .any(|c| matches!(c, GraphCell::MergeNode { .. }))
1543 })
1544 .count();
1545 assert_eq!(merge_count, 3, "Should have 3 merge nodes");
1546
1547 let vertical_count: usize = layout
1549 .rows
1550 .iter()
1551 .map(|row| {
1552 row.cells
1553 .iter()
1554 .filter(|c| matches!(c, GraphCell::Vertical { .. }))
1555 .count()
1556 })
1557 .sum();
1558
1559 assert!(
1560 vertical_count > 0,
1561 "Vertical lines should exist in diamond pattern chain"
1562 );
1563 }
1564
1565 #[test]
1567 fn test_alternating_merge_pattern() {
1568 let mut final_merge = create_event("final", vec!["merge_r2", "right3"]);
1579 final_merge.kind = GitEventKind::Merge;
1580
1581 let mut merge_r2 = create_event("merge_r2", vec!["merge_l1", "left2"]);
1582 merge_r2.kind = GitEventKind::Merge;
1583
1584 let mut merge_l1 = create_event("merge_l1", vec!["left1", "right1"]);
1585 merge_l1.kind = GitEventKind::Merge;
1586
1587 let events = vec![
1588 final_merge,
1589 create_event("right3", vec!["base"]),
1590 merge_r2,
1591 create_event("left2", vec!["base"]),
1592 merge_l1,
1593 create_event("right1", vec!["base"]),
1594 create_event("left1", vec!["base"]),
1595 create_event("base", vec![]),
1596 ];
1597
1598 let layout = build_graph(&events, None);
1599
1600 assert!(!layout.rows.is_empty());
1602
1603 let merge_count = layout
1605 .rows
1606 .iter()
1607 .filter(|row| {
1608 row.cells
1609 .iter()
1610 .any(|c| matches!(c, GraphCell::MergeNode { .. }))
1611 })
1612 .count();
1613 assert_eq!(merge_count, 3, "Should have 3 merge nodes");
1614
1615 let hashes: Vec<_> = layout
1617 .rows
1618 .iter()
1619 .filter_map(|row| row.event.as_ref().map(|e| e.short_hash.as_str()))
1620 .collect();
1621
1622 assert!(hashes.contains(&"final"));
1623 assert!(hashes.contains(&"right3"));
1624 assert!(hashes.contains(&"merge_r2"));
1625 assert!(hashes.contains(&"left2"));
1626 assert!(hashes.contains(&"merge_l1"));
1627 assert!(hashes.contains(&"right1"));
1628 assert!(hashes.contains(&"left1"));
1629 assert!(hashes.contains(&"base"));
1630 }
1631
1632 #[test]
1634 fn test_tree_structure_preserved() {
1635 let mut root = create_event("root", vec!["child1", "child2"]);
1643 root.kind = GitEventKind::Merge;
1644
1645 let mut child1 = create_event("child1", vec!["grandchild1", "grandchild2"]);
1646 child1.kind = GitEventKind::Merge;
1647
1648 let mut child2 = create_event("child2", vec!["grandchild3", "grandchild4"]);
1649 child2.kind = GitEventKind::Merge;
1650
1651 let events = vec![
1652 root,
1653 child1,
1654 create_event("grandchild1", vec!["base"]),
1655 create_event("grandchild2", vec!["base"]),
1656 child2,
1657 create_event("grandchild3", vec!["base"]),
1658 create_event("grandchild4", vec!["base"]),
1659 create_event("base", vec![]),
1660 ];
1661
1662 let layout = build_graph(&events, None);
1663
1664 assert!(!layout.rows.is_empty());
1666
1667 let event_count = layout.rows.iter().filter(|r| r.event.is_some()).count();
1669 assert_eq!(event_count, 8, "All 8 commits should be present");
1670
1671 let has_vertical = layout.rows.iter().any(|row| {
1673 row.cells
1674 .iter()
1675 .any(|c| matches!(c, GraphCell::Vertical { .. }))
1676 });
1677 let has_horizontal = layout.rows.iter().any(|row| {
1678 row.cells.iter().any(|c| {
1679 matches!(
1680 c,
1681 GraphCell::Horizontal { .. }
1682 | GraphCell::CurveUpLeft { .. }
1683 | GraphCell::CurveUpRight { .. }
1684 | GraphCell::CurveDownLeft { .. }
1685 | GraphCell::CurveDownRight { .. }
1686 )
1687 })
1688 });
1689
1690 assert!(has_vertical, "Tree should have vertical connections");
1691 assert!(has_horizontal, "Tree should have horizontal connections");
1692
1693 assert!(
1695 layout.max_column <= 5,
1696 "Columns should be reused efficiently, got max_column={}",
1697 layout.max_column
1698 );
1699 }
1700}