1use std::fmt;
18use std::str::FromStr;
19
20use serde::{Deserialize, Serialize};
21
22use crate::error::NapError;
23use crate::types::EntityType;
24
25pub const NAP_SCHEME: &str = "nap://";
27
28#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
45pub struct NapUri {
46 pub universe: String,
48
49 pub entity_type: EntityType,
51
52 pub entity_id: String,
54
55 pub fragment: Option<String>,
58}
59
60impl NapUri {
61 pub fn new(
63 universe: impl Into<String>,
64 entity_type: EntityType,
65 entity_id: impl Into<String>,
66 ) -> Self {
67 Self {
68 universe: universe.into(),
69 entity_type,
70 entity_id: entity_id.into(),
71 fragment: None,
72 }
73 }
74
75 pub fn with_fragment(
77 universe: impl Into<String>,
78 entity_type: EntityType,
79 entity_id: impl Into<String>,
80 fragment: impl Into<String>,
81 ) -> Self {
82 Self {
83 universe: universe.into(),
84 entity_type,
85 entity_id: entity_id.into(),
86 fragment: Some(fragment.into()),
87 }
88 }
89
90 pub fn identity(&self) -> String {
93 format!(
94 "nap://{}/{}/{}",
95 self.universe, self.entity_type, self.entity_id
96 )
97 }
98
99 pub fn manifest_path(&self) -> String {
104 match self.entity_type {
105 EntityType::World => "universe.yaml".to_string(),
106 _ => format!(
107 "{}/{}.yaml",
108 self.entity_type.directory_name(),
109 self.entity_id
110 ),
111 }
112 }
113}
114
115impl fmt::Display for NapUri {
116 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
117 write!(
118 f,
119 "nap://{}/{}/{}",
120 self.universe, self.entity_type, self.entity_id
121 )?;
122 if let Some(ref fragment) = self.fragment {
123 write!(f, "#{fragment}")?;
124 }
125 Ok(())
126 }
127}
128
129impl FromStr for NapUri {
130 type Err = NapError;
131
132 fn from_str(s: &str) -> Result<Self, Self::Err> {
133 let input = s.trim();
134
135 let without_scheme = input.strip_prefix(NAP_SCHEME).unwrap_or(input);
138
139 let (path_part, fragment) = match without_scheme.split_once('#') {
141 Some((path, frag)) => {
142 let frag_trimmed = frag.trim();
143 if frag_trimmed.is_empty() {
144 (path, None)
145 } else {
146 (path, Some(frag_trimmed.to_string()))
147 }
148 }
149 None => (without_scheme, None),
150 };
151
152 let segments: Vec<&str> = path_part.split('/').filter(|s| !s.is_empty()).collect();
154
155 if segments.len() < 3 {
156 return Err(NapError::InvalidUri {
157 uri: input.to_string(),
158 reason: format!(
159 "expected at least 3 path segments (universe/entity_type/entity_id), got {}",
160 segments.len()
161 ),
162 });
163 }
164
165 let universe = segments[0].to_string();
166 let entity_type: EntityType = segments[1].parse()?;
167 let entity_id = segments[2..].join("/");
169
170 if universe.is_empty() {
171 return Err(NapError::InvalidUri {
172 uri: input.to_string(),
173 reason: "universe name cannot be empty".to_string(),
174 });
175 }
176 if entity_id.is_empty() {
177 return Err(NapError::InvalidUri {
178 uri: input.to_string(),
179 reason: "entity ID cannot be empty".to_string(),
180 });
181 }
182
183 Ok(NapUri {
184 universe,
185 entity_type,
186 entity_id,
187 fragment,
188 })
189 }
190}
191
192#[cfg(test)]
193mod tests {
194 use super::*;
195
196 #[test]
197 fn test_parse_full_uri_with_fragment() {
198 let uri: NapUri = "nap://starwars/character/lukeskywalker#appearances.audienceVotes"
199 .parse()
200 .unwrap();
201 assert_eq!(uri.universe, "starwars");
202 assert_eq!(uri.entity_type, EntityType::Character);
203 assert_eq!(uri.entity_id, "lukeskywalker");
204 assert_eq!(uri.fragment.as_deref(), Some("appearances.audienceVotes"));
205 }
206
207 #[test]
208 fn test_parse_uri_without_fragment() {
209 let uri: NapUri = "nap://toystory/location/pizzapalace".parse().unwrap();
210 assert_eq!(uri.universe, "toystory");
211 assert_eq!(uri.entity_type, EntityType::Location);
212 assert_eq!(uri.entity_id, "pizzapalace");
213 assert!(uri.fragment.is_none());
214 }
215
216 #[test]
217 fn test_parse_scene_uri() {
218 let uri: NapUri = "nap://starwars/scene/cantina".parse().unwrap();
219 assert_eq!(uri.entity_type, EntityType::Scene);
220 assert_eq!(uri.entity_id, "cantina");
221 }
222
223 #[test]
224 fn test_parse_world_uri() {
225 let uri: NapUri = "nap://starwars/world/starwars".parse().unwrap();
226 assert_eq!(uri.entity_type, EntityType::World);
227 }
228
229 #[test]
230 fn test_roundtrip_display_parse() {
231 let original = NapUri::with_fragment(
232 "starwars",
233 EntityType::Character,
234 "lukeskywalker",
235 "references.appears_in",
236 );
237 let displayed = original.to_string();
238 let parsed: NapUri = displayed.parse().unwrap();
239 assert_eq!(original, parsed);
240 }
241
242 #[test]
243 fn test_identity_strips_fragment() {
244 let uri = NapUri::with_fragment(
245 "starwars",
246 EntityType::Character,
247 "lukeskywalker",
248 "appearances",
249 );
250 assert_eq!(uri.identity(), "nap://starwars/character/lukeskywalker");
251 }
252
253 #[test]
254 fn test_manifest_path() {
255 let uri_char = NapUri::new("starwars", EntityType::Character, "lukeskywalker");
256 assert_eq!(uri_char.manifest_path(), "characters/lukeskywalker.yaml");
257
258 let uri_world = NapUri::new("starwars", EntityType::World, "starwars");
259 assert_eq!(uri_world.manifest_path(), "universe.yaml");
260 }
261
262 #[test]
263 fn test_invalid_scheme_or_entity_type() {
264 let result = "http://starwars/character/luke".parse::<NapUri>();
266 assert!(result.is_err());
267 }
268
269 #[test]
270 fn test_optional_scheme() {
271 let uri: NapUri = "starwars/character/lukeskywalker#references.appears_in"
273 .parse()
274 .unwrap();
275 assert_eq!(uri.universe, "starwars");
276 assert_eq!(uri.entity_type, EntityType::Character);
277 assert_eq!(uri.entity_id, "lukeskywalker");
278 assert_eq!(uri.fragment.as_deref(), Some("references.appears_in"));
279 }
280
281 #[test]
282 fn test_bare_path_no_fragment() {
283 let uri: NapUri = "toystory/location/pizzapalace".parse().unwrap();
284 assert_eq!(uri.universe, "toystory");
285 assert_eq!(uri.entity_type, EntityType::Location);
286 assert_eq!(uri.entity_id, "pizzapalace");
287 assert!(uri.fragment.is_none());
288 }
289
290 #[test]
291 fn test_too_few_segments() {
292 let result = "nap://starwars/character".parse::<NapUri>();
293 assert!(result.is_err());
294 }
295
296 #[test]
297 fn test_bare_path_too_few_segments() {
298 let result = "starwars/character".parse::<NapUri>();
299 assert!(result.is_err());
300 }
301}