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    /// Resolve at a specific tag.
58    #[serde(default, skip_serializing_if = "Option::is_none")]
59    pub tag: Option<String>,
60
61    /// Subtree query path (overrides URI fragment). e.g., `"appearances.audienceVotes"`.
62    #[serde(default, skip_serializing_if = "Option::is_none")]
63    pub path: Option<String>,
64
65    /// Recursively resolve nested URIs. When true, the resolver will follow
66    /// all nap:// URIs found in the resolved manifest and resolve them as well.
67    #[serde(default, skip_serializing_if = "Option::is_none")]
68    pub recursive: Option<bool>,
69
70    /// Maximum recursion depth for recursive resolution. Defaults to 10 to prevent
71    /// infinite loops. Set to None for unlimited depth (not recommended).
72    #[serde(default, skip_serializing_if = "Option::is_none")]
73    pub max_depth: Option<usize>,
74}
75
76impl ResolveOptions {
77    /// Returns the query path (from options or URI fragment).
78    fn query_path(&self, uri: &NapUri) -> Option<String> {
79        self.path.clone().or_else(|| uri.fragment.clone())
80    }
81}
82
83/// The result of resolving a NAP URI.
84#[derive(Debug, Clone, Serialize, Deserialize)]
85#[serde(untagged)]
86pub enum ResolveResult {
87    /// Full manifest (no query applied).
88    Full(Box<Manifest>),
89    /// Subtree result from a query.
90    Subtree(serde_json::Value),
91}
92
93/// The NAP resolver — resolves URIs to manifests or subtrees.
94pub struct Resolver {
95    /// Base directory containing repository repositories.
96    base_path: PathBuf,
97    /// VCS backend factory (creates backend per-repo).
98    vcs_factory: fn() -> Box<dyn VcsBackend>,
99    /// Resolution configuration (default branch, etc.).
100    config: ResolveConfig,
101}
102
103impl Resolver {
104    /// Create a resolver that looks for repository repos under `base_path`.
105    ///
106    /// WARNING: Uses [`LoreBackend::from_env()`] by default. For testing,
107    /// use [`Resolver::with_vcs_factory()`] with a mock backend.
108    ///
109    /// Uses [`ResolveConfig::default()`] — meaning `default_branch` is
110    /// `None` and any resolve that omits both `branch` and `commit` will
111    /// fail with [`NapError::NoDefaultBranch`].
112    ///
113    /// # Example layout
114    /// ```text
115    /// base_path/
116    /// ├── starwars/    ← repository repo
117    /// ├── toystory/    ← repository repo
118    /// └── marvel/      ← repository repo
119    /// ```
120    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    /// Create a resolver with a custom VCS backend factory and config.
129    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    /// Open the repository for a given repository and read its resolve config.
142    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    /// Resolve a NAP URI string with options.
150    ///
151    /// # Examples
152    /// ```text
153    /// // Full manifest
154    /// resolver.resolve("nap://starwars/character/lukeskywalker", &Default::default())
155    ///
156    /// // Without scheme (auto-normalized)
157    /// resolver.resolve("starwars/character/lukeskywalker", &Default::default())
158    ///
159    /// // With branch
160    /// resolver.resolve("nap://starwars/character/lukeskywalker", &ResolveOptions {
161    ///     branch: Some("canon".to_string()),
162    ///     ..Default::default()
163    /// })
164    ///
165    /// // With fragment query (via URI)
166    /// resolver.resolve("nap://starwars/character/lukeskywalker#references.appears_in", &Default::default())
167    /// ```
168    pub fn resolve(
169        &self,
170        uri_str: &str,
171        options: &ResolveOptions,
172    ) -> Result<ResolveResult, NapError> {
173        // ── Normalization: Prepend nap:// if missing ─────────────────────
174        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    /// Resolve a parsed NAP URI with options.
191    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        // Handle recursive resolution
203        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    /// Resolve a single URI without recursion.
216    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        // ── 4-Rule Resolution ────────────────────────────────────────
225        // Rule 1: commit provided → use directly (bypass branch logic)
226        // Rule 2: branch provided, no commit → resolve branch head
227        // Rule 3: both null → use default_branch from repo config (fallback to global)
228        // Rule 4: both null and no default_branch → hard error
229        // ──────────────────────────────────────────────────────────────
230
231        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        // Read the manifest at the resolved revision
259        let manifest = repo.read_manifest_at_ref(&uri.entity_type, &uri.entity_id, &revision)?;
260
261        // Apply query if present
262        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                // Convert YAML value to JSON for consistent API output
269                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    /// Resolve a URI recursively, following nested nap:// URIs.
289    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        // Check depth limit
297        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        // Check for circular references
304        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        // Resolve the current URI
314        let result = self.resolve_uri_single(uri, options)?;
315
316        // Extract nested URIs from the result and resolve them
317        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                // Resolve nested URIs and merge them into the result
328                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                        // Merge nested manifest into parent (simple merge for now)
343                        // In the future, this could be more sophisticated based on schema
344                        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                // For subtree queries, we don't recurse (would be complex to merge)
354                debug!("subtree query, skipping recursive resolution");
355                Ok(ResolveResult::Subtree(value))
356            }
357        }
358    }
359
360    /// Extract all nap:// URIs from a manifest.
361    fn extract_nested_uris(&self, manifest: &Manifest) -> Vec<String> {
362        let mut uris = Vec::new();
363
364        // Search in properties
365        for value in manifest.properties.values() {
366            self.extract_uris_from_yaml_value(value, &mut uris);
367        }
368
369        // Search in references
370        for value in manifest.references.values() {
371            self.extract_uris_from_yaml_value(value, &mut uris);
372        }
373
374        // Deduplicate URIs to avoid resolving the same URI multiple times
375        uris.sort();
376        uris.dedup();
377        uris
378    }
379
380    /// Recursively extract nap:// URIs from YAML values.
381    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    /// Convenience: query a specific path on a URI.
401    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    /// List all repositories available.
413    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            // Check for repository.yaml or repository.yaml to identify valid repositories
419            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        // Create a character
444        let (mut manifest, _) = repo
445            .create_entity(
446                &EntityType::new("character"),
447                "lukeskywalker",
448                "Luke Skywalker",
449                "test",
450            )
451            .unwrap();
452
453        // Add properties and commit
454        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        // Create a character
650        let (mut manifest, _) = repo
651            .create_entity(
652                &EntityType::new("character"),
653                "lukeskywalker",
654                "Luke Skywalker",
655                "test",
656            )
657            .unwrap();
658
659        // Add properties and commit
660        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}