Flint Engine / Guide / API Reference

flint_core/
hash.rs

1//! Content-based hashing for change detection
2
3use serde::{Deserialize, Serialize};
4use sha2::{Digest, Sha256};
5use std::fmt;
6use std::path::Path;
7
8/// A SHA-256 based content hash for detecting changes.
9///
10/// Used to track whether scene files or other content has changed,
11/// enabling efficient incremental updates.
12#[derive(Clone, Copy, Hash, Eq, PartialEq, Serialize, Deserialize)]
13pub struct ContentHash([u8; 32]);
14
15impl ContentHash {
16    /// Compute a hash from bytes
17    pub fn from_bytes(data: &[u8]) -> Self {
18        let mut hasher = Sha256::new();
19        hasher.update(data);
20        let result = hasher.finalize();
21        Self(result.into())
22    }
23
24    /// Compute a hash from a string
25    pub fn from_str(s: &str) -> Self {
26        Self::from_bytes(s.as_bytes())
27    }
28
29    /// Get the hash as a hex string
30    pub fn to_hex(&self) -> String {
31        self.0.iter().map(|b| format!("{:02x}", b)).collect()
32    }
33
34    /// Get the raw bytes
35    pub fn as_bytes(&self) -> &[u8; 32] {
36        &self.0
37    }
38
39    /// Compute a hash from a file's contents
40    pub fn from_file<P: AsRef<Path>>(path: P) -> std::io::Result<Self> {
41        let data = std::fs::read(path)?;
42        Ok(Self::from_bytes(&data))
43    }
44
45    /// Get the hash as a prefixed hex string (e.g., "sha256:abcdef...")
46    pub fn to_prefixed_hex(&self) -> String {
47        format!("sha256:{}", self.to_hex())
48    }
49
50    /// Parse a prefixed hex string back into a ContentHash
51    pub fn from_prefixed_hex(s: &str) -> Option<Self> {
52        let hex = s.strip_prefix("sha256:")?;
53        if hex.len() != 64 {
54            return None;
55        }
56        let mut bytes = [0u8; 32];
57        for i in 0..32 {
58            bytes[i] = u8::from_str_radix(&hex[i * 2..i * 2 + 2], 16).ok()?;
59        }
60        Some(Self(bytes))
61    }
62}
63
64impl fmt::Debug for ContentHash {
65    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
66        write!(f, "ContentHash({})", &self.to_hex()[..16])
67    }
68}
69
70impl fmt::Display for ContentHash {
71    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
72        write!(f, "{}", &self.to_hex()[..16])
73    }
74}
75
76#[cfg(test)]
77mod tests {
78    use super::*;
79
80    #[test]
81    fn test_consistent_hashing() {
82        let h1 = ContentHash::from_str("hello");
83        let h2 = ContentHash::from_str("hello");
84        assert_eq!(h1, h2);
85    }
86
87    #[test]
88    fn test_different_content_different_hash() {
89        let h1 = ContentHash::from_str("hello");
90        let h2 = ContentHash::from_str("world");
91        assert_ne!(h1, h2);
92    }
93
94    #[test]
95    fn test_hex_output() {
96        let h = ContentHash::from_str("hello");
97        let hex = h.to_hex();
98        assert_eq!(hex.len(), 64); // 32 bytes * 2 hex chars
99    }
100
101    #[test]
102    fn test_prefixed_hex_roundtrip() {
103        let h = ContentHash::from_str("test data");
104        let prefixed = h.to_prefixed_hex();
105        assert!(prefixed.starts_with("sha256:"));
106        let parsed = ContentHash::from_prefixed_hex(&prefixed).unwrap();
107        assert_eq!(h, parsed);
108    }
109
110    #[test]
111    fn test_from_prefixed_hex_invalid() {
112        assert!(ContentHash::from_prefixed_hex("md5:abc").is_none());
113        assert!(ContentHash::from_prefixed_hex("sha256:tooshort").is_none());
114    }
115}