1use nanoid::nanoid;
2
3use crate::error::{MaError, MaResult as 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 #[must_use]
70 pub fn base_id(&self) -> String {
71 format!("{DID_PREFIX}{}", self.ipns)
72 }
73
74 pub fn with_fragment(&self, fragment: impl Into<String>) -> Result<Self> {
75 Self::new_url(self.ipns.clone(), Some(fragment))
76 }
77
78 #[must_use]
79 pub fn id(&self) -> String {
80 match &self.fragment {
81 Some(fragment) => format!("{}#{fragment}", self.base_id()),
82 None => self.base_id(),
83 }
84 }
85
86 pub fn parse(input: &str) -> Result<(String, Option<String>)> {
87 if input.is_empty() {
88 return Err(MaError::EmptyDid);
89 }
90
91 let stripped = input
92 .strip_prefix(DID_PREFIX)
93 .ok_or(MaError::InvalidDidPrefix)?;
94
95 let parts: Vec<_> = stripped.split('#').collect();
96 match parts.as_slice() {
97 [] | [""] => Err(MaError::MissingIdentifier),
98 [_, ..] if parts.len() > 2 => Err(MaError::InvalidDidFormat),
99 [identifier] => {
100 validate_identifier(identifier)?;
101 Ok(((*identifier).to_string(), None))
102 }
103 [identifier, fragment] => {
104 validate_identifier(identifier)?;
105 validate_fragment(fragment)?;
106 Ok(((*identifier).to_string(), Some((*fragment).to_string())))
107 }
108 _ => Err(MaError::InvalidDidFormat),
109 }
110 }
111
112 pub fn validate(input: &str) -> Result<()> {
113 Self::parse(input).map(|_| ())
114 }
115
116 pub fn validate_url(input: &str) -> Result<()> {
118 match Self::parse(input)? {
119 (_, Some(_)) => Ok(()),
120 (_, None) => Err(MaError::MissingFragment),
121 }
122 }
123
124 pub fn validate_identity(input: &str) -> Result<()> {
126 match Self::parse(input)? {
127 (_, None) => Ok(()),
128 (_, Some(_)) => Err(MaError::UnexpectedFragment),
129 }
130 }
131
132 #[must_use]
134 pub fn is_url(&self) -> bool {
135 self.fragment.is_some()
136 }
137
138 #[must_use]
140 pub fn is_bare(&self) -> bool {
141 self.fragment.is_none()
142 }
143}
144
145impl TryFrom<&str> for Did {
146 type Error = MaError;
147
148 fn try_from(value: &str) -> Result<Self> {
151 let (ipns, fragment) = Self::parse(value)?;
152 Ok(Self { ipns, fragment })
153 }
154}
155
156fn validate_identifier(input: &str) -> Result<()> {
157 if input.is_empty() {
158 return Err(MaError::MissingIdentifier);
159 }
160 if !input.chars().all(|c| c.is_ascii_alphanumeric()) {
163 return Err(MaError::InvalidIdentifier);
164 }
165 Ok(())
166}
167
168fn validate_fragment(input: &str) -> Result<()> {
171 if input.is_empty()
172 || !input
173 .bytes()
174 .all(|b| b.is_ascii_alphanumeric() || b == b'_' || b == b'-')
175 {
176 return Err(MaError::InvalidFragment(input.to_string()));
177 }
178 Ok(())
179}
180
181#[cfg(test)]
182mod tests {
183 use super::*;
184
185 const BARE: &str = "did:ma:k51qzi5uqu5abc";
186 const URL: &str = "did:ma:k51qzi5uqu5abc#lobby";
187
188 #[test]
189 fn is_url_with_fragment() {
190 let did = Did::try_from(URL).unwrap();
191 assert!(did.is_url());
192 assert!(!did.is_bare());
193 }
194
195 #[test]
196 fn is_bare_without_fragment() {
197 let did = Did::try_from(BARE).unwrap();
198 assert!(did.is_bare());
199 assert!(!did.is_url());
200 }
201
202 #[test]
203 fn validate_url_accepts_fragment() {
204 assert!(Did::validate_url(URL).is_ok());
205 }
206
207 #[test]
208 fn validate_url_rejects_bare() {
209 assert!(Did::validate_url(BARE).is_err());
210 }
211
212 #[test]
213 fn validate_identity_accepts_bare() {
214 assert!(Did::validate_identity(BARE).is_ok());
215 }
216
217 #[test]
218 fn validate_identity_rejects_fragment() {
219 assert!(Did::validate_identity(URL).is_err());
220 }
221
222 #[test]
223 fn new_url_none_generates_nanoid() {
224 let url = Did::new_url("k51qzi5uqu5abc", None::<String>).unwrap();
225 assert!(url.is_url());
226 assert!(!url.fragment.unwrap().is_empty());
227 }
228
229 #[test]
230 fn new_url_accepts_nanoid_fragment() {
231 let url = Did::new_url("k51qzi5uqu5abc", Some("bahner")).unwrap();
232 assert_eq!(url.fragment.as_deref(), Some("bahner"));
233 }
234
235 #[test]
236 fn new_url_rejects_invalid_chars() {
237 assert!(Did::new_url("k51qzi5uqu5abc", Some("has space")).is_err());
238 assert!(Did::new_url("k51qzi5uqu5abc", Some("has.dot")).is_err());
239 assert!(Did::new_url("k51qzi5uqu5abc", Some("")).is_err());
240 }
241
242 #[test]
243 fn try_from_lenient_accepts_non_nanoid_fragment() {
244 let did = Did::try_from("did:ma:k51qzi5uqu5abc#lobby").unwrap();
246 assert_eq!(did.fragment.as_deref(), Some("lobby"));
247 }
248}