1use anyhow::Result;
2use serde::{Deserialize, Serialize};
3
4pub const MYCELIUM_SCHEMA: &str = "https://cmn.dev/schemas/v1/mycelium.json";
5
6#[derive(Serialize, Deserialize, Debug, Clone)]
8pub struct Mycelium {
9 #[serde(rename = "$schema")]
10 pub schema: String,
11 pub capsule: MyceliumCapsule,
12 pub capsule_signature: String,
13}
14
15#[derive(Serialize, Deserialize, Debug, Clone)]
17pub struct MyceliumCapsule {
18 pub uri: String,
19 pub core: MyceliumCore,
20 pub core_signature: String,
21}
22
23#[derive(Serialize, Deserialize, Debug, Clone)]
25pub struct MyceliumCore {
26 pub name: String,
27 pub domain: String,
28 pub key: String,
29 pub synopsis: String,
30 #[serde(default, skip_serializing_if = "String::is_empty")]
31 pub bio: String,
32 #[serde(default)]
33 pub nutrients: Vec<Nutrient>,
34 pub updated_at_epoch_ms: u64,
35 #[serde(default)]
36 pub spores: Vec<MyceliumCoreSpore>,
37 #[serde(default)]
38 pub tastes: Vec<MyceliumCoreTaste>,
39}
40
41#[derive(Serialize, Deserialize, Debug, Clone)]
43pub struct MyceliumCoreSpore {
44 pub id: String,
45 pub hash: String,
46 pub name: String,
47 #[serde(skip_serializing_if = "Option::is_none")]
48 pub synopsis: Option<String>,
49}
50
51#[derive(Serialize, Deserialize, Debug, Clone)]
53pub struct MyceliumCoreTaste {
54 pub hash: String,
55 pub target_uri: String,
56}
57
58#[derive(Serialize, Deserialize, Debug, Clone)]
60pub struct Nutrient {
61 #[serde(rename = "type")]
62 pub kind: String,
63 #[serde(default, skip_serializing_if = "Option::is_none")]
64 pub address: Option<String>,
65 #[serde(default, skip_serializing_if = "Option::is_none")]
66 pub recipient: Option<String>,
67 #[serde(default, skip_serializing_if = "Option::is_none")]
68 pub url: Option<String>,
69 #[serde(default, skip_serializing_if = "Option::is_none")]
70 pub label: Option<String>,
71 #[serde(default, skip_serializing_if = "Option::is_none")]
72 pub chain_id: Option<u64>,
73 #[serde(default, skip_serializing_if = "Option::is_none")]
74 pub token: Option<String>,
75 #[serde(default, skip_serializing_if = "Option::is_none")]
76 pub asset_id: Option<String>,
77}
78
79impl Mycelium {
80 pub fn new(domain: &str, name: &str, synopsis: &str, updated_at_epoch_ms: u64) -> Self {
81 Self {
82 schema: MYCELIUM_SCHEMA.to_string(),
83 capsule: MyceliumCapsule {
84 uri: String::new(),
85 core: MyceliumCore {
86 name: name.to_string(),
87 domain: domain.to_string(),
88 key: String::new(),
89 synopsis: synopsis.to_string(),
90 bio: String::new(),
91 nutrients: vec![],
92 updated_at_epoch_ms,
93 spores: vec![],
94 tastes: vec![],
95 },
96 core_signature: String::new(),
97 },
98 capsule_signature: String::new(),
99 }
100 }
101
102 pub fn add_spore(
103 &mut self,
104 id: &str,
105 hash: &str,
106 name: &str,
107 synopsis: Option<&str>,
108 updated_at_epoch_ms: u64,
109 ) {
110 self.capsule.core.spores.retain(|entry| {
111 if entry.id.is_empty() {
112 entry.name != name
113 } else {
114 entry.id != id
115 }
116 });
117
118 self.capsule.core.spores.push(MyceliumCoreSpore {
119 id: id.to_string(),
120 hash: hash.to_string(),
121 name: name.to_string(),
122 synopsis: synopsis.map(str::to_string),
123 });
124 self.capsule.core.updated_at_epoch_ms = updated_at_epoch_ms;
125 }
126
127 pub fn uri(&self) -> &str {
128 &self.capsule.uri
129 }
130
131 pub fn author_domain(&self) -> &str {
132 &self.capsule.core.domain
133 }
134
135 pub fn timestamp_ms(&self) -> u64 {
136 self.capsule.core.updated_at_epoch_ms
137 }
138
139 pub fn embedded_core_key(&self) -> Option<&str> {
140 let key = self.capsule.core.key.as_str();
141 (!key.is_empty()).then_some(key)
142 }
143
144 pub fn spore_hashes(&self) -> impl Iterator<Item = &str> {
145 self.capsule
146 .core
147 .spores
148 .iter()
149 .map(|spore| spore.hash.as_str())
150 }
151
152 pub fn verify_core_signature(&self, author_key: &str) -> Result<()> {
153 crate::verify_json_signature(&self.capsule.core, &self.capsule.core_signature, author_key)
154 }
155
156 pub fn verify_capsule_signature(&self, host_key: &str) -> Result<()> {
157 crate::verify_json_signature(&self.capsule, &self.capsule_signature, host_key)
158 }
159
160 pub fn verify_signatures(&self, host_key: &str, author_key: &str) -> Result<()> {
161 self.verify_core_signature(author_key)?;
162 self.verify_capsule_signature(host_key)
163 }
164
165 pub fn computed_uri_hash(&self) -> Result<String> {
166 crate::crypto::hash::compute_signed_core_hash(
167 &self.capsule.core,
168 &self.capsule.core_signature,
169 )
170 }
171
172 pub fn verify_uri_hash(&self, expected_hash: &str) -> Result<()> {
173 let actual_hash = self.computed_uri_hash()?;
174 super::verify_expected_uri_hash(&actual_hash, expected_hash)
175 }
176}
177
178#[cfg(test)]
179#[allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
180mod tests {
181
182 use super::*;
183
184 #[test]
185 fn test_mycelium_new() {
186 let mycelium = Mycelium::new("example.com", "Example", "A test mycelium", 123);
187 assert_eq!(mycelium.schema, MYCELIUM_SCHEMA);
188 assert_eq!(mycelium.capsule.core.name, "Example");
189 assert_eq!(mycelium.capsule.core.synopsis, "A test mycelium");
190 assert_eq!(mycelium.capsule.core.domain, "example.com");
191 assert_eq!(mycelium.capsule.core.updated_at_epoch_ms, 123);
192 assert!(mycelium.capsule.core.spores.is_empty());
193 assert!(mycelium.capsule.core.tastes.is_empty());
194 }
195
196 #[test]
197 fn test_mycelium_add_spore() {
198 let mut mycelium = Mycelium::new("example.com", "Example", "", 10);
199 mycelium.add_spore("test", "b3.abc123", "test-spore", Some("A test spore"), 20);
200
201 assert_eq!(mycelium.capsule.core.spores.len(), 1);
202 assert_eq!(mycelium.capsule.core.spores[0].id, "test");
203 assert_eq!(mycelium.capsule.core.spores[0].hash, "b3.abc123");
204 assert_eq!(mycelium.capsule.core.spores[0].name, "test-spore");
205 assert_eq!(
206 mycelium.capsule.core.spores[0].synopsis,
207 Some("A test spore".to_string())
208 );
209 assert_eq!(mycelium.capsule.core.updated_at_epoch_ms, 20);
210 }
211
212 #[test]
213 fn test_mycelium_add_spore_replaces_existing() {
214 let mut mycelium = Mycelium::new("example.com", "Example", "", 10);
215 mycelium.add_spore(
216 "my-spore",
217 "b3.abc123",
218 "old-name",
219 Some("Old synopsis"),
220 20,
221 );
222 mycelium.add_spore(
223 "my-spore",
224 "b3.def456",
225 "new-name",
226 Some("New synopsis"),
227 30,
228 );
229
230 assert_eq!(mycelium.capsule.core.spores.len(), 1);
231 assert_eq!(mycelium.capsule.core.spores[0].id, "my-spore");
232 assert_eq!(mycelium.capsule.core.spores[0].hash, "b3.def456");
233 assert_eq!(mycelium.capsule.core.spores[0].name, "new-name");
234 assert_eq!(
235 mycelium.capsule.core.spores[0].synopsis,
236 Some("New synopsis".to_string())
237 );
238 assert_eq!(mycelium.capsule.core.updated_at_epoch_ms, 30);
239 }
240
241 #[test]
242 fn test_mycelium_full_serialization() {
243 let mut mycelium = Mycelium::new("dev.example", "Developer", "A Rust developer", 10);
244 mycelium.add_spore("my-lib", "b3.spore1", "my-lib", Some("A library"), 20);
245 mycelium.add_spore("my-app", "b3.spore2", "my-app", None, 30);
246 mycelium.capsule.core_signature = "ed25519.core123".to_string();
247 mycelium.capsule_signature = "ed25519.capsule123".to_string();
248 mycelium.capsule.uri = "cmn://dev.example".to_string();
249
250 let json = serde_json::to_string_pretty(&mycelium).unwrap_or_default();
251 assert!(json.contains("\"$schema\""));
252 assert!(json.contains(MYCELIUM_SCHEMA));
253 assert!(json.contains("Developer"));
254 assert!(json.contains("my-lib"));
255 assert!(json.contains("my-app"));
256 }
257
258 #[test]
259 fn test_mycelium_bio_field() {
260 let mut mycelium = Mycelium::new("example.com", "Example", "A test mycelium", 10);
261 mycelium.capsule.core.bio = "Longer biography of this mycelium".to_string();
262
263 let json = serde_json::to_string(&mycelium).unwrap_or_default();
264 assert!(json.contains("\"bio\""));
265 assert!(json.contains("Longer biography of this mycelium"));
266
267 let parsed: Mycelium = serde_json::from_str(&json).unwrap();
268 assert_eq!(parsed.capsule.core.bio, "Longer biography of this mycelium");
269 }
270
271 #[test]
272 fn test_mycelium_nutrients_field() {
273 let mut mycelium = Mycelium::new("example.com", "Example", "A test mycelium", 10);
274 mycelium.capsule.core.nutrients = vec![
275 Nutrient {
276 kind: "web".to_string(),
277 address: None,
278 recipient: None,
279 url: Some("https://example.com/sponsor".to_string()),
280 label: Some("Sponsor".to_string()),
281 chain_id: None,
282 token: None,
283 asset_id: None,
284 },
285 Nutrient {
286 kind: "evm".to_string(),
287 address: Some("0x1234567890abcdef1234567890abcdef12345678".to_string()),
288 recipient: None,
289 url: None,
290 label: Some("ETH".to_string()),
291 chain_id: Some(1),
292 token: Some("ETH".to_string()),
293 asset_id: None,
294 },
295 ];
296
297 let json = serde_json::to_string(&mycelium).unwrap_or_default();
298 assert!(json.contains("\"nutrients\""));
299 assert!(json.contains("https://example.com/sponsor"));
300 assert!(json.contains("0x1234567890abcdef1234567890abcdef12345678"));
301
302 let parsed: Mycelium = serde_json::from_str(&json).unwrap();
303 assert_eq!(parsed.capsule.core.nutrients.len(), 2);
304 assert_eq!(parsed.capsule.core.nutrients[0].kind, "web");
305 assert_eq!(parsed.capsule.core.nutrients[1].kind, "evm");
306 assert_eq!(parsed.capsule.core.nutrients[1].chain_id, Some(1));
307 }
308
309 #[test]
310 fn test_mycelium_nutrients_serialization() {
311 let nutrient = Nutrient {
312 kind: "bitcoin".to_string(),
313 address: Some("bc1qexampleaddress".to_string()),
314 recipient: Some("donations@example.com".to_string()),
315 url: Some("https://example.com/donate".to_string()),
316 label: Some("Bitcoin".to_string()),
317 chain_id: None,
318 token: None,
319 asset_id: None,
320 };
321
322 let json = serde_json::to_string(&nutrient).unwrap_or_default();
323 assert!(json.contains("\"type\":\"bitcoin\""));
324 assert!(json.contains("bc1qexampleaddress"));
325 assert!(json.contains("donations@example.com"));
326 assert!(json.contains("https://example.com/donate"));
327
328 let parsed: Nutrient = serde_json::from_str(&json).unwrap();
329 assert_eq!(parsed.kind, "bitcoin");
330 assert_eq!(parsed.address, Some("bc1qexampleaddress".to_string()));
331 assert_eq!(parsed.recipient, Some("donations@example.com".to_string()));
332 assert_eq!(parsed.url, Some("https://example.com/donate".to_string()));
333 }
334}