1use nanoid::nanoid;
2
3use crate::error::{MaError, Result};
4
5pub const DID_PREFIX: &str = "did:ma:";
6
7#[derive(Debug, Clone, PartialEq, Eq, Hash, serde::Serialize, serde::Deserialize)]
34pub struct Did {
35 pub ipns: String,
36 pub fragment: Option<String>,
39}
40
41impl Did {
42 pub fn new_identity(ipns: impl Into<String>) -> Result<Self> {
44 let ipns = ipns.into();
45 validate_identifier(&ipns)?;
46 Ok(Self {
47 ipns,
48 fragment: None,
49 })
50 }
51
52 pub fn new_url(ipns: impl Into<String>, fragment: Option<impl Into<String>>) -> Result<Self> {
56 let frag = match fragment {
57 Some(f) => f.into(),
58 None => nanoid!(),
59 };
60 let ipns = ipns.into();
61 validate_identifier(&ipns)?;
62 validate_fragment(&frag)?;
63 Ok(Self {
64 ipns,
65 fragment: Some(frag),
66 })
67 }
68
69 pub fn base_id(&self) -> String {
70 format!("{DID_PREFIX}{}", self.ipns)
71 }
72
73 pub fn with_fragment(&self, fragment: impl Into<String>) -> Result<Self> {
74 Self::new_url(self.ipns.clone(), Some(fragment))
75 }
76
77 pub fn id(&self) -> String {
78 match &self.fragment {
79 Some(fragment) => format!("{}#{fragment}", self.base_id()),
80 None => self.base_id(),
81 }
82 }
83
84 pub fn parse(input: &str) -> Result<(String, Option<String>)> {
85 if input.is_empty() {
86 return Err(MaError::EmptyDid);
87 }
88
89 let stripped = input
90 .strip_prefix(DID_PREFIX)
91 .ok_or(MaError::InvalidDidPrefix)?;
92
93 let parts: Vec<_> = stripped.split('#').collect();
94 match parts.as_slice() {
95 [] => Err(MaError::MissingIdentifier),
96 [_, ..] if parts.len() > 2 => Err(MaError::InvalidDidFormat),
97 [""] => Err(MaError::MissingIdentifier),
98 [identifier] => {
99 validate_identifier(identifier)?;
100 Ok(((*identifier).to_string(), None))
101 }
102 [identifier, fragment] => {
103 validate_identifier(identifier)?;
104 validate_fragment(fragment)?;
105 Ok(((*identifier).to_string(), Some((*fragment).to_string())))
106 }
107 _ => Err(MaError::InvalidDidFormat),
108 }
109 }
110
111 pub fn validate(input: &str) -> Result<()> {
112 Self::parse(input).map(|_| ())
113 }
114
115 pub fn validate_url(input: &str) -> Result<()> {
117 match Self::parse(input)? {
118 (_, Some(_)) => Ok(()),
119 (_, None) => Err(MaError::MissingFragment),
120 }
121 }
122
123 pub fn validate_identity(input: &str) -> Result<()> {
125 match Self::parse(input)? {
126 (_, None) => Ok(()),
127 (_, Some(_)) => Err(MaError::UnexpectedFragment),
128 }
129 }
130
131 pub fn is_url(&self) -> bool {
133 self.fragment.is_some()
134 }
135
136 pub fn is_bare(&self) -> bool {
138 self.fragment.is_none()
139 }
140}
141
142impl TryFrom<&str> for Did {
143 type Error = MaError;
144
145 fn try_from(value: &str) -> Result<Self> {
148 let (ipns, fragment) = Self::parse(value)?;
149 Ok(Self { ipns, fragment })
150 }
151}
152
153fn validate_identifier(input: &str) -> Result<()> {
154 if input.is_empty() {
155 return Err(MaError::MissingIdentifier);
156 }
157 if !input.chars().all(|c| c.is_ascii_alphanumeric()) {
160 return Err(MaError::InvalidIdentifier);
161 }
162 Ok(())
163}
164
165fn validate_fragment(input: &str) -> Result<()> {
168 if input.is_empty()
169 || !input
170 .bytes()
171 .all(|b| b.is_ascii_alphanumeric() || b == b'_' || b == b'-')
172 {
173 return Err(MaError::InvalidFragment(input.to_string()));
174 }
175 Ok(())
176}
177
178#[cfg(test)]
179mod tests {
180 use super::*;
181
182 const BARE: &str = "did:ma:k51qzi5uqu5abc";
183 const URL: &str = "did:ma:k51qzi5uqu5abc#lobby";
184
185 #[test]
186 fn is_url_with_fragment() {
187 let did = Did::try_from(URL).unwrap();
188 assert!(did.is_url());
189 assert!(!did.is_bare());
190 }
191
192 #[test]
193 fn is_bare_without_fragment() {
194 let did = Did::try_from(BARE).unwrap();
195 assert!(did.is_bare());
196 assert!(!did.is_url());
197 }
198
199 #[test]
200 fn validate_url_accepts_fragment() {
201 assert!(Did::validate_url(URL).is_ok());
202 }
203
204 #[test]
205 fn validate_url_rejects_bare() {
206 assert!(Did::validate_url(BARE).is_err());
207 }
208
209 #[test]
210 fn validate_identity_accepts_bare() {
211 assert!(Did::validate_identity(BARE).is_ok());
212 }
213
214 #[test]
215 fn validate_identity_rejects_fragment() {
216 assert!(Did::validate_identity(URL).is_err());
217 }
218
219 #[test]
220 fn new_url_none_generates_nanoid() {
221 let url = Did::new_url("k51qzi5uqu5abc", None::<String>).unwrap();
222 assert!(url.is_url());
223 assert!(!url.fragment.unwrap().is_empty());
224 }
225
226 #[test]
227 fn new_url_accepts_nanoid_fragment() {
228 let url = Did::new_url("k51qzi5uqu5abc", Some("bahner")).unwrap();
229 assert_eq!(url.fragment.as_deref(), Some("bahner"));
230 }
231
232 #[test]
233 fn new_url_rejects_invalid_chars() {
234 assert!(Did::new_url("k51qzi5uqu5abc", Some("has space")).is_err());
235 assert!(Did::new_url("k51qzi5uqu5abc", Some("has.dot")).is_err());
236 assert!(Did::new_url("k51qzi5uqu5abc", Some("")).is_err());
237 }
238
239 #[test]
240 fn try_from_lenient_accepts_non_nanoid_fragment() {
241 let did = Did::try_from("did:ma:k51qzi5uqu5abc#lobby").unwrap();
243 assert_eq!(did.fragment.as_deref(), Some("lobby"));
244 }
245}