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, tag
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/// Options for resolving a NAP URI. All are optional — omitting all
29/// resolves the current HEAD of the default branch.
30#[derive(Debug, Clone, Default, Serialize, Deserialize)]
31pub struct ResolveOptions {
32    /// Resolve at a specific branch. e.g., `"canon"`.
33    #[serde(default, skip_serializing_if = "Option::is_none")]
34    pub branch: Option<String>,
35
36    /// Resolve at a specific commit hash. e.g., `"a72c9f3b"`.
37    #[serde(default, skip_serializing_if = "Option::is_none")]
38    pub commit: Option<String>,
39
40    /// Resolve at a specific tag. e.g., `"episode-6"`.
41    #[serde(default, skip_serializing_if = "Option::is_none")]
42    pub tag: Option<String>,
43
44    /// Subtree query path (overrides URI fragment). e.g., `"appearances.audienceVotes"`.
45    #[serde(default, skip_serializing_if = "Option::is_none")]
46    pub path: Option<String>,
47}
48
49impl ResolveOptions {
50    /// Returns the Git ref to use, or None for HEAD / working tree.
51    fn git_ref(&self) -> Option<String> {
52        if let Some(ref commit) = self.commit {
53            Some(commit.clone())
54        } else if let Some(ref tag) = self.tag {
55            Some(format!("refs/tags/{tag}"))
56        } else {
57            self.branch.clone()
58        }
59    }
60
61    /// Returns the query path (from options or URI fragment).
62    fn query_path(&self, uri: &NapUri) -> Option<String> {
63        self.path.clone().or_else(|| uri.fragment.clone())
64    }
65}
66
67/// The result of resolving a NAP URI.
68#[derive(Debug, Clone, Serialize, Deserialize)]
69#[serde(untagged)]
70pub enum ResolveResult {
71    /// Full manifest (no query applied).
72    Full(Box<Manifest>),
73    /// Subtree result from a query.
74    Subtree(serde_json::Value),
75}
76
77/// The NAP resolver — resolves URIs to manifests or subtrees.
78pub struct Resolver {
79    /// Base directory containing universe repositories.
80    base_path: PathBuf,
81    /// VCS backend factory (creates backend per-repo).
82    vcs_factory: fn() -> Box<dyn VcsBackend>,
83}
84
85impl Resolver {
86    /// Create a resolver that looks for universe repos under `base_path`.
87    ///
88    /// # Example layout
89    /// ```text
90    /// base_path/
91    /// ├── starwars/    ← universe repo
92    /// ├── toystory/    ← universe repo
93    /// └── marvel/      ← universe repo
94    /// ```
95    pub fn new(base_path: &Path) -> Self {
96        Self {
97            base_path: base_path.to_path_buf(),
98            vcs_factory: || Box::new(LoreBackend::from_env()),
99        }
100    }
101
102    /// Create a resolver with a custom VCS backend factory.
103    pub fn with_vcs_factory(base_path: &Path, factory: fn() -> Box<dyn VcsBackend>) -> Self {
104        Self {
105            base_path: base_path.to_path_buf(),
106            vcs_factory: factory,
107        }
108    }
109
110    /// Open the repository for a given universe.
111    fn open_repo(&self, universe: &str) -> Result<Repository, NapError> {
112        let repo_path = self.base_path.join(universe);
113        Repository::open(&repo_path, (self.vcs_factory)())
114    }
115
116    /// Resolve a NAP URI string with options.
117    ///
118    /// # Examples
119    /// ```text
120    /// // Full manifest
121    /// resolver.resolve("nap://starwars/character/lukeskywalker", &Default::default())
122    ///
123    /// // With branch
124    /// resolver.resolve("nap://starwars/character/lukeskywalker", &ResolveOptions {
125    ///     branch: Some("canon".to_string()),
126    ///     ..Default::default()
127    /// })
128    ///
129    /// // With fragment query (via URI)
130    /// resolver.resolve("nap://starwars/character/lukeskywalker#references.appears_in", &Default::default())
131    /// ```
132    pub fn resolve(
133        &self,
134        uri_str: &str,
135        options: &ResolveOptions,
136    ) -> Result<ResolveResult, NapError> {
137        let uri: NapUri = uri_str.parse()?;
138        self.resolve_uri(&uri, options)
139    }
140
141    /// Resolve a parsed NAP URI with options.
142    pub fn resolve_uri(
143        &self,
144        uri: &NapUri,
145        options: &ResolveOptions,
146    ) -> Result<ResolveResult, NapError> {
147        debug!(
148            uri = %uri,
149            options = ?options,
150            "resolving NAP URI"
151        );
152
153        let repo = self.open_repo(&uri.universe)?;
154        let git_ref = options.git_ref();
155        let query_path = options.query_path(uri);
156
157        // Read the manifest (at ref or from working tree)
158        let manifest = match git_ref {
159            Some(ref reference) => {
160                debug!(reference = %reference, "resolving at specific ref");
161                repo.read_manifest_at_ref(uri.entity_type, &uri.entity_id, reference)?
162            }
163            None => repo.read_manifest(uri.entity_type, &uri.entity_id)?,
164        };
165
166        // Apply query if present
167        match query_path {
168            Some(ref path) => {
169                debug!(query_path = %path, "applying subtree query");
170                let yaml_value = manifest.to_value()?;
171                let result = ManifestQuery::query(&yaml_value, path, &manifest.id)?;
172
173                // Convert YAML value to JSON for consistent API output
174                let json_str = serde_yaml::to_string(&result)
175                    .map_err(|e| NapError::ManifestValidationError(e.to_string()))?;
176                let json_value: serde_json::Value = serde_yaml::from_str(&json_str)
177                    .map_err(|e| NapError::ManifestValidationError(e.to_string()))?;
178
179                info!(
180                    uri = %uri,
181                    query_path = %path,
182                    "resolved NAP URI with query"
183                );
184                Ok(ResolveResult::Subtree(json_value))
185            }
186            None => {
187                info!(uri = %uri, "resolved NAP URI (full manifest)");
188                Ok(ResolveResult::Full(Box::new(manifest)))
189            }
190        }
191    }
192
193    /// Convenience: query a specific path on a URI.
194    pub fn query(&self, uri_str: &str, path: &str) -> Result<serde_json::Value, NapError> {
195        let options = ResolveOptions {
196            path: Some(path.to_string()),
197            ..Default::default()
198        };
199        match self.resolve(uri_str, &options)? {
200            ResolveResult::Subtree(v) => Ok(v),
201            ResolveResult::Full(m) => m.to_json_value(),
202        }
203    }
204
205    /// List all universe repositories available.
206    pub fn list_universes(&self) -> Result<Vec<String>, NapError> {
207        let mut universes = Vec::new();
208        for entry in std::fs::read_dir(&self.base_path)? {
209            let entry = entry?;
210            let path = entry.path();
211            if path.is_dir()
212                && path.join(".nap").exists()
213                && let Some(name) = path.file_name().and_then(|n| n.to_str())
214            {
215                universes.push(name.to_string());
216            }
217        }
218        universes.sort();
219        Ok(universes)
220    }
221}
222
223#[cfg(test)]
224mod tests {
225    use super::*;
226    use crate::types::EntityType;
227    use crate::vcs_git::GitBackend;
228    use tempfile::TempDir;
229
230    fn setup() -> (TempDir, Resolver) {
231        let tmp = TempDir::new().unwrap();
232        let repo = Repository::init(tmp.path(), "starwars", Box::new(GitBackend::new())).unwrap();
233
234        // Create a character
235        let (mut manifest, _) = repo
236            .create_entity(
237                EntityType::Character,
238                "lukeskywalker",
239                "Luke Skywalker",
240                "test",
241            )
242            .unwrap();
243
244        // Add properties and commit
245        manifest.set_property("species", serde_yaml::Value::String("human".to_string()));
246        manifest.set_property(
247            "homeworld",
248            serde_yaml::Value::String("nap://starwars/location/tatooine".to_string()),
249        );
250        manifest.add_reference(
251            "appears_in",
252            serde_yaml::Value::Sequence(vec![serde_yaml::Value::String(
253                "nap://starwars/scene/cantina".to_string(),
254            )]),
255        );
256
257        use crate::commit::Change;
258        repo.commit_manifest(
259            &mut manifest,
260            "add Luke Skywalker details",
261            "test",
262            vec![Change::set("properties.species", None, "human".to_string())],
263        )
264        .unwrap();
265
266        let resolver = Resolver::new(tmp.path());
267        (tmp, resolver)
268    }
269
270    #[test]
271    fn test_resolve_full_manifest() {
272        let (_tmp, resolver) = setup();
273        let result = resolver
274            .resolve(
275                "nap://starwars/character/lukeskywalker",
276                &Default::default(),
277            )
278            .unwrap();
279        match result {
280            ResolveResult::Full(m) => {
281                assert_eq!(m.name, "Luke Skywalker");
282                assert_eq!(m.entity_type, EntityType::Character);
283            }
284            _ => panic!("expected full manifest"),
285        }
286    }
287
288    #[test]
289    fn test_resolve_with_fragment() {
290        let (_tmp, resolver) = setup();
291        let result = resolver
292            .resolve(
293                "nap://starwars/character/lukeskywalker#properties.species",
294                &Default::default(),
295            )
296            .unwrap();
297        match result {
298            ResolveResult::Subtree(v) => {
299                assert_eq!(v.as_str(), Some("human"));
300            }
301            _ => panic!("expected subtree"),
302        }
303    }
304
305    #[test]
306    fn test_resolve_with_options_path() {
307        let (_tmp, resolver) = setup();
308        let result = resolver
309            .resolve(
310                "nap://starwars/character/lukeskywalker",
311                &ResolveOptions {
312                    path: Some("properties.homeworld".to_string()),
313                    ..Default::default()
314                },
315            )
316            .unwrap();
317        match result {
318            ResolveResult::Subtree(v) => {
319                assert_eq!(v.as_str(), Some("nap://starwars/location/tatooine"));
320            }
321            _ => panic!("expected subtree"),
322        }
323    }
324
325    #[test]
326    fn test_query_convenience() {
327        let (_tmp, resolver) = setup();
328        let result = resolver
329            .query(
330                "nap://starwars/character/lukeskywalker",
331                "properties.species",
332            )
333            .unwrap();
334        assert_eq!(result.as_str(), Some("human"));
335    }
336
337    #[test]
338    fn test_list_universes() {
339        let (_tmp, resolver) = setup();
340        let universes = resolver.list_universes().unwrap();
341        assert!(universes.contains(&"starwars".to_string()));
342    }
343
344    #[test]
345    fn test_resolve_not_found() {
346        let (_tmp, resolver) = setup();
347        let result = resolver.resolve("nap://starwars/character/nonexistent", &Default::default());
348        assert!(result.is_err());
349    }
350}