Skip to main content

suture_common/
lib.rs

1// SPDX-License-Identifier: MIT OR Apache-2.0
2#![allow(clippy::collapsible_match)]
3//! Suture Common — Shared types, errors, and utilities used across all crates.
4//!
5//! This crate defines the foundational data structures that every other crate
6//! depends on: hashes, patch IDs, branch names, error types, and serialization
7//! helpers.
8
9use blake3::Hash as Blake3Hash;
10use serde::{Deserialize, Serialize};
11use std::fmt;
12use std::path::PathBuf;
13use thiserror::Error;
14
15// =============================================================================
16// Core Identifier Types
17// =============================================================================
18
19/// A BLAKE3 content hash (32 bytes / 256 bits).
20///
21/// Used as the canonical identifier for blobs in the Content Addressable Storage
22/// and for patch identifiers. BLAKE3 provides SIMD-accelerated hashing with a
23/// 2^128 collision resistance bound.
24#[derive(Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize, Deserialize)]
25pub struct Hash(pub [u8; 32]);
26
27impl Hash {
28    /// Compute the BLAKE3 hash of arbitrary data.
29    pub fn from_data(data: &[u8]) -> Self {
30        Self(*blake3::hash(data).as_bytes())
31    }
32
33    /// Parse a hash from a 64-character hex string.
34    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    /// Convert to a 64-character lowercase hex string.
54    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    /// The zero hash (all zeros). Used as a sentinel value.
65    pub const ZERO: Self = Self([0u8; 32]);
66
67    /// Convert to a blake3::Hash reference.
68    pub fn as_blake3(&self) -> &Blake3Hash {
69        // SAFETY: blake3::Hash is a newtype around [u8; 32] with repr(transparent),
70        // so the pointer cast is sound. The layout is verified at compile time
71        // by the repr(transparent) attribute.
72        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        // Display short form: first 12 hex chars
85        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
102/// A patch identifier — currently identical to a BLAKE3 hash of the patch content.
103pub type PatchId = Hash;
104
105/// A branch name. Must be non-empty and contain only valid UTF-8.
106#[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        // Validate: no null bytes or control characters
116        if s.contains('\0') || s.chars().any(|c| c.is_control() && c != '\n' && c != '\t') {
117            return Err(CommonError::InvalidBranchName(s));
118        }
119        // Reject names that could be confused with paths or CLI flags
120        if s.starts_with('-') || s.starts_with('.') || s.contains("..") {
121            return Err(CommonError::InvalidBranchName(s));
122        }
123        // Reject names with whitespace or problematic characters
124        // (mirrors git's check_refname_format restrictions)
125        if s.contains(['~', '^', ':', '\\', ' ']) || s.ends_with(".lock") {
126            return Err(CommonError::InvalidBranchName(s));
127        }
128        // Reject consecutive dots
129        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// =============================================================================
159// Error Types
160// =============================================================================
161
162/// Top-level error type for the suture-common crate.
163#[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// =============================================================================
196// Repository Path Types
197// =============================================================================
198
199/// The path to a file within a Suture repository (relative to repo root).
200#[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// =============================================================================
246// Status Type for Working Set Files
247// =============================================================================
248
249/// Status of a file in the working set.
250#[derive(Clone, Copy, PartialEq, Eq, Debug, Serialize, Deserialize)]
251pub enum FileStatus {
252    /// File is added but not yet committed.
253    Added,
254    /// File is modified relative to the last commit.
255    Modified,
256    /// File is deleted from the working tree.
257    Deleted,
258    /// File is unmodified (tracked but clean).
259    Clean,
260    /// File is not tracked by Suture.
261    Untracked,
262}
263
264// =============================================================================
265// Tests
266// =============================================================================
267
268#[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}