1use anyhow::Result;
2use sha2::{Digest, Sha256};
3use std::collections::HashMap;
4use std::sync::Arc;
5use tokio::sync::RwLock;
6
7#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
9pub struct CasEntry {
10 pub hash: String, pub content: String,
12 pub mime: String,
13 pub created_at: chrono::DateTime<chrono::Utc>,
14}
15
16pub struct ContentAddressedStorage {
18 store: Arc<RwLock<HashMap<String, CasEntry>>>,
19}
20
21impl ContentAddressedStorage {
22 pub fn new() -> Self {
24 Self {
25 store: Arc::new(RwLock::new(HashMap::new())),
26 }
27 }
28
29 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 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 pub async fn contains(&self, hash: &str) -> bool {
53 self.store.read().await.contains_key(hash)
54 }
55
56 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); }
122
123 #[test]
124 fn cas_default() {
125 let cas = ContentAddressedStorage::default();
126 drop(cas);
128 }
129}