gunnar-sendpack 1.1.0

git's receive-pack wire format, both ends: the send-pack conversation gitoxide does not have, plus the server-side encoders for the same grammar. Plumbing only, no gunnar types.
Documentation
//! The capability list, which both ends parse and both ends write.
//!
//! In protocol v0 — the only protocol push has — capabilities ride on the
//! **first line** of the reference advertisement and of the command list, after
//! a NUL byte. Every later line carries none. That asymmetry is the single
//! most-repeated bug in the format, and it is why [`split`] exists as one
//! function rather than as a `line.split(0)` written at four call sites.

use crate::error::{Error, Result};

/// A space-separated capability list, kept as raw strings.
///
/// Not a bitfield: `agent=…` and `object-format=…` carry values, unknown
/// capabilities must survive round-tripping for diagnostics, and the set grows
/// with every git release. A server that dropped what it did not recognise
/// would report a peer's capabilities as smaller than they were.
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct Capabilities(Vec<String>);

impl Capabilities {
    /// Parse a space-separated capability list.
    ///
    /// Empty items are dropped. git's own `receive-pack` client emits
    /// `…\0 report-status-v2 …` with a **leading** space, which a naive
    /// `split(' ')` turns into a phantom capability with an empty name.
    pub fn parse(raw: &str) -> Self {
        Capabilities(
            raw.split(' ')
                .map(str::trim)
                .filter(|s| !s.is_empty())
                .map(str::to_owned)
                .collect(),
        )
    }

    /// Parse from bytes, refusing a list that is not UTF-8.
    ///
    /// Reference *names* are bytes (see [`crate::command`]); capability names
    /// are not — every one git defines is ASCII, and a non-ASCII one would be a
    /// peer this grammar has no way to satisfy.
    pub fn parse_bytes(raw: &[u8]) -> Result<Self> {
        let text = std::str::from_utf8(raw)
            .map_err(|_| Error::protocol("the capability list is not UTF-8"))?;
        Ok(Self::parse(text))
    }

    /// Build from an ordered list.
    pub fn from_items<S: Into<String>>(items: impl IntoIterator<Item = S>) -> Self {
        Capabilities(items.into_iter().map(Into::into).collect())
    }

    /// Is this capability present, with or without a value?
    pub fn has(&self, name: &str) -> bool {
        self.0.iter().any(|c| {
            c == name || (c.starts_with(name) && c.as_bytes().get(name.len()) == Some(&b'='))
        })
    }

    /// The value of `name=value`, or `None` when absent or valueless.
    pub fn value(&self, name: &str) -> Option<&str> {
        self.0.iter().find_map(|c| {
            let rest = c.strip_prefix(name)?;
            rest.strip_prefix('=')
        })
    }

    /// Every capability, in the order it appeared.
    pub fn all(&self) -> &[String] {
        &self.0
    }

    /// The wire form: the items joined by single spaces.
    pub fn render(&self) -> String {
        self.0.join(" ")
    }

    /// Is the list empty?
    pub fn is_empty(&self) -> bool {
        self.0.is_empty()
    }
}

/// Split a first line into its content and its capability list.
///
/// Returns `(content, Some(capabilities))` when a NUL is present and
/// `(line, None)` when it is not. **A line with a NUL and nothing after it has
/// an empty capability list, not an absent one**, which is why the second
/// element is `Option<&[u8]>` rather than a slice that could be empty for two
/// different reasons.
pub fn split(line: &[u8]) -> (&[u8], Option<&[u8]>) {
    match line.iter().position(|&b| b == 0) {
        Some(i) => (&line[..i], Some(&line[i + 1..])),
        None => (line, None),
    }
}

/// Append `capabilities` to `line` the way the wire wants it: a NUL, then the
/// list. A no-op for an empty list, because `git` reads a trailing NUL with
/// nothing after it as a capability list it then fails to parse.
pub fn attach(line: &mut Vec<u8>, capabilities: &[String]) {
    if capabilities.is_empty() {
        return;
    }
    line.push(0);
    line.extend_from_slice(capabilities.join(" ").as_bytes());
}