use std::fmt;
use std::str::FromStr;
use atproto_identity::resolve::{InputType, parse_input};
use crate::errors::AturiError;
#[cfg_attr(debug_assertions, derive(Debug))]
#[derive(Clone)]
pub struct ATURI {
pub authority: String,
pub collection: String,
pub record_key: String,
}
impl fmt::Display for ATURI {
fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
write!(
f,
"at://{}/{}/{}",
self.authority, self.collection, self.record_key
)
}
}
impl FromStr for ATURI {
type Err = AturiError;
fn from_str(input: &str) -> Result<Self, Self::Err> {
let aturi = match input.strip_prefix("at://") {
Some(value) => value,
None => return Err(AturiError::MissingPrefix),
};
if aturi.ends_with('/') {
return Err(AturiError::TrailingSlash);
}
let mut components = aturi.split('/');
let authority = components.next().ok_or(AturiError::AuthorityMissing)?;
if authority.is_empty() {
return Err(AturiError::AuthorityMissing);
}
let did = match parse_input(authority) {
Ok(InputType::Handle(_)) => return Err(AturiError::HandleNotSupported),
Ok(InputType::Plc(did)) | Ok(InputType::Web(did)) => did,
Err(error) => return Err(AturiError::AuthorityParsingFailed { error }),
};
let nsid = components.next().ok_or(AturiError::CollectionMissing)?;
if nsid.trim().is_empty() {
return Err(AturiError::EmptyCollection);
}
let record_key = components.next().ok_or(AturiError::RecordKeyMissing)?;
if record_key.trim().is_empty() {
return Err(AturiError::EmptyRecordKey);
}
Ok(ATURI {
authority: did,
collection: nsid.to_string(),
record_key: record_key.to_string(),
})
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::errors::AturiError;
use std::str::FromStr;
#[test]
fn test_valid_aturi_with_plc_did() {
let input = "at://did:plc:ewvi7nxzyoun6zhxrhs64oiz/app.bsky.feed.post/3jui7kp54ic2i";
let result = ATURI::from_str(input).unwrap();
assert_eq!(result.authority, "did:plc:ewvi7nxzyoun6zhxrhs64oiz");
assert_eq!(result.collection, "app.bsky.feed.post");
assert_eq!(result.record_key, "3jui7kp54ic2i");
}
#[test]
fn test_valid_aturi_with_web_did() {
let input = "at://did:web:example.com/app.bsky.feed.post/3jui7kp54ic2i";
let result = ATURI::from_str(input).unwrap();
assert_eq!(result.authority, "did:web:example.com");
assert_eq!(result.collection, "app.bsky.feed.post");
assert_eq!(result.record_key, "3jui7kp54ic2i");
}
#[test]
fn test_valid_aturi_with_nested_web_did() {
let input = "at://did:web:example.com:8080:path:to:service/app.bsky.feed.like/3k2akjh32kj";
let result = ATURI::from_str(input).unwrap();
assert_eq!(result.authority, "did:web:example.com:8080:path:to:service");
assert_eq!(result.collection, "app.bsky.feed.like");
assert_eq!(result.record_key, "3k2akjh32kj");
}
#[test]
fn test_missing_prefix_error() {
let input = "did:plc:ewvi7nxzyoun6zhxrhs64oiz/app.bsky.feed.post/3jui7kp54ic2i";
let result = ATURI::from_str(input);
assert!(result.is_err());
assert!(matches!(result.unwrap_err(), AturiError::MissingPrefix));
}
#[test]
fn test_wrong_prefix_error() {
let input = "https://did:plc:ewvi7nxzyoun6zhxrhs64oiz/app.bsky.feed.post/3jui7kp54ic2i";
let result = ATURI::from_str(input);
assert!(result.is_err());
assert!(matches!(result.unwrap_err(), AturiError::MissingPrefix));
}
#[test]
fn test_trailing_slash_error() {
let input = "at://did:plc:ewvi7nxzyoun6zhxrhs64oiz/app.bsky.feed.post/3jui7kp54ic2i/";
let result = ATURI::from_str(input);
assert!(result.is_err());
assert!(matches!(result.unwrap_err(), AturiError::TrailingSlash));
}
#[test]
fn test_authority_missing_error() {
let input = "at://";
let result = ATURI::from_str(input);
assert!(result.is_err());
assert!(matches!(result.unwrap_err(), AturiError::AuthorityMissing));
}
#[test]
fn test_authority_missing_with_slash_error() {
let input = "at:///";
let result = ATURI::from_str(input);
assert!(result.is_err());
assert!(matches!(result.unwrap_err(), AturiError::TrailingSlash));
}
#[test]
fn test_handle_not_supported_error() {
let input = "at://alice.bsky.social/app.bsky.feed.post/3jui7kp54ic2i";
let result = ATURI::from_str(input);
assert!(result.is_err());
assert!(matches!(
result.unwrap_err(),
AturiError::HandleNotSupported
));
}
#[test]
fn test_authority_parsing_failed_error() {
let input = "at://invalid-did-format/app.bsky.feed.post/3jui7kp54ic2i";
let result = ATURI::from_str(input);
assert!(result.is_err());
assert!(matches!(
result.unwrap_err(),
AturiError::AuthorityParsingFailed { .. }
));
}
#[test]
fn test_empty_authority_parsing_failed_error() {
let input = "at:///app.bsky.feed.post/3jui7kp54ic2i";
let result = ATURI::from_str(input);
assert!(result.is_err());
assert!(matches!(result.unwrap_err(), AturiError::AuthorityMissing));
}
#[test]
fn test_collection_missing_error() {
let input = "at://did:plc:ewvi7nxzyoun6zhxrhs64oiz";
let result = ATURI::from_str(input);
assert!(result.is_err());
assert!(matches!(result.unwrap_err(), AturiError::CollectionMissing));
}
#[test]
fn test_collection_missing_with_slash_error() {
let input = "at://did:plc:ewvi7nxzyoun6zhxrhs64oiz/";
let result = ATURI::from_str(input);
assert!(result.is_err());
assert!(matches!(result.unwrap_err(), AturiError::TrailingSlash));
}
#[test]
fn test_record_key_missing_error() {
let input = "at://did:plc:ewvi7nxzyoun6zhxrhs64oiz/app.bsky.feed.post";
let result = ATURI::from_str(input);
assert!(result.is_err());
assert!(matches!(result.unwrap_err(), AturiError::RecordKeyMissing));
}
#[test]
fn test_record_key_missing_with_slash_error() {
let input = "at://did:plc:ewvi7nxzyoun6zhxrhs64oiz/app.bsky.feed.post/";
let result = ATURI::from_str(input);
assert!(result.is_err());
assert!(matches!(result.unwrap_err(), AturiError::TrailingSlash));
}
#[test]
fn test_extra_path_components_ignored() {
let input = "at://did:plc:ewvi7nxzyoun6zhxrhs64oiz/app.bsky.feed.post/3jui7kp54ic2i/extra/components";
let result = ATURI::from_str(input).unwrap();
assert_eq!(result.authority, "did:plc:ewvi7nxzyoun6zhxrhs64oiz");
assert_eq!(result.collection, "app.bsky.feed.post");
assert_eq!(result.record_key, "3jui7kp54ic2i");
}
#[test]
fn test_complex_collection_nsid() {
let input =
"at://did:plc:ewvi7nxzyoun6zhxrhs64oiz/com.example.complex.collection.type/record123";
let result = ATURI::from_str(input).unwrap();
assert_eq!(result.authority, "did:plc:ewvi7nxzyoun6zhxrhs64oiz");
assert_eq!(result.collection, "com.example.complex.collection.type");
assert_eq!(result.record_key, "record123");
}
#[test]
fn test_complex_record_key() {
let input = "at://did:plc:ewvi7nxzyoun6zhxrhs64oiz/app.bsky.feed.post/complex-record.key_with-symbols123";
let result = ATURI::from_str(input).unwrap();
assert_eq!(result.authority, "did:plc:ewvi7nxzyoun6zhxrhs64oiz");
assert_eq!(result.collection, "app.bsky.feed.post");
assert_eq!(result.record_key, "complex-record.key_with-symbols123");
}
#[test]
fn test_minimal_valid_aturi() {
let input = "at://did:plc:abcdefghijklmnopqrstuvwx/b/c";
let result = ATURI::from_str(input).unwrap();
assert_eq!(result.authority, "did:plc:abcdefghijklmnopqrstuvwx");
assert_eq!(result.collection, "b");
assert_eq!(result.record_key, "c");
}
#[test]
fn test_long_valid_aturi() {
let very_long_did = "did:plc:abcdefghijklmnopqrstuvwx";
let very_long_collection = "com.example.".to_string() + &"long".repeat(10) + ".collection";
let very_long_record_key = "record".to_string() + &"x".repeat(50);
let input = format!(
"at://{}/{}/{}",
very_long_did, very_long_collection, very_long_record_key
);
let result = ATURI::from_str(&input).unwrap();
assert_eq!(result.authority, very_long_did);
assert_eq!(result.collection, very_long_collection);
assert_eq!(result.record_key, very_long_record_key);
}
#[test]
fn test_empty_string_error() {
let input = "";
let result = ATURI::from_str(input);
assert!(result.is_err());
assert!(matches!(result.unwrap_err(), AturiError::MissingPrefix));
}
#[test]
fn test_only_prefix_error() {
let input = "at://";
let result = ATURI::from_str(input);
assert!(result.is_err());
assert!(matches!(result.unwrap_err(), AturiError::AuthorityMissing));
}
#[test]
fn test_whitespace_input_error() {
let input = " ";
let result = ATURI::from_str(input);
assert!(result.is_err());
assert!(matches!(result.unwrap_err(), AturiError::MissingPrefix));
}
#[test]
fn test_malformed_did_web_authority() {
let input = "at://did:invalid:notreal/app.bsky.feed.post/3jui7kp54ic2i";
let result = ATURI::from_str(input);
assert!(result.is_err());
assert!(matches!(
result.unwrap_err(),
AturiError::AuthorityParsingFailed { .. }
));
}
#[test]
fn test_did_method_key_authority() {
let input = "at://did:key:z6MkiTBz1ymuepAQ4HEHYSF1H8quG5GLVVQR3djdX3mDooWp/app.bsky.feed.post/3jui7kp54ic2i";
let result = ATURI::from_str(input);
assert!(result.is_err());
assert!(matches!(
result.unwrap_err(),
AturiError::AuthorityParsingFailed { .. }
));
}
#[test]
fn test_whitespace_authority_error() {
let input = "at:// /app.bsky.feed.post/3jui7kp54ic2i";
let result = ATURI::from_str(input);
assert!(result.is_err());
assert!(matches!(
result.unwrap_err(),
AturiError::AuthorityParsingFailed { .. }
));
}
#[test]
fn test_whitespace_collection_error() {
let input = "at://did:plc:abcdefghijklmnopqrstuvwx/ /3jui7kp54ic2i";
let result = ATURI::from_str(input);
assert!(result.is_err());
assert!(matches!(result.unwrap_err(), AturiError::EmptyCollection));
}
#[test]
fn test_whitespace_record_key_error() {
let input = "at://did:plc:abcdefghijklmnopqrstuvwx/app.bsky.feed.post/ ";
let result = ATURI::from_str(input);
assert!(result.is_err());
assert!(matches!(result.unwrap_err(), AturiError::EmptyRecordKey));
}
#[test]
fn test_empty_collection_error() {
let input = "at://did:plc:abcdefghijklmnopqrstuvwx//3jui7kp54ic2i";
let result = ATURI::from_str(input);
assert!(result.is_err());
assert!(matches!(result.unwrap_err(), AturiError::EmptyCollection));
}
#[test]
fn test_empty_record_key_error() {
let input = "at://did:plc:abcdefghijklmnopqrstuvwx/app.bsky.feed.post/";
let result = ATURI::from_str(input);
assert!(result.is_err());
assert!(matches!(result.unwrap_err(), AturiError::TrailingSlash));
}
#[test]
fn test_tab_collection_error() {
let input = "at://did:plc:abcdefghijklmnopqrstuvwx/\t/3jui7kp54ic2i";
let result = ATURI::from_str(input);
assert!(result.is_err());
assert!(matches!(result.unwrap_err(), AturiError::EmptyCollection));
}
#[test]
fn test_newline_record_key_error() {
let input = "at://did:plc:abcdefghijklmnopqrstuvwx/app.bsky.feed.post/\n";
let result = ATURI::from_str(input);
assert!(result.is_err());
assert!(matches!(result.unwrap_err(), AturiError::EmptyRecordKey));
}
}