michal/tit

Browse tree · Show commit · Download archive

Blob: src/http/pull_requests.rs

Raw · Blame

use askama::Template;
use axum::Router;
use axum::body::Bytes;
use axum::extract::{DefaultBodyLimit, Extension, Path, Query, State};
use axum::http::{HeaderMap, StatusCode, header};
use axum::response::Response;
use axum::routing::{get, post};
use serde::Deserialize;

use crate::markdown::{self, RenderedMarkdown};
use crate::pull_request::PullRequestError;
use crate::store::StoreError;

use super::filters;
use super::public::{patch_response, stream_patch};
use super::{
    CSRF_COOKIE, RequestActor, RequestId, WebState, authenticate_mutation, cookie,
    parse_named_form, render, render_error,
};

const MAX_PULL_REQUEST_BYTES: usize = 300 * 1024;

pub(super) fn routes() -> Router<WebState> {
    Router::new()
        .route(
            "/{owner}/{repository}/pulls",
            get(pull_request_list).post(open_pull_request),
        )
        .route(
            "/{owner}/{repository}/pulls/{number}",
            get(pull_request_detail),
        )
        .route(
            "/{owner}/{repository}/pulls/{number}/revisions",
            post(revise_pull_request),
        )
        .route(
            "/{owner}/{repository}/pulls/{number}/revisions/{revision}",
            get(download_revision_patch),
        )
        .route(
            "/{owner}/{repository}/pulls/{number}/edit",
            post(edit_pull_request),
        )
        .route(
            "/{owner}/{repository}/pulls/{number}/state",
            post(change_pull_request_state),
        )
        .route(
            "/{owner}/{repository}/pulls/{number}/reviews",
            post(create_review),
        )
        .route(
            "/{owner}/{repository}/pulls/{number}/merge",
            post(merge_pull_request),
        )
        .layer(DefaultBodyLimit::max(MAX_PULL_REQUEST_BYTES))
}

async fn download_revision_patch(
    State(state): State<WebState>,
    Extension(request_id): Extension<RequestId>,
    Extension(actor): Extension<RequestActor>,
    Path(path): Path<PullRequestRevisionPath>,
) -> Response {
    let Some(service) = state.pull_requests.clone() else {
        return internal(&request_id.0);
    };
    let Some(revision) = path.revision.strip_suffix(".patch") else {
        return bad_request(&request_id.0);
    };
    let Ok(revision) = revision.parse::<i64>() else {
        return bad_request(&request_id.0);
    };
    let owner = path.owner.clone();
    let repository = path.repository.clone();
    let number = path.number;
    let result = job(state.clone(), move || {
        service.compare_page(
            &owner,
            &repository,
            number,
            Some(revision),
            actor.0.as_deref(),
            1,
            1,
        )
    })
    .await;
    let comparison = match result {
        Ok(comparison) => comparison,
        Err(error) => return read_error(error, &request_id.0),
    };
    let is_public = comparison.detail.repository.visibility == "public";
    let body = match stream_patch(state.jobs.clone(), comparison.comparison.files).await {
        Ok(body) => body,
        Err(()) => return internal(&request_id.0),
    };
    patch_response(
        body,
        &format!(
            "{}-{}-pr-{}-revision-{}.patch",
            path.owner, path.repository, path.number, revision
        ),
        is_public,
    )
}

async fn pull_request_list(
    State(state): State<WebState>,
    Extension(request_id): Extension<RequestId>,
    Extension(actor): Extension<RequestActor>,
    Path(path): Path<RepositoryPath>,
    Query(query): Query<ListQuery>,
    headers: HeaderMap,
) -> Response {
    let Some(service) = state.pull_requests.clone() else {
        return internal(&request_id.0);
    };
    let owner = path.owner.clone();
    let repository = path.repository.clone();
    let signed_in = actor.0.is_some();
    let actor_name = actor.0;
    let actor_for_list = actor_name.clone();
    let state_filter = query.state.unwrap_or_else(|| "open".to_owned());
    let page_number = query.page.unwrap_or(1);
    let state_for_job = state_filter.clone();
    let result = job(state.clone(), move || {
        service.list_page(
            &owner,
            &repository,
            actor_for_list.as_deref(),
            &state_for_job,
            page_number,
        )
    })
    .await;
    match result {
        Ok((record, page, can_create)) => {
            let csrf = cookie(&headers, CSRF_COOKIE).unwrap_or_default();
            let branches = match state.public.as_ref() {
                Some(public) => public
                    .branch_names(actor_name, record.owner.clone(), record.slug.clone())
                    .await
                    .unwrap_or_default(),
                None => Vec::new(),
            };
            let default_owner = record.owner.clone();
            let default_repository = record.slug.clone();
            let default_branch = super::repository_job(state, move |repositories| {
                repositories.default_branch(&default_owner, &default_repository)
            })
            .await
            .unwrap_or_else(|_| "refs/heads/main".to_owned());
            render(
                StatusCode::OK,
                &PullRequestListTemplate {
                    request_id: &request_id.0,
                    signed_in,
                    owner: &record.owner,
                    repository: &record.slug,
                    pull_requests: page
                        .items
                        .iter()
                        .map(|pull_request| PullRequestListItem {
                            number: pull_request.number,
                            title: &pull_request.title,
                            state: &pull_request.state,
                            author: &pull_request.author,
                            updated_at: pull_request.updated_at,
                        })
                        .collect(),
                    csrf: &csrf,
                    can_create: can_create && !csrf.is_empty(),
                    branches,
                    default_branch: &default_branch,
                    state: &state_filter,
                    state_all: state_filter == "all",
                    state_open: state_filter == "open",
                    state_closed: state_filter == "closed",
                    state_merged: state_filter == "merged",
                    has_previous: page.page > 1,
                    has_next: page.has_next,
                    previous_page: page.page.saturating_sub(1),
                    next_page: page.page.saturating_add(1),
                },
            )
        }
        Err(error) => read_error(error, &request_id.0),
    }
}

async fn pull_request_detail(
    State(state): State<WebState>,
    Extension(request_id): Extension<RequestId>,
    Extension(actor): Extension<RequestActor>,
    Path(path): Path<PullRequestPath>,
    Query(query): Query<RevisionQuery>,
    headers: HeaderMap,
) -> Response {
    let Some(service) = state.pull_requests.clone() else {
        return internal(&request_id.0);
    };
    let owner = path.owner.clone();
    let repository = path.repository.clone();
    let signed_in = actor.0.is_some();
    let result = job(state, move || {
        service.compare_page(
            &owner,
            &repository,
            path.number,
            query.revision,
            actor.0.as_deref(),
            query.reviews_page.unwrap_or(1),
            query.timeline_page.unwrap_or(1),
        )
    })
    .await;
    match result {
        Ok(result) => {
            let csrf = cookie(&headers, CSRF_COOKIE).unwrap_or_default();
            let detail = &result.detail;
            let pull_request = &detail.pull_request;
            render(
                StatusCode::OK,
                &PullRequestTemplate {
                    request_id: &request_id.0,
                    signed_in,
                    owner: &detail.repository.owner,
                    repository: &detail.repository.slug,
                    pull_request,
                    body_html: markdown::render(&pull_request.body),
                    revisions: &detail.revisions,
                    reviews: detail
                        .reviews
                        .iter()
                        .map(|review| ReviewView {
                            id: &review.id,
                            revision: review.revision,
                            author: &review.author,
                            kind: &review.kind,
                            body_html: markdown::render(&review.body),
                            has_body: !review.body.is_empty(),
                            commit_object_id: review.commit_object_id.as_deref().unwrap_or(""),
                            path: review
                                .path
                                .as_deref()
                                .map(|path| String::from_utf8_lossy(path).into_owned())
                                .unwrap_or_default(),
                            side: review.side.as_deref().unwrap_or(""),
                            line: review
                                .line
                                .map_or_else(String::new, |line| line.to_string()),
                            outdated: review.kind == "line-comment"
                                && review.revision != pull_request_revision(detail),
                            created_at: review.created_at,
                        })
                        .collect(),
                    timeline: detail
                        .timeline
                        .iter()
                        .map(|event| TimelineView {
                            sequence: event.sequence,
                            kind: &event.kind,
                            actor: &event.actor,
                            created_at: event.created_at,
                        })
                        .collect(),
                    selected_revision: result.revision.number,
                    comparison: ComparisonView::from(&result.comparison),
                    csrf: &csrf,
                    can_revise: detail.can_revise && !csrf.is_empty(),
                    can_review: detail.can_review
                        && !csrf.is_empty()
                        && result.revision.number == pull_request_revision(detail),
                    can_merge: detail.can_merge
                        && !csrf.is_empty()
                        && result.revision.number == pull_request_revision(detail),
                    can_edit: detail.can_edit && !csrf.is_empty(),
                    can_change_state: detail.can_change_state && !csrf.is_empty(),
                    is_open: pull_request.state == "open",
                    reviews_page: detail.reviews_page,
                    reviews_has_previous: detail.reviews_page > 1,
                    reviews_has_next: detail.reviews_has_next,
                    reviews_previous_page: detail.reviews_page.saturating_sub(1),
                    reviews_next_page: detail.reviews_page.saturating_add(1),
                    timeline_page: detail.timeline_page,
                    timeline_has_previous: detail.timeline_page > 1,
                    timeline_has_next: detail.timeline_has_next,
                    timeline_previous_page: detail.timeline_page.saturating_sub(1),
                    timeline_next_page: detail.timeline_page.saturating_add(1),
                },
            )
        }
        Err(error) => read_error(error, &request_id.0),
    }
}

async fn edit_pull_request(
    State(state): State<WebState>,
    Extension(request_id): Extension<RequestId>,
    Path(path): Path<PullRequestPath>,
    headers: HeaderMap,
    body: Bytes,
) -> Response {
    let fields = match parse_named_form(&headers, &body, &["csrf", "title", "body"]) {
        Ok(fields) => fields,
        Err(()) => return bad_request(&request_id.0),
    };
    let actor =
        match authenticate_mutation(state.clone(), &headers, &fields[0], &request_id.0).await {
            Ok(actor) => actor,
            Err(response) => return response,
        };
    let Some(service) = state.pull_requests.clone() else {
        return internal(&request_id.0);
    };
    let owner = path.owner.clone();
    let repository = path.repository.clone();
    let number = path.number;
    let result = job(state, move || {
        service.edit(&owner, &repository, number, &actor, &fields[1], &fields[2])
    })
    .await;
    match result {
        Ok(()) => redirect(&path.owner, &path.repository, path.number),
        Err(error) => mutation_error(error, &request_id.0),
    }
}

async fn change_pull_request_state(
    State(state): State<WebState>,
    Extension(request_id): Extension<RequestId>,
    Path(path): Path<PullRequestPath>,
    headers: HeaderMap,
    body: Bytes,
) -> Response {
    let fields = match parse_named_form(&headers, &body, &["csrf", "state"]) {
        Ok(fields) => fields,
        Err(()) => return bad_request(&request_id.0),
    };
    let actor =
        match authenticate_mutation(state.clone(), &headers, &fields[0], &request_id.0).await {
            Ok(actor) => actor,
            Err(response) => return response,
        };
    let Some(service) = state.pull_requests.clone() else {
        return internal(&request_id.0);
    };
    let owner = path.owner.clone();
    let repository = path.repository.clone();
    let number = path.number;
    let result = job(state, move || {
        service.set_state(&owner, &repository, number, &actor, &fields[1])
    })
    .await;
    match result {
        Ok(()) => redirect(&path.owner, &path.repository, path.number),
        Err(error) => mutation_error(error, &request_id.0),
    }
}

async fn merge_pull_request(
    State(state): State<WebState>,
    Extension(request_id): Extension<RequestId>,
    Path(path): Path<PullRequestPath>,
    headers: HeaderMap,
    body: Bytes,
) -> Response {
    let fields = match parse_named_form(&headers, &body, &["csrf", "method", "confirm"]) {
        Ok(fields) => fields,
        Err(()) => return bad_request(&request_id.0),
    };
    let actor =
        match authenticate_mutation(state.clone(), &headers, &fields[0], &request_id.0).await {
            Ok(actor) => actor,
            Err(response) => return response,
        };
    if fields[2] != "yes" {
        return redirect(&path.owner, &path.repository, path.number);
    }
    let Some(service) = state.pull_requests.clone() else {
        return internal(&request_id.0);
    };
    let owner = path.owner.clone();
    let repository = path.repository.clone();
    let number = path.number;
    let result = job(state, move || {
        service.merge(&owner, &repository, number, &actor, &fields[1])
    })
    .await;
    match result {
        Ok(_) => redirect(&path.owner, &path.repository, number),
        Err(error) => mutation_error(error, &request_id.0),
    }
}

async fn create_review(
    State(state): State<WebState>,
    Extension(request_id): Extension<RequestId>,
    Path(path): Path<PullRequestPath>,
    headers: HeaderMap,
    body: Bytes,
) -> Response {
    let fields = match parse_named_form(
        &headers,
        &body,
        &[
            "csrf", "revision", "kind", "body", "path-hex", "side", "line",
        ],
    ) {
        Ok(fields) => fields,
        Err(()) => return bad_request(&request_id.0),
    };
    let actor =
        match authenticate_mutation(state.clone(), &headers, &fields[0], &request_id.0).await {
            Ok(actor) => actor,
            Err(response) => return response,
        };
    let revision = match fields[1].parse::<i64>() {
        Ok(revision) => revision,
        Err(_) => return bad_request(&request_id.0),
    };
    let path_bytes = if fields[4].is_empty() {
        None
    } else {
        match decode_hex(&fields[4]) {
            Some(path) => Some(path),
            None => return bad_request(&request_id.0),
        }
    };
    let side = (!fields[5].is_empty()).then(|| fields[5].clone());
    let line = if fields[6].is_empty() {
        None
    } else {
        match fields[6].parse::<i64>() {
            Ok(line) => Some(line),
            Err(_) => return bad_request(&request_id.0),
        }
    };
    let Some(service) = state.pull_requests.clone() else {
        return internal(&request_id.0);
    };
    let owner = path.owner.clone();
    let repository = path.repository.clone();
    let number = path.number;
    let result = job(state, move || {
        service.review(
            &owner,
            &repository,
            number,
            revision,
            &actor,
            &fields[2],
            &fields[3],
            path_bytes.as_deref(),
            side.as_deref(),
            line,
        )
    })
    .await;
    match result {
        Ok(_) => redirect(&path.owner, &path.repository, number),
        Err(error) => mutation_error(error, &request_id.0),
    }
}

async fn open_pull_request(
    State(state): State<WebState>,
    Extension(request_id): Extension<RequestId>,
    Path(path): Path<RepositoryPath>,
    headers: HeaderMap,
    body: Bytes,
) -> Response {
    let fields = match parse_named_form(
        &headers,
        &body,
        &["csrf", "title", "body", "base-ref", "head-ref"],
    ) {
        Ok(fields) => fields,
        Err(()) => return bad_request(&request_id.0),
    };
    let actor =
        match authenticate_mutation(state.clone(), &headers, &fields[0], &request_id.0).await {
            Ok(actor) => actor,
            Err(response) => return response,
        };
    let Some(service) = state.pull_requests.clone() else {
        return internal(&request_id.0);
    };
    let owner = path.owner.clone();
    let repository = path.repository.clone();
    let result = job(state, move || {
        service.open(
            &owner,
            &repository,
            &actor,
            &fields[1],
            &fields[2],
            &fields[3],
            &fields[4],
        )
    })
    .await;
    match result {
        Ok(pull_request) => redirect(&path.owner, &path.repository, pull_request.number),
        Err(error) => mutation_error(error, &request_id.0),
    }
}

async fn revise_pull_request(
    State(state): State<WebState>,
    Extension(request_id): Extension<RequestId>,
    Path(path): Path<PullRequestPath>,
    headers: HeaderMap,
    body: Bytes,
) -> Response {
    let fields = match parse_named_form(&headers, &body, &["csrf"]) {
        Ok(fields) => fields,
        Err(()) => return bad_request(&request_id.0),
    };
    let actor =
        match authenticate_mutation(state.clone(), &headers, &fields[0], &request_id.0).await {
            Ok(actor) => actor,
            Err(response) => return response,
        };
    let Some(service) = state.pull_requests.clone() else {
        return internal(&request_id.0);
    };
    let owner = path.owner.clone();
    let repository = path.repository.clone();
    let number = path.number;
    let result = job(state, move || {
        service.revise(&owner, &repository, number, &actor)
    })
    .await;
    match result {
        Ok(_) => redirect(&path.owner, &path.repository, number),
        Err(error) => mutation_error(error, &request_id.0),
    }
}

async fn job<T: Send + 'static>(
    state: WebState,
    operation: impl FnOnce() -> Result<T, PullRequestError> + Send + 'static,
) -> Result<T, PullRequestError> {
    let permit = state.jobs.acquire_owned().await.map_err(|_| {
        PullRequestError::Store(StoreError::Integrity("Web work queue is closed".to_owned()))
    })?;
    tokio::task::spawn_blocking(move || {
        let _permit = permit;
        operation()
    })
    .await
    .map_err(|_| PullRequestError::Store(StoreError::Integrity("Web task stopped".to_owned())))?
}

fn read_error(error: PullRequestError, request_id: &str) -> Response {
    match error {
        PullRequestError::Store(
            StoreError::RepositoryNotFound(_, _)
            | StoreError::PullRequestNotFound(_, _, _)
            | StoreError::PullRequestHidden,
        ) => render_error(
            StatusCode::NOT_FOUND,
            request_id,
            "Not found",
            "The pull request was not found.",
        ),
        PullRequestError::Number
        | PullRequestError::State
        | PullRequestError::Revision
        | PullRequestError::Auth(_)
        | PullRequestError::RepositoryName(_) => bad_request(request_id),
        _ => internal(request_id),
    }
}

fn mutation_error(error: PullRequestError, request_id: &str) -> Response {
    match error {
        PullRequestError::Store(StoreError::PullRequestDenied) => render_error(
            StatusCode::FORBIDDEN,
            request_id,
            "Pull-request error",
            "You cannot change pull requests in this repository.",
        ),
        PullRequestError::Store(StoreError::PullRequestState) => render_error(
            StatusCode::CONFLICT,
            request_id,
            "Pull-request conflict",
            "The pull request is not open.",
        ),
        PullRequestError::Store(
            StoreError::RepositoryNotFound(_, _)
            | StoreError::PullRequestNotFound(_, _, _)
            | StoreError::PullRequestHidden,
        ) => read_error(error, request_id),
        PullRequestError::Title
        | PullRequestError::Body
        | PullRequestError::Branch
        | PullRequestError::Number
        | PullRequestError::State
        | PullRequestError::Unchanged
        | PullRequestError::Revision
        | PullRequestError::ReviewKind
        | PullRequestError::ReviewBody
        | PullRequestError::ReviewAnchor
        | PullRequestError::MergeMethod
        | PullRequestError::Store(StoreError::PullRequestRevisionNotFound)
        | PullRequestError::Store(StoreError::PullRequestReviewAnchor)
        | PullRequestError::Git(crate::git::repository::GitRepositoryError::MissingReference(_)) => {
            bad_request(request_id)
        }
        PullRequestError::StaleRevision | PullRequestError::Mergeability => render_error(
            StatusCode::CONFLICT,
            request_id,
            "Pull-request conflict",
            "The pull request cannot be merged in its current state.",
        ),
        _ => internal(request_id),
    }
}

fn pull_request_revision(detail: &crate::store::PullRequestDetail) -> i64 {
    detail
        .revisions
        .last()
        .map_or(0, |revision| revision.number)
}

fn decode_hex(value: &str) -> Option<Vec<u8>> {
    if value.is_empty() || !value.len().is_multiple_of(2) {
        return None;
    }
    value
        .as_bytes()
        .chunks_exact(2)
        .map(|pair| {
            let pair = std::str::from_utf8(pair).ok()?;
            u8::from_str_radix(pair, 16).ok()
        })
        .collect()
}

fn bad_request(request_id: &str) -> Response {
    render_error(
        StatusCode::BAD_REQUEST,
        request_id,
        "Pull-request error",
        "The pull-request request is not valid.",
    )
}

fn internal(request_id: &str) -> Response {
    render_error(
        StatusCode::INTERNAL_SERVER_ERROR,
        request_id,
        "Pull-request error",
        "The pull-request request could not be completed.",
    )
}

fn redirect(owner: &str, repository: &str, number: i64) -> Response {
    Response::builder()
        .status(StatusCode::SEE_OTHER)
        .header(
            header::LOCATION,
            format!("/{owner}/{repository}/pulls/{number}"),
        )
        .header(header::CACHE_CONTROL, "no-store")
        .body(axum::body::Body::empty())
        .expect("the pull-request redirect is valid")
}

#[derive(Clone, Deserialize)]
struct RepositoryPath {
    owner: String,
    repository: String,
}

#[derive(Clone, Deserialize)]
struct PullRequestPath {
    owner: String,
    repository: String,
    number: i64,
}

#[derive(Clone, Deserialize)]
struct PullRequestRevisionPath {
    owner: String,
    repository: String,
    number: i64,
    revision: String,
}

#[derive(Clone, Default, Deserialize)]
struct RevisionQuery {
    revision: Option<i64>,
    reviews_page: Option<usize>,
    timeline_page: Option<usize>,
}

#[derive(Clone, Default, Deserialize)]
struct ListQuery {
    state: Option<String>,
    page: Option<usize>,
}

#[derive(Template)]
#[template(path = "pull_requests.html")]
struct PullRequestListTemplate<'a> {
    request_id: &'a str,
    signed_in: bool,
    owner: &'a str,
    repository: &'a str,
    pull_requests: Vec<PullRequestListItem<'a>>,
    csrf: &'a str,
    can_create: bool,
    branches: Vec<String>,
    default_branch: &'a str,
    state: &'a str,
    state_all: bool,
    state_open: bool,
    state_closed: bool,
    state_merged: bool,
    has_previous: bool,
    has_next: bool,
    previous_page: usize,
    next_page: usize,
}

struct PullRequestListItem<'a> {
    number: i64,
    title: &'a str,
    state: &'a str,
    author: &'a str,
    updated_at: i64,
}

#[derive(Template)]
#[template(path = "pull_request.html")]
struct PullRequestTemplate<'a> {
    request_id: &'a str,
    signed_in: bool,
    owner: &'a str,
    repository: &'a str,
    pull_request: &'a crate::store::PullRequestRecord,
    body_html: RenderedMarkdown,
    revisions: &'a [crate::store::PullRequestRevisionRecord],
    reviews: Vec<ReviewView<'a>>,
    timeline: Vec<TimelineView<'a>>,
    selected_revision: i64,
    comparison: ComparisonView,
    csrf: &'a str,
    can_revise: bool,
    can_review: bool,
    can_merge: bool,
    can_edit: bool,
    can_change_state: bool,
    is_open: bool,
    reviews_page: usize,
    reviews_has_previous: bool,
    reviews_has_next: bool,
    reviews_previous_page: usize,
    reviews_next_page: usize,
    timeline_page: usize,
    timeline_has_previous: bool,
    timeline_has_next: bool,
    timeline_previous_page: usize,
    timeline_next_page: usize,
}

struct ReviewView<'a> {
    id: &'a str,
    revision: i64,
    author: &'a str,
    kind: &'a str,
    body_html: RenderedMarkdown,
    has_body: bool,
    commit_object_id: &'a str,
    path: String,
    side: &'a str,
    line: String,
    outdated: bool,
    created_at: i64,
}

struct TimelineView<'a> {
    sequence: i64,
    kind: &'a str,
    actor: &'a str,
    created_at: i64,
}

struct ComparisonView {
    merge_base: String,
    mergeability: &'static str,
    commits: Vec<CommitView>,
    changed_paths: Vec<String>,
    files: Vec<DiffView>,
}

struct CommitView {
    id: String,
    message: String,
}

struct DiffView {
    path: String,
    path_hex: String,
    binary: bool,
    has_base: bool,
    has_head: bool,
    hunks: String,
}

impl From<&crate::git::read::Comparison> for ComparisonView {
    fn from(comparison: &crate::git::read::Comparison) -> Self {
        use crate::git::read::Mergeability;

        Self {
            merge_base: comparison
                .merge_base
                .map_or_else(|| "none".to_owned(), |id| id.to_string()),
            mergeability: match comparison.mergeability {
                Mergeability::Unrelated => "unrelated histories",
                Mergeability::AlreadyMerged => "already merged",
                Mergeability::FastForward => "fast-forward",
                Mergeability::Clean => "clean merge",
                Mergeability::Conflicting => "conflicts",
            },
            commits: comparison
                .commits
                .iter()
                .map(|commit| CommitView {
                    id: commit.id.to_string(),
                    message: String::from_utf8_lossy(&commit.message).into_owned(),
                })
                .collect(),
            changed_paths: comparison
                .changed_paths
                .iter()
                .map(|path| String::from_utf8_lossy(path).into_owned())
                .collect(),
            files: comparison
                .files
                .iter()
                .map(|file| DiffView {
                    path: String::from_utf8_lossy(&file.path).into_owned(),
                    path_hex: encode_hex(&file.path),
                    binary: file.binary,
                    has_base: file.old_id.is_some(),
                    has_head: file.new_id.is_some(),
                    hunks: String::from_utf8_lossy(&file.hunks).into_owned(),
                })
                .collect(),
        }
    }
}

fn encode_hex(bytes: &[u8]) -> String {
    let mut encoded = String::with_capacity(bytes.len().saturating_mul(2));
    for byte in bytes {
        use std::fmt::Write;
        write!(encoded, "{byte:02x}").expect("a string write cannot fail");
    }
    encoded
}