use std::fmt;
use std::str::FromStr;
use serde::{Deserialize, Serialize};
use crate::error::NapError;
use crate::types::EntityType;
pub const NAP_SCHEME: &str = "nap://";
#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
pub struct NapUri {
pub repository: String,
pub entity_type: EntityType,
pub entity_id: String,
pub fragment: Option<String>,
}
impl NapUri {
pub fn new(
repository: impl Into<String>,
entity_type: impl Into<EntityType>,
entity_id: impl Into<String>,
) -> Self {
Self {
repository: repository.into(),
entity_type: entity_type.into(),
entity_id: entity_id.into(),
fragment: None,
}
}
pub fn with_fragment(
repository: impl Into<String>,
entity_type: impl Into<EntityType>,
entity_id: impl Into<String>,
fragment: impl Into<String>,
) -> Self {
Self {
repository: repository.into(),
entity_type: entity_type.into(),
entity_id: entity_id.into(),
fragment: Some(fragment.into()),
}
}
pub fn identity(&self) -> String {
format!(
"nap://{}/{}/{}",
self.repository, self.entity_type, self.entity_id
)
}
pub fn manifest_path(&self) -> String {
if self.entity_type.as_str() == "world" {
"repository.yaml".to_string()
} else {
format!(
"{}/{}.yaml",
self.entity_type.directory_name(),
self.entity_id
)
}
}
}
impl fmt::Display for NapUri {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(
f,
"nap://{}/{}/{}",
self.repository, self.entity_type, self.entity_id
)?;
if let Some(ref fragment) = self.fragment {
write!(f, "#{fragment}")?;
}
Ok(())
}
}
impl FromStr for NapUri {
type Err = NapError;
fn from_str(s: &str) -> Result<Self, Self::Err> {
let input = s.trim();
let without_scheme = input.strip_prefix(NAP_SCHEME).unwrap_or(input);
let (path_part, fragment) = match without_scheme.split_once('#') {
Some((path, frag)) => {
let frag_trimmed = frag.trim();
if frag_trimmed.is_empty() {
(path, None)
} else {
(path, Some(frag_trimmed.to_string()))
}
}
None => (without_scheme, None),
};
let segments: Vec<&str> = path_part.split('/').filter(|s| !s.is_empty()).collect();
if segments.len() < 3 {
return Err(NapError::InvalidUri {
uri: input.to_string(),
reason: format!(
"expected at least 3 path segments (repository/entity_type/entity_id), got {}",
segments.len()
),
});
}
let repository = segments[0].to_string();
let entity_type = EntityType::new(segments[1]);
let entity_id = segments[2..].join("/");
if repository.is_empty() {
return Err(NapError::InvalidUri {
uri: input.to_string(),
reason: "repository name cannot be empty".to_string(),
});
}
if entity_id.is_empty() {
return Err(NapError::InvalidUri {
uri: input.to_string(),
reason: "entity ID cannot be empty".to_string(),
});
}
Ok(NapUri {
repository,
entity_type,
entity_id,
fragment,
})
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_parse_full_uri_with_fragment() {
let uri: NapUri = "nap://toystory/character/woody#appearances.audienceVotes"
.parse()
.unwrap();
assert_eq!(uri.repository, "toystory");
assert_eq!(uri.entity_type.as_str(), "character");
assert_eq!(uri.entity_id, "woody");
assert_eq!(uri.fragment.as_deref(), Some("appearances.audienceVotes"));
}
#[test]
fn test_parse_uri_without_fragment() {
let uri: NapUri = "nap://toystory/location/pizza-planet".parse().unwrap();
assert_eq!(uri.repository, "toystory");
assert_eq!(uri.entity_type.as_str(), "location");
assert_eq!(uri.entity_id, "pizza-planet");
assert!(uri.fragment.is_none());
}
#[test]
fn test_parse_custom_entity_type() {
let uri: NapUri = "nap://lab/paper/cold-fusion-v2".parse().unwrap();
assert_eq!(uri.repository, "lab");
assert_eq!(uri.entity_type.as_str(), "paper");
assert_eq!(uri.entity_id, "cold-fusion-v2");
}
#[test]
fn test_parse_scene_uri() {
let uri: NapUri = "nap://toystory/scene/pizza-planet".parse().unwrap();
assert_eq!(uri.entity_type.as_str(), "scene");
assert_eq!(uri.entity_id, "pizza-planet");
}
#[test]
fn test_parse_world_uri() {
let uri: NapUri = "nap://toystory/world/toystory".parse().unwrap();
assert_eq!(uri.entity_type.as_str(), "world");
}
#[test]
fn test_roundtrip_display_parse() {
let original = NapUri::with_fragment(
"toystory",
EntityType::new("character"),
"woody",
"references.appears_in",
);
let displayed = original.to_string();
let parsed: NapUri = displayed.parse().unwrap();
assert_eq!(original, parsed);
}
#[test]
fn test_identity_strips_fragment() {
let uri = NapUri::with_fragment(
"toystory",
EntityType::new("character"),
"woody",
"appearances",
);
assert_eq!(uri.identity(), "nap://toystory/character/woody");
}
#[test]
fn test_manifest_path_character() {
let uri = NapUri::new("toystory", EntityType::new("character"), "woody");
assert_eq!(uri.manifest_path(), "character/woody.yaml");
}
#[test]
fn test_manifest_path_world() {
let uri = NapUri::new("toystory", EntityType::new("world"), "toystory");
assert_eq!(uri.manifest_path(), "repository.yaml");
}
#[test]
fn test_manifest_path_custom_type() {
let uri = NapUri::new("lab", EntityType::new("paper"), "cold-fusion-v2");
assert_eq!(uri.manifest_path(), "paper/cold-fusion-v2.yaml");
}
#[test]
fn test_invalid_too_few_segments() {
let result = "nap://toystory/character".parse::<NapUri>();
assert!(result.is_err());
}
#[test]
fn test_optional_scheme() {
let uri: NapUri = "toystory/character/woody#references.appears_in"
.parse()
.unwrap();
assert_eq!(uri.repository, "toystory");
assert_eq!(uri.entity_type.as_str(), "character");
assert_eq!(uri.entity_id, "woody");
assert_eq!(uri.fragment.as_deref(), Some("references.appears_in"));
}
#[test]
fn test_bare_path_no_fragment() {
let uri: NapUri = "toystory/location/pizza-planet".parse().unwrap();
assert_eq!(uri.repository, "toystory");
assert_eq!(uri.entity_type.as_str(), "location");
assert_eq!(uri.entity_id, "pizza-planet");
assert!(uri.fragment.is_none());
}
#[test]
fn test_bare_path_too_few_segments() {
let result = "toystory/character".parse::<NapUri>();
assert!(result.is_err());
}
}