1#[derive(Debug, Clone, Copy, PartialEq, Eq)]
8pub enum GraphGlyph {
9 Empty,
10 Node(u8),
12 Pipe(u8),
14 CornerTL(u8),
17 CornerTR(u8),
19 CornerBL(u8),
21 CornerBR(u8),
23 Horizontal(u8),
25 TeeRight(u8),
27 TeeLeft(u8),
29 Cross(u8),
31 Slash(u8),
33 Backslash(u8),
35}
36
37impl GraphGlyph {
38 pub fn ch(self) -> char {
39 match self {
40 GraphGlyph::Empty => ' ',
41 GraphGlyph::Node(_) => '●',
42 GraphGlyph::Pipe(_) => '│',
43 GraphGlyph::CornerTL(_) => '╭',
44 GraphGlyph::CornerTR(_) => '╮',
45 GraphGlyph::CornerBL(_) => '╰',
46 GraphGlyph::CornerBR(_) => '╯',
47 GraphGlyph::Horizontal(_) => '─',
48 GraphGlyph::TeeRight(_) => '├',
49 GraphGlyph::TeeLeft(_) => '┤',
50 GraphGlyph::Cross(_) => '┼',
51 GraphGlyph::Slash(_) => '╱',
52 GraphGlyph::Backslash(_) => '╲',
53 }
54 }
55
56 pub fn color_id(self) -> Option<u8> {
57 match self {
58 GraphGlyph::Empty => None,
59 GraphGlyph::Node(c)
60 | GraphGlyph::Pipe(c)
61 | GraphGlyph::CornerTL(c)
62 | GraphGlyph::CornerTR(c)
63 | GraphGlyph::CornerBL(c)
64 | GraphGlyph::CornerBR(c)
65 | GraphGlyph::Horizontal(c)
66 | GraphGlyph::TeeRight(c)
67 | GraphGlyph::TeeLeft(c)
68 | GraphGlyph::Cross(c)
69 | GraphGlyph::Slash(c)
70 | GraphGlyph::Backslash(c) => Some(c),
71 }
72 }
73}
74
75#[derive(Debug, Clone)]
77pub struct GraphRow {
78 pub hash: String,
79 pub short: String,
80 pub subject: String,
81 pub author: String,
82 pub when: String,
83 pub refs: String,
85 pub lane: usize,
86 pub color: u8,
87 pub glyphs: Vec<GraphGlyph>,
89}
90
91#[derive(Debug, Clone)]
92pub(crate) struct RawCommit {
93 hash: String,
94 short: String,
95 parents: Vec<String>,
96 refs: String,
97 subject: String,
98 author: String,
99 when: String,
100}
101
102pub const LANE_COLORS: usize = 8;
104
105pub(crate) fn parse_log_output(text: &str) -> Vec<RawCommit> {
108 let mut out = Vec::new();
109 for line in text.lines() {
110 if line.is_empty() {
111 continue;
112 }
113 let parts: Vec<&str> = line.split('\0').collect();
114 if parts.len() < 7 {
115 if parts.len() < 3 {
117 continue;
118 }
119 }
120 let hash = parts.first().copied().unwrap_or("").to_string();
121 if hash.len() < 7 {
122 continue;
123 }
124 let short = parts.get(1).copied().unwrap_or(&hash[..7.min(hash.len())]).to_string();
125 let parents: Vec<String> = parts
126 .get(2)
127 .copied()
128 .unwrap_or("")
129 .split_whitespace()
130 .filter(|s| !s.is_empty())
131 .map(|s| s.to_string())
132 .collect();
133 let refs = parts
134 .get(3)
135 .copied()
136 .unwrap_or("")
137 .trim()
138 .trim_start_matches('(')
139 .trim_end_matches(')')
140 .to_string();
141 let subject = parts.get(4).copied().unwrap_or("").to_string();
142 let author = parts.get(5).copied().unwrap_or("").to_string();
143 let when = parts.get(6).copied().unwrap_or("").to_string();
144 out.push(RawCommit {
145 hash,
146 short,
147 parents,
148 refs,
149 subject,
150 author,
151 when,
152 });
153 }
154 out
155}
156
157pub(crate) fn layout_graph(commits: &[RawCommit]) -> Vec<GraphRow> {
159 if commits.is_empty() {
160 return Vec::new();
161 }
162
163 let mut active: Vec<Option<String>> = Vec::new();
166 let mut rows = Vec::with_capacity(commits.len());
168
169 let _ = commits;
171
172 for commit in commits {
173 let mut lane = None;
175 for (i, slot) in active.iter().enumerate() {
176 if slot.as_ref() == Some(&commit.hash) {
177 lane = Some(i);
178 break;
179 }
180 }
181 if lane.is_none() {
182 if let Some(i) = active.iter().position(|s| s.is_none()) {
183 active[i] = Some(commit.hash.clone());
184 lane = Some(i);
185 } else {
186 active.push(Some(commit.hash.clone()));
187 lane = Some(active.len() - 1);
188 }
189 }
190 let lane = lane.unwrap();
191 let color = (lane % LANE_COLORS) as u8;
192
193 let width = active.len().max(1).min(10);
195 let mut glyphs = vec![GraphGlyph::Empty; width];
196 for (i, slot) in active.iter().enumerate().take(width) {
197 if slot.is_some() {
198 if i == lane {
199 glyphs[i] = GraphGlyph::Node(color);
200 } else {
201 let c = (i % LANE_COLORS) as u8;
202 glyphs[i] = GraphGlyph::Pipe(c);
203 }
204 }
205 }
206
207 let parents = &commit.parents;
210 if parents.is_empty() {
211 active[lane] = None;
212 } else {
213 let first = &parents[0];
214 if let Some(existing) = active
215 .iter()
216 .enumerate()
217 .find(|(i, s)| *i != lane && s.as_ref() == Some(first))
218 .map(|(i, _)| i)
219 {
220 active[lane] = None;
222 paint_merge_link(&mut glyphs, lane, existing, color);
223 } else {
224 active[lane] = Some(first.clone());
225 }
226
227 for p in parents.iter().skip(1) {
228 if let Some(target) = active.iter().position(|s| s.as_ref() == Some(p)) {
229 paint_merge_link(&mut glyphs, lane, target, color);
230 continue;
231 }
232 if let Some(i) = active.iter().position(|s| s.is_none()) {
233 active[i] = Some(p.clone());
234 if glyphs.len() <= i {
235 glyphs.resize(i + 1, GraphGlyph::Empty);
236 }
237 paint_merge_link(&mut glyphs, lane, i, (i % LANE_COLORS) as u8);
238 } else if active.len() < 10 {
239 active.push(Some(p.clone()));
240 let i = active.len() - 1;
241 glyphs.resize(i + 1, GraphGlyph::Empty);
242 paint_merge_link(&mut glyphs, lane, i, (i % LANE_COLORS) as u8);
243 }
244 }
245 }
246
247 while active.last().is_some_and(|s| s.is_none()) {
249 active.pop();
250 }
251
252 rows.push(GraphRow {
253 hash: commit.hash.clone(),
254 short: commit.short.clone(),
255 subject: commit.subject.clone(),
256 author: commit.author.clone(),
257 when: commit.when.clone(),
258 refs: commit.refs.clone(),
259 lane,
260 color,
261 glyphs,
262 });
263 }
264
265 rows
266}
267
268fn paint_merge_link(glyphs: &mut Vec<GraphGlyph>, from: usize, to: usize, color: u8) {
270 if from == to {
271 return;
272 }
273 let (lo, hi) = if from < to { (from, to) } else { (to, from) };
274 if glyphs.len() <= hi {
275 glyphs.resize(hi + 1, GraphGlyph::Empty);
276 }
277 for i in lo + 1..hi {
279 match glyphs[i] {
280 GraphGlyph::Empty => glyphs[i] = GraphGlyph::Horizontal(color),
281 GraphGlyph::Pipe(c) => glyphs[i] = GraphGlyph::Cross(c),
282 GraphGlyph::Node(_) => {}
283 _ => glyphs[i] = GraphGlyph::Horizontal(color),
284 }
285 }
286 if !matches!(glyphs[lo], GraphGlyph::Node(_)) {
288 glyphs[lo] = if from < to {
289 GraphGlyph::TeeRight(color)
290 } else {
291 GraphGlyph::CornerBL(color)
292 };
293 }
294 if !matches!(glyphs[hi], GraphGlyph::Node(_)) {
295 glyphs[hi] = if from < to {
296 GraphGlyph::CornerTR(color)
297 } else {
298 GraphGlyph::TeeLeft(color)
299 };
300 } else if from != hi {
301 }
303}
304
305pub fn build_graph(log_text: &str) -> Vec<GraphRow> {
307 let commits = parse_log_output(log_text);
308 layout_graph(&commits)
309}
310
311pub fn lane_rgb(id: u8) -> (u8, u8, u8) {
313 const P: [(u8, u8, u8); LANE_COLORS] = [
314 (180, 120, 255), (80, 180, 255), (80, 210, 140), (255, 170, 70), (255, 120, 180), (100, 220, 220), (255, 220, 100), (160, 160, 255), ];
323 P[(id as usize) % LANE_COLORS]
324}
325
326pub fn detail_line(row: &GraphRow) -> String {
328 let mut s = format!("{} · {} · {}", row.short, row.author, row.when);
329 if !row.refs.is_empty() {
330 s.push_str(" · ");
331 s.push_str(&row.refs);
332 }
333 s
334}
335
336#[cfg(test)]
337mod tests {
338 use super::*;
339
340 #[test]
341 fn parse_single_commit() {
342 let text = "aabbccddeeff00112233445566778899aabbccdd\0aabbccd\0\0HEAD -> master\0init\0Alice\02 days ago\n";
343 let c = parse_log_output(text);
344 assert_eq!(c.len(), 1);
345 assert_eq!(c[0].short, "aabbccd");
346 assert_eq!(c[0].subject, "init");
347 assert!(c[0].parents.is_empty());
348 }
349
350 #[test]
351 fn linear_history_one_lane() {
352 let text = "\
354ccc0000000000000000000000000000000000002\0ccc0002\0bbb0000000000000000000000000000000000001\0\0third\0A\01 hour ago\n\
355bbb0000000000000000000000000000000000001\0bbb0001\0aaa0000000000000000000000000000000000000\0\0second\0A\02 hours ago\n\
356aaa0000000000000000000000000000000000000\0aaa0000\0\0\0first\0A\03 hours ago\n";
357 let rows = build_graph(text);
358 assert_eq!(rows.len(), 3);
359 assert!(rows.iter().all(|r| r.lane == 0));
361 assert!(matches!(rows[0].glyphs[0], GraphGlyph::Node(_)));
362 }
363
364 #[test]
365 fn branch_creates_second_lane() {
366 let p = "ppp0000000000000000000000000000000000000";
370 let main = "mmm0000000000000000000000000000000000000";
371 let feat = "fff0000000000000000000000000000000000000";
372 let text = format!(
373 "{feat}\0fff0000\0{p}\0\0feat work\0A\01 hour ago\n\
374 {main}\0mmm0000\0{p}\0HEAD -> master\0main work\0A\02 hours ago\n\
375 {p}\0ppp0000\0\0\0base\0A\03 hours ago\n"
376 );
377 let rows = build_graph(&text);
378 assert_eq!(rows.len(), 3);
379 assert!(rows.iter().any(|r| r.glyphs.iter().any(|g| matches!(g, GraphGlyph::Node(_)))));
381 }
382
383 #[test]
384 fn glyph_chars() {
385 assert_eq!(GraphGlyph::Node(0).ch(), '●');
386 assert_eq!(GraphGlyph::Pipe(1).ch(), '│');
387 }
388}