Skip to main content

nap_core/
resolver.rs

1//! NAP Resolver — URI → Manifest, with query and version selectors.
2//!
3//! The resolver is the primary interface for reading NAP resources.
4//! It handles:
5//! - Full manifest resolution: `nap://toystory/character/woody`
6//! - Fragment queries: `nap://toystory/character/woody#references.appears_in`
7//! - Version selectors: branch, commit
8//! - Subtree extraction for efficient AI/application access
9//!
10//! Version and branch are NEVER in the URI. They are orthogonal selectors:
11//! ```text
12//! URI + Reference + Revision Selector
13//! ```
14
15use 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/// Resolver configuration — set at construction time.
30///
31/// Controls how the resolver resolves URIs when no explicit branch or
32/// commit is provided by the caller.
33#[derive(Debug, Clone, Default)]
34pub struct ResolveConfig {
35    /// Branch to resolve when neither `branch` nor `commit` is specified
36    /// in [`ResolveOptions`].  If `None`, resolves without a branch or
37    /// commit — this will trigger a [`NapError::NoDefaultBranch`] error
38    /// for any resolve call that omits both `branch` and `commit`.
39    pub default_branch: Option<String>,
40}
41
42/// Options for resolving a NAP URI. All are optional — omitting all
43/// causes the resolver to use its [`ResolveConfig::default_branch`] (if
44/// configured) or fail with [`NapError::NoDefaultBranch`].
45#[derive(Debug, Clone, Default, Serialize, Deserialize)]
46pub struct ResolveOptions {
47    /// Resolve at a specific branch. e.g., `"canon"`.
48    /// Takes precedence over [`ResolveConfig::default_branch`].
49    #[serde(default, skip_serializing_if = "Option::is_none")]
50    pub branch: Option<String>,
51
52    /// Resolve at a specific commit hash (BLAKE3). e.g.,
53    /// `"af1349b9f5f9a1a6a0404deb36d020949b834f2a42e37e5f8d2e4ba2765f1a2f"`.
54    /// Takes precedence over `branch` and [`ResolveConfig::default_branch`].
55    #[serde(default, skip_serializing_if = "Option::is_none")]
56    pub commit: Option<String>,
57
58    /// Subtree query path (overrides URI fragment). e.g., `"appearances.audienceVotes"`.
59    #[serde(default, skip_serializing_if = "Option::is_none")]
60    pub path: Option<String>,
61
62    /// Recursively resolve nested URIs. When true, the resolver will follow
63    /// all nap:// URIs found in the resolved manifest and resolve them as well.
64    #[serde(default, skip_serializing_if = "Option::is_none")]
65    pub recursive: Option<bool>,
66
67    /// Maximum recursion depth for recursive resolution. Defaults to 10 to prevent
68    /// infinite loops. Set to None for unlimited depth (not recommended).
69    #[serde(default, skip_serializing_if = "Option::is_none")]
70    pub max_depth: Option<usize>,
71
72    /// Include per-file provenance metadata for the manifest and direct representations.
73    #[serde(default, skip_serializing_if = "Option::is_none")]
74    pub provenance: Option<bool>,
75
76    /// Hydrate known readable provenance artifacts such as prompts and run records.
77    #[serde(default, skip_serializing_if = "Option::is_none")]
78    pub include_blobs: Option<bool>,
79}
80
81impl ResolveOptions {
82    /// Returns the query path (from options or URI fragment).
83    fn query_path(&self, uri: &NapUri) -> Option<String> {
84        self.path.clone().or_else(|| uri.fragment.clone())
85    }
86}
87
88/// The result of resolving a NAP URI.
89#[derive(Debug, Clone, Serialize, Deserialize)]
90#[serde(untagged)]
91pub enum ResolveResult {
92    /// Full manifest (no query applied).
93    Full(Box<Manifest>),
94    /// Full manifest with Lore-backed per-file provenance envelope.
95    Provenance(Box<ResolveEnvelope>),
96    /// Subtree result from a query.
97    Subtree(serde_json::Value),
98}
99
100/// Envelope returned when `ResolveOptions::provenance` is enabled.
101#[derive(Debug, Clone, Serialize, Deserialize)]
102pub struct ResolveEnvelope {
103    pub manifest: Box<Manifest>,
104    pub provenance: ResolveProvenanceEnvelope,
105}
106
107/// Per-resolution provenance metadata.
108#[derive(Debug, Clone, Serialize, Deserialize)]
109pub struct ResolveProvenanceEnvelope {
110    pub revision: String,
111    pub files: Vec<ResolveProvenanceFile>,
112}
113
114/// Provenance for one file participating in an entity resolution.
115#[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/// Hydrated readable provenance artifact.
134#[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
146/// The NAP resolver — resolves URIs to manifests or subtrees.
147pub struct Resolver {
148    /// Base directory containing repository repositories.
149    base_path: PathBuf,
150    /// VCS backend factory (creates backend per-repo).
151    vcs_factory: fn() -> Box<dyn VcsBackend>,
152    /// Whether a version-control backend is configured. When `false`,
153    /// repositories are opened in unversioned mode and resolution reads the
154    /// current filesystem state (no branch/commit selectors available).
155    use_vcs: bool,
156    /// Resolution configuration (default branch, etc.).
157    config: ResolveConfig,
158}
159
160impl Resolver {
161    /// Create a resolver that looks for repository repos under `base_path`.
162    ///
163    /// Uses [`LoreBackend::from_env()`] by default **when a version-control
164    /// backend is configured** for `base_path` (i.e. a valid `provider.toml`
165    /// exists). Otherwise repositories are opened in unversioned mode. For
166    /// testing, use [`Resolver::with_vcs_factory()`] with a mock backend.
167    ///
168    /// Uses [`ResolveConfig::default()`] — meaning `default_branch` is
169    /// `None`. In versioned mode any resolve that omits both `branch` and
170    /// `commit` will fail with [`NapError::NoDefaultBranch`]; in unversioned
171    /// mode such a resolve reads the current filesystem state.
172    ///
173    /// # Example layout
174    /// ```text
175    /// base_path/
176    /// ├── toystory/    ← repository repo
177    /// ├── toystory/    ← repository repo
178    /// └── marvel/      ← repository repo
179    /// ```
180    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    /// Create a resolver with a custom VCS backend factory and config.
190    ///
191    /// Repositories are always opened in versioned mode.
192    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    /// Open the repository for a given repository and read its resolve config.
206    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    /// Resolve a NAP URI string with options.
219    ///
220    /// # Examples
221    /// ```text
222    /// // Full manifest
223    /// resolver.resolve("nap://toystory/character/woody", &Default::default())
224    ///
225    /// // Without scheme (auto-normalized)
226    /// resolver.resolve("toystory/character/woody", &Default::default())
227    ///
228    /// // With branch
229    /// resolver.resolve("nap://toystory/character/woody", &ResolveOptions {
230    ///     branch: Some("canon".to_string()),
231    ///     ..Default::default()
232    /// })
233    ///
234    /// // With fragment query (via URI)
235    /// resolver.resolve("nap://toystory/character/woody#references.appears_in", &Default::default())
236    /// ```
237    pub fn resolve(
238        &self,
239        uri_str: &str,
240        options: &ResolveOptions,
241    ) -> Result<ResolveResult, NapError> {
242        // ── Normalization: Prepend nap:// if missing ─────────────────────
243        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    /// Resolve a parsed NAP URI with options.
260    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        // Handle recursive resolution. Provenance is intentionally scoped to the
275        // requested manifest and its direct representations, not related entities.
276        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    /// Resolve a single URI without recursion.
289    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        // ── 4-Rule Resolution ────────────────────────────────────────
298        // Rule 1: commit provided → use directly (bypass branch logic)
299        // Rule 2: branch provided, no commit → resolve branch head
300        // Rule 3: both null → use default_branch from repo config (fallback to global)
301        // Rule 4: both null and no default_branch → hard error (versioned only)
302        // In unversioned mode (no backend), resolving without a revision reads
303        // the current filesystem state; branch/commit selectors are
304        // unsatisfiable and produce a ResolutionFailed error.
305        // ──────────────────────────────────────────────────────────────
306
307        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        // Read the manifest at the resolved revision, or the current filesystem
360        // state when resolving without a revision (unversioned mode).
361        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            // Provenance is VCS-backed; it cannot be produced in unversioned mode.
376            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        // Apply query if present
394        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                // Convert YAML value to JSON for consistent API output
401                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        // Provenance is VCS-backed; in unversioned mode there is nothing to read.
487        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    /// Resolve a URI recursively, following nested nap:// URIs.
628    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        // Check depth limit
636        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        // Check for circular references
643        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        // Resolve the current URI
653        let result = self.resolve_uri_single(uri, options)?;
654
655        // Extract nested URIs from the result and resolve them
656        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                // Resolve nested URIs and merge them into the result
667                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                        // Merge nested manifest into parent (simple merge for now)
682                        // In the future, this could be more sophisticated based on schema
683                        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                // For subtree queries, we don't recurse (would be complex to merge)
693                debug!("subtree query, skipping recursive resolution");
694                Ok(ResolveResult::Subtree(value))
695            }
696            ResolveResult::Provenance(envelope) => Ok(ResolveResult::Provenance(envelope)),
697        }
698    }
699
700    /// Extract all nap:// URIs from a manifest.
701    fn extract_nested_uris(&self, manifest: &Manifest) -> Vec<String> {
702        let mut uris = Vec::new();
703
704        // Search in properties
705        for value in manifest.properties.values() {
706            self.extract_uris_from_yaml_value(value, &mut uris);
707        }
708
709        // Search in references
710        for value in manifest.references.values() {
711            self.extract_uris_from_yaml_value(value, &mut uris);
712        }
713
714        // Deduplicate URIs to avoid resolving the same URI multiple times
715        uris.sort();
716        uris.dedup();
717        uris
718    }
719
720    /// Recursively extract nap:// URIs from YAML values.
721    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    /// Convenience: query a specific path on a URI.
741    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    /// List all repositories available.
756    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            // Check for repository.yaml or repository.yaml to identify valid repositories
762            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        // Create a character
788        let (mut manifest, _) = repo
789            .create_entity(&EntityType::new("character"), "woody", "Woody", "test")
790            .unwrap();
791
792        // Add properties and commit
793        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        // Create a character
1268        let (mut manifest, _) = repo
1269            .create_entity(&EntityType::new("character"), "woody", "Woody", "test")
1270            .unwrap();
1271
1272        // Add properties and commit
1273        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}