Skip to main content

nap_core/
uri.rs

1//! NAP URI parser and builder.
2//!
3//! The NAP URI scheme identifies narrative resources:
4//!
5//! ```text
6//! nap://starwars/character/lukeskywalker#appearances.audienceVotes
7//! ───┬── ───┬──── ────┬──── ──────┬────── ─────────────┬───────────
8//!  scheme universe  entity_type entity_id          fragment (query)
9//! ```
10//!
11//! **Key design decisions:**
12//! - Version/branch/tag are NEVER encoded in the URI path. They are orthogonal
13//!   selectors passed alongside the URI (mirrors Git, OCI, package managers).
14//! - Fragment (`#`) carries the query path for subtree extraction.
15//! - Entity type is singular in the URI (`character`, not `characters`).
16
17use std::fmt;
18use std::str::FromStr;
19
20use serde::{Deserialize, Serialize};
21
22use crate::error::NapError;
23use crate::types::EntityType;
24
25/// The `nap://` URI scheme constant.
26pub const NAP_SCHEME: &str = "nap://";
27
28/// A parsed NAP URI representing a narrative resource identity.
29///
30/// # Examples
31///
32/// ```
33/// use nap_core::uri::NapUri;
34///
35/// let uri: NapUri = "nap://starwars/character/lukeskywalker#references.appears_in"
36///     .parse()
37///     .unwrap();
38///
39/// assert_eq!(uri.universe, "starwars");
40/// assert_eq!(uri.entity_type, nap_core::types::EntityType::Character);
41/// assert_eq!(uri.entity_id, "lukeskywalker");
42/// assert_eq!(uri.fragment.as_deref(), Some("references.appears_in"));
43/// ```
44#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
45pub struct NapUri {
46    /// The fictional universe (repository). e.g., `"starwars"`, `"toystory"`.
47    pub universe: String,
48
49    /// The kind of entity being addressed.
50    pub entity_type: EntityType,
51
52    /// The entity's identifier (slug). e.g., `"lukeskywalker"`, `"tatooine"`.
53    pub entity_id: String,
54
55    /// Optional fragment for subtree queries. e.g., `"appearances.audienceVotes"`.
56    /// Populated from the `#` portion of the URI.
57    pub fragment: Option<String>,
58}
59
60impl NapUri {
61    /// Construct a new NAP URI without a fragment.
62    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    /// Construct a NAP URI with a fragment query path.
76    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    /// Returns the canonical URI string WITHOUT the fragment.
91    /// This is the resource identity — fragments are query concerns.
92    pub fn identity(&self) -> String {
93        format!(
94            "nap://{}/{}/{}",
95            self.universe, self.entity_type, self.entity_id
96        )
97    }
98
99    /// Returns the relative filesystem path for this entity's manifest within
100    /// a universe repository.
101    ///
102    /// e.g., `"characters/lukeskywalker.yaml"` or `"universe.yaml"` for world.
103    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        // ── Strip scheme (optional) ──────────────────────────────────────
136        // Accept both "nap://starwars/character/luke" and "starwars/character/luke".
137        let without_scheme = input.strip_prefix(NAP_SCHEME).unwrap_or(input);
138
139        // ── Split fragment ──────────────────────────────────────────────
140        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        // ── Parse path segments: universe / entity_type / entity_id ─────
153        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        // Join remaining segments to support entity IDs with slashes (defensive)
168        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        // "http:" is treated as the universe name, "starwars" fails as entity type
265        let result = "http://starwars/character/luke".parse::<NapUri>();
266        assert!(result.is_err());
267    }
268
269    #[test]
270    fn test_optional_scheme() {
271        // Bare path without nap:// scheme should resolve correctly
272        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}