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    /// WARNING: Uses [`LoreBackend::from_env()`] by default. For testing,
97    /// use [`Resolver::with_vcs_factory()`] with a mock backend.
98    ///
99    /// Uses [`ResolveConfig::default()`] — meaning `default_branch` is
100    /// `None` and any resolve that omits both `branch` and `commit` will
101    /// fail with [`NapError::NoDefaultBranch`].
102    ///
103    /// # Example layout
104    /// ```text
105    /// base_path/
106    /// ├── starwars/    ← universe repo
107    /// ├── toystory/    ← universe repo
108    /// └── marvel/      ← universe repo
109    /// ```
110    pub fn new(base_path: &Path) -> Self {
111        Self {
112            base_path: base_path.to_path_buf(),
113            vcs_factory: || Box::new(LoreBackend::from_env()),
114            config: ResolveConfig::default(),
115        }
116    }
117
118    /// Create a resolver with a custom VCS backend factory and config.
119    pub fn with_vcs_factory(
120        base_path: &Path,
121        factory: fn() -> Box<dyn VcsBackend>,
122        config: ResolveConfig,
123    ) -> Self {
124        Self {
125            base_path: base_path.to_path_buf(),
126            vcs_factory: factory,
127            config,
128        }
129    }
130
131    /// Open the repository for a given universe.
132    fn open_repo(&self, universe: &str) -> Result<Repository, NapError> {
133        let repo_path = self.base_path.join(universe);
134        Repository::open(&repo_path, (self.vcs_factory)())
135    }
136
137    /// Resolve a NAP URI string with options.
138    ///
139    /// # Examples
140    /// ```text
141    /// // Full manifest
142    /// resolver.resolve("nap://starwars/character/lukeskywalker", &Default::default())
143    ///
144    /// // Without scheme (auto-normalized)
145    /// resolver.resolve("starwars/character/lukeskywalker", &Default::default())
146    ///
147    /// // With branch
148    /// resolver.resolve("nap://starwars/character/lukeskywalker", &ResolveOptions {
149    ///     branch: Some("canon".to_string()),
150    ///     ..Default::default()
151    /// })
152    ///
153    /// // With fragment query (via URI)
154    /// resolver.resolve("nap://starwars/character/lukeskywalker#references.appears_in", &Default::default())
155    /// ```
156    pub fn resolve(
157        &self,
158        uri_str: &str,
159        options: &ResolveOptions,
160    ) -> Result<ResolveResult, NapError> {
161        // ── Normalization: Prepend nap:// if missing ─────────────────────
162        let normalized_uri_str = if uri_str.starts_with("nap://") {
163            uri_str.to_string()
164        } else {
165            format!("nap://{}", uri_str.trim_start_matches('/'))
166        };
167
168        debug!(
169            original_uri = %uri_str,
170            normalized_uri = %normalized_uri_str,
171            "normalized NAP URI"
172        );
173
174        let uri: NapUri = normalized_uri_str.parse()?;
175        self.resolve_uri(&uri, options)
176    }
177
178    /// Resolve a parsed NAP URI with options.
179    pub fn resolve_uri(
180        &self,
181        uri: &NapUri,
182        options: &ResolveOptions,
183    ) -> Result<ResolveResult, NapError> {
184        debug!(
185            uri = %uri,
186            options = ?options,
187            "resolving NAP URI"
188        );
189
190        let repo = self.open_repo(&uri.universe)?;
191        let query_path = options.query_path(uri);
192
193        // ── 4-Rule Resolution ────────────────────────────────────────
194        // Rule 1: commit provided → use directly (bypass branch logic)
195        // Rule 2: branch provided, no commit → resolve branch head
196        // Rule 3: both null → use default_branch from config
197        // Rule 4: both null and no default_branch → hard error
198        // ──────────────────────────────────────────────────────────────
199
200        let revision = match (options.commit.as_ref(), options.branch.as_ref()) {
201            (Some(commit), _) => {
202                debug!(%commit, "resolve: rule 1 — commit provided");
203                commit.clone()
204            }
205            (None, Some(branch)) => {
206                debug!(%branch, "resolve: rule 2 — branch provided");
207                repo.resolve_branch_head(branch)?
208            }
209            (None, None) => match &self.config.default_branch {
210                Some(default_branch) => {
211                    debug!(%default_branch, "resolve: rule 3 — using default_branch");
212                    repo.resolve_branch_head(default_branch)?
213                }
214                None => {
215                    debug!("resolve: rule 4 — no branch, no commit, no default_branch");
216                    return Err(NapError::NoDefaultBranch);
217                }
218            },
219        };
220
221        // Read the manifest at the resolved revision
222        let manifest = repo.read_manifest_at_ref(uri.entity_type, &uri.entity_id, &revision)?;
223
224        // Apply query if present
225        match query_path {
226            Some(ref path) => {
227                debug!(query_path = %path, "applying subtree query");
228                let yaml_value = manifest.to_value()?;
229                let result = ManifestQuery::query(&yaml_value, path, &manifest.id)?;
230
231                // Convert YAML value to JSON for consistent API output
232                let json_str = serde_yaml::to_string(&result)
233                    .map_err(|e| NapError::ManifestValidationError(e.to_string()))?;
234                let json_value: serde_json::Value = serde_yaml::from_str(&json_str)
235                    .map_err(|e| NapError::ManifestValidationError(e.to_string()))?;
236
237                info!(
238                    uri = %uri,
239                    query_path = %path,
240                    "resolved NAP URI with query"
241                );
242                Ok(ResolveResult::Subtree(json_value))
243            }
244            None => {
245                info!(uri = %uri, "resolved NAP URI (full manifest)");
246                Ok(ResolveResult::Full(Box::new(manifest)))
247            }
248        }
249    }
250
251    /// Convenience: query a specific path on a URI.
252    pub fn query(&self, uri_str: &str, path: &str) -> Result<serde_json::Value, NapError> {
253        let options = ResolveOptions {
254            path: Some(path.to_string()),
255            ..Default::default()
256        };
257        match self.resolve(uri_str, &options)? {
258            ResolveResult::Subtree(v) => Ok(v),
259            ResolveResult::Full(m) => m.to_json_value(),
260        }
261    }
262
263    /// List all universe repositories available.
264    pub fn list_universes(&self) -> Result<Vec<String>, NapError> {
265        let mut universes = Vec::new();
266        for entry in std::fs::read_dir(&self.base_path)? {
267            let entry = entry?;
268            let path = entry.path();
269            if path.is_dir()
270                && path.join(".nap").exists()
271                && let Some(name) = path.file_name().and_then(|n| n.to_str())
272            {
273                universes.push(name.to_string());
274            }
275        }
276        universes.sort();
277        Ok(universes)
278    }
279}
280
281#[cfg(test)]
282mod unit_tests {
283    use super::*;
284    use crate::test_utils::MockBackend;
285    use crate::types::EntityType;
286    use tempfile::TempDir;
287
288    fn setup() -> (TempDir, Resolver) {
289        let tmp = TempDir::new().unwrap();
290        let repo = Repository::init(tmp.path(), "starwars", Box::new(MockBackend::new())).unwrap();
291
292        // Create a character
293        let (mut manifest, _) = repo
294            .create_entity(
295                EntityType::Character,
296                "lukeskywalker",
297                "Luke Skywalker",
298                "test",
299            )
300            .unwrap();
301
302        // Add properties and commit
303        manifest.set_property("species", serde_yaml::Value::String("human".to_string()));
304        manifest.set_property(
305            "homeworld",
306            serde_yaml::Value::String("nap://starwars/location/tatooine".to_string()),
307        );
308        manifest.add_reference(
309            "appears_in",
310            serde_yaml::Value::Sequence(vec![serde_yaml::Value::String(
311                "nap://starwars/scene/cantina".to_string(),
312            )]),
313        );
314
315        use crate::commit::Change;
316        repo.commit_manifest(
317            &mut manifest,
318            "add Luke Skywalker details",
319            "test",
320            vec![Change::set("properties.species", None, "human".to_string())],
321        )
322        .unwrap();
323
324        let resolver = Resolver::with_vcs_factory(
325            tmp.path(),
326            || Box::new(MockBackend::new()),
327            ResolveConfig {
328                default_branch: Some("main".to_string()),
329            },
330        );
331        (tmp, resolver)
332    }
333
334    #[test]
335    fn test_resolve_full_manifest() {
336        let (_tmp, resolver) = setup();
337        let result = resolver
338            .resolve(
339                "nap://starwars/character/lukeskywalker",
340                &Default::default(),
341            )
342            .unwrap();
343        match result {
344            ResolveResult::Full(m) => {
345                assert_eq!(m.name, "Luke Skywalker");
346                assert_eq!(m.entity_type, EntityType::Character);
347            }
348            _ => panic!("expected full manifest"),
349        }
350    }
351
352    #[test]
353    fn test_resolve_with_fragment() {
354        let (_tmp, resolver) = setup();
355        let result = resolver
356            .resolve(
357                "nap://starwars/character/lukeskywalker#properties.species",
358                &Default::default(),
359            )
360            .unwrap();
361        match result {
362            ResolveResult::Subtree(v) => {
363                assert_eq!(v.as_str(), Some("human"));
364            }
365            _ => panic!("expected subtree"),
366        }
367    }
368
369    #[test]
370    fn test_resolve_with_options_path() {
371        let (_tmp, resolver) = setup();
372        let result = resolver
373            .resolve(
374                "nap://starwars/character/lukeskywalker",
375                &ResolveOptions {
376                    path: Some("properties.homeworld".to_string()),
377                    ..Default::default()
378                },
379            )
380            .unwrap();
381        match result {
382            ResolveResult::Subtree(v) => {
383                assert_eq!(v.as_str(), Some("nap://starwars/location/tatooine"));
384            }
385            _ => panic!("expected subtree"),
386        }
387    }
388
389    #[test]
390    fn test_query_convenience() {
391        let (_tmp, resolver) = setup();
392        let result = resolver
393            .query(
394                "nap://starwars/character/lukeskywalker",
395                "properties.species",
396            )
397            .unwrap();
398        assert_eq!(result.as_str(), Some("human"));
399    }
400
401    #[test]
402    fn test_list_universes() {
403        let (_tmp, resolver) = setup();
404        let universes = resolver.list_universes().unwrap();
405        assert!(universes.contains(&"starwars".to_string()));
406    }
407
408    #[test]
409    fn test_resolve_not_found() {
410        let (_tmp, resolver) = setup();
411        let result = resolver.resolve("nap://starwars/character/nonexistent", &Default::default());
412        assert!(result.is_err());
413    }
414
415    #[test]
416    fn test_resolve_without_scheme() {
417        let (_tmp, resolver) = setup();
418        let result = resolver
419            .resolve("starwars/character/lukeskywalker", &Default::default())
420            .unwrap();
421        match result {
422            ResolveResult::Full(m) => {
423                assert_eq!(m.name, "Luke Skywalker");
424                assert_eq!(m.entity_type, EntityType::Character);
425            }
426            _ => panic!("expected full manifest"),
427        }
428    }
429
430    #[test]
431    fn test_resolve_without_scheme_with_fragment() {
432        let (_tmp, resolver) = setup();
433        let result = resolver
434            .resolve(
435                "starwars/character/lukeskywalker#properties.species",
436                &Default::default(),
437            )
438            .unwrap();
439        match result {
440            ResolveResult::Subtree(v) => {
441                assert_eq!(v.as_str(), Some("human"));
442            }
443            _ => panic!("expected subtree"),
444        }
445    }
446
447    #[test]
448    fn test_resolve_without_leading_slash() {
449        let (_tmp, resolver) = setup();
450        let result = resolver
451            .resolve("starwars/character/lukeskywalker", &Default::default())
452            .unwrap();
453        match result {
454            ResolveResult::Full(m) => {
455                assert_eq!(m.name, "Luke Skywalker");
456            }
457            _ => panic!("expected full manifest"),
458        }
459    }
460
461    #[test]
462    fn test_resolve_with_leading_slash_without_scheme() {
463        let (_tmp, resolver) = setup();
464        let result = resolver
465            .resolve("/starwars/character/lukeskywalker", &Default::default())
466            .unwrap();
467        match result {
468            ResolveResult::Full(m) => {
469                assert_eq!(m.name, "Luke Skywalker");
470            }
471            _ => panic!("expected full manifest"),
472        }
473    }
474}
475
476#[cfg(all(test, feature = "lore-integration"))]
477mod lore_tests {
478    use super::*;
479    use crate::vcs_lore::LoreBackend;
480    use std::time::{SystemTime, UNIX_EPOCH};
481    use tempfile::TempDir;
482
483    fn unique_suffix() -> u64 {
484        SystemTime::now()
485            .duration_since(UNIX_EPOCH)
486            .unwrap()
487            .as_nanos() as u64
488    }
489
490    fn setup_lore() -> (TempDir, Resolver, String) {
491        let universe = format!("lr-{}", unique_suffix());
492        let tmp = TempDir::new().unwrap();
493        let repo =
494            Repository::init(tmp.path(), &universe, Box::new(LoreBackend::from_env())).unwrap();
495
496        // Create a character
497        let (mut manifest, _) = repo
498            .create_entity(
499                crate::types::EntityType::Character,
500                "lukeskywalker",
501                "Luke Skywalker",
502                "test",
503            )
504            .unwrap();
505
506        // Add properties and commit
507        manifest.set_property("species", serde_yaml::Value::String("human".to_string()));
508        use crate::commit::Change;
509        repo.commit_manifest(
510            &mut manifest,
511            "add Luke Skywalker details",
512            "test",
513            vec![Change::set("properties.species", None, "human".to_string())],
514        )
515        .unwrap();
516
517        let resolver = Resolver::with_vcs_factory(
518            tmp.path(),
519            || Box::new(LoreBackend::from_env()),
520            ResolveConfig {
521                default_branch: Some("main".to_string()),
522            },
523        );
524        (tmp, resolver, universe)
525    }
526
527    #[test]
528    fn test_resolve_lore_full_manifest() {
529        let (_tmp, resolver, universe) = setup_lore();
530        let uri = format!("nap://{}/character/lukeskywalker", universe);
531        let result = resolver.resolve(&uri, &Default::default()).unwrap();
532        match result {
533            ResolveResult::Full(m) => {
534                assert_eq!(m.name, "Luke Skywalker");
535            }
536            _ => panic!("expected full manifest"),
537        }
538    }
539}