Skip to main content

nap_core/
resolver.rs

1//! NAP Resolver — URI → Manifest, with query and version selectors.
2//!
3//! The resolver is the primary interface for reading NAP resources.
4//! It handles:
5//! - Full manifest resolution: `nap://starwars/character/lukeskywalker`
6//! - Fragment queries: `nap://starwars/character/lukeskywalker#references.appears_in`
7//! - Version selectors: branch, commit
8//! - Subtree extraction for efficient AI/application access
9//!
10//! Version and branch are NEVER in the URI. They are orthogonal selectors:
11//! ```text
12//! URI + Reference + Revision Selector
13//! ```
14
15use 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/// Resolver configuration — set at construction time.
29///
30/// Controls how the resolver resolves URIs when no explicit branch or
31/// commit is provided by the caller.
32#[derive(Debug, Clone, Default)]
33pub struct ResolveConfig {
34    /// Branch to resolve when neither `branch` nor `commit` is specified
35    /// in [`ResolveOptions`].  If `None`, resolves without a branch or
36    /// commit — this will trigger a [`NapError::NoDefaultBranch`] error
37    /// for any resolve call that omits both `branch` and `commit`.
38    pub default_branch: Option<String>,
39}
40
41/// Options for resolving a NAP URI. All are optional — omitting all
42/// causes the resolver to use its [`ResolveConfig::default_branch`] (if
43/// configured) or fail with [`NapError::NoDefaultBranch`].
44#[derive(Debug, Clone, Default, Serialize, Deserialize)]
45pub struct ResolveOptions {
46    /// Resolve at a specific branch. e.g., `"canon"`.
47    /// Takes precedence over [`ResolveConfig::default_branch`].
48    #[serde(default, skip_serializing_if = "Option::is_none")]
49    pub branch: Option<String>,
50
51    /// Resolve at a specific commit hash (BLAKE3). e.g.,
52    /// `"af1349b9f5f9a1a6a0404deb36d020949b834f2a42e37e5f8d2e4ba2765f1a2f"`.
53    /// Takes precedence over `branch` and [`ResolveConfig::default_branch`].
54    #[serde(default, skip_serializing_if = "Option::is_none")]
55    pub commit: Option<String>,
56
57    /// Subtree query path (overrides URI fragment). e.g., `"appearances.audienceVotes"`.
58    #[serde(default, skip_serializing_if = "Option::is_none")]
59    pub path: Option<String>,
60
61    /// Recursively resolve nested URIs. When true, the resolver will follow
62    /// all nap:// URIs found in the resolved manifest and resolve them as well.
63    #[serde(default, skip_serializing_if = "Option::is_none")]
64    pub recursive: Option<bool>,
65
66    /// Maximum recursion depth for recursive resolution. Defaults to 10 to prevent
67    /// infinite loops. Set to None for unlimited depth (not recommended).
68    #[serde(default, skip_serializing_if = "Option::is_none")]
69    pub max_depth: Option<usize>,
70}
71
72impl ResolveOptions {
73    /// Returns the query path (from options or URI fragment).
74    fn query_path(&self, uri: &NapUri) -> Option<String> {
75        self.path.clone().or_else(|| uri.fragment.clone())
76    }
77}
78
79/// The result of resolving a NAP URI.
80#[derive(Debug, Clone, Serialize, Deserialize)]
81#[serde(untagged)]
82pub enum ResolveResult {
83    /// Full manifest (no query applied).
84    Full(Box<Manifest>),
85    /// Subtree result from a query.
86    Subtree(serde_json::Value),
87}
88
89/// The NAP resolver — resolves URIs to manifests or subtrees.
90pub struct Resolver {
91    /// Base directory containing repository repositories.
92    base_path: PathBuf,
93    /// VCS backend factory (creates backend per-repo).
94    vcs_factory: fn() -> Box<dyn VcsBackend>,
95    /// Resolution configuration (default branch, etc.).
96    config: ResolveConfig,
97}
98
99impl Resolver {
100    /// Create a resolver that looks for repository repos under `base_path`.
101    ///
102    /// WARNING: Uses [`LoreBackend::from_env()`] by default. For testing,
103    /// use [`Resolver::with_vcs_factory()`] with a mock backend.
104    ///
105    /// Uses [`ResolveConfig::default()`] — meaning `default_branch` is
106    /// `None` and any resolve that omits both `branch` and `commit` will
107    /// fail with [`NapError::NoDefaultBranch`].
108    ///
109    /// # Example layout
110    /// ```text
111    /// base_path/
112    /// ├── starwars/    ← repository repo
113    /// ├── toystory/    ← repository repo
114    /// └── marvel/      ← repository repo
115    /// ```
116    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    /// Create a resolver with a custom VCS backend factory and config.
125    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    /// Open the repository for a given repository and read its resolve config.
138    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    /// Resolve a NAP URI string with options.
146    ///
147    /// # Examples
148    /// ```text
149    /// // Full manifest
150    /// resolver.resolve("nap://starwars/character/lukeskywalker", &Default::default())
151    ///
152    /// // Without scheme (auto-normalized)
153    /// resolver.resolve("starwars/character/lukeskywalker", &Default::default())
154    ///
155    /// // With branch
156    /// resolver.resolve("nap://starwars/character/lukeskywalker", &ResolveOptions {
157    ///     branch: Some("canon".to_string()),
158    ///     ..Default::default()
159    /// })
160    ///
161    /// // With fragment query (via URI)
162    /// resolver.resolve("nap://starwars/character/lukeskywalker#references.appears_in", &Default::default())
163    /// ```
164    pub fn resolve(
165        &self,
166        uri_str: &str,
167        options: &ResolveOptions,
168    ) -> Result<ResolveResult, NapError> {
169        // ── Normalization: Prepend nap:// if missing ─────────────────────
170        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    /// Resolve a parsed NAP URI with options.
187    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        // Handle recursive resolution
199        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    /// Resolve a single URI without recursion.
212    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        // ── 4-Rule Resolution ────────────────────────────────────────
221        // Rule 1: commit provided → use directly (bypass branch logic)
222        // Rule 2: branch provided, no commit → resolve branch head
223        // Rule 3: both null → use default_branch from repo config (fallback to global)
224        // Rule 4: both null and no default_branch → hard error
225        // ──────────────────────────────────────────────────────────────
226
227        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        // Read the manifest at the resolved revision
255        let manifest = repo.read_manifest_at_ref(&uri.entity_type, &uri.entity_id, &revision)?;
256
257        // Apply query if present
258        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                // Convert YAML value to JSON for consistent API output
265                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    /// Resolve a URI recursively, following nested nap:// URIs.
285    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        // Check depth limit
293        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        // Check for circular references
300        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        // Resolve the current URI
310        let result = self.resolve_uri_single(uri, options)?;
311
312        // Extract nested URIs from the result and resolve them
313        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                // Resolve nested URIs and merge them into the result
324                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                        // Merge nested manifest into parent (simple merge for now)
339                        // In the future, this could be more sophisticated based on schema
340                        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                // For subtree queries, we don't recurse (would be complex to merge)
350                debug!("subtree query, skipping recursive resolution");
351                Ok(ResolveResult::Subtree(value))
352            }
353        }
354    }
355
356    /// Extract all nap:// URIs from a manifest.
357    fn extract_nested_uris(&self, manifest: &Manifest) -> Vec<String> {
358        let mut uris = Vec::new();
359
360        // Search in properties
361        for value in manifest.properties.values() {
362            self.extract_uris_from_yaml_value(value, &mut uris);
363        }
364
365        // Search in references
366        for value in manifest.references.values() {
367            self.extract_uris_from_yaml_value(value, &mut uris);
368        }
369
370        // Deduplicate URIs to avoid resolving the same URI multiple times
371        uris.sort();
372        uris.dedup();
373        uris
374    }
375
376    /// Recursively extract nap:// URIs from YAML values.
377    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    /// Convenience: query a specific path on a URI.
397    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    /// List all repositories available.
409    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            // Check for repository.yaml or repository.yaml to identify valid repositories
415            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        // Create a character
440        let (mut manifest, _) = repo
441            .create_entity(
442                &EntityType::new("character"),
443                "lukeskywalker",
444                "Luke Skywalker",
445                "test",
446            )
447            .unwrap();
448
449        // Add properties and commit
450        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        // Create a character
646        let (mut manifest, _) = repo
647            .create_entity(
648                &EntityType::new("character"),
649                "lukeskywalker",
650                "Luke Skywalker",
651                "test",
652            )
653            .unwrap();
654
655        // Add properties and commit
656        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}