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 #[serde(default, skip_serializing_if = "Option::is_none")]
68 pub recursive: Option<bool>,
69
70 #[serde(default, skip_serializing_if = "Option::is_none")]
73 pub max_depth: Option<usize>,
74}
75
76impl ResolveOptions {
77 fn query_path(&self, uri: &NapUri) -> Option<String> {
79 self.path.clone().or_else(|| uri.fragment.clone())
80 }
81}
82
83#[derive(Debug, Clone, Serialize, Deserialize)]
85#[serde(untagged)]
86pub enum ResolveResult {
87 Full(Box<Manifest>),
89 Subtree(serde_json::Value),
91}
92
93pub struct Resolver {
95 base_path: PathBuf,
97 vcs_factory: fn() -> Box<dyn VcsBackend>,
99 config: ResolveConfig,
101}
102
103impl Resolver {
104 pub fn new(base_path: &Path) -> Self {
121 Self {
122 base_path: base_path.to_path_buf(),
123 vcs_factory: || Box::new(LoreBackend::from_env()),
124 config: ResolveConfig::default(),
125 }
126 }
127
128 pub fn with_vcs_factory(
130 base_path: &Path,
131 factory: fn() -> Box<dyn VcsBackend>,
132 config: ResolveConfig,
133 ) -> Self {
134 Self {
135 base_path: base_path.to_path_buf(),
136 vcs_factory: factory,
137 config,
138 }
139 }
140
141 fn open_repo(&self, repository: &str) -> Result<(Repository, ResolveConfig), NapError> {
143 let repo_path = self.base_path.join(repository);
144 let repo = Repository::open(&repo_path, (self.vcs_factory)())?;
145 let repo_config = repo.read_resolve_config();
146 Ok((repo, repo_config))
147 }
148
149 pub fn resolve(
169 &self,
170 uri_str: &str,
171 options: &ResolveOptions,
172 ) -> Result<ResolveResult, NapError> {
173 let normalized_uri_str = if uri_str.starts_with("nap://") {
175 uri_str.to_string()
176 } else {
177 format!("nap://{}", uri_str.trim_start_matches('/'))
178 };
179
180 debug!(
181 original_uri = %uri_str,
182 normalized_uri = %normalized_uri_str,
183 "normalized NAP URI"
184 );
185
186 let uri: NapUri = normalized_uri_str.parse()?;
187 self.resolve_uri(&uri, options)
188 }
189
190 pub fn resolve_uri(
192 &self,
193 uri: &NapUri,
194 options: &ResolveOptions,
195 ) -> Result<ResolveResult, NapError> {
196 debug!(
197 uri = %uri,
198 options = ?options,
199 "resolving NAP URI"
200 );
201
202 if options.recursive.unwrap_or(false) {
204 return self.resolve_uri_recursive(
205 uri,
206 options,
207 0,
208 &mut std::collections::HashSet::new(),
209 );
210 }
211
212 self.resolve_uri_single(uri, options)
213 }
214
215 fn resolve_uri_single(
217 &self,
218 uri: &NapUri,
219 options: &ResolveOptions,
220 ) -> Result<ResolveResult, NapError> {
221 let (repo, repo_config) = self.open_repo(&uri.repository)?;
222 let query_path = options.query_path(uri);
223
224 let revision = match (options.commit.as_ref(), options.branch.as_ref()) {
232 (Some(commit), _) => {
233 debug!(%commit, "resolve: rule 1 — commit provided");
234 commit.clone()
235 }
236 (None, Some(branch)) => {
237 debug!(%branch, "resolve: rule 2 — branch provided");
238 repo.resolve_branch_head(branch)?
239 }
240 (None, None) => match &repo_config.default_branch {
241 Some(default_branch) => {
242 debug!(%default_branch, "resolve: rule 3 — using repo default_branch");
243 repo.resolve_branch_head(default_branch)?
244 }
245 None => match &self.config.default_branch {
246 Some(global_default_branch) => {
247 debug!(%global_default_branch, "resolve: rule 3 — using global default_branch");
248 repo.resolve_branch_head(global_default_branch)?
249 }
250 None => {
251 debug!("resolve: rule 4 — no branch, no commit, no default_branch");
252 return Err(NapError::NoDefaultBranch);
253 }
254 },
255 },
256 };
257
258 let manifest = repo.read_manifest_at_ref(&uri.entity_type, &uri.entity_id, &revision)?;
260
261 match query_path {
263 Some(ref path) => {
264 debug!(query_path = %path, "applying subtree query");
265 let yaml_value = manifest.to_value()?;
266 let result = ManifestQuery::query(&yaml_value, path, &manifest.id)?;
267
268 let json_str = serde_yaml::to_string(&result)
270 .map_err(|e| NapError::ManifestValidationError(e.to_string()))?;
271 let json_value: serde_json::Value = serde_yaml::from_str(&json_str)
272 .map_err(|e| NapError::ManifestValidationError(e.to_string()))?;
273
274 info!(
275 uri = %uri,
276 query_path = %path,
277 "resolved NAP URI with query"
278 );
279 Ok(ResolveResult::Subtree(json_value))
280 }
281 None => {
282 info!(uri = %uri, "resolved NAP URI (full manifest)");
283 Ok(ResolveResult::Full(Box::new(manifest)))
284 }
285 }
286 }
287
288 fn resolve_uri_recursive(
290 &self,
291 uri: &NapUri,
292 options: &ResolveOptions,
293 depth: usize,
294 visited: &mut std::collections::HashSet<String>,
295 ) -> Result<ResolveResult, NapError> {
296 let max_depth = options.max_depth.unwrap_or(10);
298 if depth >= max_depth {
299 debug!(depth, max_depth, "reached maximum recursion depth");
300 return self.resolve_uri_single(uri, options);
301 }
302
303 let uri_str = uri.to_string();
305 if visited.contains(&uri_str) {
306 debug!(uri = %uri_str, "detected circular reference, stopping recursion");
307 return self.resolve_uri_single(uri, options);
308 }
309 visited.insert(uri_str.clone());
310
311 debug!(uri = %uri_str, depth, "recursively resolving URI");
312
313 let result = self.resolve_uri_single(uri, options)?;
315
316 match result {
318 ResolveResult::Full(manifest) => {
319 let nested_uris = self.extract_nested_uris(&manifest);
320 if nested_uris.is_empty() {
321 debug!(uri = %uri_str, "no nested URIs found, returning manifest");
322 return Ok(ResolveResult::Full(manifest));
323 }
324
325 debug!(uri = %uri_str, count = nested_uris.len(), "found nested URIs, resolving recursively");
326
327 let mut resolved_manifest = (*manifest).clone();
329 for nested_uri in nested_uris {
330 let nested_uri_parsed: NapUri = nested_uri.parse()?;
331
332 let nested_result = self
333 .resolve_uri_recursive(&nested_uri_parsed, options, depth + 1, visited)
334 .map_err(|e| {
335 NapError::Other(format!(
336 "failed to resolve nested URI '{}' while resolving '{}': {}",
337 nested_uri, uri_str, e
338 ))
339 })?;
340
341 if let ResolveResult::Full(nested_manifest) = nested_result {
342 for (key, value) in nested_manifest.properties {
345 resolved_manifest.properties.insert(key, value);
346 }
347 }
348 }
349
350 Ok(ResolveResult::Full(Box::new(resolved_manifest)))
351 }
352 ResolveResult::Subtree(value) => {
353 debug!("subtree query, skipping recursive resolution");
355 Ok(ResolveResult::Subtree(value))
356 }
357 }
358 }
359
360 fn extract_nested_uris(&self, manifest: &Manifest) -> Vec<String> {
362 let mut uris = Vec::new();
363
364 for value in manifest.properties.values() {
366 self.extract_uris_from_yaml_value(value, &mut uris);
367 }
368
369 for value in manifest.references.values() {
371 self.extract_uris_from_yaml_value(value, &mut uris);
372 }
373
374 uris.sort();
376 uris.dedup();
377 uris
378 }
379
380 fn extract_uris_from_yaml_value(&self, value: &serde_yaml::Value, uris: &mut Vec<String>) {
382 match value {
383 serde_yaml::Value::String(s) if s.starts_with("nap://") => {
384 uris.push(s.clone());
385 }
386 serde_yaml::Value::Sequence(seq) => {
387 for item in seq {
388 self.extract_uris_from_yaml_value(item, uris);
389 }
390 }
391 serde_yaml::Value::Mapping(map) => {
392 for (_, v) in map {
393 self.extract_uris_from_yaml_value(v, uris);
394 }
395 }
396 _ => {}
397 }
398 }
399
400 pub fn query(&self, uri_str: &str, path: &str) -> Result<serde_json::Value, NapError> {
402 let options = ResolveOptions {
403 path: Some(path.to_string()),
404 ..Default::default()
405 };
406 match self.resolve(uri_str, &options)? {
407 ResolveResult::Subtree(v) => Ok(v),
408 ResolveResult::Full(m) => m.to_json_value(),
409 }
410 }
411
412 pub fn list_repositories(&self) -> Result<Vec<String>, NapError> {
414 let mut repositories = Vec::new();
415 for entry in std::fs::read_dir(&self.base_path)? {
416 let entry = entry?;
417 let path = entry.path();
418 if path.is_dir()
420 && (path.join("repository.yaml").exists() || path.join("repository.yaml").exists())
421 && let Some(name) = path.file_name().and_then(|n| n.to_str())
422 {
423 repositories.push(name.to_string());
424 }
425 }
426 repositories.sort();
427 Ok(repositories)
428 }
429}
430
431#[cfg(test)]
432mod unit_tests {
433 use super::*;
434 use crate::test_utils::MockBackend;
435 use crate::types::EntityType;
436 use tempfile::TempDir;
437
438 fn setup() -> (TempDir, Resolver) {
439 let tmp = TempDir::new().unwrap();
440 let repo_path = tmp.path().join("starwars");
441 let repo = Repository::init(&repo_path, "starwars", Box::new(MockBackend::new())).unwrap();
442
443 let (mut manifest, _) = repo
445 .create_entity(
446 &EntityType::new("character"),
447 "lukeskywalker",
448 "Luke Skywalker",
449 "test",
450 )
451 .unwrap();
452
453 manifest.set_property("species", serde_yaml::Value::String("human".to_string()));
455 manifest.set_property(
456 "homeworld",
457 serde_yaml::Value::String("nap://starwars/location/tatooine".to_string()),
458 );
459 manifest.add_reference(
460 "appears_in",
461 serde_yaml::Value::Sequence(vec![serde_yaml::Value::String(
462 "nap://starwars/scene/cantina".to_string(),
463 )]),
464 );
465
466 use crate::commit::Change;
467 repo.commit_manifest(
468 &mut manifest,
469 "add Luke Skywalker details",
470 "test",
471 vec![Change::set("properties.species", None, "human".to_string())],
472 )
473 .unwrap();
474
475 let resolver = Resolver::with_vcs_factory(
476 tmp.path(),
477 || Box::new(MockBackend::new()),
478 ResolveConfig {
479 default_branch: Some("main".to_string()),
480 },
481 );
482 (tmp, resolver)
483 }
484
485 #[test]
486 fn test_resolve_full_manifest() {
487 let (_tmp, resolver) = setup();
488 let result = resolver
489 .resolve(
490 "nap://starwars/character/lukeskywalker",
491 &Default::default(),
492 )
493 .unwrap();
494 match result {
495 ResolveResult::Full(m) => {
496 assert_eq!(m.name, "Luke Skywalker");
497 assert_eq!(m.entity_type.as_str(), "character");
498 }
499 _ => panic!("expected full manifest"),
500 }
501 }
502
503 #[test]
504 fn test_resolve_with_fragment() {
505 let (_tmp, resolver) = setup();
506 let result = resolver
507 .resolve(
508 "nap://starwars/character/lukeskywalker#properties.species",
509 &Default::default(),
510 )
511 .unwrap();
512 match result {
513 ResolveResult::Subtree(v) => {
514 assert_eq!(v.as_str(), Some("human"));
515 }
516 _ => panic!("expected subtree"),
517 }
518 }
519
520 #[test]
521 fn test_resolve_with_options_path() {
522 let (_tmp, resolver) = setup();
523 let result = resolver
524 .resolve(
525 "nap://starwars/character/lukeskywalker",
526 &ResolveOptions {
527 path: Some("properties.homeworld".to_string()),
528 ..Default::default()
529 },
530 )
531 .unwrap();
532 match result {
533 ResolveResult::Subtree(v) => {
534 assert_eq!(v.as_str(), Some("nap://starwars/location/tatooine"));
535 }
536 _ => panic!("expected subtree"),
537 }
538 }
539
540 #[test]
541 fn test_query_convenience() {
542 let (_tmp, resolver) = setup();
543 let result = resolver
544 .query(
545 "nap://starwars/character/lukeskywalker",
546 "properties.species",
547 )
548 .unwrap();
549 assert_eq!(result.as_str(), Some("human"));
550 }
551
552 #[test]
553 fn test_list_repositories() {
554 let (_tmp, resolver) = setup();
555 let repositories = resolver.list_repositories().unwrap();
556 assert!(repositories.contains(&"starwars".to_string()));
557 }
558
559 #[test]
560 fn test_resolve_not_found() {
561 let (_tmp, resolver) = setup();
562 let result = resolver.resolve("nap://starwars/character/nonexistent", &Default::default());
563 assert!(result.is_err());
564 }
565
566 #[test]
567 fn test_resolve_without_scheme() {
568 let (_tmp, resolver) = setup();
569 let result = resolver
570 .resolve("starwars/character/lukeskywalker", &Default::default())
571 .unwrap();
572 match result {
573 ResolveResult::Full(m) => {
574 assert_eq!(m.name, "Luke Skywalker");
575 assert_eq!(m.entity_type.as_str(), "character");
576 }
577 _ => panic!("expected full manifest"),
578 }
579 }
580
581 #[test]
582 fn test_resolve_without_scheme_with_fragment() {
583 let (_tmp, resolver) = setup();
584 let result = resolver
585 .resolve(
586 "starwars/character/lukeskywalker#properties.species",
587 &Default::default(),
588 )
589 .unwrap();
590 match result {
591 ResolveResult::Subtree(v) => {
592 assert_eq!(v.as_str(), Some("human"));
593 }
594 _ => panic!("expected subtree"),
595 }
596 }
597
598 #[test]
599 fn test_resolve_without_leading_slash() {
600 let (_tmp, resolver) = setup();
601 let result = resolver
602 .resolve("starwars/character/lukeskywalker", &Default::default())
603 .unwrap();
604 match result {
605 ResolveResult::Full(m) => {
606 assert_eq!(m.name, "Luke Skywalker");
607 }
608 _ => panic!("expected full manifest"),
609 }
610 }
611
612 #[test]
613 fn test_resolve_with_leading_slash_without_scheme() {
614 let (_tmp, resolver) = setup();
615 let result = resolver
616 .resolve("/starwars/character/lukeskywalker", &Default::default())
617 .unwrap();
618 match result {
619 ResolveResult::Full(m) => {
620 assert_eq!(m.name, "Luke Skywalker");
621 }
622 _ => panic!("expected full manifest"),
623 }
624 }
625}
626
627#[cfg(all(test, feature = "lore-integration"))]
628mod lore_tests {
629 use super::*;
630 use crate::types::EntityType;
631 use crate::vcs_lore::LoreBackend;
632 use std::time::{SystemTime, UNIX_EPOCH};
633 use tempfile::TempDir;
634
635 fn unique_suffix() -> u64 {
636 SystemTime::now()
637 .duration_since(UNIX_EPOCH)
638 .unwrap()
639 .as_nanos() as u64
640 }
641
642 fn setup_lore() -> (TempDir, Resolver, String) {
643 let repository = format!("lr-{}", unique_suffix());
644 let tmp = TempDir::new().unwrap();
645 let repo_path = tmp.path().join(&repository);
646 let repo =
647 Repository::init(&repo_path, &repository, Box::new(LoreBackend::from_env())).unwrap();
648
649 let (mut manifest, _) = repo
651 .create_entity(
652 &EntityType::new("character"),
653 "lukeskywalker",
654 "Luke Skywalker",
655 "test",
656 )
657 .unwrap();
658
659 manifest.set_property("species", serde_yaml::Value::String("human".to_string()));
661 use crate::commit::Change;
662 repo.commit_manifest(
663 &mut manifest,
664 "add Luke Skywalker details",
665 "test",
666 vec![Change::set("properties.species", None, "human".to_string())],
667 )
668 .unwrap();
669
670 let resolver = Resolver::with_vcs_factory(
671 tmp.path(),
672 || Box::new(LoreBackend::from_env()),
673 ResolveConfig {
674 default_branch: Some("main".to_string()),
675 },
676 );
677 (tmp, resolver, repository)
678 }
679
680 #[test]
681 fn test_resolve_lore_full_manifest() {
682 let (_tmp, resolver, repository) = setup_lore();
683 let uri = format!("nap://{}/character/lukeskywalker", repository);
684 let result = resolver.resolve(&uri, &Default::default()).unwrap();
685 match result {
686 ResolveResult::Full(m) => {
687 assert_eq!(m.name, "Luke Skywalker");
688 }
689 _ => panic!("expected full manifest"),
690 }
691 }
692}