Skip to main content

aegis_memory/
cas.rs

1use anyhow::Result;
2use sha2::{Digest, Sha256};
3use std::collections::HashMap;
4use std::sync::Arc;
5use tokio::sync::RwLock;
6
7/// Content-addressed storage: SHA-256 hash → content blob
8#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
9pub struct CasEntry {
10    pub hash: String, // hex SHA-256
11    pub content: String,
12    pub mime: String,
13    pub created_at: chrono::DateTime<chrono::Utc>,
14}
15
16/// In-memory content-addressed storage keyed by SHA-256 hash.
17pub struct ContentAddressedStorage {
18    store: Arc<RwLock<HashMap<String, CasEntry>>>,
19}
20
21impl ContentAddressedStorage {
22    /// Create a new empty content-addressed store.
23    pub fn new() -> Self {
24        Self {
25            store: Arc::new(RwLock::new(HashMap::new())),
26        }
27    }
28
29    /// Store content; returns its SHA-256 hex hash (idempotent).
30    pub async fn put(&self, content: &str, mime: &str) -> Result<String> {
31        let hash = Self::hash(content);
32        let mut store = self.store.write().await;
33        store.entry(hash.clone()).or_insert_with(|| CasEntry {
34            hash: hash.clone(),
35            content: content.to_string(),
36            mime: mime.to_string(),
37            created_at: chrono::Utc::now(),
38        });
39        Ok(hash)
40    }
41
42    /// Retrieve content by hash.
43    pub async fn get(&self, hash: &str) -> Result<CasEntry> {
44        let store = self.store.read().await;
45        store
46            .get(hash)
47            .cloned()
48            .ok_or_else(|| anyhow::anyhow!("CAS: not found: {}", hash))
49    }
50
51    /// Check whether content with the given hash exists in the store.
52    pub async fn contains(&self, hash: &str) -> bool {
53        self.store.read().await.contains_key(hash)
54    }
55
56    /// Compute the SHA-256 hex digest of `content`.
57    pub fn hash(content: &str) -> String {
58        let mut hasher = Sha256::new();
59        hasher.update(content.as_bytes());
60        hex::encode(hasher.finalize())
61    }
62}
63
64impl Default for ContentAddressedStorage {
65    fn default() -> Self {
66        Self::new()
67    }
68}
69
70#[cfg(test)]
71mod tests {
72    use super::*;
73
74    #[tokio::test]
75    async fn cas_put_and_get() {
76        let cas = ContentAddressedStorage::new();
77        let hash = cas.put("hello world", "text/plain").await.unwrap();
78        assert!(!hash.is_empty());
79        let entry = cas.get(&hash).await.unwrap();
80        assert_eq!(entry.content, "hello world");
81        assert_eq!(entry.mime, "text/plain");
82    }
83
84    #[tokio::test]
85    async fn cas_idempotent_put() {
86        let cas = ContentAddressedStorage::new();
87        let h1 = cas.put("same content", "text/plain").await.unwrap();
88        let h2 = cas.put("same content", "text/plain").await.unwrap();
89        assert_eq!(h1, h2, "same content should produce same hash");
90    }
91
92    #[tokio::test]
93    async fn cas_different_content_different_hash() {
94        let cas = ContentAddressedStorage::new();
95        let h1 = cas.put("content A", "text/plain").await.unwrap();
96        let h2 = cas.put("content B", "text/plain").await.unwrap();
97        assert_ne!(h1, h2);
98    }
99
100    #[tokio::test]
101    async fn cas_contains() {
102        let cas = ContentAddressedStorage::new();
103        let hash = cas.put("test", "text/plain").await.unwrap();
104        assert!(cas.contains(&hash).await);
105        assert!(!cas.contains("nonexistent").await);
106    }
107
108    #[tokio::test]
109    async fn cas_get_not_found() {
110        let cas = ContentAddressedStorage::new();
111        let result = cas.get("deadbeef").await;
112        assert!(result.is_err());
113    }
114
115    #[test]
116    fn cas_hash_deterministic() {
117        let h1 = ContentAddressedStorage::hash("test input");
118        let h2 = ContentAddressedStorage::hash("test input");
119        assert_eq!(h1, h2);
120        assert_eq!(h1.len(), 64); // SHA-256 hex = 64 chars
121    }
122
123    #[test]
124    fn cas_default() {
125        let cas = ContentAddressedStorage::default();
126        // Just verify it constructs without panic
127        drop(cas);
128    }
129}