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