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 repository: 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 repository: impl Into<String>,
64 entity_type: impl Into<EntityType>,
65 entity_id: impl Into<String>,
66 ) -> Self {
67 Self {
68 repository: repository.into(),
69 entity_type: entity_type.into(),
70 entity_id: entity_id.into(),
71 fragment: None,
72 }
73 }
74
75 pub fn with_fragment(
77 repository: impl Into<String>,
78 entity_type: impl Into<EntityType>,
79 entity_id: impl Into<String>,
80 fragment: impl Into<String>,
81 ) -> Self {
82 Self {
83 repository: repository.into(),
84 entity_type: entity_type.into(),
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.repository, self.entity_type, self.entity_id
96 )
97 }
98
99 pub fn manifest_path(&self) -> String {
104 if self.entity_type.as_str() == "world" {
105 "repository.yaml".to_string()
106 } else {
107 format!(
108 "{}/{}.yaml",
109 self.entity_type.directory_name(),
110 self.entity_id
111 )
112 }
113 }
114}
115
116impl fmt::Display for NapUri {
117 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
118 write!(
119 f,
120 "nap://{}/{}/{}",
121 self.repository, self.entity_type, self.entity_id
122 )?;
123 if let Some(ref fragment) = self.fragment {
124 write!(f, "#{fragment}")?;
125 }
126 Ok(())
127 }
128}
129
130impl FromStr for NapUri {
131 type Err = NapError;
132
133 fn from_str(s: &str) -> Result<Self, Self::Err> {
134 let input = s.trim();
135
136 let without_scheme = input.strip_prefix(NAP_SCHEME).unwrap_or(input);
139
140 let (path_part, fragment) = match without_scheme.split_once('#') {
142 Some((path, frag)) => {
143 let frag_trimmed = frag.trim();
144 if frag_trimmed.is_empty() {
145 (path, None)
146 } else {
147 (path, Some(frag_trimmed.to_string()))
148 }
149 }
150 None => (without_scheme, None),
151 };
152
153 let segments: Vec<&str> = path_part.split('/').filter(|s| !s.is_empty()).collect();
155
156 if segments.len() < 3 {
157 return Err(NapError::InvalidUri {
158 uri: input.to_string(),
159 reason: format!(
160 "expected at least 3 path segments (repository/entity_type/entity_id), got {}",
161 segments.len()
162 ),
163 });
164 }
165
166 let repository = segments[0].to_string();
167 let entity_type = EntityType::new(segments[1]);
168 let entity_id = segments[2..].join("/");
170
171 if repository.is_empty() {
172 return Err(NapError::InvalidUri {
173 uri: input.to_string(),
174 reason: "repository name cannot be empty".to_string(),
175 });
176 }
177 if entity_id.is_empty() {
178 return Err(NapError::InvalidUri {
179 uri: input.to_string(),
180 reason: "entity ID cannot be empty".to_string(),
181 });
182 }
183
184 Ok(NapUri {
185 repository,
186 entity_type,
187 entity_id,
188 fragment,
189 })
190 }
191}
192
193#[cfg(test)]
194mod tests {
195 use super::*;
196
197 #[test]
198 fn test_parse_full_uri_with_fragment() {
199 let uri: NapUri = "nap://toystory/character/woody#appearances.audienceVotes"
200 .parse()
201 .unwrap();
202 assert_eq!(uri.repository, "toystory");
203 assert_eq!(uri.entity_type.as_str(), "character");
204 assert_eq!(uri.entity_id, "woody");
205 assert_eq!(uri.fragment.as_deref(), Some("appearances.audienceVotes"));
206 }
207
208 #[test]
209 fn test_parse_uri_without_fragment() {
210 let uri: NapUri = "nap://toystory/location/pizza-planet".parse().unwrap();
211 assert_eq!(uri.repository, "toystory");
212 assert_eq!(uri.entity_type.as_str(), "location");
213 assert_eq!(uri.entity_id, "pizza-planet");
214 assert!(uri.fragment.is_none());
215 }
216
217 #[test]
218 fn test_parse_custom_entity_type() {
219 let uri: NapUri = "nap://lab/paper/cold-fusion-v2".parse().unwrap();
220 assert_eq!(uri.repository, "lab");
221 assert_eq!(uri.entity_type.as_str(), "paper");
222 assert_eq!(uri.entity_id, "cold-fusion-v2");
223 }
224
225 #[test]
226 fn test_parse_scene_uri() {
227 let uri: NapUri = "nap://toystory/scene/pizza-planet".parse().unwrap();
228 assert_eq!(uri.entity_type.as_str(), "scene");
229 assert_eq!(uri.entity_id, "pizza-planet");
230 }
231
232 #[test]
233 fn test_parse_world_uri() {
234 let uri: NapUri = "nap://toystory/world/toystory".parse().unwrap();
235 assert_eq!(uri.entity_type.as_str(), "world");
236 }
237
238 #[test]
239 fn test_roundtrip_display_parse() {
240 let original = NapUri::with_fragment(
241 "toystory",
242 EntityType::new("character"),
243 "woody",
244 "references.appears_in",
245 );
246 let displayed = original.to_string();
247 let parsed: NapUri = displayed.parse().unwrap();
248 assert_eq!(original, parsed);
249 }
250
251 #[test]
252 fn test_identity_strips_fragment() {
253 let uri = NapUri::with_fragment(
254 "toystory",
255 EntityType::new("character"),
256 "woody",
257 "appearances",
258 );
259 assert_eq!(uri.identity(), "nap://toystory/character/woody");
260 }
261
262 #[test]
263 fn test_manifest_path_character() {
264 let uri = NapUri::new("toystory", EntityType::new("character"), "woody");
265 assert_eq!(uri.manifest_path(), "character/woody.yaml");
266 }
267
268 #[test]
269 fn test_manifest_path_world() {
270 let uri = NapUri::new("toystory", EntityType::new("world"), "toystory");
271 assert_eq!(uri.manifest_path(), "repository.yaml");
272 }
273
274 #[test]
275 fn test_manifest_path_custom_type() {
276 let uri = NapUri::new("lab", EntityType::new("paper"), "cold-fusion-v2");
277 assert_eq!(uri.manifest_path(), "paper/cold-fusion-v2.yaml");
278 }
279
280 #[test]
281 fn test_invalid_too_few_segments() {
282 let result = "nap://toystory/character".parse::<NapUri>();
283 assert!(result.is_err());
284 }
285
286 #[test]
287 fn test_optional_scheme() {
288 let uri: NapUri = "toystory/character/woody#references.appears_in"
289 .parse()
290 .unwrap();
291 assert_eq!(uri.repository, "toystory");
292 assert_eq!(uri.entity_type.as_str(), "character");
293 assert_eq!(uri.entity_id, "woody");
294 assert_eq!(uri.fragment.as_deref(), Some("references.appears_in"));
295 }
296
297 #[test]
298 fn test_bare_path_no_fragment() {
299 let uri: NapUri = "toystory/location/pizza-planet".parse().unwrap();
300 assert_eq!(uri.repository, "toystory");
301 assert_eq!(uri.entity_type.as_str(), "location");
302 assert_eq!(uri.entity_id, "pizza-planet");
303 assert!(uri.fragment.is_none());
304 }
305
306 #[test]
307 fn test_bare_path_too_few_segments() {
308 let result = "toystory/character".parse::<NapUri>();
309 assert!(result.is_err());
310 }
311}