1use 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#[derive(Debug, Clone, Default, Serialize, Deserialize)]
31pub struct ResolveOptions {
32 #[serde(default, skip_serializing_if = "Option::is_none")]
34 pub branch: Option<String>,
35
36 #[serde(default, skip_serializing_if = "Option::is_none")]
38 pub commit: Option<String>,
39
40 #[serde(default, skip_serializing_if = "Option::is_none")]
42 pub tag: Option<String>,
43
44 #[serde(default, skip_serializing_if = "Option::is_none")]
46 pub path: Option<String>,
47}
48
49impl ResolveOptions {
50 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 fn query_path(&self, uri: &NapUri) -> Option<String> {
63 self.path.clone().or_else(|| uri.fragment.clone())
64 }
65}
66
67#[derive(Debug, Clone, Serialize, Deserialize)]
69#[serde(untagged)]
70pub enum ResolveResult {
71 Full(Box<Manifest>),
73 Subtree(serde_json::Value),
75}
76
77pub struct Resolver {
79 base_path: PathBuf,
81 vcs_factory: fn() -> Box<dyn VcsBackend>,
83}
84
85impl Resolver {
86 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 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 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 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 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 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 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 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 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 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 let (mut manifest, _) = repo
236 .create_entity(
237 EntityType::Character,
238 "lukeskywalker",
239 "Luke Skywalker",
240 "test",
241 )
242 .unwrap();
243
244 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}