1use std::fmt;
4
5#[derive(Debug, Clone, PartialEq, Eq, Hash)]
17pub struct RecordRef {
18 pub did: String,
20 pub collection: String,
22 pub rkey: Option<String>,
24}
25
26#[derive(Debug, thiserror::Error, Clone, PartialEq, Eq)]
28pub enum RefError {
29 #[error("{0:?} does not begin with at://")]
31 NotAnAtUri(String),
32 #[error(
35 "{0:?} names neither a record nor a collection: at://<did>/<collection>/<rkey> is one \
36 record, at://<did>/<collection> is a collection"
37 )]
38 NotOneRecord(String),
39 #[error("{0:?} is a handle rather than a DID; resolve it before reading a record at it")]
41 NotADid(String),
42 #[error("{0:?} has an empty {1}")]
44 Empty(String, &'static str),
45}
46
47impl RecordRef {
48 pub fn new(
50 did: impl Into<String>,
51 collection: impl Into<String>,
52 rkey: impl Into<String>,
53 ) -> Self {
54 Self {
55 did: did.into(),
56 collection: collection.into(),
57 rkey: Some(rkey.into()),
58 }
59 }
60
61 pub fn collection(did: impl Into<String>, collection: impl Into<String>) -> Self {
63 Self {
64 did: did.into(),
65 collection: collection.into(),
66 rkey: None,
67 }
68 }
69
70 #[must_use]
72 pub fn is_collection(&self) -> bool {
73 self.rkey.is_none()
74 }
75
76 pub fn parse(at_uri: &str) -> Result<Self, RefError> {
83 let rest = at_uri
84 .strip_prefix("at://")
85 .ok_or_else(|| RefError::NotAnAtUri(at_uri.to_owned()))?;
86 let mut parts = rest.split('/');
87 let (Some(authority), Some(collection), rkey, None) =
88 (parts.next(), parts.next(), parts.next(), parts.next())
89 else {
90 return Err(RefError::NotOneRecord(at_uri.to_owned()));
91 };
92 if !authority.starts_with("did:") {
93 return Err(RefError::NotADid(at_uri.to_owned()));
94 }
95 for (segment, name) in [(authority, "did"), (collection, "collection")]
96 .into_iter()
97 .chain(rkey.map(|rkey| (rkey, "rkey")))
98 {
99 if segment.is_empty() {
100 return Err(RefError::Empty(at_uri.to_owned(), name));
101 }
102 }
103 Ok(Self {
104 did: authority.to_owned(),
105 collection: collection.to_owned(),
106 rkey: rkey.map(str::to_owned),
107 })
108 }
109
110 pub fn at_uri(&self) -> String {
112 self.to_string()
113 }
114}
115
116impl fmt::Display for RecordRef {
117 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
118 write!(f, "at://{}/{}", self.did, self.collection)?;
119 match &self.rkey {
120 Some(rkey) => write!(f, "/{rkey}"),
121 None => Ok(()),
122 }
123 }
124}
125
126#[cfg(test)]
127mod tests {
128 use super::*;
129
130 #[test]
131 fn an_at_uri_round_trips_through_a_reference() {
132 let uri = "at://did:plc:abcdefghijklmnopqrstuvwx/app.bsky.feed.threadgate/3kabc";
133 let parsed = RecordRef::parse(uri).expect("one record");
134 assert_eq!(parsed.did, "did:plc:abcdefghijklmnopqrstuvwx");
135 assert_eq!(parsed.collection, "app.bsky.feed.threadgate");
136 assert_eq!(parsed.rkey.as_deref(), Some("3kabc"));
137 assert_eq!(parsed.at_uri(), uri);
138 assert!(!parsed.is_collection());
139 }
140
141 #[test]
144 fn a_collection_uri_round_trips_through_a_reference() {
145 let uri = "at://did:plc:abcdefghijklmnopqrstuvwx/community.lexicon.preference.ai";
146 let parsed = RecordRef::parse(uri).expect("one collection");
147 assert_eq!(parsed.rkey, None);
148 assert!(parsed.is_collection());
149 assert_eq!(parsed.at_uri(), uri);
150 assert_eq!(
151 RecordRef::collection(
152 "did:plc:abcdefghijklmnopqrstuvwx",
153 "community.lexicon.preference.ai"
154 ),
155 parsed
156 );
157 }
158
159 #[test]
162 fn nothing_but_one_record_or_collection_parses() {
163 for (uri, expected) in [
164 (
165 "https://example.test/x",
166 RefError::NotAnAtUri("https://example.test/x".to_owned()),
167 ),
168 (
169 "at://did:plc:abc/app.bsky.feed.post/3k/extra",
170 RefError::NotOneRecord("at://did:plc:abc/app.bsky.feed.post/3k/extra".to_owned()),
171 ),
172 (
173 "at://alice.example/app.bsky.feed.post/3k",
174 RefError::NotADid("at://alice.example/app.bsky.feed.post/3k".to_owned()),
175 ),
176 (
177 "at://did:plc:abc/app.bsky.feed.post/",
178 RefError::Empty("at://did:plc:abc/app.bsky.feed.post/".to_owned(), "rkey"),
179 ),
180 ] {
181 assert_eq!(RecordRef::parse(uri), Err(expected), "{uri}");
182 }
183 }
184}