1use std::collections::BTreeMap;
16use std::path::{Component, Path, PathBuf};
17
18use serde::{Deserialize, Serialize};
19use tracing::{debug, info};
20
21use crate::error::NapError;
22use crate::manifest::Manifest;
23use crate::query::ManifestQuery;
24use crate::repository::Repository;
25use crate::uri::NapUri;
26use crate::vcs::VcsBackend;
27use crate::vcs_lore::LoreBackend;
28
29#[derive(Debug, Clone, Default)]
34pub struct ResolveConfig {
35 pub default_branch: Option<String>,
40}
41
42#[derive(Debug, Clone, Default, Serialize, Deserialize)]
46pub struct ResolveOptions {
47 #[serde(default, skip_serializing_if = "Option::is_none")]
50 pub branch: Option<String>,
51
52 #[serde(default, skip_serializing_if = "Option::is_none")]
56 pub commit: Option<String>,
57
58 #[serde(default, skip_serializing_if = "Option::is_none")]
60 pub path: Option<String>,
61
62 #[serde(default, skip_serializing_if = "Option::is_none")]
65 pub recursive: Option<bool>,
66
67 #[serde(default, skip_serializing_if = "Option::is_none")]
70 pub max_depth: Option<usize>,
71
72 #[serde(default, skip_serializing_if = "Option::is_none")]
74 pub provenance: Option<bool>,
75
76 #[serde(default, skip_serializing_if = "Option::is_none")]
78 pub include_blobs: Option<bool>,
79}
80
81impl ResolveOptions {
82 fn query_path(&self, uri: &NapUri) -> Option<String> {
84 self.path.clone().or_else(|| uri.fragment.clone())
85 }
86}
87
88#[derive(Debug, Clone, Serialize, Deserialize)]
90#[serde(untagged)]
91pub enum ResolveResult {
92 Full(Box<Manifest>),
94 Provenance(Box<ResolveEnvelope>),
96 Subtree(serde_json::Value),
98}
99
100#[derive(Debug, Clone, Serialize, Deserialize)]
102pub struct ResolveEnvelope {
103 pub manifest: Box<Manifest>,
104 pub provenance: ResolveProvenanceEnvelope,
105}
106
107#[derive(Debug, Clone, Serialize, Deserialize)]
109pub struct ResolveProvenanceEnvelope {
110 pub revision: String,
111 pub files: Vec<ResolveProvenanceFile>,
112}
113
114#[derive(Debug, Clone, Serialize, Deserialize)]
116pub struct ResolveProvenanceFile {
117 pub role: String,
118 #[serde(default, skip_serializing_if = "Option::is_none")]
119 pub name: Option<String>,
120 #[serde(default, skip_serializing_if = "Option::is_none")]
121 pub path: Option<String>,
122 #[serde(default, skip_serializing_if = "Option::is_none")]
123 pub uri: Option<String>,
124 #[serde(default, skip_serializing_if = "Option::is_none")]
125 pub hash: Option<String>,
126 #[serde(default, skip_serializing_if = "Option::is_none")]
127 pub format: Option<String>,
128 pub provenance: serde_json::Value,
129 #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
130 pub blobs: BTreeMap<String, HydratedProvenanceBlob>,
131}
132
133#[derive(Debug, Clone, Serialize, Deserialize)]
135pub struct HydratedProvenanceBlob {
136 pub address: String,
137 pub content: String,
138 pub truncated: bool,
139 pub original_bytes: usize,
140 pub included_bytes: usize,
141}
142
143const MAX_CONDENSED_METADATA_VALUE_BYTES: usize = 256;
144const MAX_HYDRATED_BLOB_BYTES: usize = 12_000;
145
146pub struct Resolver {
148 base_path: PathBuf,
150 vcs_factory: fn() -> Box<dyn VcsBackend>,
152 config: ResolveConfig,
154}
155
156impl Resolver {
157 pub fn new(base_path: &Path) -> Self {
174 Self {
175 base_path: base_path.to_path_buf(),
176 vcs_factory: || Box::new(LoreBackend::from_env()),
177 config: ResolveConfig::default(),
178 }
179 }
180
181 pub fn with_vcs_factory(
183 base_path: &Path,
184 factory: fn() -> Box<dyn VcsBackend>,
185 config: ResolveConfig,
186 ) -> Self {
187 Self {
188 base_path: base_path.to_path_buf(),
189 vcs_factory: factory,
190 config,
191 }
192 }
193
194 fn open_repo(&self, repository: &str) -> Result<(Repository, ResolveConfig), NapError> {
196 let repo_path = self.base_path.join(repository);
197 let repo = Repository::open(&repo_path, (self.vcs_factory)())?;
198 let repo_config = repo.read_resolve_config();
199 Ok((repo, repo_config))
200 }
201
202 pub fn resolve(
222 &self,
223 uri_str: &str,
224 options: &ResolveOptions,
225 ) -> Result<ResolveResult, NapError> {
226 let normalized_uri_str = if uri_str.starts_with("nap://") {
228 uri_str.to_string()
229 } else {
230 format!("nap://{}", uri_str.trim_start_matches('/'))
231 };
232
233 debug!(
234 original_uri = %uri_str,
235 normalized_uri = %normalized_uri_str,
236 "normalized NAP URI"
237 );
238
239 let uri: NapUri = normalized_uri_str.parse()?;
240 self.resolve_uri(&uri, options)
241 }
242
243 pub fn resolve_uri(
245 &self,
246 uri: &NapUri,
247 options: &ResolveOptions,
248 ) -> Result<ResolveResult, NapError> {
249 debug!(
250 uri = %uri,
251 options = ?options,
252 "resolving NAP URI"
253 );
254
255 let wants_provenance =
256 options.provenance.unwrap_or(false) || options.include_blobs.unwrap_or(false);
257
258 if options.recursive.unwrap_or(false) && !wants_provenance {
261 return self.resolve_uri_recursive(
262 uri,
263 options,
264 0,
265 &mut std::collections::HashSet::new(),
266 );
267 }
268
269 self.resolve_uri_single(uri, options)
270 }
271
272 fn resolve_uri_single(
274 &self,
275 uri: &NapUri,
276 options: &ResolveOptions,
277 ) -> Result<ResolveResult, NapError> {
278 let (repo, repo_config) = self.open_repo(&uri.repository)?;
279 let query_path = options.query_path(uri);
280
281 let revision = match (options.commit.as_ref(), options.branch.as_ref()) {
289 (Some(commit), _) => {
290 debug!(%commit, "resolve: rule 1 — commit provided");
291 commit.clone()
292 }
293 (None, Some(branch)) => {
294 debug!(%branch, "resolve: rule 2 — branch provided");
295 repo.resolve_branch_head(branch)?
296 }
297 (None, None) => match &repo_config.default_branch {
298 Some(default_branch) => {
299 debug!(%default_branch, "resolve: rule 3 — using repo default_branch");
300 repo.resolve_branch_head(default_branch)?
301 }
302 None => match &self.config.default_branch {
303 Some(global_default_branch) => {
304 debug!(%global_default_branch, "resolve: rule 3 — using global default_branch");
305 repo.resolve_branch_head(global_default_branch)?
306 }
307 None => {
308 debug!("resolve: rule 4 — no branch, no commit, no default_branch");
309 return Err(NapError::NoDefaultBranch);
310 }
311 },
312 },
313 };
314
315 let manifest = repo.read_manifest_at_ref(&uri.entity_type, &uri.entity_id, &revision)?;
317
318 let wants_provenance =
319 options.provenance.unwrap_or(false) || options.include_blobs.unwrap_or(false);
320 if wants_provenance {
321 if let Some(path) = query_path {
322 return Err(NapError::Other(format!(
323 "provenance envelopes are only supported for full manifest resolution, not subtree query '{path}'"
324 )));
325 }
326
327 let envelope = self.build_provenance_envelope(
328 &repo,
329 uri,
330 manifest,
331 &revision,
332 options.include_blobs.unwrap_or(false),
333 )?;
334 info!(uri = %uri, "resolved NAP URI with provenance");
335 return Ok(ResolveResult::Provenance(Box::new(envelope)));
336 }
337
338 match query_path {
340 Some(ref path) => {
341 debug!(query_path = %path, "applying subtree query");
342 let yaml_value = manifest.to_value()?;
343 let result = ManifestQuery::query(&yaml_value, path, &manifest.id)?;
344
345 let json_str = serde_yaml::to_string(&result)
347 .map_err(|e| NapError::ManifestValidationError(e.to_string()))?;
348 let json_value: serde_json::Value = serde_yaml::from_str(&json_str)
349 .map_err(|e| NapError::ManifestValidationError(e.to_string()))?;
350
351 info!(
352 uri = %uri,
353 query_path = %path,
354 "resolved NAP URI with query"
355 );
356 Ok(ResolveResult::Subtree(json_value))
357 }
358 None => {
359 info!(uri = %uri, "resolved NAP URI (full manifest)");
360 Ok(ResolveResult::Full(Box::new(manifest)))
361 }
362 }
363 }
364
365 fn build_provenance_envelope(
366 &self,
367 repo: &Repository,
368 uri: &NapUri,
369 manifest: Manifest,
370 revision: &str,
371 include_blobs: bool,
372 ) -> Result<ResolveEnvelope, NapError> {
373 let manifest_path = uri.manifest_path();
374 let mut files = vec![self.build_provenance_file(
375 repo,
376 revision,
377 "manifest",
378 None,
379 Some(manifest_path.clone()),
380 None,
381 None,
382 None,
383 include_blobs,
384 )?];
385
386 for (name, representation) in &manifest.representations {
387 let resolved_path = representation
388 .uri
389 .as_deref()
390 .map(|representation_uri| {
391 Self::resolve_representation_path(&manifest_path, representation_uri)
392 })
393 .transpose()?
394 .flatten();
395
396 files.push(self.build_provenance_file(
397 repo,
398 revision,
399 "representation",
400 Some(name.clone()),
401 resolved_path,
402 representation.uri.clone(),
403 Some(representation.hash.clone()),
404 Some(representation.format.clone()),
405 include_blobs,
406 )?);
407 }
408
409 Ok(ResolveEnvelope {
410 manifest: Box::new(manifest),
411 provenance: ResolveProvenanceEnvelope {
412 revision: revision.to_string(),
413 files,
414 },
415 })
416 }
417
418 #[allow(clippy::too_many_arguments)]
419 fn build_provenance_file(
420 &self,
421 repo: &Repository,
422 revision: &str,
423 role: &str,
424 name: Option<String>,
425 path: Option<String>,
426 uri: Option<String>,
427 hash: Option<String>,
428 format: Option<String>,
429 include_blobs: bool,
430 ) -> Result<ResolveProvenanceFile, NapError> {
431 let metadata = match path.as_deref() {
432 Some(path) => repo
433 .vcs()
434 .file_metadata_at_ref(&repo.root, path, revision)?,
435 None => None,
436 };
437
438 let blobs = if include_blobs {
439 match metadata.as_ref() {
440 Some(metadata) => Self::hydrate_known_blobs(repo, metadata)?,
441 None => BTreeMap::new(),
442 }
443 } else {
444 BTreeMap::new()
445 };
446
447 let provenance = match metadata {
448 Some(metadata) => {
449 let condensed = Self::condense_metadata(metadata);
450 if condensed.is_empty() {
451 serde_json::Value::String("none".to_string())
452 } else {
453 serde_json::to_value(condensed).map_err(|e| {
454 NapError::Other(format!("failed to serialize provenance metadata: {e}"))
455 })?
456 }
457 }
458 None => serde_json::Value::String("none".to_string()),
459 };
460
461 Ok(ResolveProvenanceFile {
462 role: role.to_string(),
463 name,
464 path,
465 uri,
466 hash,
467 format,
468 provenance,
469 blobs,
470 })
471 }
472
473 fn condense_metadata(metadata: BTreeMap<String, String>) -> BTreeMap<String, String> {
474 metadata
475 .into_iter()
476 .filter(|(_, value)| value.len() <= MAX_CONDENSED_METADATA_VALUE_BYTES)
477 .collect()
478 }
479
480 fn hydrate_known_blobs(
481 repo: &Repository,
482 metadata: &BTreeMap<String, String>,
483 ) -> Result<BTreeMap<String, HydratedProvenanceBlob>, NapError> {
484 let known_blob_keys = [
485 ("prompt", "nap.provenance.prompt.address"),
486 ("run", "nap.provenance.run.address"),
487 ("parameters", "nap.provenance.parameters.address"),
488 ];
489
490 let mut blobs = BTreeMap::new();
491 for (name, metadata_key) in known_blob_keys {
492 let Some(address) = metadata.get(metadata_key) else {
493 continue;
494 };
495 let content = repo.vcs().read_provenance_blob(&repo.root, address)?;
496 blobs.insert(name.to_string(), Self::truncate_blob(address, &content));
497 }
498 Ok(blobs)
499 }
500
501 fn truncate_blob(address: &str, content: &str) -> HydratedProvenanceBlob {
502 let original_bytes = content.len();
503 let mut included_bytes = 0;
504 let mut truncated_content = String::new();
505
506 for ch in content.chars() {
507 let next_len = included_bytes + ch.len_utf8();
508 if next_len > MAX_HYDRATED_BLOB_BYTES {
509 break;
510 }
511 truncated_content.push(ch);
512 included_bytes = next_len;
513 }
514
515 HydratedProvenanceBlob {
516 address: address.to_string(),
517 content: truncated_content,
518 truncated: included_bytes < original_bytes,
519 original_bytes,
520 included_bytes,
521 }
522 }
523
524 fn resolve_representation_path(
525 manifest_path: &str,
526 representation_uri: &str,
527 ) -> Result<Option<String>, NapError> {
528 if representation_uri.contains("://") {
529 return Ok(None);
530 }
531
532 let representation_path = Path::new(representation_uri);
533 if representation_path.is_absolute() {
534 return Err(NapError::InvalidQueryPath(format!(
535 "representation URI must be relative for provenance lookup: {representation_uri}"
536 )));
537 }
538
539 let mut clean = PathBuf::new();
540 for component in representation_path.components() {
541 match component {
542 Component::Normal(part) => clean.push(part),
543 Component::CurDir => {}
544 Component::ParentDir | Component::RootDir | Component::Prefix(_) => {
545 return Err(NapError::InvalidQueryPath(format!(
546 "unsafe representation URI for provenance lookup: {representation_uri}"
547 )));
548 }
549 }
550 }
551
552 let manifest_dir = Path::new(manifest_path).parent().unwrap_or(Path::new(""));
553 Ok(Some(Self::path_to_lore_path(&manifest_dir.join(clean))))
554 }
555
556 fn path_to_lore_path(path: &Path) -> String {
557 path.components()
558 .filter_map(|component| match component {
559 Component::Normal(part) => Some(part.to_string_lossy().into_owned()),
560 _ => None,
561 })
562 .collect::<Vec<_>>()
563 .join("/")
564 }
565
566 fn resolve_uri_recursive(
568 &self,
569 uri: &NapUri,
570 options: &ResolveOptions,
571 depth: usize,
572 visited: &mut std::collections::HashSet<String>,
573 ) -> Result<ResolveResult, NapError> {
574 let max_depth = options.max_depth.unwrap_or(10);
576 if depth >= max_depth {
577 debug!(depth, max_depth, "reached maximum recursion depth");
578 return self.resolve_uri_single(uri, options);
579 }
580
581 let uri_str = uri.to_string();
583 if visited.contains(&uri_str) {
584 debug!(uri = %uri_str, "detected circular reference, stopping recursion");
585 return self.resolve_uri_single(uri, options);
586 }
587 visited.insert(uri_str.clone());
588
589 debug!(uri = %uri_str, depth, "recursively resolving URI");
590
591 let result = self.resolve_uri_single(uri, options)?;
593
594 match result {
596 ResolveResult::Full(manifest) => {
597 let nested_uris = self.extract_nested_uris(&manifest);
598 if nested_uris.is_empty() {
599 debug!(uri = %uri_str, "no nested URIs found, returning manifest");
600 return Ok(ResolveResult::Full(manifest));
601 }
602
603 debug!(uri = %uri_str, count = nested_uris.len(), "found nested URIs, resolving recursively");
604
605 let mut resolved_manifest = (*manifest).clone();
607 for nested_uri in nested_uris {
608 let nested_uri_parsed: NapUri = nested_uri.parse()?;
609
610 let nested_result = self
611 .resolve_uri_recursive(&nested_uri_parsed, options, depth + 1, visited)
612 .map_err(|e| {
613 NapError::Other(format!(
614 "failed to resolve nested URI '{}' while resolving '{}': {}",
615 nested_uri, uri_str, e
616 ))
617 })?;
618
619 if let ResolveResult::Full(nested_manifest) = nested_result {
620 for (key, value) in nested_manifest.properties {
623 resolved_manifest.properties.insert(key, value);
624 }
625 }
626 }
627
628 Ok(ResolveResult::Full(Box::new(resolved_manifest)))
629 }
630 ResolveResult::Subtree(value) => {
631 debug!("subtree query, skipping recursive resolution");
633 Ok(ResolveResult::Subtree(value))
634 }
635 ResolveResult::Provenance(envelope) => Ok(ResolveResult::Provenance(envelope)),
636 }
637 }
638
639 fn extract_nested_uris(&self, manifest: &Manifest) -> Vec<String> {
641 let mut uris = Vec::new();
642
643 for value in manifest.properties.values() {
645 self.extract_uris_from_yaml_value(value, &mut uris);
646 }
647
648 for value in manifest.references.values() {
650 self.extract_uris_from_yaml_value(value, &mut uris);
651 }
652
653 uris.sort();
655 uris.dedup();
656 uris
657 }
658
659 fn extract_uris_from_yaml_value(&self, value: &serde_yaml::Value, uris: &mut Vec<String>) {
661 match value {
662 serde_yaml::Value::String(s) if s.starts_with("nap://") => {
663 uris.push(s.clone());
664 }
665 serde_yaml::Value::Sequence(seq) => {
666 for item in seq {
667 self.extract_uris_from_yaml_value(item, uris);
668 }
669 }
670 serde_yaml::Value::Mapping(map) => {
671 for (_, v) in map {
672 self.extract_uris_from_yaml_value(v, uris);
673 }
674 }
675 _ => {}
676 }
677 }
678
679 pub fn query(&self, uri_str: &str, path: &str) -> Result<serde_json::Value, NapError> {
681 let options = ResolveOptions {
682 path: Some(path.to_string()),
683 ..Default::default()
684 };
685 match self.resolve(uri_str, &options)? {
686 ResolveResult::Subtree(v) => Ok(v),
687 ResolveResult::Full(m) => m.to_json_value(),
688 ResolveResult::Provenance(envelope) => serde_json::to_value(envelope).map_err(|e| {
689 NapError::Other(format!("failed to serialize provenance envelope: {e}"))
690 }),
691 }
692 }
693
694 pub fn list_repositories(&self) -> Result<Vec<String>, NapError> {
696 let mut repositories = Vec::new();
697 for entry in std::fs::read_dir(&self.base_path)? {
698 let entry = entry?;
699 let path = entry.path();
700 if path.is_dir()
702 && (path.join("repository.yaml").exists() || path.join("repository.yaml").exists())
703 && let Some(name) = path.file_name().and_then(|n| n.to_str())
704 {
705 repositories.push(name.to_string());
706 }
707 }
708 repositories.sort();
709 Ok(repositories)
710 }
711}
712
713#[cfg(test)]
714mod unit_tests {
715 use super::*;
716 use crate::manifest::Representation;
717 use crate::test_utils::MockBackend;
718 use crate::types::EntityType;
719 use tempfile::TempDir;
720
721 fn setup() -> (TempDir, Resolver) {
722 let tmp = TempDir::new().unwrap();
723 let repo_path = tmp.path().join("starwars");
724 let repo = Repository::init(&repo_path, "starwars", Box::new(MockBackend::new())).unwrap();
725
726 let (mut manifest, _) = repo
728 .create_entity(
729 &EntityType::new("character"),
730 "lukeskywalker",
731 "Luke Skywalker",
732 "test",
733 )
734 .unwrap();
735
736 manifest.set_property("species", serde_yaml::Value::String("human".to_string()));
738 manifest.set_property(
739 "homeworld",
740 serde_yaml::Value::String("nap://starwars/location/tatooine".to_string()),
741 );
742 manifest.add_reference(
743 "appears_in",
744 serde_yaml::Value::Sequence(vec![serde_yaml::Value::String(
745 "nap://starwars/scene/cantina".to_string(),
746 )]),
747 );
748 manifest.set_representation(
749 "face_image",
750 Representation {
751 hash: "blake3:9753abf79e5aef60bd95ab76c1e5a14d01239beb37ff9897b6af8e040eb2413a"
752 .to_string(),
753 format: "png".to_string(),
754 uri: Some("face_image.png".to_string()),
755 tier: None,
756 },
757 );
758
759 use crate::commit::Change;
760 repo.commit_manifest(
761 &mut manifest,
762 "add Luke Skywalker details",
763 "test",
764 vec![Change::set("properties.species", None, "human".to_string())],
765 )
766 .unwrap();
767
768 let resolver = Resolver::with_vcs_factory(
769 tmp.path(),
770 || Box::new(MockBackend::new()),
771 ResolveConfig {
772 default_branch: Some("main".to_string()),
773 },
774 );
775 (tmp, resolver)
776 }
777
778 #[test]
779 fn test_resolve_full_manifest() {
780 let (_tmp, resolver) = setup();
781 let result = resolver
782 .resolve(
783 "nap://starwars/character/lukeskywalker",
784 &Default::default(),
785 )
786 .unwrap();
787 match result {
788 ResolveResult::Full(m) => {
789 assert_eq!(m.name, "Luke Skywalker");
790 assert_eq!(m.entity_type.as_str(), "character");
791 }
792 _ => panic!("expected full manifest"),
793 }
794 }
795
796 fn write_mock_metadata(repo_path: &Path, metadata: BTreeMap<String, BTreeMap<String, String>>) {
797 std::fs::write(
798 repo_path.join(".mock_file_metadata.json"),
799 serde_json::to_string(&metadata).unwrap(),
800 )
801 .unwrap();
802 }
803
804 fn write_mock_blobs(repo_path: &Path, blobs: BTreeMap<String, String>) {
805 std::fs::write(
806 repo_path.join(".mock_provenance_blobs.json"),
807 serde_json::to_string(&blobs).unwrap(),
808 )
809 .unwrap();
810 }
811
812 fn resolve_with_provenance(resolver: &Resolver) -> ResolveEnvelope {
813 let result = resolver
814 .resolve(
815 "nap://starwars/character/lukeskywalker",
816 &ResolveOptions {
817 provenance: Some(true),
818 ..Default::default()
819 },
820 )
821 .unwrap();
822 match result {
823 ResolveResult::Provenance(envelope) => *envelope,
824 _ => panic!("expected provenance envelope"),
825 }
826 }
827
828 #[test]
829 fn test_resolve_with_provenance_returns_manifest_and_direct_file_entries() {
830 let (tmp, resolver) = setup();
831 let repo_path = tmp.path().join("starwars");
832 write_mock_metadata(
833 &repo_path,
834 BTreeMap::from([
835 (
836 "character/lukeskywalker.yaml".to_string(),
837 BTreeMap::from([
838 ("nap.provenance.kind".to_string(), "edit".to_string()),
839 ("nap.provenance.model".to_string(), "gpt-5".to_string()),
840 (
841 "nap.provenance.long".to_string(),
842 "x".repeat(MAX_CONDENSED_METADATA_VALUE_BYTES + 1),
843 ),
844 ]),
845 ),
846 (
847 "character/face_image.png".to_string(),
848 BTreeMap::from([("nap.provenance.kind".to_string(), "generation".to_string())]),
849 ),
850 ]),
851 );
852
853 let envelope = resolve_with_provenance(&resolver);
854 assert_eq!(envelope.manifest.name, "Luke Skywalker");
855 assert_eq!(envelope.provenance.files.len(), 2);
856
857 let manifest_file = &envelope.provenance.files[0];
858 assert_eq!(manifest_file.role, "manifest");
859 assert_eq!(
860 manifest_file.path.as_deref(),
861 Some("character/lukeskywalker.yaml")
862 );
863 assert_eq!(manifest_file.provenance["nap.provenance.kind"], "edit");
864 assert!(
865 manifest_file
866 .provenance
867 .get("nap.provenance.long")
868 .is_none()
869 );
870
871 let representation_file = &envelope.provenance.files[1];
872 assert_eq!(representation_file.role, "representation");
873 assert_eq!(representation_file.name.as_deref(), Some("face_image"));
874 assert_eq!(
875 representation_file.path.as_deref(),
876 Some("character/face_image.png")
877 );
878 assert_eq!(representation_file.uri.as_deref(), Some("face_image.png"));
879 assert_eq!(representation_file.format.as_deref(), Some("png"));
880 }
881
882 #[test]
883 fn test_resolve_with_provenance_records_path_and_revision_metadata_lookups() {
884 let (tmp, resolver) = setup();
885 let repo_path = tmp.path().join("starwars");
886 let envelope = resolve_with_provenance(&resolver);
887
888 let requests: Vec<BTreeMap<String, String>> = serde_json::from_str(
889 &std::fs::read_to_string(repo_path.join(".mock_metadata_requests.json")).unwrap(),
890 )
891 .unwrap();
892 assert_eq!(requests.len(), 2);
893 assert_eq!(
894 requests[0].get("path").unwrap(),
895 "character/lukeskywalker.yaml"
896 );
897 assert_eq!(
898 requests[0].get("revision").unwrap(),
899 &envelope.provenance.revision
900 );
901 assert_eq!(requests[1].get("path").unwrap(), "character/face_image.png");
902 assert_eq!(
903 requests[1].get("revision").unwrap(),
904 &envelope.provenance.revision
905 );
906 assert!(!requests.iter().any(|request| {
907 request
908 .get("path")
909 .is_some_and(|path| path.starts_with("blake3:"))
910 }));
911 }
912
913 #[test]
914 fn test_resolve_with_provenance_uses_none_for_missing_metadata() {
915 let (_tmp, resolver) = setup();
916 let envelope = resolve_with_provenance(&resolver);
917 assert_eq!(envelope.provenance.files[0].provenance, "none");
918 assert_eq!(envelope.provenance.files[1].provenance, "none");
919 }
920
921 #[test]
922 fn test_resolve_with_include_blobs_hydrates_known_readable_artifacts() {
923 let (tmp, resolver) = setup();
924 let repo_path = tmp.path().join("starwars");
925 write_mock_metadata(
926 &repo_path,
927 BTreeMap::from([(
928 "character/lukeskywalker.yaml".to_string(),
929 BTreeMap::from([
930 (
931 "nap.provenance.prompt.address".to_string(),
932 "lore:prompt:1".to_string(),
933 ),
934 (
935 "unrelated.artifact.address".to_string(),
936 "lore:binary:1".to_string(),
937 ),
938 ]),
939 )]),
940 );
941 write_mock_blobs(
942 &repo_path,
943 BTreeMap::from([("lore:prompt:1".to_string(), "Describe Luke".to_string())]),
944 );
945
946 let result = resolver
947 .resolve(
948 "nap://starwars/character/lukeskywalker",
949 &ResolveOptions {
950 provenance: Some(true),
951 include_blobs: Some(true),
952 ..Default::default()
953 },
954 )
955 .unwrap();
956 let ResolveResult::Provenance(envelope) = result else {
957 panic!("expected provenance envelope");
958 };
959
960 let blobs = &envelope.provenance.files[0].blobs;
961 assert_eq!(blobs.len(), 1);
962 assert_eq!(blobs["prompt"].content, "Describe Luke");
963 assert!(!blobs["prompt"].truncated);
964 }
965
966 #[test]
967 fn test_include_blobs_implies_provenance_envelope() {
968 let (_tmp, resolver) = setup();
969 let result = resolver
970 .resolve(
971 "nap://starwars/character/lukeskywalker",
972 &ResolveOptions {
973 include_blobs: Some(true),
974 ..Default::default()
975 },
976 )
977 .unwrap();
978 assert!(matches!(result, ResolveResult::Provenance(_)));
979 }
980
981 #[test]
982 fn test_resolve_with_include_blobs_truncates_readable_artifacts() {
983 let (tmp, resolver) = setup();
984 let repo_path = tmp.path().join("starwars");
985 write_mock_metadata(
986 &repo_path,
987 BTreeMap::from([(
988 "character/lukeskywalker.yaml".to_string(),
989 BTreeMap::from([(
990 "nap.provenance.prompt.address".to_string(),
991 "lore:prompt:large".to_string(),
992 )]),
993 )]),
994 );
995 write_mock_blobs(
996 &repo_path,
997 BTreeMap::from([(
998 "lore:prompt:large".to_string(),
999 "x".repeat(MAX_HYDRATED_BLOB_BYTES + 10),
1000 )]),
1001 );
1002
1003 let result = resolver
1004 .resolve(
1005 "nap://starwars/character/lukeskywalker",
1006 &ResolveOptions {
1007 provenance: Some(true),
1008 include_blobs: Some(true),
1009 ..Default::default()
1010 },
1011 )
1012 .unwrap();
1013 let ResolveResult::Provenance(envelope) = result else {
1014 panic!("expected provenance envelope");
1015 };
1016 let blob = &envelope.provenance.files[0].blobs["prompt"];
1017 assert!(blob.truncated);
1018 assert_eq!(blob.original_bytes, MAX_HYDRATED_BLOB_BYTES + 10);
1019 assert_eq!(blob.included_bytes, MAX_HYDRATED_BLOB_BYTES);
1020 assert_eq!(blob.content.len(), MAX_HYDRATED_BLOB_BYTES);
1021 }
1022
1023 #[test]
1024 fn test_provenance_rejects_unsafe_representation_paths() {
1025 let tmp = TempDir::new().unwrap();
1026 let repo_path = tmp.path().join("starwars");
1027 let repo = Repository::init(&repo_path, "starwars", Box::new(MockBackend::new())).unwrap();
1028 let (mut manifest, _) = repo
1029 .create_entity(&EntityType::new("character"), "leia", "Leia Organa", "test")
1030 .unwrap();
1031 manifest.set_representation(
1032 "unsafe",
1033 Representation {
1034 hash: "blake3:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"
1035 .to_string(),
1036 format: "png".to_string(),
1037 uri: Some("../secret.png".to_string()),
1038 tier: None,
1039 },
1040 );
1041 use crate::commit::Change;
1042 repo.commit_manifest(
1043 &mut manifest,
1044 "add unsafe representation",
1045 "test",
1046 vec![Change::set(
1047 "representations.unsafe",
1048 None,
1049 "unsafe".to_string(),
1050 )],
1051 )
1052 .unwrap();
1053
1054 let resolver = Resolver::with_vcs_factory(
1055 tmp.path(),
1056 || Box::new(MockBackend::new()),
1057 ResolveConfig {
1058 default_branch: Some("main".to_string()),
1059 },
1060 );
1061 let err = resolver
1062 .resolve(
1063 "nap://starwars/character/leia",
1064 &ResolveOptions {
1065 provenance: Some(true),
1066 ..Default::default()
1067 },
1068 )
1069 .unwrap_err();
1070 assert!(err.to_string().contains("unsafe representation URI"));
1071 }
1072
1073 #[test]
1074 fn test_resolve_with_fragment() {
1075 let (_tmp, resolver) = setup();
1076 let result = resolver
1077 .resolve(
1078 "nap://starwars/character/lukeskywalker#properties.species",
1079 &Default::default(),
1080 )
1081 .unwrap();
1082 match result {
1083 ResolveResult::Subtree(v) => {
1084 assert_eq!(v.as_str(), Some("human"));
1085 }
1086 _ => panic!("expected subtree"),
1087 }
1088 }
1089
1090 #[test]
1091 fn test_resolve_with_options_path() {
1092 let (_tmp, resolver) = setup();
1093 let result = resolver
1094 .resolve(
1095 "nap://starwars/character/lukeskywalker",
1096 &ResolveOptions {
1097 path: Some("properties.homeworld".to_string()),
1098 ..Default::default()
1099 },
1100 )
1101 .unwrap();
1102 match result {
1103 ResolveResult::Subtree(v) => {
1104 assert_eq!(v.as_str(), Some("nap://starwars/location/tatooine"));
1105 }
1106 _ => panic!("expected subtree"),
1107 }
1108 }
1109
1110 #[test]
1111 fn test_query_convenience() {
1112 let (_tmp, resolver) = setup();
1113 let result = resolver
1114 .query(
1115 "nap://starwars/character/lukeskywalker",
1116 "properties.species",
1117 )
1118 .unwrap();
1119 assert_eq!(result.as_str(), Some("human"));
1120 }
1121
1122 #[test]
1123 fn test_list_repositories() {
1124 let (_tmp, resolver) = setup();
1125 let repositories = resolver.list_repositories().unwrap();
1126 assert!(repositories.contains(&"starwars".to_string()));
1127 }
1128
1129 #[test]
1130 fn test_resolve_not_found() {
1131 let (_tmp, resolver) = setup();
1132 let result = resolver.resolve("nap://starwars/character/nonexistent", &Default::default());
1133 assert!(result.is_err());
1134 }
1135
1136 #[test]
1137 fn test_resolve_without_scheme() {
1138 let (_tmp, resolver) = setup();
1139 let result = resolver
1140 .resolve("starwars/character/lukeskywalker", &Default::default())
1141 .unwrap();
1142 match result {
1143 ResolveResult::Full(m) => {
1144 assert_eq!(m.name, "Luke Skywalker");
1145 assert_eq!(m.entity_type.as_str(), "character");
1146 }
1147 _ => panic!("expected full manifest"),
1148 }
1149 }
1150
1151 #[test]
1152 fn test_resolve_without_scheme_with_fragment() {
1153 let (_tmp, resolver) = setup();
1154 let result = resolver
1155 .resolve(
1156 "starwars/character/lukeskywalker#properties.species",
1157 &Default::default(),
1158 )
1159 .unwrap();
1160 match result {
1161 ResolveResult::Subtree(v) => {
1162 assert_eq!(v.as_str(), Some("human"));
1163 }
1164 _ => panic!("expected subtree"),
1165 }
1166 }
1167
1168 #[test]
1169 fn test_resolve_without_leading_slash() {
1170 let (_tmp, resolver) = setup();
1171 let result = resolver
1172 .resolve("starwars/character/lukeskywalker", &Default::default())
1173 .unwrap();
1174 match result {
1175 ResolveResult::Full(m) => {
1176 assert_eq!(m.name, "Luke Skywalker");
1177 }
1178 _ => panic!("expected full manifest"),
1179 }
1180 }
1181
1182 #[test]
1183 fn test_resolve_with_leading_slash_without_scheme() {
1184 let (_tmp, resolver) = setup();
1185 let result = resolver
1186 .resolve("/starwars/character/lukeskywalker", &Default::default())
1187 .unwrap();
1188 match result {
1189 ResolveResult::Full(m) => {
1190 assert_eq!(m.name, "Luke Skywalker");
1191 }
1192 _ => panic!("expected full manifest"),
1193 }
1194 }
1195}
1196
1197#[cfg(all(test, feature = "lore-integration"))]
1198mod lore_tests {
1199 use super::*;
1200 use crate::types::EntityType;
1201 use crate::vcs_lore::LoreBackend;
1202 use std::time::{SystemTime, UNIX_EPOCH};
1203 use tempfile::TempDir;
1204
1205 fn unique_suffix() -> u64 {
1206 SystemTime::now()
1207 .duration_since(UNIX_EPOCH)
1208 .unwrap()
1209 .as_nanos() as u64
1210 }
1211
1212 fn setup_lore() -> (TempDir, Resolver, String) {
1213 let repository = format!("lr-{}", unique_suffix());
1214 let tmp = TempDir::new().unwrap();
1215 let repo_path = tmp.path().join(&repository);
1216 let repo =
1217 Repository::init(&repo_path, &repository, Box::new(LoreBackend::from_env())).unwrap();
1218
1219 let (mut manifest, _) = repo
1221 .create_entity(
1222 &EntityType::new("character"),
1223 "lukeskywalker",
1224 "Luke Skywalker",
1225 "test",
1226 )
1227 .unwrap();
1228
1229 manifest.set_property("species", serde_yaml::Value::String("human".to_string()));
1231 use crate::commit::Change;
1232 repo.commit_manifest(
1233 &mut manifest,
1234 "add Luke Skywalker details",
1235 "test",
1236 vec![Change::set("properties.species", None, "human".to_string())],
1237 )
1238 .unwrap();
1239
1240 let resolver = Resolver::with_vcs_factory(
1241 tmp.path(),
1242 || Box::new(LoreBackend::from_env()),
1243 ResolveConfig {
1244 default_branch: Some("main".to_string()),
1245 },
1246 );
1247 (tmp, resolver, repository)
1248 }
1249
1250 #[test]
1251 fn test_resolve_lore_full_manifest() {
1252 let (_tmp, resolver, repository) = setup_lore();
1253 let uri = format!("nap://{}/character/lukeskywalker", repository);
1254 let result = resolver.resolve(&uri, &Default::default()).unwrap();
1255 match result {
1256 ResolveResult::Full(m) => {
1257 assert_eq!(m.name, "Luke Skywalker");
1258 }
1259 _ => panic!("expected full manifest"),
1260 }
1261 }
1262}