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
66impl ResolveOptions {
67    /// Returns the query path (from options or URI fragment).
68    fn query_path(&self, uri: &NapUri) -> Option<String> {
69        self.path.clone().or_else(|| uri.fragment.clone())
70    }
71}
72
73/// The result of resolving a NAP URI.
74#[derive(Debug, Clone, Serialize, Deserialize)]
75#[serde(untagged)]
76pub enum ResolveResult {
77    /// Full manifest (no query applied).
78    Full(Box<Manifest>),
79    /// Subtree result from a query.
80    Subtree(serde_json::Value),
81}
82
83/// The NAP resolver — resolves URIs to manifests or subtrees.
84pub struct Resolver {
85    /// Base directory containing universe repositories.
86    base_path: PathBuf,
87    /// VCS backend factory (creates backend per-repo).
88    vcs_factory: fn() -> Box<dyn VcsBackend>,
89    /// Resolution configuration (default branch, etc.).
90    config: ResolveConfig,
91}
92
93impl Resolver {
94    /// Create a resolver that looks for universe repos under `base_path`.
95    ///
96    /// Uses [`ResolveConfig::default()`] — meaning `default_branch` is
97    /// `None` and any resolve that omits both `branch` and `commit` will
98    /// fail with [`NapError::NoDefaultBranch`].
99    ///
100    /// # Example layout
101    /// ```text
102    /// base_path/
103    /// ├── starwars/    ← universe repo
104    /// ├── toystory/    ← universe repo
105    /// └── marvel/      ← universe repo
106    /// ```
107    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    /// Create a resolver with a custom VCS backend factory and config.
116    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    /// Open the repository for a given universe.
129    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    /// Resolve a NAP URI string with options.
135    ///
136    /// # Examples
137    /// ```text
138    /// // Full manifest
139    /// resolver.resolve("nap://starwars/character/lukeskywalker", &Default::default())
140    ///
141    /// // Without scheme (auto-normalized)
142    /// resolver.resolve("starwars/character/lukeskywalker", &Default::default())
143    ///
144    /// // With branch
145    /// resolver.resolve("nap://starwars/character/lukeskywalker", &ResolveOptions {
146    ///     branch: Some("canon".to_string()),
147    ///     ..Default::default()
148    /// })
149    ///
150    /// // With fragment query (via URI)
151    /// resolver.resolve("nap://starwars/character/lukeskywalker#references.appears_in", &Default::default())
152    /// ```
153    pub fn resolve(
154        &self,
155        uri_str: &str,
156        options: &ResolveOptions,
157    ) -> Result<ResolveResult, NapError> {
158        // ── Normalization: Prepend nap:// if missing ─────────────────────
159        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    /// Resolve a parsed NAP URI with options.
176    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        // ── 4-Rule Resolution ────────────────────────────────────────
191        // Rule 1: commit provided → use directly (bypass branch logic)
192        // Rule 2: branch provided, no commit → resolve branch head
193        // Rule 3: both null → use default_branch from config
194        // Rule 4: both null and no default_branch → hard error
195        // ──────────────────────────────────────────────────────────────
196
197        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        // Read the manifest at the resolved revision
219        let manifest = repo.read_manifest_at_ref(uri.entity_type, &uri.entity_id, &revision)?;
220
221        // Apply query if present
222        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                // Convert YAML value to JSON for consistent API output
229                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    /// Convenience: query a specific path on a URI.
249    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    /// List all universe repositories available.
261    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        // Create a character
290        let (mut manifest, _) = repo
291            .create_entity(
292                EntityType::Character,
293                "lukeskywalker",
294                "Luke Skywalker",
295                "test",
296            )
297            .unwrap();
298
299        // Add properties and commit
300        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}