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| 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        // Read the manifest at the resolved revision, or the current filesystem
356        // state when resolving without a revision (unversioned mode).
357        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            // Provenance is VCS-backed; it cannot be produced in unversioned mode.
374            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        // Apply query if present
392        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                // Convert YAML value to JSON for consistent API output
399                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        // Provenance is VCS-backed; in unversioned mode there is nothing to read.
485        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    /// Resolve a URI recursively, following nested nap:// URIs.
624    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        // Check depth limit
632        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        // Check for circular references
639        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        // Resolve the current URI
649        let result = self.resolve_uri_single(uri, options)?;
650
651        // Extract nested URIs from the result and resolve them
652        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                // Resolve nested URIs and merge them into the result
663                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                        // Merge nested manifest into parent (simple merge for now)
678                        // In the future, this could be more sophisticated based on schema
679                        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                // For subtree queries, we don't recurse (would be complex to merge)
689                debug!("subtree query, skipping recursive resolution");
690                Ok(ResolveResult::Subtree(value))
691            }
692            ResolveResult::Provenance(envelope) => Ok(ResolveResult::Provenance(envelope)),
693        }
694    }
695
696    /// Extract all nap:// URIs from a manifest.
697    fn extract_nested_uris(&self, manifest: &Manifest) -> Vec<String> {
698        let mut uris = Vec::new();
699
700        // Search in properties
701        for value in manifest.properties.values() {
702            self.extract_uris_from_yaml_value(value, &mut uris);
703        }
704
705        // Search in references
706        for value in manifest.references.values() {
707            self.extract_uris_from_yaml_value(value, &mut uris);
708        }
709
710        // Deduplicate URIs to avoid resolving the same URI multiple times
711        uris.sort();
712        uris.dedup();
713        uris
714    }
715
716    /// Recursively extract nap:// URIs from YAML values.
717    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    /// Convenience: query a specific path on a URI.
737    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    /// List all repositories available.
752    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            // Check for repository.yaml or repository.yaml to identify valid repositories
758            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        // Create a character
784        let (mut manifest, _) = repo
785            .create_entity(&EntityType::new("character"), "woody", "Woody", "test")
786            .unwrap();
787
788        // Add properties and commit
789        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        // Create a character
1264        let (mut manifest, _) = repo
1265            .create_entity(&EntityType::new("character"), "woody", "Woody", "test")
1266            .unwrap();
1267
1268        // Add properties and commit
1269        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}