1use std::fmt;
9
10use crate::error::RepoError;
11
12#[derive(Debug, Clone, PartialEq, Eq, Hash)]
17pub struct AtUri {
18 pub authority: String,
20 pub collection: String,
22 pub rkey: String,
24}
25
26impl AtUri {
27 pub fn new(
34 authority: impl Into<String>,
35 collection: impl Into<String>,
36 rkey: impl Into<String>,
37 ) -> Result<Self, RepoError> {
38 let authority = authority.into();
39 let collection = collection.into();
40 let rkey = rkey.into();
41
42 if !authority.starts_with("did:") {
43 return Err(RepoError::InvalidAtUri(format!(
44 "authority must start with 'did:', got '{authority}'"
45 )));
46 }
47
48 if !collection.contains('.') {
49 return Err(RepoError::InvalidAtUri(format!(
50 "collection must be an NSID containing at least one '.', got '{collection}'"
51 )));
52 }
53
54 if rkey.is_empty() {
55 return Err(RepoError::InvalidAtUri(
56 "rkey must be non-empty".to_string(),
57 ));
58 }
59
60 Ok(Self {
61 authority,
62 collection,
63 rkey,
64 })
65 }
66
67 pub fn parse(uri: &str) -> Result<Self, RepoError> {
71 let stripped = uri.strip_prefix("at://").ok_or_else(|| {
72 RepoError::InvalidAtUri(format!("AT-URI must start with 'at://', got '{uri}'"))
73 })?;
74
75 let mut parts = stripped.splitn(3, '/');
76
77 let authority = parts
78 .next()
79 .filter(|s| !s.is_empty())
80 .ok_or_else(|| RepoError::InvalidAtUri("missing authority in AT-URI".to_string()))?;
81
82 let collection = parts
83 .next()
84 .filter(|s| !s.is_empty())
85 .ok_or_else(|| RepoError::InvalidAtUri("missing collection in AT-URI".to_string()))?;
86
87 let rkey = parts
88 .next()
89 .filter(|s| !s.is_empty())
90 .ok_or_else(|| RepoError::InvalidAtUri("missing rkey in AT-URI".to_string()))?;
91
92 Self::new(authority, collection, rkey)
93 }
94}
95
96impl fmt::Display for AtUri {
97 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
98 write!(
99 f,
100 "at://{}/{}/{}",
101 self.authority, self.collection, self.rkey
102 )
103 }
104}
105
106#[cfg(test)]
107mod tests {
108 use super::*;
109
110 #[test]
111 fn test_parse_valid_uri() {
112 let uri = AtUri::parse("at://did:plc:abc123/app.bsky.feed.post/3jt5tsfbx2s2a").unwrap();
113 assert_eq!(uri.authority, "did:plc:abc123");
114 assert_eq!(uri.collection, "app.bsky.feed.post");
115 assert_eq!(uri.rkey, "3jt5tsfbx2s2a");
116 }
117
118 #[test]
119 fn test_new_valid() {
120 let uri = AtUri::new("did:plc:xyz", "com.example.record", "abc").unwrap();
121 assert_eq!(uri.authority, "did:plc:xyz");
122 assert_eq!(uri.collection, "com.example.record");
123 assert_eq!(uri.rkey, "abc");
124 }
125
126 #[test]
127 fn test_display() {
128 let uri = AtUri::new("did:plc:abc", "app.bsky.feed.post", "rkey1").unwrap();
129 assert_eq!(uri.to_string(), "at://did:plc:abc/app.bsky.feed.post/rkey1");
130 }
131
132 #[test]
133 fn test_roundtrip() {
134 let original = "at://did:plc:abc123/app.bsky.feed.post/3jt5tsfbx2s2a";
135 let parsed = AtUri::parse(original).unwrap();
136 assert_eq!(parsed.to_string(), original);
137 }
138
139 #[test]
140 fn test_parse_missing_prefix() {
141 let result = AtUri::parse("https://example.com/foo/bar");
142 assert!(result.is_err());
143 }
144
145 #[test]
146 fn test_parse_missing_rkey() {
147 let result = AtUri::parse("at://did:plc:abc/app.bsky.feed.post");
148 assert!(result.is_err());
149 }
150
151 #[test]
152 fn test_parse_missing_collection() {
153 let result = AtUri::parse("at://did:plc:abc");
154 assert!(result.is_err());
155 }
156
157 #[test]
158 fn test_new_invalid_authority() {
159 let result = AtUri::new("plc:abc", "app.bsky.feed.post", "rkey1");
160 assert!(result.is_err());
161 }
162
163 #[test]
164 fn test_new_invalid_collection_no_dot() {
165 let result = AtUri::new("did:plc:abc", "nocollection", "rkey1");
166 assert!(result.is_err());
167 }
168
169 #[test]
170 fn test_new_empty_rkey() {
171 let result = AtUri::new("did:plc:abc", "app.bsky.feed.post", "");
172 assert!(result.is_err());
173 }
174}