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