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