1#![allow(clippy::collapsible_match)]
2use blake3::Hash as Blake3Hash;
9use serde::{Deserialize, Serialize};
10use std::fmt;
11use std::path::PathBuf;
12use thiserror::Error;
13
14#[derive(Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize, Deserialize)]
24pub struct Hash(pub [u8; 32]);
25
26impl Hash {
27 pub fn from_data(data: &[u8]) -> Self {
29 Self(*blake3::hash(data).as_bytes())
30 }
31
32 pub fn from_hex(hex: &str) -> Result<Self, CommonError> {
34 if hex.len() != 64 {
35 return Err(CommonError::InvalidHashLength(hex.len()));
36 }
37 let mut bytes = [0u8; 32];
38 hex.as_bytes()
39 .chunks_exact(2)
40 .zip(bytes.iter_mut())
41 .try_for_each(|(chunk, byte)| {
42 *byte = u8::from_str_radix(
43 std::str::from_utf8(chunk).map_err(|_| CommonError::InvalidHex)?,
44 16,
45 )
46 .map_err(|_| CommonError::InvalidHex)?;
47 Ok::<_, CommonError>(())
48 })?;
49 Ok(Self(bytes))
50 }
51
52 pub fn to_hex(&self) -> String {
54 const HEX_CHARS: &[u8; 16] = b"0123456789abcdef";
55 let mut s = String::with_capacity(64);
56 for byte in &self.0 {
57 s.push(HEX_CHARS[(byte >> 4) as usize] as char);
58 s.push(HEX_CHARS[(byte & 0x0f) as usize] as char);
59 }
60 s
61 }
62
63 pub const ZERO: Self = Self([0u8; 32]);
65
66 pub fn as_blake3(&self) -> &Blake3Hash {
68 unsafe { &*(&self.0 as *const [u8; 32] as *const Blake3Hash) }
72 }
73}
74
75impl fmt::Debug for Hash {
76 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
77 write!(f, "Hash({})", self.to_hex())
78 }
79}
80
81impl fmt::Display for Hash {
82 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
83 let hex = self.to_hex();
85 write!(f, "{}…", &hex[..12])
86 }
87}
88
89impl From<Blake3Hash> for Hash {
90 fn from(h: Blake3Hash) -> Self {
91 Self(*h.as_bytes())
92 }
93}
94
95impl From<[u8; 32]> for Hash {
96 fn from(bytes: [u8; 32]) -> Self {
97 Self(bytes)
98 }
99}
100
101pub type PatchId = Hash;
103
104#[derive(Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
106pub struct BranchName(pub String);
107
108impl BranchName {
109 pub fn new(name: impl Into<String>) -> Result<Self, CommonError> {
110 let s = name.into();
111 if s.is_empty() {
112 return Err(CommonError::EmptyBranchName);
113 }
114 if s.contains('\0') {
116 return Err(CommonError::InvalidBranchName(s));
117 }
118 Ok(Self(s))
119 }
120
121 pub fn as_str(&self) -> &str {
122 &self.0
123 }
124}
125
126impl fmt::Debug for BranchName {
127 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
128 write!(f, "Branch({})", self.0)
129 }
130}
131
132impl fmt::Display for BranchName {
133 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
134 write!(f, "{}", self.0)
135 }
136}
137
138impl AsRef<str> for BranchName {
139 fn as_ref(&self) -> &str {
140 &self.0
141 }
142}
143
144#[derive(Error, Debug)]
150pub enum CommonError {
151 #[error("invalid hash length: expected 64 hex chars, got {0}")]
152 InvalidHashLength(usize),
153
154 #[error("invalid hexadecimal string")]
155 InvalidHex,
156
157 #[error("branch name must not be empty")]
158 EmptyBranchName,
159
160 #[error("invalid branch name: {0}")]
161 InvalidBranchName(String),
162
163 #[error("I/O error: {0}")]
164 Io(#[from] std::io::Error),
165
166 #[error("{0}")]
167 Custom(String),
168}
169
170#[derive(Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
176pub struct RepoPath(pub String);
177
178impl RepoPath {
179 pub fn new(path: impl Into<String>) -> Result<Self, CommonError> {
180 let s = path.into();
181 if s.is_empty() {
182 return Err(CommonError::Custom("repo path must not be empty".into()));
183 }
184 if s.contains('\0') {
185 return Err(CommonError::Custom(
186 "repo path must not contain null bytes".into(),
187 ));
188 }
189 let p = std::path::Path::new(&s);
190 if p.is_absolute() {
191 return Err(CommonError::Custom(
192 "repo path must be relative, not absolute".into(),
193 ));
194 }
195 if p.components()
196 .any(|c| matches!(c, std::path::Component::ParentDir))
197 {
198 return Err(CommonError::Custom(
199 "repo path must not contain '..' components".into(),
200 ));
201 }
202 Ok(Self(s))
203 }
204
205 pub fn as_str(&self) -> &str {
206 &self.0
207 }
208
209 pub fn to_path_buf(&self) -> PathBuf {
210 PathBuf::from(&self.0)
211 }
212}
213
214impl fmt::Debug for RepoPath {
215 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
216 write!(f, "RepoPath({})", self.0)
217 }
218}
219
220impl fmt::Display for RepoPath {
221 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
222 write!(f, "{}", self.0)
223 }
224}
225
226#[derive(Clone, Copy, PartialEq, Eq, Debug, Serialize, Deserialize)]
232pub enum FileStatus {
233 Added,
235 Modified,
237 Deleted,
239 Clean,
241 Untracked,
243}
244
245#[cfg(test)]
250mod tests {
251 use super::*;
252
253 #[test]
254 fn test_hash_from_data_deterministic() {
255 let data = b"hello, suture";
256 let h1 = Hash::from_data(data);
257 let h2 = Hash::from_data(data);
258 assert_eq!(h1, h2, "Hash must be deterministic");
259 }
260
261 #[test]
262 fn test_hash_different_data() {
263 let h1 = Hash::from_data(b"hello");
264 let h2 = Hash::from_data(b"world");
265 assert_ne!(h1, h2, "Different data must produce different hashes");
266 }
267
268 #[test]
269 fn test_hash_hex_roundtrip() {
270 let data = b"test data for hex roundtrip";
271 let hash = Hash::from_data(data);
272 let hex = hash.to_hex();
273 assert_eq!(hex.len(), 64, "Hex string must be 64 characters");
274
275 let parsed = Hash::from_hex(&hex).expect("Valid hex must parse");
276 assert_eq!(hash, parsed, "Hex roundtrip must preserve hash");
277 }
278
279 #[test]
280 fn test_hash_from_hex_invalid() {
281 assert!(Hash::from_hex("too short").is_err());
282 assert!(Hash::from_hex("not hex!!characters!!64!!").is_err());
283 }
284
285 #[test]
286 fn test_hash_zero() {
287 let zero = Hash::ZERO;
288 assert_eq!(zero.to_hex(), "0".repeat(64));
289 }
290
291 #[test]
292 fn test_branch_name_valid() {
293 assert!(BranchName::new("main").is_ok());
294 assert!(BranchName::new("feature/my-feature").is_ok());
295 assert!(BranchName::new("fix-123").is_ok());
296 }
297
298 #[test]
299 fn test_branch_name_invalid() {
300 assert!(BranchName::new("").is_err());
301 assert!(BranchName::new("has\0null").is_err());
302 }
303
304 #[test]
305 fn test_repo_path() {
306 let path = RepoPath::new("src/main.rs").unwrap();
307 assert_eq!(path.as_str(), "src/main.rs");
308 }
309}