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)]
33pub struct ResolveConfig {
34 pub default_branch: Option<String>,
39}
40
41#[derive(Debug, Clone, Default, Serialize, Deserialize)]
45pub struct ResolveOptions {
46 #[serde(default, skip_serializing_if = "Option::is_none")]
49 pub branch: Option<String>,
50
51 #[serde(default, skip_serializing_if = "Option::is_none")]
55 pub commit: Option<String>,
56
57 #[serde(default, skip_serializing_if = "Option::is_none")]
59 pub tag: Option<String>,
60
61 #[serde(default, skip_serializing_if = "Option::is_none")]
63 pub path: Option<String>,
64}
65
66impl ResolveOptions {
67 fn query_path(&self, uri: &NapUri) -> Option<String> {
69 self.path.clone().or_else(|| uri.fragment.clone())
70 }
71}
72
73#[derive(Debug, Clone, Serialize, Deserialize)]
75#[serde(untagged)]
76pub enum ResolveResult {
77 Full(Box<Manifest>),
79 Subtree(serde_json::Value),
81}
82
83pub struct Resolver {
85 base_path: PathBuf,
87 vcs_factory: fn() -> Box<dyn VcsBackend>,
89 config: ResolveConfig,
91}
92
93impl Resolver {
94 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 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 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 pub fn resolve(
157 &self,
158 uri_str: &str,
159 options: &ResolveOptions,
160 ) -> Result<ResolveResult, NapError> {
161 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 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 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 let manifest = repo.read_manifest_at_ref(uri.entity_type, &uri.entity_id, &revision)?;
223
224 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 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 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 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 let (mut manifest, _) = repo
294 .create_entity(
295 EntityType::Character,
296 "lukeskywalker",
297 "Luke Skywalker",
298 "test",
299 )
300 .unwrap();
301
302 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 let (mut manifest, _) = repo
498 .create_entity(
499 crate::types::EntityType::Character,
500 "lukeskywalker",
501 "Luke Skywalker",
502 "test",
503 )
504 .unwrap();
505
506 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}