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') || s.chars().any(|c| c.is_control() && c != '\n' && c != '\t') {
116 return Err(CommonError::InvalidBranchName(s));
117 }
118 if s.starts_with('-') || s.starts_with('.') || s.contains("..") {
120 return Err(CommonError::InvalidBranchName(s));
121 }
122 if s.contains(['~', '^', ':', '\\', ' ']) || s.ends_with(".lock") {
125 return Err(CommonError::InvalidBranchName(s));
126 }
127 if s.contains("...") {
129 return Err(CommonError::InvalidBranchName(s));
130 }
131 Ok(Self(s))
132 }
133
134 pub fn as_str(&self) -> &str {
135 &self.0
136 }
137}
138
139impl fmt::Debug for BranchName {
140 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
141 write!(f, "Branch({})", self.0)
142 }
143}
144
145impl fmt::Display for BranchName {
146 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
147 write!(f, "{}", self.0)
148 }
149}
150
151impl AsRef<str> for BranchName {
152 fn as_ref(&self) -> &str {
153 &self.0
154 }
155}
156
157#[derive(Error, Debug)]
163pub enum CommonError {
164 #[error("invalid hash length: expected 64 hex chars, got {0}")]
165 InvalidHashLength(usize),
166
167 #[error("invalid hexadecimal string")]
168 InvalidHex,
169
170 #[error("branch name must not be empty")]
171 EmptyBranchName,
172
173 #[error("invalid branch name: {0}")]
174 InvalidBranchName(String),
175
176 #[error("I/O error: {0}")]
177 Io(#[from] std::io::Error),
178
179 #[error("{0}")]
180 Custom(String),
181}
182
183#[derive(Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
189pub struct RepoPath(pub String);
190
191impl RepoPath {
192 pub fn new(path: impl Into<String>) -> Result<Self, CommonError> {
193 let s = path.into();
194 if s.is_empty() {
195 return Err(CommonError::Custom("repo path must not be empty".into()));
196 }
197 if s.contains('\0') {
198 return Err(CommonError::Custom(
199 "repo path must not contain null bytes".into(),
200 ));
201 }
202 let p = std::path::Path::new(&s);
203 if p.is_absolute() {
204 return Err(CommonError::Custom(
205 "repo path must be relative, not absolute".into(),
206 ));
207 }
208 if p.components()
209 .any(|c| matches!(c, std::path::Component::ParentDir))
210 {
211 return Err(CommonError::Custom(
212 "repo path must not contain '..' components".into(),
213 ));
214 }
215 Ok(Self(s))
216 }
217
218 pub fn as_str(&self) -> &str {
219 &self.0
220 }
221
222 pub fn to_path_buf(&self) -> PathBuf {
223 PathBuf::from(&self.0)
224 }
225}
226
227impl fmt::Debug for RepoPath {
228 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
229 write!(f, "RepoPath({})", self.0)
230 }
231}
232
233impl fmt::Display for RepoPath {
234 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
235 write!(f, "{}", self.0)
236 }
237}
238
239#[derive(Clone, Copy, PartialEq, Eq, Debug, Serialize, Deserialize)]
245pub enum FileStatus {
246 Added,
248 Modified,
250 Deleted,
252 Clean,
254 Untracked,
256}
257
258#[cfg(test)]
263mod tests {
264 use super::*;
265
266 #[test]
267 fn test_hash_from_data_deterministic() {
268 let data = b"hello, suture";
269 let h1 = Hash::from_data(data);
270 let h2 = Hash::from_data(data);
271 assert_eq!(h1, h2, "Hash must be deterministic");
272 }
273
274 #[test]
275 fn test_hash_different_data() {
276 let h1 = Hash::from_data(b"hello");
277 let h2 = Hash::from_data(b"world");
278 assert_ne!(h1, h2, "Different data must produce different hashes");
279 }
280
281 #[test]
282 fn test_hash_hex_roundtrip() {
283 let data = b"test data for hex roundtrip";
284 let hash = Hash::from_data(data);
285 let hex = hash.to_hex();
286 assert_eq!(hex.len(), 64, "Hex string must be 64 characters");
287
288 let parsed = Hash::from_hex(&hex).expect("Valid hex must parse");
289 assert_eq!(hash, parsed, "Hex roundtrip must preserve hash");
290 }
291
292 #[test]
293 fn test_hash_from_hex_invalid() {
294 assert!(Hash::from_hex("too short").is_err());
295 assert!(Hash::from_hex("not hex!!characters!!64!!").is_err());
296 }
297
298 #[test]
299 fn test_hash_zero() {
300 let zero = Hash::ZERO;
301 assert_eq!(zero.to_hex(), "0".repeat(64));
302 }
303
304 #[test]
305 fn test_branch_name_valid() {
306 assert!(BranchName::new("main").is_ok());
307 assert!(BranchName::new("feature/my-feature").is_ok());
308 assert!(BranchName::new("fix-123").is_ok());
309 }
310
311 #[test]
312 fn test_branch_name_invalid() {
313 assert!(BranchName::new("").is_err());
314 assert!(BranchName::new("has\0null").is_err());
315 }
316
317 #[test]
318 fn test_repo_path() {
319 let path = RepoPath::new("src/main.rs").unwrap();
320 assert_eq!(path.as_str(), "src/main.rs");
321 }
322}