use blake3::Hash as Blake3Hash;
use serde::{Deserialize, Serialize};
use std::fmt;
use std::path::PathBuf;
use thiserror::Error;
#[derive(Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize, Deserialize)]
pub struct Hash(pub [u8; 32]);
impl Hash {
pub fn from_data(data: &[u8]) -> Self {
Self(*blake3::hash(data).as_bytes())
}
pub fn from_hex(hex: &str) -> Result<Self, CommonError> {
if hex.len() != 64 {
return Err(CommonError::InvalidHashLength(hex.len()));
}
let mut bytes = [0u8; 32];
hex.as_bytes()
.chunks_exact(2)
.zip(bytes.iter_mut())
.try_for_each(|(chunk, byte)| {
*byte = u8::from_str_radix(
std::str::from_utf8(chunk).map_err(|_| CommonError::InvalidHex)?,
16,
)
.map_err(|_| CommonError::InvalidHex)?;
Ok::<_, CommonError>(())
})?;
Ok(Self(bytes))
}
pub fn to_hex(&self) -> String {
self.0.iter().map(|b| format!("{b:02x}")).collect()
}
pub const ZERO: Self = Self([0u8; 32]);
pub fn as_blake3(&self) -> &Blake3Hash {
unsafe { &*(&self.0 as *const [u8; 32] as *const Blake3Hash) }
}
}
impl fmt::Debug for Hash {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "Hash({})", self.to_hex())
}
}
impl fmt::Display for Hash {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
let hex = self.to_hex();
write!(f, "{}…", &hex[..12])
}
}
impl From<Blake3Hash> for Hash {
fn from(h: Blake3Hash) -> Self {
Self(*h.as_bytes())
}
}
impl From<[u8; 32]> for Hash {
fn from(bytes: [u8; 32]) -> Self {
Self(bytes)
}
}
pub type PatchId = Hash;
#[derive(Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
pub struct BranchName(pub String);
impl BranchName {
pub fn new(name: impl Into<String>) -> Result<Self, CommonError> {
let s = name.into();
if s.is_empty() {
return Err(CommonError::EmptyBranchName);
}
if s.contains('\0') {
return Err(CommonError::InvalidBranchName(s));
}
Ok(Self(s))
}
pub fn as_str(&self) -> &str {
&self.0
}
}
impl fmt::Debug for BranchName {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "Branch({})", self.0)
}
}
impl fmt::Display for BranchName {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "{}", self.0)
}
}
impl AsRef<str> for BranchName {
fn as_ref(&self) -> &str {
&self.0
}
}
#[derive(Error, Debug)]
pub enum CommonError {
#[error("invalid hash length: expected 64 hex chars, got {0}")]
InvalidHashLength(usize),
#[error("invalid hexadecimal string")]
InvalidHex,
#[error("branch name must not be empty")]
EmptyBranchName,
#[error("invalid branch name: {0}")]
InvalidBranchName(String),
#[error("I/O error: {0}")]
Io(#[from] std::io::Error),
#[error("{0}")]
Custom(String),
}
#[derive(Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
pub struct RepoPath(pub String);
impl RepoPath {
pub fn new(path: impl Into<String>) -> Result<Self, CommonError> {
let s = path.into();
if s.is_empty() {
return Err(CommonError::Custom("repo path must not be empty".into()));
}
Ok(Self(s))
}
pub fn as_str(&self) -> &str {
&self.0
}
pub fn to_path_buf(&self) -> PathBuf {
PathBuf::from(&self.0)
}
}
impl fmt::Debug for RepoPath {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "RepoPath({})", self.0)
}
}
impl fmt::Display for RepoPath {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "{}", self.0)
}
}
#[derive(Clone, Copy, PartialEq, Eq, Debug, Serialize, Deserialize)]
pub enum FileStatus {
Added,
Modified,
Deleted,
Clean,
Untracked,
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_hash_from_data_deterministic() {
let data = b"hello, suture";
let h1 = Hash::from_data(data);
let h2 = Hash::from_data(data);
assert_eq!(h1, h2, "Hash must be deterministic");
}
#[test]
fn test_hash_different_data() {
let h1 = Hash::from_data(b"hello");
let h2 = Hash::from_data(b"world");
assert_ne!(h1, h2, "Different data must produce different hashes");
}
#[test]
fn test_hash_hex_roundtrip() {
let data = b"test data for hex roundtrip";
let hash = Hash::from_data(data);
let hex = hash.to_hex();
assert_eq!(hex.len(), 64, "Hex string must be 64 characters");
let parsed = Hash::from_hex(&hex).expect("Valid hex must parse");
assert_eq!(hash, parsed, "Hex roundtrip must preserve hash");
}
#[test]
fn test_hash_from_hex_invalid() {
assert!(Hash::from_hex("too short").is_err());
assert!(Hash::from_hex("not hex!!characters!!64!!").is_err());
}
#[test]
fn test_hash_zero() {
let zero = Hash::ZERO;
assert_eq!(zero.to_hex(), "0".repeat(64));
}
#[test]
fn test_branch_name_valid() {
assert!(BranchName::new("main").is_ok());
assert!(BranchName::new("feature/my-feature").is_ok());
assert!(BranchName::new("fix-123").is_ok());
}
#[test]
fn test_branch_name_invalid() {
assert!(BranchName::new("").is_err());
assert!(BranchName::new("has\0null").is_err());
}
#[test]
fn test_repo_path() {
let path = RepoPath::new("src/main.rs").unwrap();
assert_eq!(path.as_str(), "src/main.rs");
}
}