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