1use anyhow::{anyhow, bail, Result};
2use serde::{Deserialize, Deserializer, Serialize, Serializer};
3use std::fmt::{Display, Formatter};
4use std::str::FromStr;
5
6use super::validate_domain;
7
8#[derive(Debug, Clone, Copy, PartialEq, Eq)]
10pub enum CmnUriKind {
11 Domain,
13 Spore,
15 Mycelium,
17 Taste,
19}
20
21impl CmnUriKind {
22 pub fn as_str(self) -> &'static str {
23 match self {
24 Self::Domain => "domain",
25 Self::Spore => "spore",
26 Self::Mycelium => "mycelium",
27 Self::Taste => "taste",
28 }
29 }
30}
31
32impl Display for CmnUriKind {
33 fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
34 f.write_str(self.as_str())
35 }
36}
37
38impl FromStr for CmnUriKind {
39 type Err = anyhow::Error;
40
41 fn from_str(value: &str) -> Result<Self> {
42 match value {
43 "domain" => Ok(Self::Domain),
44 "spore" => Ok(Self::Spore),
45 "mycelium" => Ok(Self::Mycelium),
46 "taste" => Ok(Self::Taste),
47 _ => Err(anyhow!(
48 "Invalid CMN URI kind '{}'. Must be one of: domain, spore, mycelium, taste",
49 value
50 )),
51 }
52 }
53}
54
55impl Serialize for CmnUriKind {
56 fn serialize<S>(&self, serializer: S) -> std::result::Result<S::Ok, S::Error>
57 where
58 S: Serializer,
59 {
60 serializer.serialize_str(self.as_str())
61 }
62}
63
64impl<'de> Deserialize<'de> for CmnUriKind {
65 fn deserialize<D>(deserializer: D) -> std::result::Result<Self, D::Error>
66 where
67 D: Deserializer<'de>,
68 {
69 let value = String::deserialize(deserializer)?;
70 Self::from_str(&value).map_err(serde::de::Error::custom)
71 }
72}
73
74#[derive(Debug, Clone, PartialEq)]
76pub struct CmnUri {
77 pub domain: String,
78 pub hash: Option<String>,
79 pub kind: CmnUriKind,
80}
81
82impl CmnUri {
83 pub fn parse(uri: &str) -> Result<Self, String> {
99 parse_uri(uri).map_err(|e| e.to_string())
100 }
101
102 pub fn hash_filename(&self) -> Option<String> {
104 self.hash.clone()
105 }
106
107 pub fn is_spore(&self) -> bool {
109 self.kind == CmnUriKind::Spore
110 }
111
112 pub fn is_domain(&self) -> bool {
114 self.kind == CmnUriKind::Domain
115 }
116
117 pub fn is_taste(&self) -> bool {
119 self.kind == CmnUriKind::Taste
120 }
121
122 pub fn is_mycelium(&self) -> bool {
124 self.kind == CmnUriKind::Mycelium
125 }
126}
127
128pub fn parse_uri(uri: &str) -> Result<CmnUri> {
161 let rest = uri
162 .strip_prefix("cmn://")
163 .ok_or_else(|| anyhow!("URI must start with 'cmn://'"))?;
164
165 let trimmed = rest.trim_end_matches('/');
166 if trimmed.is_empty() {
167 return Err(anyhow!("Missing domain in URI"));
168 }
169
170 let (domain, path) = match trimmed.split_once('/') {
171 Some((domain, path)) if !path.is_empty() => (domain.to_string(), Some(path.to_string())),
172 Some((domain, _)) => (domain.to_string(), None),
173 None => (trimmed.to_string(), None),
174 };
175
176 validate_domain(&domain)?;
177
178 let (kind, hash) = match path {
179 None => (CmnUriKind::Domain, None),
180 Some(path) => {
181 if path == "taste" {
182 return Err(anyhow!("Taste URI missing hash after /taste/"));
183 } else if path == "mycelium" {
184 return Err(anyhow!("Mycelium URI missing hash after /mycelium/"));
185 } else if let Some(taste_hash) = path.strip_prefix("taste/") {
186 if taste_hash.is_empty() {
187 return Err(anyhow!("Taste URI missing hash after /taste/"));
188 }
189 let normalized = crate::crypto::parse_hash(taste_hash)
190 .map(|hash| crate::crypto::format_hash(hash.algorithm, &hash.bytes))
191 .map_err(|e| anyhow!("Invalid taste hash '{}': {}", taste_hash, e))?;
192 (CmnUriKind::Taste, Some(normalized))
193 } else if let Some(mycelium_hash) = path.strip_prefix("mycelium/") {
194 if mycelium_hash.is_empty() {
195 return Err(anyhow!("Mycelium URI missing hash after /mycelium/"));
196 }
197 let normalized = crate::crypto::parse_hash(mycelium_hash)
198 .map(|hash| crate::crypto::format_hash(hash.algorithm, &hash.bytes))
199 .map_err(|e| anyhow!("Invalid mycelium hash '{}': {}", mycelium_hash, e))?;
200 (CmnUriKind::Mycelium, Some(normalized))
201 } else {
202 let normalized = crate::crypto::parse_hash(&path)
203 .map(|hash| crate::crypto::format_hash(hash.algorithm, &hash.bytes))
204 .map_err(|e| anyhow!("Invalid spore hash '{}': {}", path, e))?;
205 (CmnUriKind::Spore, Some(normalized))
206 }
207 }
208 };
209
210 Ok(CmnUri { domain, hash, kind })
211}
212
213pub fn normalize_taste_target_uri(uri: &str) -> Result<String> {
222 let parsed = parse_uri(uri)?;
223 match parsed.kind {
224 CmnUriKind::Domain => Ok(build_domain_uri(&parsed.domain)),
225 CmnUriKind::Spore => {
226 let hash = parsed
227 .hash
228 .ok_or_else(|| anyhow!("Spore target URI is missing hash"))?;
229 Ok(build_spore_uri(&parsed.domain, &hash))
230 }
231 CmnUriKind::Mycelium => {
232 let hash = parsed
233 .hash
234 .ok_or_else(|| anyhow!("Mycelium target URI is missing hash"))?;
235 Ok(build_mycelium_uri(&parsed.domain, &hash))
236 }
237 CmnUriKind::Taste => {
238 bail!("Taste target_uri must be one of: domain URI, spore URI, mycelium URI")
239 }
240 }
241}
242
243pub fn build_spore_uri(domain: &str, hash: &str) -> String {
245 format!("cmn://{}/{}", domain, hash)
246}
247
248pub fn build_domain_uri(domain: &str) -> String {
250 format!("cmn://{}", domain)
251}
252
253pub fn build_taste_uri(domain: &str, hash: &str) -> String {
255 format!("cmn://{}/taste/{}", domain, hash)
256}
257
258pub fn build_mycelium_uri(domain: &str, hash: &str) -> String {
260 format!("cmn://{}/mycelium/{}", domain, hash)
261}
262
263#[cfg(test)]
264mod tests {
265 #![allow(clippy::expect_used, clippy::unwrap_used)]
266
267 use super::*;
268
269 #[test]
270 fn test_parse_spore_uri() {
271 let uri = parse_uri("cmn://example.com/b3.3yMR7vZQ9hL").unwrap();
272 assert_eq!(uri.domain, "example.com");
273 assert_eq!(uri.hash, Some("b3.3yMR7vZQ9hL".to_string()));
274 assert!(uri.is_spore());
275 assert_eq!(uri.kind, CmnUriKind::Spore);
276 }
277
278 #[test]
279 fn test_parse_domain_uri() {
280 let uri = parse_uri("cmn://example.com").unwrap();
281 assert_eq!(uri.domain, "example.com");
282 assert_eq!(uri.hash, None);
283 assert!(uri.is_domain());
284 assert_eq!(uri.kind, CmnUriKind::Domain);
285 }
286
287 #[test]
288 fn test_parse_domain_uri_trailing_slash() {
289 let uri = parse_uri("cmn://example.com/").unwrap();
290 assert_eq!(uri.domain, "example.com");
291 assert_eq!(uri.hash, None);
292 }
293
294 #[test]
295 fn test_parse_taste_uri() {
296 let uri = parse_uri("cmn://alice.dev/taste/b3.7tRkW2xPqL9nH").unwrap();
297 assert_eq!(uri.domain, "alice.dev");
298 assert_eq!(uri.hash, Some("b3.7tRkW2xPqL9nH".to_string()));
299 assert!(uri.is_taste());
300 assert_eq!(uri.kind, CmnUriKind::Taste);
301 }
302
303 #[test]
304 fn test_parse_mycelium_uri() {
305 let uri = parse_uri("cmn://example.com/mycelium/b3.7tRkW2xPqL9nH").unwrap();
306 assert_eq!(uri.domain, "example.com");
307 assert_eq!(uri.hash, Some("b3.7tRkW2xPqL9nH".to_string()));
308 assert!(uri.is_mycelium());
309 assert_eq!(uri.kind, CmnUriKind::Mycelium);
310 }
311
312 #[test]
313 fn test_parse_mycelium_uri_missing_hash() {
314 assert!(parse_uri("cmn://example.com/mycelium/").is_err());
315 assert!(parse_uri("cmn://example.com/mycelium").is_err());
316 }
317
318 #[test]
319 fn test_parse_taste_uri_missing_hash() {
320 assert!(parse_uri("cmn://alice.dev/taste/").is_err());
321 assert!(parse_uri("cmn://alice.dev/taste").is_err());
322 }
323
324 #[test]
325 fn test_parse_invalid_uri() {
326 assert!(parse_uri("http://example.com/spore").is_err());
327 assert!(parse_uri("cmn://").is_err());
328 assert!(parse_uri("cmn:///spore").is_err());
329 assert!(parse_uri("cmn://example.com/not-a-hash").is_err());
330 }
331
332 #[test]
333 fn test_build_spore_uri() {
334 let uri = build_spore_uri("example.com", "b3.3yMR7vZQ9hL");
335 assert_eq!(uri, "cmn://example.com/b3.3yMR7vZQ9hL");
336 }
337
338 #[test]
339 fn test_build_domain_uri() {
340 let uri = build_domain_uri("example.com");
341 assert_eq!(uri, "cmn://example.com");
342 }
343
344 #[test]
345 fn test_build_taste_uri() {
346 let uri = build_taste_uri("alice.dev", "b3.7tRkW2xPqL9nH");
347 assert_eq!(uri, "cmn://alice.dev/taste/b3.7tRkW2xPqL9nH");
348 }
349
350 #[test]
351 fn test_build_mycelium_uri() {
352 let uri = build_mycelium_uri("example.com", "b3.7tRkW2xPqL9nH");
353 assert_eq!(uri, "cmn://example.com/mycelium/b3.7tRkW2xPqL9nH");
354 }
355
356 #[test]
357 fn test_normalize_taste_target_uri_domain() {
358 let uri = normalize_taste_target_uri("cmn://example.com/").unwrap();
359 assert_eq!(uri, "cmn://example.com");
360 }
361
362 #[test]
363 fn test_normalize_taste_target_uri_spore() {
364 let uri = normalize_taste_target_uri("cmn://example.com/b3.3yMR7vZQ9hL").unwrap();
365 assert_eq!(uri, "cmn://example.com/b3.3yMR7vZQ9hL");
366 }
367
368 #[test]
369 fn test_normalize_taste_target_uri_mycelium() {
370 let uri = normalize_taste_target_uri("cmn://example.com/mycelium/b3.3yMR7vZQ9hL").unwrap();
371 assert_eq!(uri, "cmn://example.com/mycelium/b3.3yMR7vZQ9hL");
372 }
373
374 #[test]
375 fn test_normalize_taste_target_uri_rejects_taste_uri() {
376 assert!(normalize_taste_target_uri("cmn://example.com/taste/b3.3yMR7vZQ9hL").is_err());
377 }
378}