Skip to main content

atrg_repo/
at_uri.rs

1//! AT Protocol URI parsing and construction.
2//!
3//! AT-URIs follow the format `at://{authority}/{collection}/{rkey}` where:
4//! - `authority` is a DID (e.g. `did:plc:xyz123`)
5//! - `collection` is an NSID (e.g. `app.bsky.feed.post`)
6//! - `rkey` is the record key
7
8use std::fmt;
9
10use crate::error::RepoError;
11
12/// Parsed AT Protocol URI.
13///
14/// Represents a fully-qualified reference to a record in an AT Protocol
15/// repository: `at://{authority}/{collection}/{rkey}`.
16#[derive(Debug, Clone, PartialEq, Eq, Hash)]
17pub struct AtUri {
18    /// The DID of the repository owner (e.g. `did:plc:abc123`).
19    pub authority: String,
20    /// The collection NSID (e.g. `app.bsky.feed.post`).
21    pub collection: String,
22    /// The record key within the collection.
23    pub rkey: String,
24}
25
26impl AtUri {
27    /// Create a new AT-URI from its components.
28    ///
29    /// Validates that:
30    /// - `authority` starts with `did:`
31    /// - `collection` contains at least one `.`
32    /// - `rkey` is non-empty
33    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    /// Parse an AT-URI string.
68    ///
69    /// Expected format: `at://{authority}/{collection}/{rkey}`
70    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}