1use std::collections::{HashMap, HashSet};
6
7use crate::vcs::LogEntry;
8
9#[derive(Debug, Clone, Copy, PartialEq, Eq)]
12pub struct Lane {
13 pub id: usize,
14 pub head: bool,
15}
16
17#[derive(Debug, Clone, Copy, PartialEq, Eq)]
20pub struct Cell {
21 pub glyph: char,
22 pub glyph_lane: Option<Lane>,
23 pub fill: char,
24 pub fill_lane: Option<Lane>,
25}
26
27#[derive(Clone)]
28struct Slot {
29 awaits: String,
30 id: usize,
31}
32
33#[derive(Clone, Copy, PartialEq, Eq)]
34enum Join {
35 Ends,
37 Opens,
39 Meets,
41}
42
43pub fn layout(entries: &[LogEntry], head: Option<&str>) -> Vec<Vec<Cell>> {
46 let chain = first_parent_chain(entries, head);
47 let mut head_lane = None;
50 let mut slots: Vec<Option<Slot>> = Vec::new();
51 let mut next_id = 0;
52 let mut rows = Vec::with_capacity(entries.len());
53 for entry in entries {
54 let col = slots
55 .iter()
56 .position(|slot| slot.as_ref().is_some_and(|s| s.awaits == entry.oid))
57 .or_else(|| slots.iter().position(Option::is_none))
58 .unwrap_or_else(|| {
59 slots.push(None);
60 slots.len() - 1
61 });
62 let id = slots.get(col).and_then(Option::as_ref).map_or_else(
63 || {
64 next_id += 1;
65 next_id - 1
66 },
67 |slot| slot.id,
68 );
69 let own = Lane {
70 id,
71 head: chain.contains(entry.oid.as_str()),
72 };
73 let walked = head_lane;
74 if own.head {
75 head_lane = Some(id);
76 }
77 let lane = |slot: &Slot| Lane {
78 id: slot.id,
79 head: [walked, head_lane].contains(&Some(slot.id))
80 && chain.contains(slot.awaits.as_str()),
81 };
82 let before = slots.clone();
83 let mut joins: Vec<(usize, Join, Lane)> = Vec::new();
84 for (index, slot) in slots.iter_mut().enumerate() {
85 if index != col
86 && let Some(waiting) = slot.take_if(|s| s.awaits == entry.oid)
87 {
88 joins.push((index, Join::Ends, lane(&waiting)));
89 }
90 }
91 if let Some(own_slot) = slots.get_mut(col) {
92 *own_slot = entry.parents.first().map(|parent| Slot {
93 awaits: parent.clone(),
94 id,
95 });
96 }
97 for parent in entry.parents.iter().skip(1) {
98 if let Some(index) = slots
99 .iter()
100 .position(|slot| slot.as_ref().is_some_and(|s| &s.awaits == parent))
101 {
102 let met = slots.get(index).and_then(Option::as_ref).map(lane);
103 joins.extend(met.map(|met| (index, Join::Meets, met)));
104 continue;
105 }
106 let index = (0..slots.len())
108 .find(|&index| {
109 slots.get(index).is_some_and(Option::is_none)
110 && !joins.iter().any(|(joined, ..)| *joined == index)
111 })
112 .unwrap_or_else(|| {
113 slots.push(None);
114 slots.len() - 1
115 });
116 let slot = Slot {
117 awaits: parent.clone(),
118 id: next_id,
119 };
120 next_id += 1;
121 joins.push((index, Join::Opens, lane(&slot)));
122 if let Some(free) = slots.get_mut(index) {
123 *free = Some(slot);
124 }
125 }
126 rows.push(draw_row(col, own, &before, &joins, &lane));
127 while slots.last().is_some_and(Option::is_none) {
128 slots.pop();
129 }
130 }
131 rows
132}
133
134fn draw_row(
135 col: usize,
136 own: Lane,
137 before: &[Option<Slot>],
138 joins: &[(usize, Join, Lane)],
139 lane: &impl Fn(&Slot) -> Lane,
140) -> Vec<Cell> {
141 let width = joins
142 .iter()
143 .map(|(index, ..)| index + 1)
144 .chain([before.len(), col + 1])
145 .max()
146 .unwrap_or(0);
147 let run_crossing_gap_after = |index: usize| {
148 joins.iter().find(|(target, ..)| {
149 let (lo, hi) = (col.min(*target), col.max(*target));
150 index >= lo && index < hi
151 })
152 };
153 let passing = |index: usize| before.get(index).and_then(Option::as_ref).map(lane);
154 (0..width)
155 .map(|index| {
156 let crossing = index
157 .checked_sub(1)
158 .and_then(run_crossing_gap_after)
159 .filter(|_| run_crossing_gap_after(index).is_some());
160 let (glyph, glyph_lane) = if index == col {
161 ('●', Some(own))
162 } else if let Some((_, join, joined)) = joins.iter().find(|(t, ..)| *t == index) {
163 let right = index > col;
164 let beyond = if right {
166 run_crossing_gap_after(index).is_some()
167 } else {
168 index
169 .checked_sub(1)
170 .and_then(run_crossing_gap_after)
171 .is_some()
172 };
173 let glyph = match (join, right, beyond) {
174 (Join::Ends, _, true) => '┴',
175 (Join::Opens, _, true) => '┬',
176 (Join::Meets, _, true) => '┼',
177 (Join::Ends, true, false) => '╯',
178 (Join::Ends, false, false) => '╰',
179 (Join::Opens, true, false) => '╮',
180 (Join::Opens, false, false) => '╭',
181 (Join::Meets, true, false) => '┤',
182 (Join::Meets, false, false) => '├',
183 };
184 (glyph, Some(*joined))
185 } else if let Some(through) = passing(index) {
186 (if crossing.is_some() { '┼' } else { '│' }, Some(through))
187 } else if let Some((.., run)) = crossing {
188 ('─', Some(*run))
189 } else {
190 (' ', None)
191 };
192 let (fill, fill_lane) =
193 run_crossing_gap_after(index).map_or((' ', None), |(.., run)| ('─', Some(*run)));
194 Cell {
195 glyph,
196 glyph_lane,
197 fill,
198 fill_lane,
199 }
200 })
201 .collect()
202}
203
204fn first_parent_chain<'a>(entries: &'a [LogEntry], head: Option<&'a str>) -> HashSet<&'a str> {
206 let parents: HashMap<&str, Option<&str>> = entries
207 .iter()
208 .map(|entry| {
209 (
210 entry.oid.as_str(),
211 entry.parents.first().map(String::as_str),
212 )
213 })
214 .collect();
215 let mut chain = HashSet::new();
216 let mut at = head;
217 while let Some(oid) = at
218 && chain.insert(oid)
219 {
220 at = parents.get(oid).copied().flatten();
221 }
222 chain
223}
224
225#[cfg(test)]
226mod tests {
227 use super::*;
228
229 fn entry(oid: &str, parents: &[&str]) -> LogEntry {
230 LogEntry {
231 oid: oid.to_owned(),
232 oid7: oid.to_owned(),
233 refs: Vec::new(),
234 parents: parents.iter().map(|p| (*p).to_owned()).collect(),
235 subject: String::new(),
236 author: String::new(),
237 time_unix: 0,
238 change_id: None,
239 }
240 }
241
242 fn text(rows: &[Vec<Cell>]) -> Vec<String> {
243 rows.iter()
244 .map(|row| {
245 row.iter()
246 .flat_map(|cell| [cell.glyph, cell.fill])
247 .collect::<String>()
248 .trim_end()
249 .to_owned()
250 })
251 .collect()
252 }
253
254 #[test]
255 fn a_straight_history_is_one_lane() {
256 let rows = layout(
257 &[entry("c", &["b"]), entry("b", &["a"]), entry("a", &[])],
258 Some("c"),
259 );
260 assert_eq!(text(&rows), ["●", "●", "●"]);
261 assert!(
262 rows.iter()
263 .all(|row| row[0].glyph_lane.is_some_and(|l| l.head))
264 );
265 }
266
267 #[test]
268 fn a_merge_opens_a_lane_that_ends_at_the_fork() {
269 let rows = layout(
271 &[
272 entry("m", &["b", "f"]),
273 entry("f", &["a"]),
274 entry("b", &["a"]),
275 entry("a", &[]),
276 ],
277 Some("m"),
278 );
279 assert_eq!(text(&rows), ["●─╮", "│ ●", "● │", "●─╯"]);
280 let side = rows[1][1].glyph_lane.expect("the merged branch has a lane");
281 assert!(!side.head, "only the first-parent line is HEAD's");
282 assert!(rows[2][0].glyph_lane.is_some_and(|l| l.head));
283 }
284
285 #[test]
286 fn two_branch_tips_share_their_fork() {
287 let rows = layout(
288 &[entry("x", &["a"]), entry("y", &["a"]), entry("a", &[])],
289 Some("y"),
290 );
291 assert_eq!(text(&rows), ["●", "│ ●", "●─╯"]);
292 assert_eq!(rows[0][0].glyph_lane.map(|l| l.head), Some(false));
293 assert_eq!(
294 rows[1][0].glyph_lane.map(|l| l.head),
295 Some(false),
296 "x's lane stays its own colour down to the fork"
297 );
298 assert_eq!(rows[2][1].glyph_lane.map(|l| l.head), Some(true));
299 }
300
301 #[test]
302 fn a_branch_forked_from_a_merge_meets_it_while_the_merge_opens_a_lane() {
303 let rows = layout(
304 &[
305 entry("x", &["a"]),
306 entry("y", &["a"]),
307 entry("a", &["b", "c"]),
308 entry("c", &["b"]),
309 entry("b", &[]),
310 ],
311 Some("y"),
312 );
313 assert_eq!(text(&rows), ["●", "│ ●", "●─┴─╮", "│ ●", "●───╯"]);
314 }
315
316 #[test]
317 fn lanes_meeting_one_commit_close_into_it_in_one_run() {
318 let rows = layout(
319 &[
320 entry("x", &["a"]),
321 entry("y", &["a"]),
322 entry("z", &["a"]),
323 entry("a", &[]),
324 ],
325 None,
326 );
327 assert_eq!(text(&rows).last().map(String::as_str), Some("●─┴─╯"));
328 }
329
330 #[test]
331 fn a_run_crossing_a_lane_draws_a_crossing() {
332 let rows = layout(
334 &[
335 entry("x", &["a"]),
336 entry("y", &["p"]),
337 entry("z", &["a"]),
338 entry("a", &[]),
339 entry("p", &["r"]),
340 entry("r", &[]),
341 ],
342 None,
343 );
344 assert_eq!(text(&rows), ["●", "│ ●", "│ │ ●", "●─┼─╯", " ●", " ●"]);
345 }
346}