1use std::path::{Path, PathBuf};
16
17use serde::{Deserialize, Serialize};
18use tracing::{debug, info};
19
20use crate::error::NapError;
21use crate::manifest::Manifest;
22use crate::query::ManifestQuery;
23use crate::repository::Repository;
24use crate::uri::NapUri;
25use crate::vcs::VcsBackend;
26use crate::vcs_lore::LoreBackend;
27
28#[derive(Debug, Clone, Default)]
33pub struct ResolveConfig {
34 pub default_branch: Option<String>,
39}
40
41#[derive(Debug, Clone, Default, Serialize, Deserialize)]
45pub struct ResolveOptions {
46 #[serde(default, skip_serializing_if = "Option::is_none")]
49 pub branch: Option<String>,
50
51 #[serde(default, skip_serializing_if = "Option::is_none")]
55 pub commit: Option<String>,
56
57 #[serde(default, skip_serializing_if = "Option::is_none")]
59 pub tag: Option<String>,
60
61 #[serde(default, skip_serializing_if = "Option::is_none")]
63 pub path: Option<String>,
64}
65
66impl ResolveOptions {
67 fn query_path(&self, uri: &NapUri) -> Option<String> {
69 self.path.clone().or_else(|| uri.fragment.clone())
70 }
71}
72
73#[derive(Debug, Clone, Serialize, Deserialize)]
75#[serde(untagged)]
76pub enum ResolveResult {
77 Full(Box<Manifest>),
79 Subtree(serde_json::Value),
81}
82
83pub struct Resolver {
85 base_path: PathBuf,
87 vcs_factory: fn() -> Box<dyn VcsBackend>,
89 config: ResolveConfig,
91}
92
93impl Resolver {
94 pub fn new(base_path: &Path) -> Self {
108 Self {
109 base_path: base_path.to_path_buf(),
110 vcs_factory: || Box::new(LoreBackend::from_env()),
111 config: ResolveConfig::default(),
112 }
113 }
114
115 pub fn with_vcs_factory(
117 base_path: &Path,
118 factory: fn() -> Box<dyn VcsBackend>,
119 config: ResolveConfig,
120 ) -> Self {
121 Self {
122 base_path: base_path.to_path_buf(),
123 vcs_factory: factory,
124 config,
125 }
126 }
127
128 fn open_repo(&self, universe: &str) -> Result<Repository, NapError> {
130 let repo_path = self.base_path.join(universe);
131 Repository::open(&repo_path, (self.vcs_factory)())
132 }
133
134 pub fn resolve(
151 &self,
152 uri_str: &str,
153 options: &ResolveOptions,
154 ) -> Result<ResolveResult, NapError> {
155 let uri: NapUri = uri_str.parse()?;
156 self.resolve_uri(&uri, options)
157 }
158
159 pub fn resolve_uri(
161 &self,
162 uri: &NapUri,
163 options: &ResolveOptions,
164 ) -> Result<ResolveResult, NapError> {
165 debug!(
166 uri = %uri,
167 options = ?options,
168 "resolving NAP URI"
169 );
170
171 let repo = self.open_repo(&uri.universe)?;
172 let query_path = options.query_path(uri);
173
174 let revision = match (options.commit.as_ref(), options.branch.as_ref()) {
182 (Some(commit), _) => {
183 debug!(%commit, "resolve: rule 1 — commit provided");
184 commit.clone()
185 }
186 (None, Some(branch)) => {
187 debug!(%branch, "resolve: rule 2 — branch provided");
188 repo.resolve_branch_head(branch)?
189 }
190 (None, None) => match &self.config.default_branch {
191 Some(default_branch) => {
192 debug!(%default_branch, "resolve: rule 3 — using default_branch");
193 repo.resolve_branch_head(default_branch)?
194 }
195 None => {
196 debug!("resolve: rule 4 — no branch, no commit, no default_branch");
197 return Err(NapError::NoDefaultBranch);
198 }
199 },
200 };
201
202 let manifest = repo.read_manifest_at_ref(uri.entity_type, &uri.entity_id, &revision)?;
204
205 match query_path {
207 Some(ref path) => {
208 debug!(query_path = %path, "applying subtree query");
209 let yaml_value = manifest.to_value()?;
210 let result = ManifestQuery::query(&yaml_value, path, &manifest.id)?;
211
212 let json_str = serde_yaml::to_string(&result)
214 .map_err(|e| NapError::ManifestValidationError(e.to_string()))?;
215 let json_value: serde_json::Value = serde_yaml::from_str(&json_str)
216 .map_err(|e| NapError::ManifestValidationError(e.to_string()))?;
217
218 info!(
219 uri = %uri,
220 query_path = %path,
221 "resolved NAP URI with query"
222 );
223 Ok(ResolveResult::Subtree(json_value))
224 }
225 None => {
226 info!(uri = %uri, "resolved NAP URI (full manifest)");
227 Ok(ResolveResult::Full(Box::new(manifest)))
228 }
229 }
230 }
231
232 pub fn query(&self, uri_str: &str, path: &str) -> Result<serde_json::Value, NapError> {
234 let options = ResolveOptions {
235 path: Some(path.to_string()),
236 ..Default::default()
237 };
238 match self.resolve(uri_str, &options)? {
239 ResolveResult::Subtree(v) => Ok(v),
240 ResolveResult::Full(m) => m.to_json_value(),
241 }
242 }
243
244 pub fn list_universes(&self) -> Result<Vec<String>, NapError> {
246 let mut universes = Vec::new();
247 for entry in std::fs::read_dir(&self.base_path)? {
248 let entry = entry?;
249 let path = entry.path();
250 if path.is_dir()
251 && path.join(".nap").exists()
252 && let Some(name) = path.file_name().and_then(|n| n.to_str())
253 {
254 universes.push(name.to_string());
255 }
256 }
257 universes.sort();
258 Ok(universes)
259 }
260}
261
262#[cfg(test)]
263mod tests {
264 use super::*;
265 use crate::types::EntityType;
266 use crate::vcs_git::GitBackend;
267 use tempfile::TempDir;
268
269 fn setup() -> (TempDir, Resolver) {
270 let tmp = TempDir::new().unwrap();
271 let repo = Repository::init(tmp.path(), "starwars", Box::new(GitBackend::new())).unwrap();
272
273 let (mut manifest, _) = repo
275 .create_entity(
276 EntityType::Character,
277 "lukeskywalker",
278 "Luke Skywalker",
279 "test",
280 )
281 .unwrap();
282
283 manifest.set_property("species", serde_yaml::Value::String("human".to_string()));
285 manifest.set_property(
286 "homeworld",
287 serde_yaml::Value::String("nap://starwars/location/tatooine".to_string()),
288 );
289 manifest.add_reference(
290 "appears_in",
291 serde_yaml::Value::Sequence(vec![serde_yaml::Value::String(
292 "nap://starwars/scene/cantina".to_string(),
293 )]),
294 );
295
296 use crate::commit::Change;
297 repo.commit_manifest(
298 &mut manifest,
299 "add Luke Skywalker details",
300 "test",
301 vec![Change::set("properties.species", None, "human".to_string())],
302 )
303 .unwrap();
304
305 let resolver = Resolver::with_vcs_factory(
306 tmp.path(),
307 || Box::new(GitBackend::new()),
308 ResolveConfig {
309 default_branch: Some("main".to_string()),
310 },
311 );
312 (tmp, resolver)
313 }
314
315 #[test]
316 fn test_resolve_full_manifest() {
317 let (_tmp, resolver) = setup();
318 let result = resolver
319 .resolve(
320 "nap://starwars/character/lukeskywalker",
321 &Default::default(),
322 )
323 .unwrap();
324 match result {
325 ResolveResult::Full(m) => {
326 assert_eq!(m.name, "Luke Skywalker");
327 assert_eq!(m.entity_type, EntityType::Character);
328 }
329 _ => panic!("expected full manifest"),
330 }
331 }
332
333 #[test]
334 fn test_resolve_with_fragment() {
335 let (_tmp, resolver) = setup();
336 let result = resolver
337 .resolve(
338 "nap://starwars/character/lukeskywalker#properties.species",
339 &Default::default(),
340 )
341 .unwrap();
342 match result {
343 ResolveResult::Subtree(v) => {
344 assert_eq!(v.as_str(), Some("human"));
345 }
346 _ => panic!("expected subtree"),
347 }
348 }
349
350 #[test]
351 fn test_resolve_with_options_path() {
352 let (_tmp, resolver) = setup();
353 let result = resolver
354 .resolve(
355 "nap://starwars/character/lukeskywalker",
356 &ResolveOptions {
357 path: Some("properties.homeworld".to_string()),
358 ..Default::default()
359 },
360 )
361 .unwrap();
362 match result {
363 ResolveResult::Subtree(v) => {
364 assert_eq!(v.as_str(), Some("nap://starwars/location/tatooine"));
365 }
366 _ => panic!("expected subtree"),
367 }
368 }
369
370 #[test]
371 fn test_query_convenience() {
372 let (_tmp, resolver) = setup();
373 let result = resolver
374 .query(
375 "nap://starwars/character/lukeskywalker",
376 "properties.species",
377 )
378 .unwrap();
379 assert_eq!(result.as_str(), Some("human"));
380 }
381
382 #[test]
383 fn test_list_universes() {
384 let (_tmp, resolver) = setup();
385 let universes = resolver.list_universes().unwrap();
386 assert!(universes.contains(&"starwars".to_string()));
387 }
388
389 #[test]
390 fn test_resolve_not_found() {
391 let (_tmp, resolver) = setup();
392 let result = resolver.resolve("nap://starwars/character/nonexistent", &Default::default());
393 assert!(result.is_err());
394 }
395}