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