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://toystory/character/woody#appearances.audienceVotes
7//! ───┬── ───┬──── ────┬──── ──────┬────── ─────────────┬───────────
8//!  scheme repository  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 VCS, OCI, package managers).
14//! - Fragment (`#`) carries the query path for subtree extraction.
15//! - Entity type is any non-empty string — fully dynamic and user-defined.
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://toystory/character/woody#references.appears_in"
36///     .parse()
37///     .unwrap();
38///
39/// assert_eq!(uri.repository, "toystory");
40/// assert_eq!(uri.entity_type.as_str(), "character");
41/// assert_eq!(uri.entity_id, "woody");
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 repository name (directory under base_dir). e.g., `"toystory"`, `"pokemon"`.
47    pub repository: String,
48
49    /// The kind of entity being addressed. Any non-empty string is valid.
50    pub entity_type: EntityType,
51
52    /// The entity's identifier (slug). e.g., `"woody"`, `"pikachu"`.
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        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    /// Construct a NAP URI with a fragment query path.
76    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    /// 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.repository, self.entity_type, self.entity_id
96        )
97    }
98
99    /// Returns the relative filesystem path for this entity's manifest within
100    /// a repository.
101    ///
102    /// e.g., `"character/woody.yaml"` or `"repository.yaml"` for repo metadata.
103    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        // ── Strip scheme (optional) ──────────────────────────────────────
137        // Accept both "nap://toystory/character/woody" and "toystory/character/woody".
138        let without_scheme = input.strip_prefix(NAP_SCHEME).unwrap_or(input);
139
140        // ── Split fragment ──────────────────────────────────────────────
141        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        // ── Parse path segments: repository / entity_type / entity_id ─────
154        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        // Join remaining segments to support entity IDs with slashes (defensive)
169        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}