Skip to main content

suture_common/
lib.rs

1//! Suture Common — Shared types, errors, and utilities used across all crates.
2//!
3//! This crate defines the foundational data structures that every other crate
4//! depends on: hashes, patch IDs, branch names, error types, and serialization
5//! helpers.
6
7use blake3::Hash as Blake3Hash;
8use serde::{Deserialize, Serialize};
9use std::fmt;
10use std::path::PathBuf;
11use thiserror::Error;
12
13// =============================================================================
14// Core Identifier Types
15// =============================================================================
16
17/// A BLAKE3 content hash (32 bytes / 256 bits).
18///
19/// Used as the canonical identifier for blobs in the Content Addressable Storage
20/// and for patch identifiers. BLAKE3 provides SIMD-accelerated hashing with a
21/// 2^128 collision resistance bound.
22#[derive(Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize, Deserialize)]
23pub struct Hash(pub [u8; 32]);
24
25impl Hash {
26    /// Compute the BLAKE3 hash of arbitrary data.
27    pub fn from_data(data: &[u8]) -> Self {
28        Self(*blake3::hash(data).as_bytes())
29    }
30
31    /// Parse a hash from a 64-character hex string.
32    pub fn from_hex(hex: &str) -> Result<Self, CommonError> {
33        if hex.len() != 64 {
34            return Err(CommonError::InvalidHashLength(hex.len()));
35        }
36        let mut bytes = [0u8; 32];
37        hex.as_bytes()
38            .chunks_exact(2)
39            .zip(bytes.iter_mut())
40            .try_for_each(|(chunk, byte)| {
41                *byte = u8::from_str_radix(
42                    std::str::from_utf8(chunk).map_err(|_| CommonError::InvalidHex)?,
43                    16,
44                )
45                .map_err(|_| CommonError::InvalidHex)?;
46                Ok::<_, CommonError>(())
47            })?;
48        Ok(Self(bytes))
49    }
50
51    /// Convert to a 64-character lowercase hex string.
52    pub fn to_hex(&self) -> String {
53        self.0.iter().map(|b| format!("{b:02x}")).collect()
54    }
55
56    /// The zero hash (all zeros). Used as a sentinel value.
57    pub const ZERO: Self = Self([0u8; 32]);
58
59    /// Convert to a blake3::Hash reference.
60    pub fn as_blake3(&self) -> &Blake3Hash {
61        // SAFETY: blake3::Hash is a newtype around [u8; 32] with repr(transparent),
62        // so the pointer cast is sound. The layout is verified at compile time
63        // by the repr(transparent) attribute.
64        unsafe { &*(&self.0 as *const [u8; 32] as *const Blake3Hash) }
65    }
66}
67
68impl fmt::Debug for Hash {
69    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
70        write!(f, "Hash({})", self.to_hex())
71    }
72}
73
74impl fmt::Display for Hash {
75    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
76        // Display short form: first 12 hex chars
77        let hex = self.to_hex();
78        write!(f, "{}…", &hex[..12])
79    }
80}
81
82impl From<Blake3Hash> for Hash {
83    fn from(h: Blake3Hash) -> Self {
84        Self(*h.as_bytes())
85    }
86}
87
88impl From<[u8; 32]> for Hash {
89    fn from(bytes: [u8; 32]) -> Self {
90        Self(bytes)
91    }
92}
93
94/// A patch identifier — currently identical to a BLAKE3 hash of the patch content.
95pub type PatchId = Hash;
96
97/// A branch name. Must be non-empty and contain only valid UTF-8.
98#[derive(Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
99pub struct BranchName(pub String);
100
101impl BranchName {
102    pub fn new(name: impl Into<String>) -> Result<Self, CommonError> {
103        let s = name.into();
104        if s.is_empty() {
105            return Err(CommonError::EmptyBranchName);
106        }
107        // Validate: no null bytes
108        if s.contains('\0') {
109            return Err(CommonError::InvalidBranchName(s));
110        }
111        Ok(Self(s))
112    }
113
114    pub fn as_str(&self) -> &str {
115        &self.0
116    }
117}
118
119impl fmt::Debug for BranchName {
120    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
121        write!(f, "Branch({})", self.0)
122    }
123}
124
125impl fmt::Display for BranchName {
126    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
127        write!(f, "{}", self.0)
128    }
129}
130
131impl AsRef<str> for BranchName {
132    fn as_ref(&self) -> &str {
133        &self.0
134    }
135}
136
137// =============================================================================
138// Error Types
139// =============================================================================
140
141/// Top-level error type for the suture-common crate.
142#[derive(Error, Debug)]
143pub enum CommonError {
144    #[error("invalid hash length: expected 64 hex chars, got {0}")]
145    InvalidHashLength(usize),
146
147    #[error("invalid hexadecimal string")]
148    InvalidHex,
149
150    #[error("branch name must not be empty")]
151    EmptyBranchName,
152
153    #[error("invalid branch name: {0}")]
154    InvalidBranchName(String),
155
156    #[error("I/O error: {0}")]
157    Io(#[from] std::io::Error),
158
159    #[error("{0}")]
160    Custom(String),
161}
162
163// =============================================================================
164// Repository Path Types
165// =============================================================================
166
167/// The path to a file within a Suture repository (relative to repo root).
168#[derive(Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
169pub struct RepoPath(pub String);
170
171impl RepoPath {
172    pub fn new(path: impl Into<String>) -> Result<Self, CommonError> {
173        let s = path.into();
174        if s.is_empty() {
175            return Err(CommonError::Custom("repo path must not be empty".into()));
176        }
177        Ok(Self(s))
178    }
179
180    pub fn as_str(&self) -> &str {
181        &self.0
182    }
183
184    pub fn to_path_buf(&self) -> PathBuf {
185        PathBuf::from(&self.0)
186    }
187}
188
189impl fmt::Debug for RepoPath {
190    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
191        write!(f, "RepoPath({})", self.0)
192    }
193}
194
195impl fmt::Display for RepoPath {
196    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
197        write!(f, "{}", self.0)
198    }
199}
200
201// =============================================================================
202// Status Type for Working Set Files
203// =============================================================================
204
205/// Status of a file in the working set.
206#[derive(Clone, Copy, PartialEq, Eq, Debug, Serialize, Deserialize)]
207pub enum FileStatus {
208    /// File is added but not yet committed.
209    Added,
210    /// File is modified relative to the last commit.
211    Modified,
212    /// File is deleted from the working tree.
213    Deleted,
214    /// File is unmodified (tracked but clean).
215    Clean,
216    /// File is not tracked by Suture.
217    Untracked,
218}
219
220// =============================================================================
221// Tests
222// =============================================================================
223
224#[cfg(test)]
225mod tests {
226    use super::*;
227
228    #[test]
229    fn test_hash_from_data_deterministic() {
230        let data = b"hello, suture";
231        let h1 = Hash::from_data(data);
232        let h2 = Hash::from_data(data);
233        assert_eq!(h1, h2, "Hash must be deterministic");
234    }
235
236    #[test]
237    fn test_hash_different_data() {
238        let h1 = Hash::from_data(b"hello");
239        let h2 = Hash::from_data(b"world");
240        assert_ne!(h1, h2, "Different data must produce different hashes");
241    }
242
243    #[test]
244    fn test_hash_hex_roundtrip() {
245        let data = b"test data for hex roundtrip";
246        let hash = Hash::from_data(data);
247        let hex = hash.to_hex();
248        assert_eq!(hex.len(), 64, "Hex string must be 64 characters");
249
250        let parsed = Hash::from_hex(&hex).expect("Valid hex must parse");
251        assert_eq!(hash, parsed, "Hex roundtrip must preserve hash");
252    }
253
254    #[test]
255    fn test_hash_from_hex_invalid() {
256        assert!(Hash::from_hex("too short").is_err());
257        assert!(Hash::from_hex("not hex!!characters!!64!!").is_err());
258    }
259
260    #[test]
261    fn test_hash_zero() {
262        let zero = Hash::ZERO;
263        assert_eq!(zero.to_hex(), "0".repeat(64));
264    }
265
266    #[test]
267    fn test_branch_name_valid() {
268        assert!(BranchName::new("main").is_ok());
269        assert!(BranchName::new("feature/my-feature").is_ok());
270        assert!(BranchName::new("fix-123").is_ok());
271    }
272
273    #[test]
274    fn test_branch_name_invalid() {
275        assert!(BranchName::new("").is_err());
276        assert!(BranchName::new("has\0null").is_err());
277    }
278
279    #[test]
280    fn test_repo_path() {
281        let path = RepoPath::new("src/main.rs").unwrap();
282        assert_eq!(path.as_str(), "src/main.rs");
283    }
284}