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 or control characters
115        if s.contains('\0') || s.chars().any(|c| c.is_control() && c != '\n' && c != '\t') {
116            return Err(CommonError::InvalidBranchName(s));
117        }
118        // Reject names that could be confused with paths or CLI flags
119        if s.starts_with('-') || s.starts_with('.') || s.contains("..") {
120            return Err(CommonError::InvalidBranchName(s));
121        }
122        // Reject names with whitespace or problematic characters
123        // (mirrors git's check_refname_format restrictions)
124        if s.contains(['~', '^', ':', '\\', ' ']) || s.ends_with(".lock") {
125            return Err(CommonError::InvalidBranchName(s));
126        }
127        // Reject consecutive dots
128        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// =============================================================================
158// Error Types
159// =============================================================================
160
161/// Top-level error type for the suture-common crate.
162#[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// =============================================================================
184// Repository Path Types
185// =============================================================================
186
187/// The path to a file within a Suture repository (relative to repo root).
188#[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// =============================================================================
240// Status Type for Working Set Files
241// =============================================================================
242
243/// Status of a file in the working set.
244#[derive(Clone, Copy, PartialEq, Eq, Debug, Serialize, Deserialize)]
245pub enum FileStatus {
246    /// File is added but not yet committed.
247    Added,
248    /// File is modified relative to the last commit.
249    Modified,
250    /// File is deleted from the working tree.
251    Deleted,
252    /// File is unmodified (tracked but clean).
253    Clean,
254    /// File is not tracked by Suture.
255    Untracked,
256}
257
258// =============================================================================
259// Tests
260// =============================================================================
261
262#[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}