use crate::{PubkyId, APP_PATH};
use serde::{Deserialize, Serialize};
use std::convert::{From, TryFrom};
use super::super::path::try_parse_pubky_path;
use super::super::{ParsedUri, Resource};
use super::tag::TagPath;
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub enum ExtendedParsedUri {
PubkyApp {
user_id: PubkyId,
resource: Resource,
},
UniversalTag {
user_id: PubkyId,
app: String,
resource: Resource,
},
}
impl ExtendedParsedUri {
pub fn user_id(&self) -> &PubkyId {
match self {
ExtendedParsedUri::PubkyApp { user_id, .. }
| ExtendedParsedUri::UniversalTag { user_id, .. } => user_id,
}
}
pub fn resource(&self) -> &Resource {
match self {
ExtendedParsedUri::PubkyApp { resource, .. }
| ExtendedParsedUri::UniversalTag { resource, .. } => resource,
}
}
pub fn app(&self) -> &str {
match self {
ExtendedParsedUri::PubkyApp { .. } => "pubky.app",
ExtendedParsedUri::UniversalTag { app, .. } => app.as_str(),
}
}
pub fn tag_id(&self) -> Option<&str> {
match self {
ExtendedParsedUri::PubkyApp { .. } => None,
ExtendedParsedUri::UniversalTag { resource, .. } => match resource {
Resource::Tag(id) => Some(id.as_str()),
_ => None,
},
}
}
pub fn try_to_uri_str(&self) -> Result<String, String> {
match self {
ExtendedParsedUri::PubkyApp { user_id, resource } => ParsedUri {
user_id: user_id.clone(),
resource: resource.clone(),
}
.try_to_uri_str(),
ExtendedParsedUri::UniversalTag {
user_id,
app,
resource,
} => {
let Resource::Tag(tag_id) = resource else {
return Err(format!(
"Universal tag URI must have Tag resource, got {resource}"
));
};
Ok(TagPath {
user_id: user_id.clone(),
app: app.clone(),
tag_id: tag_id.clone(),
}
.to_uri_str())
}
}
}
}
impl From<ParsedUri> for ExtendedParsedUri {
fn from(parsed: ParsedUri) -> Self {
ExtendedParsedUri::PubkyApp {
user_id: parsed.user_id,
resource: parsed.resource,
}
}
}
impl TryFrom<&str> for ExtendedParsedUri {
type Error = String;
fn try_from(uri: &str) -> Result<Self, Self::Error> {
let path = try_parse_pubky_path(uri)?;
if path.app == APP_PATH.trim_matches('/') {
return ParsedUri::try_from(uri).map(Into::into);
}
if let Some(parsed) = TagPath::parse(uri) {
return Ok(ExtendedParsedUri::UniversalTag {
user_id: parsed.user_id,
app: parsed.app,
resource: Resource::Tag(parsed.tag_id),
});
}
Err(format!("URI is not a recognized universal tag path: {uri}"))
}
}
impl TryFrom<String> for ExtendedParsedUri {
type Error = String;
fn try_from(uri: String) -> Result<Self, Self::Error> {
ExtendedParsedUri::try_from(uri.as_str())
}
}
#[cfg(test)]
mod tests {
use super::*;
const USER_ID: &str = "operrr8wsbpr3ue9d4qj41ge1kcc6r7fdiy6o3ugjrrhi4y77rdo";
#[test]
fn test_parse_standard_post_uri() {
let post_id = "0032SSN7Q4EVG";
let uri = format!("pubky://{USER_ID}/pub/pubky.app/posts/{post_id}");
let parsed = ExtendedParsedUri::try_from(uri.as_str()).expect("post URI should parse");
assert!(matches!(parsed, ExtendedParsedUri::PubkyApp { .. }));
assert_eq!(parsed.resource(), &Resource::Post(post_id.to_string()));
assert_eq!(parsed.app(), "pubky.app");
}
#[test]
fn test_parse_standard_tag_uri() {
let tag_id = "8Z8CWH8NVYQY39ZEBFGKQWWEKG";
let uri = format!("pubky://{USER_ID}/pub/pubky.app/tags/{tag_id}");
let parsed = ExtendedParsedUri::try_from(uri.as_str()).expect("tag URI should parse");
assert!(matches!(parsed, ExtendedParsedUri::PubkyApp { .. }));
assert_eq!(parsed.resource(), &Resource::Tag(tag_id.to_string()));
assert_eq!(parsed.app(), "pubky.app");
assert_eq!(parsed.tag_id(), None);
}
#[test]
fn test_parse_universal_tag_uri_mapky() {
let tag_id = "ABC123";
let uri = format!("pubky://{USER_ID}/pub/mapky/tags/{tag_id}");
let parsed = ExtendedParsedUri::try_from(uri.as_str()).expect("mapky tag URI should parse");
assert!(matches!(parsed, ExtendedParsedUri::UniversalTag { .. }));
assert_eq!(parsed.user_id(), &PubkyId::try_from(USER_ID).unwrap());
assert_eq!(parsed.app(), "mapky");
assert_eq!(parsed.resource(), &Resource::Tag(tag_id.to_string()));
assert_eq!(parsed.tag_id(), Some(tag_id));
}
#[test]
fn test_parse_universal_tag_uri_eventky() {
let tag_id = "XYZ789";
let uri = format!("pubky://{USER_ID}/pub/eventky.app/tags/{tag_id}");
let parsed =
ExtendedParsedUri::try_from(uri.as_str()).expect("eventky tag URI should parse");
assert!(matches!(parsed, ExtendedParsedUri::UniversalTag { .. }));
assert_eq!(parsed.user_id(), &PubkyId::try_from(USER_ID).unwrap());
assert_eq!(parsed.app(), "eventky.app");
assert_eq!(parsed.resource(), &Resource::Tag(tag_id.to_string()));
assert_eq!(parsed.tag_id(), Some(tag_id));
}
#[test]
fn test_reject_universal_non_tag_path() {
let uri = format!("pubky://{USER_ID}/pub/eventky.app/posts/123");
assert!(ExtendedParsedUri::try_from(uri.as_str()).is_err());
}
#[test]
fn test_reject_non_pubky_scheme() {
assert!(ExtendedParsedUri::try_from("https://example.com/pub/pubky.app/").is_err());
}
#[test]
fn test_reject_missing_user_id() {
assert!(ExtendedParsedUri::try_from("pubky:///pub/pubky.app/").is_err());
}
#[test]
fn test_propagate_invalid_pubky_id_on_pubky_app_uri() {
let uri = "pubky://not-a-pubky-id/pub/pubky.app/posts/0032SSN7Q4EVG";
let extended_err =
ExtendedParsedUri::try_from(uri).expect_err("invalid pubky id should fail");
let parsed_err = ParsedUri::try_from(uri).expect_err("invalid pubky id should fail");
assert_eq!(extended_err, parsed_err);
}
#[test]
fn test_propagate_invalid_follow_id_on_pubky_app_uri() {
let uri = format!("pubky://{USER_ID}/pub/pubky.app/follows/NOT_A_PUBKY_ID");
let extended_err =
ExtendedParsedUri::try_from(uri.as_str()).expect_err("invalid follow id should fail");
let parsed_err =
ParsedUri::try_from(uri.as_str()).expect_err("invalid follow id should fail");
assert_eq!(extended_err, parsed_err);
}
#[test]
fn test_propagate_structural_error_for_foreign_uri() {
let uri = "pubky://not-a-pubky-id/pub/mapky/tags/ABC123";
let extended_err = ExtendedParsedUri::try_from(uri)
.expect_err("invalid pubky id on foreign URI should fail");
let path_err = try_parse_pubky_path(uri).expect_err("invalid pubky id should fail");
assert_eq!(extended_err, path_err);
}
#[test]
fn test_uppercase_scheme() {
let uri = format!("PUBKY://{USER_ID}/pub/mapky/tags/ABC123");
assert!(ExtendedParsedUri::try_from(uri.as_str()).is_ok());
}
#[test]
fn test_universal_tag_uri_with_query_string() {
let uri = format!("pubky://{USER_ID}/pub/mapky/tags/ABC123?foo=bar");
let parsed = ExtendedParsedUri::try_from(uri.as_str())
.expect("universal tag URI with query should parse");
assert!(matches!(parsed, ExtendedParsedUri::UniversalTag { .. }));
assert_eq!(parsed.resource(), &Resource::Tag("ABC123".to_string()));
assert_eq!(parsed.tag_id(), Some("ABC123"));
}
#[test]
fn test_universal_tag_uri_with_fragment() {
let uri = format!("pubky://{USER_ID}/pub/mapky/tags/ABC123#section");
let parsed = ExtendedParsedUri::try_from(uri.as_str())
.expect("universal tag URI with fragment should parse");
assert!(matches!(parsed, ExtendedParsedUri::UniversalTag { .. }));
assert_eq!(parsed.resource(), &Resource::Tag("ABC123".to_string()));
assert_eq!(parsed.tag_id(), Some("ABC123"));
}
#[test]
fn test_pubky_app_uri_roundtrip() {
let uri = format!("pubky://{USER_ID}/pub/pubky.app/posts/0032SSN7Q4EVG");
let parsed = ExtendedParsedUri::try_from(uri.as_str()).expect("post URI should parse");
assert_eq!(
parsed.try_to_uri_str().expect("roundtrip should succeed"),
uri
);
}
#[test]
fn test_universal_tag_uri_roundtrip() {
let uri = format!("pubky://{USER_ID}/pub/mapky/tags/ABC123");
let parsed = ExtendedParsedUri::try_from(uri.as_str()).expect("mapky tag URI should parse");
assert_eq!(
parsed.try_to_uri_str().expect("roundtrip should succeed"),
uri
);
}
#[test]
fn test_universal_tag_uri_roundtrip_normalizes_input() {
let input = format!("PUBKY://{USER_ID}/pub/mapky/tags/ABC123?foo=bar#section");
let expected = format!("pubky://{USER_ID}/pub/mapky/tags/ABC123");
let parsed = ExtendedParsedUri::try_from(input.as_str()).expect("URI should parse");
assert_eq!(
parsed.try_to_uri_str().expect("roundtrip should succeed"),
expected
);
}
}