thin-fetch 0.1.0

Low-level Git crate to fetch objects with high granularity
Documentation
use std::str::FromStr;
use std::{collections::HashMap, io::Read};

use ureq::Body;
use ureq::http::Response;
use ureq::{BodyReader, http::StatusCode};

use crate::error::{Error, WithError as _, bail};
use crate::object::Object;
use crate::parcel::{Parcel, ParcelDecoder};
use crate::{Execute as _, HashKind, LsRefs, Reference};

/// Remote Git repository.
///
/// # Example
///
/// ```rust
/// use thin_fetch::{HashKind, Repo};
///
/// let url = "https://git.ddd.rip/ptrcnull/thin-fetch.git";
/// let repo = Repo::new(url, HashKind::Sha1);
///
/// // perform an operation on the repo:
/// let refs = repo.list_refs();
/// ```
pub struct Repo {
    url: String,
    hash_kind: HashKind,
    object_cache: HashMap<String, Object>,
}

impl Repo {
    /// Create new repo with a given URL, discovering the hash kind from the server.
    ///
    /// # Errors
    ///
    /// Returns an error when the remote repo does not support v2 protocol,
    /// or does not send `object-format` in capabilities.
    pub fn new_discover<S: Into<String>>(url: S) -> Result<Self, Error> {
        let mut repo = Self {
            url: url.into(),
            hash_kind: HashKind::Sha1,
            object_cache: HashMap::new(),
        };

        let res = repo.discover().with_err_msg("could not discover")?;

        if let Some(format) = &res.object_format {
            repo.hash_kind = HashKind::from_str(format)?;
        } else {
            bail!("remote repo did not send object-format");
        }

        Ok(repo)
    }

    /// Create new repo with a given URL and hash kind.
    ///
    /// Assumes that the remote repo does support v2 protocol.
    pub fn new<S: Into<String>>(url: S, hash_kind: HashKind) -> Self {
        Self {
            url: url.into(),
            hash_kind,
            object_cache: HashMap::new(),
        }
    }

    /// Call the repo with a body that serializes into [`Parcel`](crate::Parcel).
    ///
    /// # Errors
    ///
    /// Returns an error when the HTTP request fails,
    /// or the parcel cannot be parsed.
    pub fn call<C: Into<Parcel>>(&self, cmd: C) -> Result<Parcel, Error> {
        let dec = self.stream(cmd)?;
        dec.collect()
    }

    /// Call the repo with a body that serializes into [`Parcel`](crate::Parcel).
    ///
    /// # Errors
    ///
    /// Returns an error when the HTTP request fails,
    /// or the parcel cannot be parsed.
    pub fn stream<C: Into<Parcel>>(
        &self,
        cmd: C,
    ) -> Result<ParcelDecoder<BodyReader<'static>>, Error> {
        let body: Parcel = cmd.into();

        let mut data: Vec<u8> = vec![];
        body.write_to(&mut data)
            .with_err_msg("could not serialize parcel")?;

        let agent = ureq::Agent::config_builder()
            .http_status_as_error(false)
            .build()
            .new_agent();

        let res = agent
            .post(format!("{}/git-upload-pack", self.url))
            .header("Git-Protocol", "version=2")
            .header("Content-Type", "application/x-git-upload-pack-request")
            .header("Accept", "application/x-git-upload-pack-result")
            .send(data)
            .with_err_msg("could not send HTTP request")?;

        parse_response(res)
    }

    /// List all remote references.
    ///
    /// # Errors
    ///
    /// Returns an error when the command fails to execute.
    pub fn list_refs(&self) -> Result<Vec<Reference>, Error> {
        let cmd = LsRefs {
            peel: true,
            symrefs: true,
            unborn: true,
            ref_prefix: None,
            hash_kind: self.hash_kind,
        };

        cmd.execute(self)
    }

    /// Get value of single remote ref.
    ///
    /// # Errors
    ///
    /// Returns an error when the command fails to execute.
    pub fn get_ref(&self, needle: &str) -> Result<Option<Reference>, Error> {
        let refs = self
            .list_refs_prefix(needle)
            .with_err_msg("could not get refs")?;

        Ok(refs.into_iter().find(|r| r.name == needle))
    }

    /// List all remote references, filtering by a given prefix.
    ///
    /// # Errors
    ///
    /// Returns an error when the command fails to execute.
    pub fn list_refs_prefix<P: Into<String>>(&self, prefix: P) -> Result<Vec<Reference>, Error> {
        let cmd = LsRefs {
            peel: true,
            symrefs: true,
            unborn: true,
            ref_prefix: Some(prefix.into()),
            hash_kind: self.hash_kind,
        };

        cmd.execute(self)
    }

    pub fn objects(&self) -> impl Iterator<Item = &Object> {
        self.object_cache.values()
    }
}

mod discover;
mod fetch;
mod get;

fn parse_response(res: Response<Body>) -> Result<ParcelDecoder<BodyReader<'static>>, Error> {
    let status = res.status();

    if let Some(len) = res.headers().get("content-length")
        && len == "0"
    {
        bail!("server returned empty body (status {})", status);
    }

    let body = res.into_body();
    let mut reader = body.into_reader();

    if status != StatusCode::OK {
        let mut body = Vec::new();
        reader
            .read_to_end(&mut body)
            .with_err_msg("could not read HTTP response")?;

        if body.is_empty() {
            bail!("HTTP remote error: {status}");
        }

        if let Ok(err) = str::from_utf8(&body) {
            bail!("HTTP remote error: {status}: {err}")
        }

        bail!("HTTP remote error: {status} ({} bytes)", body.len());
    }

    Ok(ParcelDecoder::new(reader))
}