Skip to main content

suture_common/
lib.rs

1#![allow(clippy::collapsible_match)]
2//! Suture Common — Shared types, errors, and utilities used across all crates.
3//!
4//! This crate defines the foundational data structures that every other crate
5//! depends on: hashes, patch IDs, branch names, error types, and serialization
6//! helpers.
7
8use blake3::Hash as Blake3Hash;
9use serde::{Deserialize, Serialize};
10use std::fmt;
11use std::path::PathBuf;
12use thiserror::Error;
13
14// =============================================================================
15// Core Identifier Types
16// =============================================================================
17
18/// A BLAKE3 content hash (32 bytes / 256 bits).
19///
20/// Used as the canonical identifier for blobs in the Content Addressable Storage
21/// and for patch identifiers. BLAKE3 provides SIMD-accelerated hashing with a
22/// 2^128 collision resistance bound.
23#[derive(Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize, Deserialize)]
24pub struct Hash(pub [u8; 32]);
25
26impl Hash {
27    /// Compute the BLAKE3 hash of arbitrary data.
28    pub fn from_data(data: &[u8]) -> Self {
29        Self(*blake3::hash(data).as_bytes())
30    }
31
32    /// Parse a hash from a 64-character hex string.
33    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    /// Convert to a 64-character lowercase hex string.
53    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    /// The zero hash (all zeros). Used as a sentinel value.
64    pub const ZERO: Self = Self([0u8; 32]);
65
66    /// Convert to a blake3::Hash reference.
67    pub fn as_blake3(&self) -> &Blake3Hash {
68        // SAFETY: blake3::Hash is a newtype around [u8; 32] with repr(transparent),
69        // so the pointer cast is sound. The layout is verified at compile time
70        // by the repr(transparent) attribute.
71        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        // Display short form: first 12 hex chars
84        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
101/// A patch identifier — currently identical to a BLAKE3 hash of the patch content.
102pub type PatchId = Hash;
103
104/// A branch name. Must be non-empty and contain only valid UTF-8.
105#[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        // Validate: no null bytes
115        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// =============================================================================
145// Error Types
146// =============================================================================
147
148/// Top-level error type for the suture-common crate.
149#[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// =============================================================================
171// Repository Path Types
172// =============================================================================
173
174/// The path to a file within a Suture repository (relative to repo root).
175#[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// =============================================================================
227// Status Type for Working Set Files
228// =============================================================================
229
230/// Status of a file in the working set.
231#[derive(Clone, Copy, PartialEq, Eq, Debug, Serialize, Deserialize)]
232pub enum FileStatus {
233    /// File is added but not yet committed.
234    Added,
235    /// File is modified relative to the last commit.
236    Modified,
237    /// File is deleted from the working tree.
238    Deleted,
239    /// File is unmodified (tracked but clean).
240    Clean,
241    /// File is not tracked by Suture.
242    Untracked,
243}
244
245// =============================================================================
246// Tests
247// =============================================================================
248
249#[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}