Your browser sucks, JavaScript disabled! Gunnar's console needs JavaScript and WebAssembly. Git over SSH on port 2222 does not.

nordisk/gunnar

crates/gunnar-forge/src/driver.rs

//! **The seam a fourth forge is added at**, and — more importantly — the seam
//! the token deliberately does *not* cross.
//!
//! # Why a driver trait and not three `list_*` functions
//!
//! Three standalone functions would each have to: build a URL, attach a token,
//! follow pagination, classify a status code, decide whether a 403 was a rate
//! limit, and turn a `ureq::Error` into something that does not carry a URL.
//! That is six chances to get the *security-relevant* half wrong, multiplied by
//! the number of forges, and the fourth forge is written by whoever needs it —
//! quickly, from the shape of the previous three.
//!
//! So the split is not "one module per forge" but **"the part that is different"
//! versus "the part that must never vary"**:
//!
//! | this trait decides | [`crate::engine`] decides |
//! |---|---|
//! | the path and query for page *n* | issuing the request at all |
//! | which header name spells the token, and its prefix | putting the token in it |
//! | how one page of JSON becomes `Repo`s | whether there *is* a next page loop |
//! | whether the forge says there is another page | every status → `ForgeError` mapping |
//!
//! **A driver never receives the token.** [`ForgeDriver::auth`] returns the
//! *shape* of the header — a name and a prefix, both `&'static str` — and the
//! engine formats the value. A driver therefore cannot log it, cannot put it in
//! an error, and cannot append it to a query string, because it does not have
//! it. That is the whole reason the trait is shaped this way; a
//! `fn request(&self, token: &str) -> Request` would have been shorter and
//! would have handed every future forge author the credential.
//!
//! # What a fourth forge costs
//!
//! One file implementing five methods, one constructor on [`crate::Forge`], and
//! one variant on `ForgeKind`. Azure DevOps and AWS CodeCommit are deliberately
//! not here: Azure's model is org → project → repo, so "list an account's
//! repositories" is two levels of pagination and not one, and AWS stopped
//! accepting new CodeCommit customers in 2024. Both would have bent this trait
//! to serve a case nobody has.

use crate::error::ForgeError;
use crate::repo::{ForgeKind, Repo};

/// How a forge spells its credential, with the credential left out.
///
/// `prefix` includes its own trailing space when it needs one, so the engine
/// does a single concatenation and there is no "did I forget the space" bug to
/// have. The three real spellings:
///
/// * GitHub — `Authorization: Bearer <token>`
/// * Gitea/Forgejo — `Authorization: token <token>`
/// * GitLab — `Authorization: Bearer <token>`
///
/// GitLab also accepts `PRIVATE-TOKEN: <token>`, and that is what most examples
/// show. This crate uses `Bearer` for it anyway, because `PRIVATE-TOKEN` accepts
/// a personal access token and **rejects an OAuth access token**, while `Bearer`
/// accepts both. The UI half does the OAuth dance and hands us whatever it got;
/// a header that works for only one of the two token kinds would fail exactly
/// for the caller this crate exists to serve.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub struct AuthHeader {
    /// The header name, e.g. `Authorization`.
    pub name: &'static str,
    /// What goes in front of the token, e.g. `"Bearer "` — trailing space
    /// included.
    pub prefix: &'static str,
}

/// The response headers of one page, in the order the forge sent them.
///
/// A `Vec` and not a map: there are a dozen of them, lookup is a linear scan of
/// twelve `eq_ignore_ascii_case` calls, and a map would have to decide what to
/// do with a repeated header. Case-insensitive because HTTP/2 lowercases every
/// name and HTTP/1.1 does not, so `Link` and `link` are the same header arriving
/// from the same forge over two protocol versions.
#[derive(Clone, Debug, Default)]
pub struct Headers(pub(crate) Vec<(String, String)>);

impl Headers {
    /// The first value for `name`, case-insensitively.
    pub fn get(&self, name: &str) -> Option<&str> {
        self.0
            .iter()
            .find(|(k, _)| k.eq_ignore_ascii_case(name))
            .map(|(_, v)| v.as_str())
    }
}

/// What a driver is told after a page came back, so it can say whether to ask
/// for another.
///
/// It carries no body: a driver that wanted to decide "next page" from the body
/// already got the body in [`ForgeDriver::parse_page`] and can count what it
/// returned — which is what `received` is.
#[derive(Debug)]
pub struct PageMeta<'a> {
    /// The 1-based page number that was just fetched.
    pub page: u32,
    /// The page size that was asked for.
    pub per_page: u32,
    /// How many repositories that page actually yielded.
    pub received: usize,
    /// How many repositories have been collected in total, this page included.
    ///
    /// Present because `page * per_page` is NOT the same number: every one of
    /// these forges silently clamps an over-large page size to its own maximum
    /// and serves it, so a driver comparing `page * per_page` against a
    /// `X-Total-Count` would stop the listing early on any instance whose
    /// maximum is below what we asked for. Counting what actually arrived
    /// cannot be wrong about it.
    pub seen_total: usize,
    /// The response headers.
    pub headers: &'a Headers,
}

/// One forge's REST dialect. See the module docs for the split.
pub trait ForgeDriver: Send + Sync {
    /// Which forge this is, for [`Repo::forge`] and for every error.
    fn kind(&self) -> ForgeKind;

    /// The header name and prefix the token goes in. Never the token.
    fn auth(&self) -> AuthHeader;

    /// Path and query for page `page` (1-based), relative to the base URL and
    /// starting with `/`.
    ///
    /// The base URL is a *parameter* of the constructor and not a constant
    /// here, because every one of these three forges is routinely self-hosted —
    /// a Forgejo at `git.example.com` is the common case this crate was asked
    /// for. Hard-coding `codeberg.org` would have made the self-hosted path a
    /// fork of the driver rather than an argument.
    fn page_path(&self, page: u32, per_page: u32) -> String;

    /// Turn one page's JSON body into repositories.
    ///
    /// Errors here are [`ForgeError::Malformed`] and name a *field*, never a
    /// value — see `crate::error`.
    fn parse_page(&self, body: &str) -> Result<Vec<Repo>, ForgeError>;

    /// The next page number, or `None` when the list is complete.
    ///
    /// Returning a number that is not greater than `meta.page` is treated by
    /// the engine as "done", so a driver misreading a header can waste a
    /// request but cannot spin.
    fn next_page(&self, meta: &PageMeta<'_>) -> Option<u32>;

    /// Fixed request headers that are not the credential — GitHub's
    /// `X-GitHub-Api-Version` is the only real user. `&'static` so a driver
    /// cannot smuggle per-call state (such as a token it was handed by some
    /// future refactor) through here.
    fn extra_headers(&self) -> &'static [(&'static str, &'static str)] {
        &[]
    }
}