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    /// // With branch
142    /// resolver.resolve("nap://starwars/character/lukeskywalker", &ResolveOptions {
143    ///     branch: Some("canon".to_string()),
144    ///     ..Default::default()
145    /// })
146    ///
147    /// // With fragment query (via URI)
148    /// resolver.resolve("nap://starwars/character/lukeskywalker#references.appears_in", &Default::default())
149    /// ```
150    pub fn resolve(
151        &self,
152        uri_str: &str,
153        options: &ResolveOptions,
154    ) -> Result<ResolveResult, NapError> {
155        let uri: NapUri = uri_str.parse()?;
156        self.resolve_uri(&uri, options)
157    }
158
159    /// Resolve a parsed NAP URI with options.
160    pub fn resolve_uri(
161        &self,
162        uri: &NapUri,
163        options: &ResolveOptions,
164    ) -> Result<ResolveResult, NapError> {
165        debug!(
166            uri = %uri,
167            options = ?options,
168            "resolving NAP URI"
169        );
170
171        let repo = self.open_repo(&uri.universe)?;
172        let query_path = options.query_path(uri);
173
174        // ── 4-Rule Resolution ────────────────────────────────────────
175        // Rule 1: commit provided → use directly (bypass branch logic)
176        // Rule 2: branch provided, no commit → resolve branch head
177        // Rule 3: both null → use default_branch from config
178        // Rule 4: both null and no default_branch → hard error
179        // ──────────────────────────────────────────────────────────────
180
181        let revision = match (options.commit.as_ref(), options.branch.as_ref()) {
182            (Some(commit), _) => {
183                debug!(%commit, "resolve: rule 1 — commit provided");
184                commit.clone()
185            }
186            (None, Some(branch)) => {
187                debug!(%branch, "resolve: rule 2 — branch provided");
188                repo.resolve_branch_head(branch)?
189            }
190            (None, None) => match &self.config.default_branch {
191                Some(default_branch) => {
192                    debug!(%default_branch, "resolve: rule 3 — using default_branch");
193                    repo.resolve_branch_head(default_branch)?
194                }
195                None => {
196                    debug!("resolve: rule 4 — no branch, no commit, no default_branch");
197                    return Err(NapError::NoDefaultBranch);
198                }
199            },
200        };
201
202        // Read the manifest at the resolved revision
203        let manifest = repo.read_manifest_at_ref(uri.entity_type, &uri.entity_id, &revision)?;
204
205        // Apply query if present
206        match query_path {
207            Some(ref path) => {
208                debug!(query_path = %path, "applying subtree query");
209                let yaml_value = manifest.to_value()?;
210                let result = ManifestQuery::query(&yaml_value, path, &manifest.id)?;
211
212                // Convert YAML value to JSON for consistent API output
213                let json_str = serde_yaml::to_string(&result)
214                    .map_err(|e| NapError::ManifestValidationError(e.to_string()))?;
215                let json_value: serde_json::Value = serde_yaml::from_str(&json_str)
216                    .map_err(|e| NapError::ManifestValidationError(e.to_string()))?;
217
218                info!(
219                    uri = %uri,
220                    query_path = %path,
221                    "resolved NAP URI with query"
222                );
223                Ok(ResolveResult::Subtree(json_value))
224            }
225            None => {
226                info!(uri = %uri, "resolved NAP URI (full manifest)");
227                Ok(ResolveResult::Full(Box::new(manifest)))
228            }
229        }
230    }
231
232    /// Convenience: query a specific path on a URI.
233    pub fn query(&self, uri_str: &str, path: &str) -> Result<serde_json::Value, NapError> {
234        let options = ResolveOptions {
235            path: Some(path.to_string()),
236            ..Default::default()
237        };
238        match self.resolve(uri_str, &options)? {
239            ResolveResult::Subtree(v) => Ok(v),
240            ResolveResult::Full(m) => m.to_json_value(),
241        }
242    }
243
244    /// List all universe repositories available.
245    pub fn list_universes(&self) -> Result<Vec<String>, NapError> {
246        let mut universes = Vec::new();
247        for entry in std::fs::read_dir(&self.base_path)? {
248            let entry = entry?;
249            let path = entry.path();
250            if path.is_dir()
251                && path.join(".nap").exists()
252                && let Some(name) = path.file_name().and_then(|n| n.to_str())
253            {
254                universes.push(name.to_string());
255            }
256        }
257        universes.sort();
258        Ok(universes)
259    }
260}
261
262#[cfg(test)]
263mod tests {
264    use super::*;
265    use crate::types::EntityType;
266    use crate::vcs_git::GitBackend;
267    use tempfile::TempDir;
268
269    fn setup() -> (TempDir, Resolver) {
270        let tmp = TempDir::new().unwrap();
271        let repo = Repository::init(tmp.path(), "starwars", Box::new(GitBackend::new())).unwrap();
272
273        // Create a character
274        let (mut manifest, _) = repo
275            .create_entity(
276                EntityType::Character,
277                "lukeskywalker",
278                "Luke Skywalker",
279                "test",
280            )
281            .unwrap();
282
283        // Add properties and commit
284        manifest.set_property("species", serde_yaml::Value::String("human".to_string()));
285        manifest.set_property(
286            "homeworld",
287            serde_yaml::Value::String("nap://starwars/location/tatooine".to_string()),
288        );
289        manifest.add_reference(
290            "appears_in",
291            serde_yaml::Value::Sequence(vec![serde_yaml::Value::String(
292                "nap://starwars/scene/cantina".to_string(),
293            )]),
294        );
295
296        use crate::commit::Change;
297        repo.commit_manifest(
298            &mut manifest,
299            "add Luke Skywalker details",
300            "test",
301            vec![Change::set("properties.species", None, "human".to_string())],
302        )
303        .unwrap();
304
305        let resolver = Resolver::with_vcs_factory(
306            tmp.path(),
307            || Box::new(GitBackend::new()),
308            ResolveConfig {
309                default_branch: Some("main".to_string()),
310            },
311        );
312        (tmp, resolver)
313    }
314
315    #[test]
316    fn test_resolve_full_manifest() {
317        let (_tmp, resolver) = setup();
318        let result = resolver
319            .resolve(
320                "nap://starwars/character/lukeskywalker",
321                &Default::default(),
322            )
323            .unwrap();
324        match result {
325            ResolveResult::Full(m) => {
326                assert_eq!(m.name, "Luke Skywalker");
327                assert_eq!(m.entity_type, EntityType::Character);
328            }
329            _ => panic!("expected full manifest"),
330        }
331    }
332
333    #[test]
334    fn test_resolve_with_fragment() {
335        let (_tmp, resolver) = setup();
336        let result = resolver
337            .resolve(
338                "nap://starwars/character/lukeskywalker#properties.species",
339                &Default::default(),
340            )
341            .unwrap();
342        match result {
343            ResolveResult::Subtree(v) => {
344                assert_eq!(v.as_str(), Some("human"));
345            }
346            _ => panic!("expected subtree"),
347        }
348    }
349
350    #[test]
351    fn test_resolve_with_options_path() {
352        let (_tmp, resolver) = setup();
353        let result = resolver
354            .resolve(
355                "nap://starwars/character/lukeskywalker",
356                &ResolveOptions {
357                    path: Some("properties.homeworld".to_string()),
358                    ..Default::default()
359                },
360            )
361            .unwrap();
362        match result {
363            ResolveResult::Subtree(v) => {
364                assert_eq!(v.as_str(), Some("nap://starwars/location/tatooine"));
365            }
366            _ => panic!("expected subtree"),
367        }
368    }
369
370    #[test]
371    fn test_query_convenience() {
372        let (_tmp, resolver) = setup();
373        let result = resolver
374            .query(
375                "nap://starwars/character/lukeskywalker",
376                "properties.species",
377            )
378            .unwrap();
379        assert_eq!(result.as_str(), Some("human"));
380    }
381
382    #[test]
383    fn test_list_universes() {
384        let (_tmp, resolver) = setup();
385        let universes = resolver.list_universes().unwrap();
386        assert!(universes.contains(&"starwars".to_string()));
387    }
388
389    #[test]
390    fn test_resolve_not_found() {
391        let (_tmp, resolver) = setup();
392        let result = resolver.resolve("nap://starwars/character/nonexistent", &Default::default());
393        assert!(result.is_err());
394    }
395}