weavatrix-git 0.3.1

Fast, bounded, evidence-carrying Git reader with an optional read-only MCP server
Documentation
use crate::{GitError, Result};
use std::{fmt, str::FromStr};

#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
pub enum HashKind {
    Sha1,
    Sha256,
}

impl HashKind {
    #[must_use]
    pub const fn bytes(self) -> usize {
        match self {
            Self::Sha1 => 20,
            Self::Sha256 => 32,
        }
    }

    #[must_use]
    pub const fn hex_len(self) -> usize {
        self.bytes() * 2
    }
}

#[derive(Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
pub struct ObjectId {
    bytes: [u8; 32],
    len: u8,
}

impl ObjectId {
    pub fn from_bytes(bytes: &[u8]) -> Result<Self> {
        if bytes.len() != HashKind::Sha1.bytes() && bytes.len() != HashKind::Sha256.bytes() {
            return Err(GitError::InvalidFormat(format!(
                "object id has {} bytes",
                bytes.len()
            )));
        }
        let mut value = [0_u8; 32];
        value[..bytes.len()].copy_from_slice(bytes);
        Ok(Self {
            bytes: value,
            len: u8::try_from(bytes.len()).expect("supported hashes fit in u8"),
        })
    }

    pub fn from_hex_for(hex: &str, kind: HashKind) -> Result<Self> {
        if hex.len() != kind.hex_len() {
            return Err(GitError::InvalidFormat(format!(
                "expected {} hexadecimal object-id characters",
                kind.hex_len()
            )));
        }
        let mut bytes = [0_u8; 32];
        for (index, pair) in hex.as_bytes().chunks_exact(2).enumerate() {
            bytes[index] = decode_hex(pair[0])? << 4 | decode_hex(pair[1])?;
        }
        Ok(Self {
            bytes,
            len: u8::try_from(kind.bytes()).expect("supported hashes fit in u8"),
        })
    }

    #[must_use]
    pub const fn kind(self) -> HashKind {
        if self.len == 20 {
            HashKind::Sha1
        } else {
            HashKind::Sha256
        }
    }

    #[must_use]
    pub fn as_bytes(&self) -> &[u8] {
        &self.bytes[..usize::from(self.len)]
    }

    #[must_use]
    pub fn to_hex(self) -> String {
        const HEX: &[u8; 16] = b"0123456789abcdef";
        let mut output = String::with_capacity(usize::from(self.len) * 2);
        for byte in self.as_bytes() {
            output.push(char::from(HEX[usize::from(byte >> 4)]));
            output.push(char::from(HEX[usize::from(byte & 0x0f)]));
        }
        output
    }
}

impl fmt::Debug for ObjectId {
    fn fmt(&self, output: &mut fmt::Formatter<'_>) -> fmt::Result {
        output
            .debug_tuple("ObjectId")
            .field(&self.to_hex())
            .finish()
    }
}

impl fmt::Display for ObjectId {
    fn fmt(&self, output: &mut fmt::Formatter<'_>) -> fmt::Result {
        output.write_str(&self.to_hex())
    }
}

impl FromStr for ObjectId {
    type Err = GitError;

    fn from_str(value: &str) -> Result<Self> {
        match value.len() {
            40 => Self::from_hex_for(value, HashKind::Sha1),
            64 => Self::from_hex_for(value, HashKind::Sha256),
            length => Err(GitError::InvalidFormat(format!(
                "unsupported object-id length {length}"
            ))),
        }
    }
}

fn decode_hex(value: u8) -> Result<u8> {
    match value {
        b'0'..=b'9' => Ok(value - b'0'),
        b'a'..=b'f' => Ok(value - b'a' + 10),
        b'A'..=b'F' => Ok(value - b'A' + 10),
        _ => Err(GitError::InvalidFormat(
            "object id contains non-hexadecimal characters".to_owned(),
        )),
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn parses_sha1_and_sha256_roundtrip() {
        let sha256 = "ab".repeat(32);
        for value in ["0123456789abcdef0123456789abcdef01234567", sha256.as_str()] {
            let id: ObjectId = value.parse().unwrap();
            assert_eq!(id.to_string(), value);
        }
    }

    #[test]
    fn rejects_invalid_object_ids() {
        assert!("abc".parse::<ObjectId>().is_err());
        assert!(ObjectId::from_hex_for(&"z".repeat(40), HashKind::Sha1).is_err());
        assert!(ObjectId::from_bytes(&[0; 21]).is_err());
    }

    #[test]
    fn exposes_hash_kind_bytes_and_debug_value() {
        let sha1 = ObjectId::from_bytes(&[0xab; 20]).unwrap();
        let sha256 = ObjectId::from_hex_for(&"CD".repeat(32), HashKind::Sha256).unwrap();
        assert_eq!(sha1.kind(), HashKind::Sha1);
        assert_eq!(sha1.as_bytes(), &[0xab; 20]);
        assert!(format!("{sha1:?}").contains("abab"));
        assert_eq!(sha256.kind(), HashKind::Sha256);
        assert_eq!(sha256.to_hex(), "cd".repeat(32));
    }
}