quinjet 0.0.46

A fast, live, keyboard-first Git source-control interface for the terminal
#[cfg_attr(not(test), expect(clippy::wildcard_imports, reason = "shared"))]
use super::*;

pub(super) fn pull_request_file_document(
    output: &[u8],
    pull_request: &PullRequest,
    file: &PullRequestFile,
    truncated: bool,
) -> DiffDocument {
    let file_additions = count_patch_lines(output, b'+');
    let file_deletions = count_patch_lines(output, b'-');
    let mut document = parse_diff(
        output,
        format!("PR #{}  ยท  {}", pull_request.number, file.path.display()),
        Some(&file.path),
        truncated,
    );
    document.truncated |= truncated;
    document.pull_request_details = Some(PullRequestDetails {
        number: pull_request.number,
        title: pull_request.title.clone(),
        description: pull_request.description.clone(),
        author: pull_request.author.clone(),
        state: pull_request.state.clone(),
        is_draft: pull_request.is_draft,
        updated_at: pull_request.updated_at.clone(),
        url: pull_request.url.clone(),
        base_repository: pull_request.base_repository.display_name(),
        base_ref: pull_request.base_ref.clone(),
        base_remotes: pull_request.base_repository.remotes.clone(),
        head_repository: pull_request.head_repository.as_ref().map(|repository| {
            if pull_request
                .base_repository
                .host()
                .eq_ignore_ascii_case("github.com")
                || pull_request.base_repository.host().is_empty()
            {
                repository.clone()
            } else {
                format!("{}/{repository}", pull_request.base_repository.host())
            }
        }),
        head_ref: pull_request.head_ref.clone(),
        head_remotes: pull_request.head_remotes.clone(),
        is_cross_repository: pull_request.is_cross_repository,
        changed_files: pull_request.changed_files,
        additions: pull_request.additions,
        deletions: pull_request.deletions,
        selected_file: Some(file.path.to_string_lossy().into_owned()),
        selected_file_additions: file_additions,
        selected_file_deletions: file_deletions,
    });
    document
}

pub(super) fn count_patch_lines(output: &[u8], marker: u8) -> usize {
    output
        .split(|byte| *byte == b'\n')
        .filter(|line| {
            line.first() == Some(&marker)
                && !line.starts_with(if marker == b'+' { b"+++ " } else { b"--- " })
        })
        .count()
}

pub(super) fn pull_request_view_args(repository: &GitHubRepository, number: u64) -> Vec<OsString> {
    let (owner, name) = repository
        .name_with_owner
        .split_once('/')
        .unwrap_or((repository.name_with_owner.as_str(), ""));
    vec![
        OsString::from("api"),
        OsString::from("graphql"),
        OsString::from("--hostname"),
        OsString::from(repository.host()),
        OsString::from("-f"),
        OsString::from(format!("owner={owner}")),
        OsString::from("-f"),
        OsString::from(format!("name={name}")),
        OsString::from("-F"),
        OsString::from(format!("number={number}")),
        OsString::from("-f"),
        OsString::from(format!("query={PULL_REQUEST_QUERY}")),
        OsString::from("--jq"),
        OsString::from(PULL_REQUEST_VIEW_TSV_JQ),
    ]
}

pub(super) fn parse_pull_requests(
    output: &[u8],
    base_repository: &GitHubRepository,
    repositories: &[GitHubRepository],
) -> Result<Vec<PullRequest>> {
    let mut pull_requests = Vec::new();
    for (index, record) in output.split(|byte| *byte == b'\n').enumerate() {
        if record.is_empty() {
            continue;
        }
        let fields = parse_tsv_record::<PULL_REQUEST_TSV_FIELDS>(record)
            .with_context(|| format!("invalid pull-request record {}", index + 1))?;
        pull_requests.push(parse_pull_request_fields(
            fields,
            base_repository,
            repositories,
        )?);
    }
    Ok(pull_requests)
}

pub(super) fn parse_pull_request_fields(
    fields: [String; PULL_REQUEST_TSV_FIELDS],
    base_repository: &GitHubRepository,
    repositories: &[GitHubRepository],
) -> Result<PullRequest> {
    let [
        node_id,
        number,
        title,
        description,
        author,
        state,
        draft,
        updated_at,
        url,
        base_ref,
        head_ref,
        head_repository_name,
        cross_repository,
        additions,
        deletions,
        changed_files,
        base_oid,
        head_oid,
        created_at,
        is_locked,
        viewer_can_close,
        viewer_can_reopen,
        viewer_can_update,
        viewer_can_update_branch,
        viewer_can_subscribe,
        viewer_can_react,
        viewer_did_author,
        viewer_subscription,
        merge_state,
        mergeable,
        maintainer_can_modify,
        viewer_can_merge_as_admin,
        auto_merge_method,
        merge_queue_entry_id,
        merge_queue_position,
        merge_queue_state,
        merge_queue_id,
        review_decision,
    ] = fields;
    let head_repository = (!head_repository_name.is_empty()).then_some(head_repository_name);
    let head_remotes = head_repository
        .as_deref()
        .map(|name| matching_remotes(repositories, base_repository.host(), name))
        .unwrap_or_default();
    Ok(PullRequest {
        number: parse_field(&number, "number")?,
        title: bounded_text(&title, MAX_PULL_REQUEST_TITLE_BYTES),
        description: bounded_text(&description, MAX_PULL_REQUEST_BODY_BYTES),
        author,
        state: state.to_ascii_uppercase(),
        is_draft: parse_field(&draft, "draft state")?,
        updated_at,
        url,
        base_ref,
        head_ref,
        base_repository: base_repository.clone(),
        head_repository,
        head_remotes,
        is_cross_repository: parse_field(&cross_repository, "cross-repository state")?,
        additions: parse_field(&additions, "addition count")?,
        deletions: parse_field(&deletions, "deletion count")?,
        changed_files: parse_field(&changed_files, "changed-file count")?,
        base_oid,
        head_oid,
        created_at,
        action_state: PullRequestActionState {
            node_id,
            is_locked: parse_field(&is_locked, "conversation lock state")?,
            viewer_can_close: parse_field(&viewer_can_close, "close permission")?,
            viewer_can_reopen: parse_field(&viewer_can_reopen, "reopen permission")?,
            viewer_can_update: parse_field(&viewer_can_update, "update permission")?,
            viewer_can_update_branch: parse_field(
                &viewer_can_update_branch,
                "branch update permission",
            )?,
            viewer_can_subscribe: parse_field(&viewer_can_subscribe, "subscription permission")?,
            viewer_can_react: parse_field(&viewer_can_react, "reaction permission")?,
            viewer_did_author: parse_field(&viewer_did_author, "authorship state")?,
            viewer_subscription,
            merge_state,
            mergeable,
            maintainer_can_modify: parse_field(
                &maintainer_can_modify,
                "maintainer modification state",
            )?,
            viewer_can_merge_as_admin: parse_field(
                &viewer_can_merge_as_admin,
                "administrator merge permission",
            )?,
            auto_merge_method,
            merge_queue_entry_id,
            merge_queue_position: parse_field(&merge_queue_position, "merge queue position")?,
            merge_queue_state,
            merge_queue_id,
            review_decision,
        },
    })
}

pub(super) fn bounded_text(value: &str, maximum: usize) -> String {
    if value.len() <= maximum {
        return value.to_owned();
    }
    let mut end = maximum;
    while !value.is_char_boundary(end) {
        end = end.saturating_sub(1);
    }
    format!("{}โ€ฆ", value.get(..end).unwrap_or_default())
}

pub(super) fn parse_tsv_record<const FIELDS: usize>(record: &[u8]) -> Result<[String; FIELDS]> {
    let record = record.strip_suffix(b"\r").unwrap_or(record);
    let fields: Vec<_> = record
        .split(|byte| *byte == b'\t')
        .map(text)
        .map(|field| unescape_tsv(&field))
        .collect();
    let received = fields.len();
    fields
        .try_into()
        .map_err(|_| anyhow!("expected {FIELDS} tab-separated fields, received {received}"))
}

pub(super) fn unescape_tsv(value: &str) -> String {
    let mut output = String::with_capacity(value.len());
    let mut characters = value.chars();
    while let Some(character) = characters.next() {
        if character != '\\' {
            output.push(character);
            continue;
        }
        match characters.next() {
            Some('t') => output.push('\t'),
            Some('n') => output.push('\n'),
            Some('r') => output.push('\r'),
            Some(other) => {
                if other != '\\' {
                    output.push('\\');
                }
                output.push(other);
            }
            None => output.push('\\'),
        }
    }
    output
}

pub(super) fn parse_field<T>(value: &str, label: &str) -> Result<T>
where
    T: std::str::FromStr,
    T::Err: std::fmt::Display,
{
    value
        .parse()
        .map_err(|error| anyhow::anyhow!("invalid {label} `{value}`: {error}"))
}

pub(super) fn select_repository<'a>(
    repositories: &'a [GitHubRepository],
    selected: Option<&GitHubRepository>,
) -> Option<&'a GitHubRepository> {
    selected
        .and_then(|selected| {
            repositories.iter().find(|repository| {
                repository
                    .url
                    .trim_end_matches('/')
                    .eq_ignore_ascii_case(selected.url.trim_end_matches('/'))
            })
        })
        .or_else(|| repositories.first())
}

pub(super) fn remote_url_for_gh(url: &str) -> String {
    if let Some((scheme, rest)) = url.split_once("://") {
        let (authority, path) = rest.split_once('/').unwrap_or((rest, ""));
        let authority = authority.rsplit('@').next().unwrap_or(authority);
        let authority = authority.split(['?', '#']).next().unwrap_or(authority);
        let path = path.split(['?', '#']).next().unwrap_or(path);
        return if path.is_empty() {
            format!("{scheme}://{authority}")
        } else {
            format!("{scheme}://{authority}/{path}")
        };
    }

    if let Some((_, target)) = url.rsplit_once('@')
        && let Some((host, path)) = target.split_once(':')
        && !host.is_empty()
        && !path.is_empty()
    {
        return format!("ssh://{host}/{path}");
    }
    url.to_owned()
}

pub(super) fn repository_from_remote_url(url: &str) -> Option<GitHubRepository> {
    let sanitized = remote_url_for_gh(url);
    let (scheme, rest) = sanitized.split_once("://")?;
    if !matches!(scheme, "http" | "https" | "ssh") {
        return None;
    }
    let (host, path) = rest.split_once('/')?;
    if !host.eq_ignore_ascii_case("github.com") || path.is_empty() {
        return None;
    }
    let mut components = path.trim_matches('/').split('/');
    let owner = components.next()?;
    let raw_name = components.next()?;
    let name = raw_name.strip_suffix(".git").unwrap_or(raw_name);
    if owner.is_empty() || name.is_empty() || components.next().is_some() {
        return None;
    }
    let canonical_scheme = if scheme == "ssh" { "https" } else { scheme };
    Some(GitHubRepository {
        name_with_owner: format!("{owner}/{name}"),
        url: format!("{canonical_scheme}://{host}/{owner}/{name}"),
        remotes: Vec::new(),
    })
}

pub(super) fn group_remote_urls(remote_urls: &[RemoteUrl]) -> Vec<(String, Vec<String>)> {
    let mut grouped: BTreeMap<String, BTreeSet<String>> = BTreeMap::new();
    for remote_url in remote_urls {
        let _ = grouped
            .entry(remote_url_for_gh(&remote_url.url))
            .or_default()
            .insert(remote_url.remote.clone());
    }
    grouped
        .into_iter()
        .map(|(url, remotes)| (url, remotes.into_iter().collect()))
        .collect()
}

pub(super) fn matching_remotes(
    repositories: &[GitHubRepository],
    host: &str,
    name_with_owner: &str,
) -> Vec<String> {
    repositories
        .iter()
        .find(|repository| {
            repository.host().eq_ignore_ascii_case(host)
                && repository
                    .name_with_owner
                    .eq_ignore_ascii_case(name_with_owner)
        })
        .map(|repository| repository.remotes.clone())
        .unwrap_or_default()
}

pub(super) fn merge_repository(
    repositories: &mut BTreeMap<String, GitHubRepository>,
    mut repository: GitHubRepository,
    remote: Option<&str>,
) {
    let key = repository.url.trim_end_matches('/').to_lowercase();
    let entry = repositories.entry(key).or_insert_with(|| {
        repository.url = repository.url.trim_end_matches('/').to_owned();
        repository
    });
    if let Some(remote) = remote
        && !entry.remotes.iter().any(|existing| existing == remote)
    {
        entry.remotes.push(remote.to_owned());
    }
}

pub(super) fn repository_host(url: &str) -> Option<&str> {
    let (_, rest) = url.split_once("://")?;
    rest.split('/').next().filter(|host| !host.is_empty())
}