1use std::path::{Path, PathBuf};
16
17use serde::{Deserialize, Serialize};
18use tracing::{debug, info};
19
20use crate::error::NapError;
21use crate::manifest::Manifest;
22use crate::query::ManifestQuery;
23use crate::repository::Repository;
24use crate::uri::NapUri;
25use crate::vcs::VcsBackend;
26use crate::vcs_lore::LoreBackend;
27
28#[derive(Debug, Clone, Default)]
33pub struct ResolveConfig {
34 pub default_branch: Option<String>,
39}
40
41#[derive(Debug, Clone, Default, Serialize, Deserialize)]
45pub struct ResolveOptions {
46 #[serde(default, skip_serializing_if = "Option::is_none")]
49 pub branch: Option<String>,
50
51 #[serde(default, skip_serializing_if = "Option::is_none")]
55 pub commit: Option<String>,
56
57 #[serde(default, skip_serializing_if = "Option::is_none")]
59 pub tag: Option<String>,
60
61 #[serde(default, skip_serializing_if = "Option::is_none")]
63 pub path: Option<String>,
64}
65
66impl ResolveOptions {
67 fn query_path(&self, uri: &NapUri) -> Option<String> {
69 self.path.clone().or_else(|| uri.fragment.clone())
70 }
71}
72
73#[derive(Debug, Clone, Serialize, Deserialize)]
75#[serde(untagged)]
76pub enum ResolveResult {
77 Full(Box<Manifest>),
79 Subtree(serde_json::Value),
81}
82
83pub struct Resolver {
85 base_path: PathBuf,
87 vcs_factory: fn() -> Box<dyn VcsBackend>,
89 config: ResolveConfig,
91}
92
93impl Resolver {
94 pub fn new(base_path: &Path) -> Self {
108 Self {
109 base_path: base_path.to_path_buf(),
110 vcs_factory: || Box::new(LoreBackend::from_env()),
111 config: ResolveConfig::default(),
112 }
113 }
114
115 pub fn with_vcs_factory(
117 base_path: &Path,
118 factory: fn() -> Box<dyn VcsBackend>,
119 config: ResolveConfig,
120 ) -> Self {
121 Self {
122 base_path: base_path.to_path_buf(),
123 vcs_factory: factory,
124 config,
125 }
126 }
127
128 fn open_repo(&self, universe: &str) -> Result<Repository, NapError> {
130 let repo_path = self.base_path.join(universe);
131 Repository::open(&repo_path, (self.vcs_factory)())
132 }
133
134 pub fn resolve(
154 &self,
155 uri_str: &str,
156 options: &ResolveOptions,
157 ) -> Result<ResolveResult, NapError> {
158 let normalized_uri_str = if uri_str.starts_with("nap://") {
160 uri_str.to_string()
161 } else {
162 format!("nap://{}", uri_str.trim_start_matches('/'))
163 };
164
165 debug!(
166 original_uri = %uri_str,
167 normalized_uri = %normalized_uri_str,
168 "normalized NAP URI"
169 );
170
171 let uri: NapUri = normalized_uri_str.parse()?;
172 self.resolve_uri(&uri, options)
173 }
174
175 pub fn resolve_uri(
177 &self,
178 uri: &NapUri,
179 options: &ResolveOptions,
180 ) -> Result<ResolveResult, NapError> {
181 debug!(
182 uri = %uri,
183 options = ?options,
184 "resolving NAP URI"
185 );
186
187 let repo = self.open_repo(&uri.universe)?;
188 let query_path = options.query_path(uri);
189
190 let revision = match (options.commit.as_ref(), options.branch.as_ref()) {
198 (Some(commit), _) => {
199 debug!(%commit, "resolve: rule 1 — commit provided");
200 commit.clone()
201 }
202 (None, Some(branch)) => {
203 debug!(%branch, "resolve: rule 2 — branch provided");
204 repo.resolve_branch_head(branch)?
205 }
206 (None, None) => match &self.config.default_branch {
207 Some(default_branch) => {
208 debug!(%default_branch, "resolve: rule 3 — using default_branch");
209 repo.resolve_branch_head(default_branch)?
210 }
211 None => {
212 debug!("resolve: rule 4 — no branch, no commit, no default_branch");
213 return Err(NapError::NoDefaultBranch);
214 }
215 },
216 };
217
218 let manifest = repo.read_manifest_at_ref(uri.entity_type, &uri.entity_id, &revision)?;
220
221 match query_path {
223 Some(ref path) => {
224 debug!(query_path = %path, "applying subtree query");
225 let yaml_value = manifest.to_value()?;
226 let result = ManifestQuery::query(&yaml_value, path, &manifest.id)?;
227
228 let json_str = serde_yaml::to_string(&result)
230 .map_err(|e| NapError::ManifestValidationError(e.to_string()))?;
231 let json_value: serde_json::Value = serde_yaml::from_str(&json_str)
232 .map_err(|e| NapError::ManifestValidationError(e.to_string()))?;
233
234 info!(
235 uri = %uri,
236 query_path = %path,
237 "resolved NAP URI with query"
238 );
239 Ok(ResolveResult::Subtree(json_value))
240 }
241 None => {
242 info!(uri = %uri, "resolved NAP URI (full manifest)");
243 Ok(ResolveResult::Full(Box::new(manifest)))
244 }
245 }
246 }
247
248 pub fn query(&self, uri_str: &str, path: &str) -> Result<serde_json::Value, NapError> {
250 let options = ResolveOptions {
251 path: Some(path.to_string()),
252 ..Default::default()
253 };
254 match self.resolve(uri_str, &options)? {
255 ResolveResult::Subtree(v) => Ok(v),
256 ResolveResult::Full(m) => m.to_json_value(),
257 }
258 }
259
260 pub fn list_universes(&self) -> Result<Vec<String>, NapError> {
262 let mut universes = Vec::new();
263 for entry in std::fs::read_dir(&self.base_path)? {
264 let entry = entry?;
265 let path = entry.path();
266 if path.is_dir()
267 && path.join(".nap").exists()
268 && let Some(name) = path.file_name().and_then(|n| n.to_str())
269 {
270 universes.push(name.to_string());
271 }
272 }
273 universes.sort();
274 Ok(universes)
275 }
276}
277
278#[cfg(test)]
279mod tests {
280 use super::*;
281 use crate::types::EntityType;
282 use crate::vcs_git::GitBackend;
283 use tempfile::TempDir;
284
285 fn setup() -> (TempDir, Resolver) {
286 let tmp = TempDir::new().unwrap();
287 let repo = Repository::init(tmp.path(), "starwars", Box::new(GitBackend::new())).unwrap();
288
289 let (mut manifest, _) = repo
291 .create_entity(
292 EntityType::Character,
293 "lukeskywalker",
294 "Luke Skywalker",
295 "test",
296 )
297 .unwrap();
298
299 manifest.set_property("species", serde_yaml::Value::String("human".to_string()));
301 manifest.set_property(
302 "homeworld",
303 serde_yaml::Value::String("nap://starwars/location/tatooine".to_string()),
304 );
305 manifest.add_reference(
306 "appears_in",
307 serde_yaml::Value::Sequence(vec![serde_yaml::Value::String(
308 "nap://starwars/scene/cantina".to_string(),
309 )]),
310 );
311
312 use crate::commit::Change;
313 repo.commit_manifest(
314 &mut manifest,
315 "add Luke Skywalker details",
316 "test",
317 vec![Change::set("properties.species", None, "human".to_string())],
318 )
319 .unwrap();
320
321 let resolver = Resolver::with_vcs_factory(
322 tmp.path(),
323 || Box::new(GitBackend::new()),
324 ResolveConfig {
325 default_branch: Some("main".to_string()),
326 },
327 );
328 (tmp, resolver)
329 }
330
331 #[test]
332 fn test_resolve_full_manifest() {
333 let (_tmp, resolver) = setup();
334 let result = resolver
335 .resolve(
336 "nap://starwars/character/lukeskywalker",
337 &Default::default(),
338 )
339 .unwrap();
340 match result {
341 ResolveResult::Full(m) => {
342 assert_eq!(m.name, "Luke Skywalker");
343 assert_eq!(m.entity_type, EntityType::Character);
344 }
345 _ => panic!("expected full manifest"),
346 }
347 }
348
349 #[test]
350 fn test_resolve_with_fragment() {
351 let (_tmp, resolver) = setup();
352 let result = resolver
353 .resolve(
354 "nap://starwars/character/lukeskywalker#properties.species",
355 &Default::default(),
356 )
357 .unwrap();
358 match result {
359 ResolveResult::Subtree(v) => {
360 assert_eq!(v.as_str(), Some("human"));
361 }
362 _ => panic!("expected subtree"),
363 }
364 }
365
366 #[test]
367 fn test_resolve_with_options_path() {
368 let (_tmp, resolver) = setup();
369 let result = resolver
370 .resolve(
371 "nap://starwars/character/lukeskywalker",
372 &ResolveOptions {
373 path: Some("properties.homeworld".to_string()),
374 ..Default::default()
375 },
376 )
377 .unwrap();
378 match result {
379 ResolveResult::Subtree(v) => {
380 assert_eq!(v.as_str(), Some("nap://starwars/location/tatooine"));
381 }
382 _ => panic!("expected subtree"),
383 }
384 }
385
386 #[test]
387 fn test_query_convenience() {
388 let (_tmp, resolver) = setup();
389 let result = resolver
390 .query(
391 "nap://starwars/character/lukeskywalker",
392 "properties.species",
393 )
394 .unwrap();
395 assert_eq!(result.as_str(), Some("human"));
396 }
397
398 #[test]
399 fn test_list_universes() {
400 let (_tmp, resolver) = setup();
401 let universes = resolver.list_universes().unwrap();
402 assert!(universes.contains(&"starwars".to_string()));
403 }
404
405 #[test]
406 fn test_resolve_not_found() {
407 let (_tmp, resolver) = setup();
408 let result = resolver.resolve("nap://starwars/character/nonexistent", &Default::default());
409 assert!(result.is_err());
410 }
411
412 #[test]
413 fn test_resolve_without_scheme() {
414 let (_tmp, resolver) = setup();
415 let result = resolver
416 .resolve("starwars/character/lukeskywalker", &Default::default())
417 .unwrap();
418 match result {
419 ResolveResult::Full(m) => {
420 assert_eq!(m.name, "Luke Skywalker");
421 assert_eq!(m.entity_type, EntityType::Character);
422 }
423 _ => panic!("expected full manifest"),
424 }
425 }
426
427 #[test]
428 fn test_resolve_without_scheme_with_fragment() {
429 let (_tmp, resolver) = setup();
430 let result = resolver
431 .resolve(
432 "starwars/character/lukeskywalker#properties.species",
433 &Default::default(),
434 )
435 .unwrap();
436 match result {
437 ResolveResult::Subtree(v) => {
438 assert_eq!(v.as_str(), Some("human"));
439 }
440 _ => panic!("expected subtree"),
441 }
442 }
443
444 #[test]
445 fn test_resolve_without_leading_slash() {
446 let (_tmp, resolver) = setup();
447 let result = resolver
448 .resolve("starwars/character/lukeskywalker", &Default::default())
449 .unwrap();
450 match result {
451 ResolveResult::Full(m) => {
452 assert_eq!(m.name, "Luke Skywalker");
453 }
454 _ => panic!("expected full manifest"),
455 }
456 }
457
458 #[test]
459 fn test_resolve_with_leading_slash_without_scheme() {
460 let (_tmp, resolver) = setup();
461 let result = resolver
462 .resolve("/starwars/character/lukeskywalker", &Default::default())
463 .unwrap();
464 match result {
465 ResolveResult::Full(m) => {
466 assert_eq!(m.name, "Luke Skywalker");
467 }
468 _ => panic!("expected full manifest"),
469 }
470 }
471}