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