git-ents.gitmain
⌘K
foforge
commit 0ad4c4a
roots: add commit history and a per-commit unified diff view

The files browser had no way to see how the repository got here. GET /commits walks HEAD newest-first via gix’s rev_walk, 50 per page with an "older" link continuing the walk past the last shown commit; GET /commit/{oid} shows the full message, author, parents, and a colorized unified diff against the first parent (or the empty tree, for a root commit), rendered directly from gix::diff::blob — InternedInput plus diff_with_slider_heuristics for the hunks, ConsumeBinaryHunk for the textual unified-diff format — so no new diff dependency is needed (gix’s default features already enable blob-diff. Both routes render with Tab::Files active: history is a view of the code, not a tab of its own, reached from a new link beside the files browser’s crumbs.

roots: add GET /commits and GET /commit/{oid} over gix’s rev_walk and tree diff roots: link to commit history from the files browser’s crumbs roots: port the pre-redo commit metadata CSS classes Assisted-by: Claude:claude-sonnet-5 EOF )

Joseph D. Carpinelli · 1 month ago

Reviews

No reviews of this commit yet — record a verdict below.

Start a review

verdict

crates/cli/ents-web/src/router.rs @@ -48,6 +48,8 @@ ) .route("/effects", get(pages::effects::list::<O>)) .route("/effects/{name}", get(pages::effects::show::<O>)) + .route("/commits", get(pages::commits::list::<O>)) + .route("/commit/{oid}", get(pages::commits::show::<O>)) .route("/files", get(pages::files::root::<O>)) .route("/files/{*path}", get(pages::files::show::<O>)) .route("/meta", get(pages::meta::show::<O>))
crates/cli/ents-web/tests/router.rs @@ -115,6 +115,23 @@ dir } +/// The full hex object id of `dir`'s current `HEAD` commit, read via `git +/// rev-parse` -- what `crate::pages::commits`'s tests below build +/// `/commit/{oid}` request paths from. +fn head_oid(dir: &std::path::Path) -> String { + let output = std::process::Command::new("git") + .arg("-C") + .arg(dir) + .args(["rev-parse", "HEAD"]) + .output() + .expect("git runs"); + assert!(output.status.success(), "git rev-parse HEAD failed"); + String::from_utf8(output.stdout) + .expect("utf8 oid") + .trim() + .to_owned() +} + /// `roots.local`: this crate's route table never exposes git's own /// smart-HTTP transport -- a request that would name it (`info/refs` with /// a `service` query, exactly the URL stock `git clone`/`git fetch` sends @@ -777,3 +794,96 @@ let md_body = String::from_utf8(md_body.to_vec()).expect("utf8 html"); assert!(md_body.contains("<h1>Doc Title</h1>")); } + +/// `GET /commits` lists the repository's commit history: the seeded +/// commit's own short id appears, linking into `/commit/{oid}`. +#[tokio::test] +async fn commits_list_shows_a_fixture_commit() { + let dir = seed_repo(&[("README.md", "# hi\n")]); + let oid = head_oid(dir.path()); + let state = build_state_at( + FixtureIdentity { + name: "local-user", + key: Keypair::from_seed(1), + }, + dir.path().to_owned(), + ); + let router = ents_web::router(state); + + let response = router + .oneshot( + Request::get("/commits") + .body(Body::empty()) + .expect("request"), + ) + .await + .expect("in-process call"); + assert_eq!(response.status(), StatusCode::OK); + let body = response + .into_body() + .collect() + .await + .expect("body") + .to_bytes(); + let body = String::from_utf8(body.to_vec()).expect("utf8 html"); + assert!(body.contains(&format!("/commit/{oid}"))); + assert!(body.contains("seed"), "the seeded commit's subject renders"); +} + +/// `GET /commit/{oid}` shows the commit's subject, its author, and a +/// colorized diff line for the file it introduced. +#[tokio::test] +async fn commit_show_renders_the_subject_and_a_diff_line() { + let dir = seed_repo(&[("README.md", "# hi\n")]); + let oid = head_oid(dir.path()); + let state = build_state_at( + FixtureIdentity { + name: "local-user", + key: Keypair::from_seed(1), + }, + dir.path().to_owned(), + ); + let router = ents_web::router(state); + + let response = router + .oneshot( + Request::get(format!("/commit/{oid}")) + .body(Body::empty()) + .expect("request"), + ) + .await + .expect("in-process call"); + assert_eq!(response.status(), StatusCode::OK); + let body = response + .into_body() + .collect() + .await + .expect("body") + .to_bytes(); + let body = String::from_utf8(body.to_vec()).expect("utf8 html"); + assert!(body.contains("seed"), "the commit's subject renders"); + assert!( + body.contains("class=\"ln add\""), + "the root commit's diff renders its added lines" + ); +} + +/// `GET /commit/{oid}` on a malformed id is a 404, never a panic or 500. +#[tokio::test] +async fn commit_show_on_an_invalid_oid_is_not_found_not_a_crash() { + let state = build_state(FixtureIdentity { + name: "local-user", + key: Keypair::from_seed(1), + }); + let router = ents_web::router(state); + + let response = router + .oneshot( + Request::get("/commit/zzz") + .body(Body::empty()) + .expect("request"), + ) + .await + .expect("in-process call"); + assert_eq!(response.status(), StatusCode::NOT_FOUND); +}
crates/cli/ents-web/src/assets/ents.css @@ -229,6 +229,13 @@ .crumbs a { text-decoration: underline; text-decoration-color: color-mix(in srgb, currentColor 25%, transparent); } .crumbs .sep { color: var(--color-text-muted); opacity: .55; } .crumbs .here { color: var(--color-text-muted); } +.crumbs-history { margin-left: auto; font-size: .82rem; color: var(--color-text-muted); } + +/* Commit history and view (`crate::pages::commits`). */ +.commit-subject { font-weight: 600; font-size: 1.05rem; margin-bottom: .3rem; } +.commit-msg { font-family: var(--font-mono); font-size: .9rem; white-space: pre-wrap; word-break: break-word; margin-bottom: .3rem; } +.commit-meta { color: var(--color-text-muted); font-size: .85rem; margin-top: .2rem; } +.commit-meta a { text-decoration: underline; text-decoration-color: color-mix(in srgb, currentColor 25%, transparent); } .blob { display: grid; grid-template-columns: auto minmax(0, 1fr); background: var(--color-surface); border: 1px solid var(--color-border); border-radius: var(--radius-sm); box-shadow: var(--shadow-sm); overflow: hidden; margin-bottom: 1.5rem; } .blob pre { font-family: var(--font-mono); font-size: .82rem; line-height: 1.55; margin: 0; padding: 1rem 0; }
crates/cli/ents-web/src/pages/files.rs @@ -216,7 +216,10 @@ } /// Breadcrumb navigation from the repository's files root down through -/// `path`, `chevron-right` icons separating segments. +/// `path`, `chevron-right` icons separating segments, plus a trailing +/// link into `crate::pages::commits`'s `GET /commits` history -- the file +/// browser's one entry point into commit history, since history is a view +/// of the code, not a tab of its own (`crate::pages::mod`'s own doc). fn crumbs(path: &str) -> Markup { let parts: Vec<&str> = path.split('/').filter(|s| !s.is_empty()).collect(); let mut acc = String::new(); @@ -240,6 +243,7 @@ None => span.here { (label) }, } } + a.crumbs-history href="/commits" { "history" } } } }
crates/cli/ents-web/src/pages/mod.rs @@ -13,10 +13,13 @@ //! [`redactions`], and [`inbox`] additionally share one `meta` tab and //! [`META_SECTIONS`] rail rather than each carrying its own top-level tab //! (see [`Tab`]'s own doc); [`meta`] is that group's `GET /meta` landing -//! page. +//! page. [`commits`] is a view of the code, not a tab of its own -- both +//! its routes render with [`Tab::Files`] active, reached from +//! [`files`]'s own "history" link. pub mod account; pub mod comments; +pub mod commits; pub mod dashboard; pub mod effects; pub mod files; @@ -297,3 +300,52 @@ Err(Error::BadCsrf) } } + +/// A unix timestamp rendered as a relative "time ago" label, measured +/// against the current time -- hand-rolled from epoch seconds rather than +/// pulling in a date-formatting dependency, mirroring +/// `pre-redo:crates/git-ents-server/src/web/pages.rs`'s own `ago`/ +/// `ago_seconds`. Shared by [`super::dashboard`]'s freshness strip and +/// [`super::commits`]'s list/show pages, the only places this crate names +/// a commit's age. +pub(crate) fn ago(then_seconds: i64) -> String { + let now = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map(|d| i64::try_from(d.as_secs()).unwrap_or(i64::MAX)) + .unwrap_or(0); + let secs = now.saturating_sub(then_seconds).max(0); + let mins = secs.checked_div(60).unwrap_or(0); + let hours = mins.checked_div(60).unwrap_or(0); + let days = hours.checked_div(24).unwrap_or(0); + if mins == 0 { + "just now".to_owned() + } else if hours == 0 { + ago_plural(mins, "minute") + } else if days == 0 { + ago_plural(hours, "hour") + } else if days < 30 { + ago_plural(days, "day") + } else if days < 365 { + ago_plural(days.checked_div(30).unwrap_or(0), "month") + } else { + ago_plural(days.checked_div(365).unwrap_or(0), "year") + } +} + +/// Format `n` whole `unit`s with an "ago" suffix, pluralizing as needed -- +/// [`ago`]'s own helper. +fn ago_plural(n: i64, unit: &str) -> String { + if n == 1 { + format!("1 {unit} ago") + } else { + format!("{n} {unit}s ago") + } +} + +/// A commit id shortened to seven hex characters for display -- mirrors +/// `pre-redo:crates/git-ents-server/src/web/pages.rs`'s own `short_oid`. +/// Falls back to the full id on the (practically unreachable) case that a +/// 7-character prefix is invalid for `oid`'s hash kind. +pub(crate) fn short_oid(oid: &ObjectId) -> String { + gix_hash::Prefix::new(oid, 7).map_or_else(|_| oid.to_string(), |prefix| prefix.to_string()) +}
crates/cli/ents-web/src/pages/commits.rs @@ -1,0 +1,514 @@ +//! `GET /commits`, `GET /commit/{oid}`: a read-only commit history and +//! per-commit unified diff over `HEAD` -- a view of the code, not a tab of +//! its own (both routes render with [`super::Tab::Files`] active; see +//! [`super`]'s own doc), reached from [`super::files`]'s "history" link. +//! +//! Reads go through `gix`'s high-level `Repository`/`Commit`/`Tree` types, +//! opened fresh per request from `state.path`, exactly as +//! [`super::files`]/[`super::dashboard`] browse `HEAD` -- `facet-git-tree`'s +//! typed-tree convention is for meta-ref entities, not browsing arbitrary +//! repository history. The unified diff itself is built directly on top of +//! `gix::diff::blob` (`gix_diff`'s own re-export through the `gix` +//! facade): [`gix::diff::blob::InternedInput`] interns each side's lines, +//! [`gix::diff::blob::diff_with_slider_heuristics`] computes the hunks, and +//! [`gix::diff::blob::unified_diff::ConsumeBinaryHunk`] renders them as the +//! same textual unified-diff format `git diff` itself produces, which +//! [`diff_class`] then colorizes line by line -- no new dependency, since +//! `gix`'s default features already enable `blob-diff`. + +use std::sync::Arc; + +use axum::extract::{Path, Query, State}; +use gix::bstr::ByteSlice as _; +use gix::diff::blob::unified_diff::{ConsumeBinaryHunk, ContextSize}; +use gix::diff::blob::{Algorithm, InternedInput, UnifiedDiff, diff_with_slider_heuristics}; +use gix::object::tree::diff::Change; +use gix_hash::ObjectId; +use gix_object::{Find, Write}; +use maud::{Markup, html}; +use serde::Deserialize; + +use crate::error::{Error, Result}; +use crate::state::AppState; + +/// One page of `GET /commits`. +const PAGE_SIZE: usize = 50; + +/// The largest total diff rendered in full on `GET /commit/{oid}` -- past +/// it, reading every changed blob into memory and diffing it would be +/// unbounded, so the page shows a truncation notice instead (mirrors +/// `pre-redo:crates/git-ents-server/src/web/pages.rs`'s own +/// `MAX_RENDER_BYTES`, at this page's own, smaller budget). +const MAX_DIFF_BYTES: usize = 1024 * 1024; + +/// The query parameters `GET /commits` accepts. +#[derive(Debug, Deserialize)] +pub struct ListQuery { + /// Continue the walk just past this previously shown commit (the + /// "older" link) -- omitted for the first page. + from: Option<String>, +} + +/// One row of `GET /commits`. +struct CommitRow { + /// The full commit id, the `/commit/{oid}` link target. + oid: ObjectId, + /// [`super::short_oid`] of `oid`, the row's displayed, mono id. + short: String, + /// The commit message's title line. + subject: String, + /// The commit author's display name. + author: String, + /// [`super::ago`] of the commit author's time. + ago: String, +} + +/// `GET /commits`: the repository's commit history, newest first, 50 per +/// page. +/// +/// # Errors +/// +/// Never fails on an unopenable repository or an unborn `HEAD` -- both +/// degrade to a blankslate. +pub async fn list<O>( + State(state): State<Arc<AppState<O>>>, + Query(params): Query<ListQuery>, +) -> Result<Markup> +where + O: Find + Write + Send + 'static, +{ + let (rows, older) = commit_rows(&state, params.from.as_deref()); + Ok(super::layout( + &super::RepoHeader::from_state(&state), + &super::identity_label(&state), + super::Tab::Files, + "commits", + html! { + @if rows.is_empty() { + (blankslate()) + } @else { + div.card { + div.card-header { "commits" } + table.entity-list { + thead { + tr { th { "commit" } th { "subject" } th { "author" } th { "when" } } + } + tbody { + @for row in &rows { + tr { + td { a href={ "/commit/" (row.oid) } { code { (row.short) } } } + td { (row.subject) } + td { (row.author) } + td { (row.ago) } + } + } + } + } + } + @if let Some(from) = older { + nav.crumbs { + a href={ "/commits?from=" (from) } { "older" } + } + } + } + }, + )) +} + +/// Up to [`PAGE_SIZE`] rows starting at `from` (or `HEAD` when `from` is +/// `None`), newest first, plus the oid to continue from for an "older" +/// link when more commits remain. Best-effort: an unopenable repository, +/// an unborn `HEAD`, or an unparsable/unresolvable `from` all degrade to +/// an empty page rather than an error. +fn commit_rows<O>(state: &AppState<O>, from: Option<&str>) -> (Vec<CommitRow>, Option<String>) { + let Ok(repo) = gix::open(&state.path) else { + return (Vec::new(), None); + }; + let continuing = from.and_then(|hex| ObjectId::from_hex(hex.as_bytes()).ok()); + let tip = match continuing { + Some(oid) => oid, + None => { + let Ok(head) = repo.head_id() else { + return (Vec::new(), None); + }; + head.detach() + } + }; + let Ok(walk) = repo + .rev_walk([tip]) + .sorting(gix::revision::walk::Sorting::ByCommitTime( + gix::traverse::commit::simple::CommitTimeOrder::NewestFirst, + )) + .all() + else { + return (Vec::new(), None); + }; + + let skip = if continuing.is_some() { 1 } else { 0 }; + let mut rows: Vec<CommitRow> = Vec::new(); + let mut has_more = false; + for info in walk.skip(skip) { + let Ok(info) = info else { break }; + if rows.len() == PAGE_SIZE { + has_more = true; + break; + } + let Ok(commit) = info.object() else { continue }; + let Ok(message) = commit.message() else { + continue; + }; + let Ok(author) = commit.author() else { + continue; + }; + let seconds = author.time().map(|time| time.seconds).unwrap_or(0); + let oid = info.id().detach(); + rows.push(CommitRow { + oid, + short: super::short_oid(&oid), + subject: message.title.to_str_lossy().into_owned(), + author: author.name.to_str_lossy().into_owned(), + ago: super::ago(seconds), + }); + } + let older = has_more + .then(|| rows.last().map(|row| row.oid.to_string())) + .flatten(); + (rows, older) +} + +/// The empty-history placeholder: an unborn `HEAD`, or a repository this +/// page could not open at all. +fn blankslate() -> Markup { + html! { + div.card { + div.blankslate { + h2 { "No commits yet" } + p { "This repository has no history to show." } + } + } + } +} + +/// `GET /commit/{oid}`: a single commit's full message, metadata, and a +/// unified diff against its first parent (or the empty tree, for a root +/// commit). +/// +/// # Errors +/// +/// [`Error::NotFound`] if `oid` is not a well-formed object id or does not +/// name a commit in the served repository. +pub async fn show<O>( + State(state): State<Arc<AppState<O>>>, + Path(oid): Path<String>, +) -> Result<Markup> +where + O: Find + Write + Send + 'static, +{ + let object_id = parse_oid(&oid)?; + let repo = gix::open(&state.path).map_err(|source| Error::Repo(source.to_string()))?; + let commit = repo + .find_commit(object_id) + .ok() + .ok_or_else(|| Error::NotFound { what: oid.clone() })?; + let message = commit + .message() + .map_err(|source| Error::Repo(source.to_string()))?; + let subject = message.title.to_str_lossy().into_owned(); + let body = message + .body + .map(|body| body.to_str_lossy().into_owned()) + .filter(|body| !body.is_empty()); + let author = commit + .author() + .map_err(|source| Error::Repo(source.to_string()))?; + let author_name = author.name.to_str_lossy().into_owned(); + let ago = author.time().map(|time| super::ago(time.seconds)).ok(); + let parents: Vec<ObjectId> = commit.parent_ids().map(|id| id.detach()).collect(); + let new_tree = commit + .tree() + .map_err(|source| Error::Repo(source.to_string()))?; + + let old_tree = match parents.first() { + Some(parent) => Some( + repo.find_commit(*parent) + .map_err(|source| Error::Repo(source.to_string()))? + .tree() + .map_err(|source| Error::Repo(source.to_string()))?, + ), + None => None, + }; + let empty_tree = repo.empty_tree(); + let old_tree_ref = old_tree.as_ref().unwrap_or(&empty_tree); + let (diff, truncated) = diff_sections(&repo, old_tree_ref, &new_tree); + + Ok(super::layout( + &super::RepoHeader::from_state(&state), + &super::identity_label(&state), + super::Tab::Files, + &subject, + html! { + div.card { + div.card-header { "commit " code { (super::short_oid(&object_id)) } } + div.commit { + div.commit-subject { (subject) } + @if let Some(body) = &body { + div.commit-msg { (body) } + } + div.commit-meta { + (author_name) + @if let Some(ago) = &ago { " \u{b7} " (ago) } + } + div.commit-meta { + "tree " a href={ "/files" } { "browse at HEAD" } + @if !parents.is_empty() { + " \u{b7} parents: " + @for (index, parent) in parents.iter().enumerate() { + @if index > 0 { ", " } + a href={ "/commit/" (parent) } { code { (super::short_oid(parent)) } } + } + } @else { + " \u{b7} root commit" + } + } + } + } + (diff) + @if truncated { + div.card { div.binary { "Diff truncated (over " (MAX_DIFF_BYTES / (1024 * 1024)) " MiB)." } } + } + }, + )) +} + +/// Validate `text` as a full, well-formed object id -- hex characters only, +/// at the exact length the served repository's hash kind expects (this +/// page does not resolve abbreviated prefixes; [`super::commits::list`]'s +/// own links always carry the full id). +/// +/// # Errors +/// +/// [`Error::NotFound`] if `text` is empty, not hex, or the wrong length. +fn parse_oid(text: &str) -> Result<ObjectId> { + if text.is_empty() || text.len() > 64 || !text.bytes().all(|b| b.is_ascii_hexdigit()) { + return Err(Error::NotFound { + what: text.to_owned(), + }); + } + ObjectId::from_hex(text.as_bytes()) + .ok() + .ok_or_else(|| Error::NotFound { + what: text.to_owned(), + }) +} + +/// One `.diff` section per changed file between `old_tree` and `new_tree`, +/// plus whether the total rendered bytes exceeded [`MAX_DIFF_BYTES`] (in +/// which case the caller shows a truncation notice). Best-effort: a change +/// this function cannot read renders as a bare file header with no hunks +/// rather than failing the whole page. +fn diff_sections( + repo: &gix::Repository, + old_tree: &gix::Tree<'_>, + new_tree: &gix::Tree<'_>, +) -> (Markup, bool) { + let Ok(mut platform) = old_tree.changes() else { + return (html! {}, false); + }; + let mut sections = Vec::new(); + let mut total: usize = 0; + let mut truncated = false; + let _outcome = platform.for_each_to_obtain_tree(new_tree, |change| { + if truncated { + return Ok::<_, std::convert::Infallible>(std::ops::ControlFlow::Break(())); + } + let (section, bytes) = render_change(repo, &change); + total = total.saturating_add(bytes); + sections.push(section); + if total > MAX_DIFF_BYTES { + truncated = true; + } + Ok(std::ops::ControlFlow::Continue(())) + }); + (html! { @for section in &sections { (section) } }, truncated) +} + +/// One changed file's `.diff` section: a `.file`-classed header naming the +/// path (and, on a rename, the old path it moved from), followed by either +/// a `.meta`-classed "binary file changed" notice or the colorized unified +/// diff between its old and new blob content. Returns the section's +/// rendered byte cost, so [`diff_sections`] can track the page's overall +/// budget. +fn render_change(repo: &gix::Repository, change: &Change<'_, '_, '_>) -> (Markup, usize) { + let (old_id, new_id, path, rename_from) = match *change { + Change::Addition { location, id, .. } => ( + None, + Some(id.detach()), + location.to_str_lossy().into_owned(), + None, + ), + Change::Deletion { location, id, .. } => ( + Some(id.detach()), + None, + location.to_str_lossy().into_owned(), + None, + ), + Change::Modification { + location, + previous_id, + id, + .. + } => ( + Some(previous_id.detach()), + Some(id.detach()), + location.to_str_lossy().into_owned(), + None, + ), + Change::Rewrite { + location, + source_location, + source_id, + id, + .. + } => ( + Some(source_id.detach()), + Some(id.detach()), + location.to_str_lossy().into_owned(), + Some(source_location.to_str_lossy().into_owned()), + ), + }; + + let old_bytes = old_id.and_then(|id| blob_bytes(repo, id)); + let new_bytes = new_id.and_then(|id| blob_bytes(repo, id)); + let cost = old_bytes + .as_ref() + .map_or(0, Vec::len) + .saturating_add(new_bytes.as_ref().map_or(0, Vec::len)); + let binary = + old_bytes.as_deref().is_some_and(is_binary) || new_bytes.as_deref().is_some_and(is_binary); + + let header = html! { + span.ln.file { + @if let Some(from) = &rename_from { (from) " \u{2192} " } + (path) + "\n" + } + }; + let body = if binary { + html! { span.ln.meta { "Binary file changed.\n" } } + } else { + let old_text = old_bytes.as_deref().map_or_else(String::new, |bytes| { + String::from_utf8_lossy(bytes).into_owned() + }); + let new_text = new_bytes.as_deref().map_or_else(String::new, |bytes| { + String::from_utf8_lossy(bytes).into_owned() + }); + unified_diff(&old_text, &new_text) + }; + (html! { div.diff { (header) (body) } }, cost) +} + +/// `id`'s blob content, or `None` when it cannot be read as a blob (a +/// submodule/gitlink entry, or a read failure) -- best-effort, mirroring +/// [`diff_sections`]'s own degrade-don't-fail stance. +fn blob_bytes(repo: &gix::Repository, id: ObjectId) -> Option<Vec<u8>> { + Some( + repo.find_object(id) + .ok()? + .try_into_blob() + .ok()? + .data + .clone(), + ) +} + +/// Whether `bytes` looks like binary content (a NUL byte in the leading +/// chunk, the same heuristic [`super::files::is_binary`] and pre-redo's own +/// `is_binary` use). +fn is_binary(bytes: &[u8]) -> bool { + bytes.iter().take(8000).any(|b| *b == 0) +} + +/// `old_text` and `new_text` rendered as a colorized unified diff: each +/// hunk built via [`gix::diff::blob::InternedInput`] and +/// [`diff_with_slider_heuristics`], then rendered to the textual unified +/// diff format through [`ConsumeBinaryHunk`] and colorized line by line via +/// [`diff_class`] -- mirrors `pre-redo:.../pages.rs`'s own `diff_view`, +/// its `git diff`-shelled-out patch text replaced with this page's own +/// `gix`-computed one. +fn unified_diff(old_text: &str, new_text: &str) -> Markup { + if old_text == new_text { + return html! {}; + } + let input = InternedInput::new(old_text, new_text); + let diff = diff_with_slider_heuristics(Algorithm::Histogram, &input); + let Ok(patch) = UnifiedDiff::new( + &diff, + &input, + ConsumeBinaryHunk::new(String::new(), "\n"), + ContextSize::symmetrical(3), + ) + .consume() else { + return html! {}; + }; + html! { + @for line in patch.lines() { + span class={ "ln " (diff_class(line)) } { (line) "\n" } + } + } +} + +/// The CSS class for a unified-diff line, chosen from its leading marker +/// (mirrors `pre-redo:.../pages.rs`'s own `diff_class`). +fn diff_class(line: &str) -> &'static str { + if line.starts_with("@@") { + "hunk" + } else if line.starts_with('+') { + "add" + } else if line.starts_with('-') { + "del" + } else { + "ctx" + } +} + +#[cfg(test)] +mod tests { + #![allow(clippy::expect_used, reason = "unit test")] + + use rstest::rstest; + + use super::*; + + #[rstest] + #[case::empty("", false)] + #[case::not_hex("zzzzzzz", false)] + #[case::too_long( + "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + false + )] + #[case::sha1("aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", true)] + fn parse_oid_accepts_only_well_formed_hex_ids(#[case] text: &str, #[case] valid: bool) { + assert_eq!(parse_oid(text).is_ok(), valid); + } + + #[test] + fn diff_class_colors_hunk_add_del_and_context_lines() { + assert_eq!(diff_class("@@ -1,2 +1,2 @@"), "hunk"); + assert_eq!(diff_class("+added"), "add"); + assert_eq!(diff_class("-removed"), "del"); + assert_eq!(diff_class(" context"), "ctx"); + } + + #[test] + fn unified_diff_renders_colored_added_and_removed_lines() { + let rendered = unified_diff("a\nb\n", "a\nc\n").into_string(); + assert!(rendered.contains("class=\"ln del\"")); + assert!(rendered.contains("class=\"ln add\"")); + } + + #[test] + fn unified_diff_of_identical_text_renders_nothing() { + let rendered = unified_diff("same\n", "same\n").into_string(); + assert!(rendered.is_empty()); + } +}