Parent [>]
1//! Commit log list and detail pages.
2//!
3//! Covers two routes: the commit log (`/{repo}/commits`) and the commit detail
4//! page (`/{repo}/commits/{sha}`).  The `list` handler renders a paginated
5//! commit log with an SVG ancestry graph and inline ref indicators.  The
6//! `detail` handler renders a single commit view with full metadata, a diff
7//! stat, and per-file inline diffs via `gix::diff::blob::UnifiedDiff`.
8
9use std::collections::HashMap;
10use std::fmt::Write;
11use std::ops::ControlFlow;
12
13use askama::Template;
14use askama_web::WebTemplate;
15use axum::extract::{Path, Query, State};
16use axum::response::IntoResponse;
17use gix::bstr::ByteSlice;
18use serde::Deserialize;
19
20use crate::config::SiteConfig;
21use crate::error::{Error, Result};
22use crate::filters;
23use crate::git::{self, GitResultExt as _, commit_date, committer_date};
24use crate::routes::{AppState, RepoName};
25
26use gix::diff::blob::UnifiedDiff;
27use gix::diff::blob::platform::prepare_diff::Operation;
28use gix::diff::blob::unified_diff::{ConsumeHunk, ContextSize, DiffLineKind, HunkHeader};
29
30/// Number of commits per page in the log view.
31const PAGE_SIZE: usize = 50;
32
33// SVG graph layout constants.
34// `ROW_H` is the per-row height, `LANE_W` is the per-lane width (spacing
35// between parallel tracks), `NODE_R` is the dot radius, and `BEND_R` is
36// the corner radius used when an edge changes lanes.
37const ROW_H: f64 = 40.0;
38const LANE_W: f64 = 20.0;
39const NODE_R: f64 = 5.0;
40const OUTER_R: f64 = ROW_H / 2.0;
41const STROKE: f64 = 2.0;
42const Y_MID: f64 = ROW_H / 2.0;
43const BEND_R: f64 = 6.0;
44
45/// Color palette used for SVG graph lanes.
46/// Each lane gets one color from this cycle.
47const COLORS: &[&str] = &[
48    "var(--blue)",
49    "var(--red)",
50    "var(--green)",
51    "var(--yellow)",
52    "var(--violet)",
53    "var(--magenta)",
54    "var(--cyan)",
55    "var(--orange)",
56];
57
58/// (row, column) positions for every commit in the current page, plus a
59/// snapshot of active lanes entering the page from above so the SVG renderer
60/// can draw continuation edges.
61struct Layout {
62    positions: HashMap<String, (usize, usize)>,
63    active_at_page_start: Vec<Option<String>>,
64}
65
66/// Assigns each commit a (row, column) position for the SVG graph.
67///
68/// Uses a column-stealing algorithm: a commit claims the column its first
69/// parent occupies, freeing the previous column for other branches. Lane
70/// re-use keeps the graph narrow.
71#[allow(clippy::option_if_let_else)]
72fn compute_layout(commits: &[git::CommitEntry], page_start: usize) -> Layout {
73    let mut active: Vec<Option<&str>> = vec![];
74    let mut positions = HashMap::new();
75    let mut active_at_page_start = vec![];
76
77    for (row, commit) in commits.iter().enumerate() {
78        if row == page_start {
79            active_at_page_start = active
80                .iter()
81                .map(|opt| opt.map(std::string::ToString::to_string))
82                .collect();
83        }
84
85        let sha = commit.sha.as_str();
86        let col = match active.iter().position(|s| *s == Some(sha)) {
87            Some(i) => i,
88            None => {
89                if let Some(i) = active.iter().position(std::option::Option::is_none) {
90                    active[i] = Some(sha);
91                    i
92                } else {
93                    active.push(Some(sha));
94                    active.len() - 1
95                }
96            }
97        };
98
99        positions.insert(commit.sha.clone(), (row, col));
100
101        if commit.parents.is_empty() {
102            active[col] = None;
103            while matches!(active.last(), Some(None)) {
104                active.pop();
105            }
106        } else {
107            let first = commit.parents[0].as_str();
108            if active.iter().position(|s| *s == Some(first)) != Some(col) {
109                active[col] = None;
110                while matches!(active.last(), Some(None)) {
111                    active.pop();
112                }
113                if active.iter().all(|s| *s != Some(first)) {
114                    match active.iter().position(std::option::Option::is_none) {
115                        Some(i) => active[i] = Some(first),
116                        None => active.push(Some(first)),
117                    }
118                }
119            }
120
121            for parent in commit.parents.iter().skip(1) {
122                if active.contains(&Some(parent.as_str())) {
123                    continue;
124                }
125                match active.iter().position(std::option::Option::is_none) {
126                    Some(i) => active[i] = Some(parent),
127                    None => active.push(Some(parent)),
128                }
129            }
130        }
131    }
132
133    Layout {
134        positions,
135        active_at_page_start,
136    }
137}
138
139/// Renders the SVG ancestry graph for commits[`page_start..page_end`].
140///
141/// Three passes:
142/// 1. Continuation edges entering from above the viewport.
143/// 2. Edges from each visible commit to its parents (straight lines for
144///    same-column, L-shaped bends for lane changes, S-shaped curves for
145///    merge arrows).
146/// 3. Node circles and lane-highlight backgrounds on top.
147#[allow(clippy::cast_precision_loss, clippy::too_many_lines)]
148fn render_page_svg(
149    commits: &[git::CommitEntry],
150    layout: &Layout,
151    page_start: usize,
152    page_end: usize,
153) -> String {
154    if page_start >= page_end {
155        return String::new();
156    }
157
158    let mut all_cols: Vec<usize> = Vec::new();
159    for c in &commits[page_start..page_end] {
160        if let Some(&(_, col)) = layout.positions.get(&c.sha) {
161            all_cols.push(col);
162        }
163    }
164
165    let min_col = all_cols.iter().copied().min().unwrap_or(0);
166    let page_max_col = all_cols.iter().copied().max().unwrap_or(0);
167    let clamp_col = |c: usize| c.max(min_col).min(page_max_col);
168
169    let svg_h = (page_end - page_start) as f64 * ROW_H;
170    let svg_w = ((page_max_col - min_col + 1) as f64).mul_add(LANE_W, OUTER_R);
171
172    let lx = |col: usize| -> f64 { ((col - min_col) as f64).mul_add(LANE_W, OUTER_R) };
173    let ry = |abs_row: usize| -> f64 {
174        ((abs_row.saturating_sub(page_start)) as f64).mul_add(ROW_H, Y_MID)
175    };
176
177    let mut body = String::new();
178
179    // Pass 1: continuation edges entering from above.
180    for (col, opt_sha) in layout.active_at_page_start.iter().enumerate() {
181        let Some(sha) = opt_sha else { continue };
182        let Some(&(abs_row, _)) = layout.positions.get(sha.as_str()) else {
183            continue;
184        };
185        let x = lx(clamp_col(col));
186        let c = COLORS[col % COLORS.len()];
187        let y_to = if abs_row < page_start {
188            0.0
189        } else if abs_row < page_end {
190            ry(abs_row)
191        } else {
192            svg_h
193        };
194
195        let _ = write!(
196            body,
197            r#"<line x1="{x:.1}" y1="0.0" x2="{x:.1}" y2="{y_to:.1}" stroke="{c}" stroke-width="{STROKE}"/>"#
198        );
199    }
200
201    // Pass 2: edges originating from commits on this page.
202    for commit in &commits[page_start..page_end] {
203        let &(abs_row, col) = layout
204            .positions
205            .get(&commit.sha)
206            .expect("commit must have a layout position");
207        let x1 = lx(col);
208        let y1 = ry(abs_row);
209        let c = COLORS[col % COLORS.len()];
210
211        for (idx, parent_sha) in commit.parents.iter().enumerate() {
212            let (x2, y2, p_col) = match layout.positions.get(parent_sha.as_str()) {
213                Some(&(p_row, p_col)) if p_row >= page_start && p_row < page_end => {
214                    (lx(p_col), ry(p_row), p_col)
215                }
216                Some(&(p_row, p_col)) => (
217                    lx(clamp_col(p_col)),
218                    if p_row < page_start { 0.0 } else { svg_h },
219                    p_col,
220                ),
221                None => {
222                    let _ = write!(
223                        body,
224                        r#"<line x1="{x1:.1}" y1="{y1:.1}" x2="{x1:.1}" y2="{svg_h:.1}" stroke="{c}" stroke-width="{STROKE}"/>"#
225                    );
226                    continue;
227                }
228            };
229
230            if col == p_col {
231                let _ = write!(
232                    body,
233                    r#"<line x1="{x1:.1}" y1="{y1:.1}" x2="{x2:.1}" y2="{y2:.1}" stroke="{c}" stroke-width="{STROKE}"/>"#
234                );
235            } else if idx == 0 {
236                let r = BEND_R.min((x2 - x1).abs());
237                let (arc_x, sweep) = if x2 < x1 { (x1 - r, 1) } else { (x1 + r, 0) };
238
239                let _ = write!(
240                    body,
241                    r#"<path d="M {x1:.1} {y1:.1} L {x1:.1} {:.1} A {r:.1} {r:.1} 0 0 {sweep} {arc_x:.1} {y2:.1} L {x2:.1} {y2:.1}" stroke="{c}" stroke-width="{STROKE}" fill="none"/>"#,
242                    y2 - r,
243                );
244            } else {
245                let pc = COLORS[p_col % COLORS.len()];
246                let r = BEND_R.min((x2 - x1).abs());
247                let (arc_x, sweep) = if x2 > x1 { (x2 - r, 1) } else { (x2 + r, 0) };
248
249                let _ = write!(
250                    body,
251                    r#"<path d="M {x1:.1} {y1:.1} L {arc_x:.1} {y1:.1} A {r:.1} {r:.1} 0 0 {sweep} {x2:.1} {:.1} L {x2:.1} {y2:.1}" stroke="{pc}" stroke-width="{STROKE}" fill="none"/>"#,
252                    y1 + r,
253                );
254            }
255        }
256    }
257
258    // Pass 3: circles on top.
259    for commit in &commits[page_start..page_end] {
260        let &(abs_row, col) = layout
261            .positions
262            .get(&commit.sha)
263            .expect("commit must have a layout position");
264        let cx = lx(col);
265        let cy = ry(abs_row);
266        let c = COLORS[col % COLORS.len()];
267        let row_top = cy - Y_MID;
268        let rect_x = cx - OUTER_R;
269
270        let _ = write!(
271            body,
272            r#"<path d="M {rect_x:.1} {top:.1} A {inner:.1} {inner:.1} 0 0 1 {right:.1} {row_top:.1} L {svg_w:.1} {row_top:.1} L {svg_w:.1} {bot:.1} L {right:.1} {bot:.1} A {inner:.1} {inner:.1} 0 0 1 {rect_x:.1} {bot_sub:.1} Z" fill="{c}" opacity="0.10"/>"#,
273            top = row_top + OUTER_R,
274            inner = OUTER_R,
275            right = rect_x + OUTER_R,
276            bot = row_top + ROW_H,
277            bot_sub = row_top + ROW_H - OUTER_R,
278        );
279
280        let _ = write!(
281            body,
282            r#"<circle cx="{cx:.1}" cy="{cy:.1}" r="{NODE_R}" fill="{c}"/>"#
283        );
284    }
285
286    format!(
287        r#"<svg width="{svg_w:.1}" height="{svg_h:.1}" xmlns="http://www.w3.org/2000/svg" aria-hidden="true">{body}</svg>"#
288    )
289}
290
291/// Query parameters for the commit log page.
292#[derive(Deserialize, Default)]
293pub struct PageQuery {
294    /// Cursor commit SHA — show commits older than this one (exclusive).
295    /// When absent, the log starts from HEAD.
296    #[serde(default)]
297    after: Option<String>,
298}
299
300/// A branch or tag ref pointing at a commit.
301struct RefLabel {
302    name: String,
303    kind: String,
304}
305
306/// A single row of the commit log, ready for the template.
307struct CommitRow {
308    sha: String,
309    message: String,
310    date: String,
311    color: String,
312    refs: Vec<RefLabel>,
313}
314
315/// Template data for the paginated commit log page.
316#[derive(Template, WebTemplate)]
317#[template(path = "commit_list.html")]
318struct CommitList {
319    repo: String,
320    graph_svg: String,
321    commits: Vec<CommitRow>,
322    /// Cursor used for this page (`None` means we started from HEAD).
323    after: Option<String>,
324    /// Cursor for the next page of older commits (`None` means no more pages).
325    next_cursor: Option<String>,
326    site: std::sync::Arc<SiteConfig>,
327}
328
329/// Renders a paginated commit log with an SVG ancestry graph.
330pub async fn list(
331    State(state): State<AppState>,
332    RepoName(repo): RepoName,
333    Query(q): Query<PageQuery>,
334) -> Result<impl IntoResponse> {
335    let git_repo = git::open_repo(&state.root, &repo)?;
336
337    let after = q.after.filter(|s| !s.is_empty());
338
339    // Load cursor commit (for graph context) + PAGE_SIZE visible + 1 overflow.
340    // The cursor commit (index 0) provides incoming-edge context for the SVG
341    // renderer; it is skipped below via `page_start`.
342    let load_count = 1 + PAGE_SIZE + 1;
343    let entries = git::load_commits(&git_repo, after.as_deref(), load_count)?;
344
345    let page_start = if after.is_some() { 1 } else { 0 };
346    let visible_end = (page_start + PAGE_SIZE).min(entries.len());
347    let has_next = visible_end > page_start && entries.len() > page_start + PAGE_SIZE;
348
349    let layout = compute_layout(&entries, page_start);
350    let graph_svg = render_page_svg(&entries, &layout, page_start, visible_end);
351
352    let mut refs_by_sha: HashMap<String, Vec<RefLabel>> = HashMap::new();
353    if let Ok(refs) = git_repo.references() {
354        for mut branch in git::flatten_refs(refs.local_branches()) {
355            if let Ok(commit) = branch.peel_to_commit() {
356                refs_by_sha
357                    .entry(commit.id().to_string())
358                    .or_default()
359                    .push(RefLabel {
360                        name: branch.name().shorten().to_string(),
361                        kind: "branch".into(),
362                    });
363            }
364        }
365
366        for mut tag in git::flatten_refs(refs.tags()) {
367            if let Ok(commit) = tag.peel_to_commit() {
368                refs_by_sha
369                    .entry(commit.id().to_string())
370                    .or_default()
371                    .push(RefLabel {
372                        name: tag.name().shorten().to_string(),
373                        kind: "tag".into(),
374                    });
375            }
376        }
377    }
378
379    let commits = entries[page_start..visible_end]
380        .iter()
381        .map(|e| CommitRow {
382            color: layout
383                .positions
384                .get(&e.sha)
385                .map_or("", |&(_, col)| COLORS[col % COLORS.len()])
386                .to_string(),
387            sha: e.sha.clone(),
388            message: e.message.clone(),
389            date: e.date.clone(),
390            refs: refs_by_sha.remove(e.sha.as_str()).unwrap_or_default(),
391        })
392        .collect();
393
394    let next_cursor = if has_next {
395        // has_next is only true when visible_end > page_start, so this is safe.
396        Some(entries[visible_end - 1].sha.clone())
397    } else {
398        None
399    };
400
401    Ok(CommitList {
402        repo,
403        graph_svg,
404        commits,
405        after,
406        next_cursor,
407        site: state.site,
408    })
409}
410
411/// Path parameter for the commit detail page.
412#[derive(Deserialize)]
413pub(super) struct CommitPath {
414    sha: String,
415}
416
417/// A single line inside a diff hunk, ready for the template.
418///
419/// `marker` is the visible prefix character (`+`, `-`, or `~`).
420/// `css_class` is the CSS class suffix used to color the marker.
421/// `content` is the rest of the line.
422struct DiffLine {
423    marker: String,
424    css_class: String,
425    content: String,
426}
427
428/// Collects hunks produced by `UnifiedDiff` into a `Vec<Vec<DiffLine>>`,
429/// counting added and removed lines at the same time.
430struct DiffHunkCollector<'a> {
431    hunks: &'a mut Vec<Vec<DiffLine>>,
432    file_added: &'a mut u64,
433    file_removed: &'a mut u64,
434}
435
436impl ConsumeHunk for DiffHunkCollector<'_> {
437    type Out = ();
438
439    fn consume_hunk(
440        &mut self,
441        _header: HunkHeader,
442        lines: &[(DiffLineKind, &[u8])],
443    ) -> std::io::Result<()> {
444        let mut hunk = Vec::new();
445        for (kind, content) in lines {
446            let content_str = std::str::from_utf8(content)
447                .unwrap_or("<binary>")
448                .to_string();
449
450            let (marker, css_class) = match kind {
451                DiffLineKind::Context => ("~".into(), "ctx".into()),
452                DiffLineKind::Add => ("+".into(), "+".into()),
453                DiffLineKind::Remove => ("-".into(), "-".into()),
454            };
455
456            match kind {
457                DiffLineKind::Add => *self.file_added += 1,
458                DiffLineKind::Remove => *self.file_removed += 1,
459                DiffLineKind::Context => {}
460            }
461
462            hunk.push(DiffLine {
463                marker,
464                css_class,
465                content: content_str,
466            });
467        }
468
469        self.hunks.push(hunk);
470        Ok(())
471    }
472
473    fn finish(self) -> Self::Out {}
474}
475
476/// A single file touched by a commit, with its inline diff hunks.
477struct FileChange {
478    path: String,
479    change_type: String,
480    hunks: Vec<Vec<DiffLine>>,
481}
482
483/// Template data for the commit detail page.
484#[derive(Template, WebTemplate)]
485#[template(path = "commit.html")]
486struct CommitDetail {
487    repo: String,
488    sha: String,
489    author: String,
490    committer: String,
491    date: String,
492    committer_date: String,
493    message: String,
494    parents: Vec<(String, String)>,
495    refs: Vec<RefLabel>,
496    files_changed: u64,
497    lines_added: u64,
498    lines_removed: u64,
499    file_changes: Vec<FileChange>,
500    site: std::sync::Arc<SiteConfig>,
501}
502
503/// Displays a single commit: metadata, diff stat, and per-file inline diffs.
504#[allow(clippy::too_many_lines)]
505pub async fn detail(
506    State(state): State<AppState>,
507    RepoName(repo): RepoName,
508    Path(params): Path<CommitPath>,
509) -> Result<impl IntoResponse> {
510    let sha = params.sha;
511
512    let git_repo = git::open_repo(&state.root, &repo)?;
513
514    let oid = gix::ObjectId::from_hex(sha.as_bytes())
515        .map_err(|_| Error::BadRequest(format!("invalid sha: {sha}")))?;
516
517    let commit = git_repo
518        .find_object(oid)
519        .map_err(|_| Error::NotFound(format!("commit {sha}")))?
520        .try_into_commit()
521        .map_err(|_| Error::BadRequest(format!("{sha} is not a commit")))?;
522
523    let sha = commit.id().to_string();
524
525    let author = commit
526        .author()
527        .map(|a| format!("{} <{}>", a.name, a.email))
528        .unwrap_or_default();
529
530    let committer = commit
531        .committer()
532        .map(|c| format!("{} <{}>", c.name, c.email))
533        .unwrap_or_default();
534
535    let date = commit_date(&commit).unwrap_or_default();
536    let committer_date = committer_date(&commit).unwrap_or_default();
537    let message = commit.message_raw_sloppy().to_str_lossy().into_owned();
538
539    let parents: Vec<(String, String)> = commit
540        .parent_ids()
541        .map(|id| {
542            let s = id.to_string();
543            let short = s[..8].to_string();
544            (s, short)
545        })
546        .collect();
547
548    // Collect refs (branches + tags) pointing to this commit.
549    let mut refs: Vec<RefLabel> = Vec::new();
550    if let Ok(rs) = git_repo.references() {
551        for mut branch in git::flatten_refs(rs.local_branches()) {
552            if let Ok(c) = branch.peel_to_commit()
553                && c.id().to_string() == sha
554            {
555                refs.push(RefLabel {
556                    name: branch.name().shorten().to_string(),
557                    kind: "branch".into(),
558                });
559            }
560        }
561
562        for mut tag in git::flatten_refs(rs.tags()) {
563            if let Ok(c) = tag.peel_to_commit()
564                && c.id().to_string() == sha
565            {
566                refs.push(RefLabel {
567                    name: tag.name().shorten().to_string(),
568                    kind: "tag".into(),
569                });
570            }
571        }
572    }
573
574    // Compute diff stat against the first parent (or empty tree for root commits).
575    let commit_tree = commit.tree().corrupt()?;
576
577    let parent_tree = commit
578        .parent_ids()
579        .next()
580        .and_then(|pid| pid.object().ok())
581        .and_then(|o| o.try_into_commit().ok())
582        .and_then(|c| c.tree().ok());
583
584    let mut resource_cache = git_repo.diff_resource_cache_for_tree_diff().corrupt()?;
585
586    let mut files_changed = 0u64;
587    let mut lines_added = 0u64;
588    let mut lines_removed = 0u64;
589    let mut file_changes: Vec<FileChange> = Vec::new();
590
591    #[allow(clippy::option_if_let_else)]
592    let source_tree = match parent_tree {
593        Some(ref t) => t,
594        None => &git_repo.empty_tree(),
595    };
596
597    source_tree
598        .changes()
599        .corrupt()?
600        .for_each_to_obtain_tree(&commit_tree, |change| {
601            // Skip directory entries - only show leaf-level file changes.
602            if change.entry_mode().is_tree() {
603                return Ok::<_, std::convert::Infallible>(ControlFlow::Continue(()));
604            }
605
606            let path = change.location().to_str_lossy().into_owned();
607            let change_type = match change {
608                gix::object::tree::diff::Change::Addition { .. } => "A",
609                gix::object::tree::diff::Change::Deletion { .. } => "D",
610                gix::object::tree::diff::Change::Modification { .. } => "M",
611                gix::object::tree::diff::Change::Rewrite { .. } => "R",
612            };
613
614            files_changed += 1;
615
616            let mut hunks = Vec::new();
617            let mut added = 0u64;
618            let mut removed = 0u64;
619
620            if let Ok(platform) = change.diff(&mut resource_cache) {
621                platform
622                    .resource_cache
623                    .options
624                    .skip_internal_diff_if_external_is_configured = false;
625                if let Ok(prep) = platform.resource_cache.prepare_diff()
626                    && let Operation::InternalDiff { algorithm } = prep.operation
627                {
628                    let input = prep.interned_input();
629                    let collector = DiffHunkCollector {
630                        hunks: &mut hunks,
631                        file_added: &mut added,
632                        file_removed: &mut removed,
633                    };
634                    let sink = UnifiedDiff::new(&input, collector, ContextSize::symmetrical(3));
635                    let _ = gix::diff::blob::diff(algorithm, &input, sink);
636                }
637            }
638
639            resource_cache.clear_resource_cache_keep_allocation();
640
641            lines_added += added;
642            lines_removed += removed;
643
644            file_changes.push(FileChange {
645                path,
646                change_type: change_type.to_string(),
647                hunks,
648            });
649
650            Ok::<_, std::convert::Infallible>(ControlFlow::Continue(()))
651        })
652        .corrupt()?;
653
654    Ok(CommitDetail {
655        repo,
656        sha,
657        author,
658        committer,
659        date,
660        committer_date,
661        message,
662        parents,
663        refs,
664        files_changed,
665        lines_added,
666        lines_removed,
667        file_changes,
668        site: state.site,
669    })
670}
671
672#[cfg(test)]
673mod tests {
674    use super::*;
675
676    fn commit(sha: &str, parents: &[&str]) -> git::CommitEntry {
677        git::CommitEntry {
678            sha: sha.to_string(),
679            message: String::new(),
680            date: String::new(),
681            parents: parents.iter().map(|s| s.to_string()).collect(),
682        }
683    }
684
685    // --- compute_layout ---
686
687    #[test]
688    fn layout_linear_chain() {
689        let commits = vec![
690            commit("C", &["B"]),
691            commit("B", &["A"]),
692            commit("A", &[]),
693        ];
694        let layout = compute_layout(&commits, 0);
695        assert_eq!(layout.positions.len(), 3);
696        assert_eq!(layout.positions.get("C"), Some(&(0, 0)));
697        assert_eq!(layout.positions.get("B"), Some(&(1, 0)));
698        assert_eq!(layout.positions.get("A"), Some(&(2, 0)));
699    }
700
701    #[test]
702    fn layout_diamond_merge() {
703        let commits = vec![
704            commit("D", &["B", "C"]),
705            commit("C", &["A"]),
706            commit("B", &["A"]),
707            commit("A", &[]),
708        ];
709        let layout = compute_layout(&commits, 0);
710        assert_eq!(layout.positions.get("D"), Some(&(0, 0)));
711        assert_eq!(layout.positions.get("C"), Some(&(1, 1)));
712        assert_eq!(layout.positions.get("B"), Some(&(2, 0)));
713        assert_eq!(layout.positions.get("A"), Some(&(3, 1)));
714    }
715
716    #[test]
717    fn layout_root_commit() {
718        let commits = vec![commit("A", &[])];
719        let layout = compute_layout(&commits, 0);
720        assert_eq!(layout.positions.get("A"), Some(&(0, 0)));
721        assert!(layout.active_at_page_start.is_empty());
722    }
723
724    #[test]
725    fn layout_octopus_merge() {
726        let commits = vec![
727            commit("E", &["B", "C", "D"]),
728            commit("D", &["A"]),
729            commit("C", &["A"]),
730            commit("B", &["A"]),
731            commit("A", &[]),
732        ];
733        let layout = compute_layout(&commits, 0);
734        assert_eq!(layout.positions.get("E"), Some(&(0, 0)));
735        assert_eq!(layout.positions.get("D"), Some(&(1, 2)));
736        assert_eq!(layout.positions.get("C"), Some(&(2, 1)));
737        assert_eq!(layout.positions.get("B"), Some(&(3, 0)));
738        assert_eq!(layout.positions.get("A"), Some(&(4, 2)));
739    }
740
741    #[test]
742    fn layout_empty_input() {
743        let layout = compute_layout(&[], 0);
744        assert!(layout.positions.is_empty());
745        assert!(layout.active_at_page_start.is_empty());
746    }
747
748    #[test]
749    fn layout_page_start_captures_active() {
750        let commits = vec![
751            commit("D", &["C"]),
752            commit("C", &["B"]),
753            commit("B", &["A"]),
754            commit("A", &[]),
755        ];
756        let layout = compute_layout(&commits, 1);
757        assert_eq!(layout.active_at_page_start.len(), 1);
758        assert_eq!(layout.active_at_page_start[0], Some("C".to_string()));
759    }
760
761    #[test]
762    fn layout_lane_steal_then_free() {
763        let commits = vec![
764            commit("C", &["B"]),
765            commit("B", &["A"]),
766            commit("A", &[]),
767        ];
768        let layout = compute_layout(&commits, 0);
769        // C steals lane 0, B inherits it, A frees it
770        assert_eq!(layout.positions.get("C"), Some(&(0, 0)));
771        assert_eq!(layout.positions.get("B"), Some(&(1, 0)));
772        assert_eq!(layout.positions.get("A"), Some(&(2, 0)));
773    }
774
775    // --- render_page_svg ---
776
777    #[test]
778    fn svg_empty_range() {
779        let layout = compute_layout(&[], 0);
780        assert_eq!(render_page_svg(&[], &layout, 0, 0), "");
781    }
782
783    #[test]
784    fn svg_single_commit() {
785        let commits = vec![commit("A", &[])];
786        let layout = compute_layout(&commits, 0);
787        let svg = render_page_svg(&commits, &layout, 0, 1);
788        assert!(svg.starts_with(r#"<svg width="#), "expected SVG start, got: {svg}");
789        assert!(svg.contains("<circle"), "expected circle element");
790        assert!(svg.ends_with("</svg>"));
791    }
792
793    #[test]
794    fn svg_two_commits_same_lane() {
795        let commits = vec![commit("B", &["A"]), commit("A", &[])];
796        let layout = compute_layout(&commits, 0);
797        let svg = render_page_svg(&commits, &layout, 0, 2);
798        assert!(svg.contains("<line"), "expected line element");
799        assert_eq!(svg.matches("<line").count(), 1);
800        assert_eq!(svg.matches("<circle").count(), 2);
801    }
802
803    // --- DiffHunkCollector ---
804
805    #[test]
806    fn diff_hunk_context_only() {
807        let mut hunks = Vec::new();
808        let mut added = 0;
809        let mut removed = 0;
810        let mut collector = DiffHunkCollector {
811            hunks: &mut hunks,
812            file_added: &mut added,
813            file_removed: &mut removed,
814        };
815        let header = gix::diff::blob::unified_diff::HunkHeader {
816            before_hunk_start: 1,
817            before_hunk_len: 1,
818            after_hunk_start: 1,
819            after_hunk_len: 1,
820        };
821        collector
822            .consume_hunk(header, &[(DiffLineKind::Context, b"keep")])
823            .unwrap();
824        assert_eq!(hunks.len(), 1);
825        assert_eq!(hunks[0].len(), 1);
826        assert_eq!(hunks[0][0].marker, "~");
827        assert_eq!(hunks[0][0].content, "keep");
828        assert_eq!(added, 0);
829        assert_eq!(removed, 0);
830    }
831
832    #[test]
833    fn diff_hunk_mixed_lines() {
834        let mut hunks = Vec::new();
835        let mut added = 0;
836        let mut removed = 0;
837        let mut collector = DiffHunkCollector {
838            hunks: &mut hunks,
839            file_added: &mut added,
840            file_removed: &mut removed,
841        };
842        let header = gix::diff::blob::unified_diff::HunkHeader {
843            before_hunk_start: 1,
844            before_hunk_len: 2,
845            after_hunk_start: 1,
846            after_hunk_len: 2,
847        };
848        collector
849            .consume_hunk(
850                header,
851                &[
852                    (DiffLineKind::Context, b"ctx"),
853                    (DiffLineKind::Add, b"new"),
854                    (DiffLineKind::Remove, b"old"),
855                ],
856            )
857            .unwrap();
858        assert_eq!(hunks.len(), 1);
859        assert_eq!(hunks[0].len(), 3);
860        assert_eq!(hunks[0][0].marker, "~");
861        assert_eq!(hunks[0][1].marker, "+");
862        assert_eq!(hunks[0][2].marker, "-");
863        assert_eq!(added, 1);
864        assert_eq!(removed, 1);
865    }
866
867    #[test]
868    fn diff_hunk_binary_content() {
869        let mut hunks = Vec::new();
870        let mut added = 0;
871        let mut removed = 0;
872        let mut collector = DiffHunkCollector {
873            hunks: &mut hunks,
874            file_added: &mut added,
875            file_removed: &mut removed,
876        };
877        let header = gix::diff::blob::unified_diff::HunkHeader {
878            before_hunk_start: 1,
879            before_hunk_len: 1,
880            after_hunk_start: 1,
881            after_hunk_len: 1,
882        };
883        collector
884            .consume_hunk(header, &[(DiffLineKind::Add, b"\xff\xfe\x00\x01")])
885            .unwrap();
886        // Invalid UTF-8 should fall back to <binary>
887        assert_eq!(hunks[0][0].content, "<binary>");
888    }
889
890    #[test]
891    fn diff_hunk_empty_input() {
892        let mut hunks = Vec::new();
893        let mut added = 0;
894        let mut removed = 0;
895        let mut collector = DiffHunkCollector {
896            hunks: &mut hunks,
897            file_added: &mut added,
898            file_removed: &mut removed,
899        };
900        let header = gix::diff::blob::unified_diff::HunkHeader {
901            before_hunk_start: 0,
902            before_hunk_len: 0,
903            after_hunk_start: 0,
904            after_hunk_len: 0,
905        };
906        collector.consume_hunk(header, &[]).unwrap();
907        assert_eq!(hunks.len(), 1);
908        assert!(hunks[0].is_empty());
909    }
910}