1use std::path::{Path, PathBuf};
22
23use tracing::{debug, info};
24
25use crate::commit::{Change, Commit};
26use crate::error::NapError;
27use crate::manifest::Manifest;
28use crate::types::EntityType;
29use crate::uri::NapUri;
30use crate::vcs::VcsBackend;
31
32const NAP_DIR: &str = ".nap";
34
35pub struct Repository {
37 pub root: PathBuf,
39 pub universe: String,
41 vcs: Box<dyn VcsBackend>,
43}
44
45impl Repository {
46 pub fn open(path: &Path, vcs: Box<dyn VcsBackend>) -> Result<Self, NapError> {
48 let nap_dir = path.join(NAP_DIR);
49 if !nap_dir.exists() {
50 return Err(NapError::RepositoryNotFound(path.display().to_string()));
51 }
52 let universe = path
53 .file_name()
54 .and_then(|n| n.to_str())
55 .unwrap_or("unknown")
56 .to_string();
57
58 debug!(
59 path = %path.display(),
60 universe = %universe,
61 "opened NAP repository"
62 );
63
64 Ok(Self {
65 root: path.to_path_buf(),
66 universe,
67 vcs,
68 })
69 }
70
71 pub fn init(path: &Path, universe: &str, vcs: Box<dyn VcsBackend>) -> Result<Self, NapError> {
73 let repo_root = path.join(universe);
74 if repo_root.join(NAP_DIR).exists() {
75 return Err(NapError::RepositoryAlreadyExists(
76 repo_root.display().to_string(),
77 ));
78 }
79
80 info!(
81 path = %repo_root.display(),
82 universe = %universe,
83 "initializing NAP repository"
84 );
85
86 std::fs::create_dir_all(&repo_root)?;
88 std::fs::create_dir_all(repo_root.join(NAP_DIR))?;
89
90 for entity_type in EntityType::subdirectory_types() {
92 std::fs::create_dir_all(repo_root.join(entity_type.directory_name()))?;
93 }
94
95 let config = format!(
97 "# NAP Repository Configuration\nuniverse: {universe}\nprotocol_version: \"0.1.0\"\n"
98 );
99 std::fs::write(repo_root.join(NAP_DIR).join("config.yaml"), config)?;
100
101 let world_manifest = Manifest::new(
103 universe,
104 EntityType::World,
105 universe,
106 &format!("{universe} Universe"),
107 );
108 world_manifest.to_file(&repo_root.join("universe.yaml"))?;
109
110 vcs.init(&repo_root)?;
112
113 vcs.commit(
115 &repo_root,
116 &format!("Initialize {universe} universe"),
117 "nap-init",
118 )?;
119
120 info!(
121 path = %repo_root.display(),
122 universe = %universe,
123 "NAP repository initialized successfully"
124 );
125
126 Ok(Self {
127 root: repo_root,
128 universe: universe.to_string(),
129 vcs,
130 })
131 }
132
133 pub fn manifest_path(&self, entity_type: EntityType, entity_id: &str) -> PathBuf {
135 let uri = NapUri::new(&self.universe, entity_type, entity_id);
136 self.root.join(uri.manifest_path())
137 }
138
139 pub fn read_manifest(
141 &self,
142 entity_type: EntityType,
143 entity_id: &str,
144 ) -> Result<Manifest, NapError> {
145 let path = self.manifest_path(entity_type, entity_id);
146 debug!(
147 path = %path.display(),
148 entity_type = %entity_type,
149 entity_id = %entity_id,
150 "reading manifest"
151 );
152 Manifest::from_file(&path)
153 }
154
155 pub fn read_manifest_at_ref(
157 &self,
158 entity_type: EntityType,
159 entity_id: &str,
160 reference: &str,
161 ) -> Result<Manifest, NapError> {
162 let uri = NapUri::new(&self.universe, entity_type, entity_id);
163 let file_path = uri.manifest_path();
164
165 debug!(
166 file_path = %file_path,
167 reference = %reference,
168 "reading manifest at ref"
169 );
170
171 let content = self
172 .vcs
173 .read_file_at_ref(&self.root, &file_path, Some(reference))?;
174 Manifest::from_yaml(&content)
175 }
176
177 pub fn write_manifest(&self, manifest: &Manifest) -> Result<PathBuf, NapError> {
179 let uri: NapUri = manifest.id.parse()?;
180 let path = self.root.join(uri.manifest_path());
181
182 debug!(
183 path = %path.display(),
184 manifest_id = %manifest.id,
185 "writing manifest"
186 );
187
188 manifest.to_file(&path)?;
189 Ok(path)
190 }
191
192 pub fn create_entity(
194 &self,
195 entity_type: EntityType,
196 entity_id: &str,
197 name: &str,
198 author: &str,
199 ) -> Result<(Manifest, String), NapError> {
200 let mut manifest = Manifest::new(&self.universe, entity_type, entity_id, name);
201
202 crate::schema::validate_manifest(&manifest)
204 .map_err(|errors| NapError::ManifestValidationError(errors.join("; ")))?;
205
206 self.write_manifest(&manifest)?;
208
209 let commit_message = format!("Create {entity_type} '{name}' ({entity_id})");
211 let commit_hash = self.vcs.commit(&self.root, &commit_message, author)?;
212
213 manifest.head = Some(commit_hash.clone());
215 manifest.bump_version();
216 self.write_manifest(&manifest)?;
217
218 info!(
219 manifest_id = %manifest.id,
220 commit_hash = %commit_hash,
221 "created entity"
222 );
223
224 Ok((manifest, commit_hash))
225 }
226
227 pub fn commit_manifest(
229 &self,
230 manifest: &mut Manifest,
231 message: &str,
232 author: &str,
233 changes: Vec<Change>,
234 ) -> Result<Commit, NapError> {
235 crate::schema::validate_manifest(manifest)
237 .map_err(|errors| NapError::ManifestValidationError(errors.join("; ")))?;
238
239 manifest.bump_version();
241
242 self.write_manifest(manifest)?;
244
245 let manifest_hash = manifest.content_hash()?.as_str().to_string();
247
248 let vcs_hash = self.vcs.commit(&self.root, message, author)?;
250
251 let nap_commit = Commit::new(
253 manifest.head.clone(),
254 author,
255 message,
256 &manifest_hash,
257 changes,
258 );
259
260 manifest.head = Some(vcs_hash.clone());
263 self.write_manifest(manifest)?;
264
265 debug!(
266 manifest_id = %manifest.id,
267 version = manifest.version,
268 nap_commit_id = %nap_commit.id,
269 vcs_hash = %vcs_hash,
270 "manifest committed"
271 );
272
273 Ok(nap_commit)
274 }
275
276 pub fn history(
278 &self,
279 entity_type: EntityType,
280 entity_id: &str,
281 limit: usize,
282 ) -> Result<Vec<crate::vcs::CommitInfo>, NapError> {
283 let uri = NapUri::new(&self.universe, entity_type, entity_id);
284 let file_path = uri.manifest_path();
285 self.vcs.log(&self.root, Some(&file_path), limit)
286 }
287
288 pub fn list_entities(&self, entity_type: EntityType) -> Result<Vec<String>, NapError> {
290 let dir = self.root.join(entity_type.directory_name());
291 if !dir.exists() {
292 return Ok(vec![]);
293 }
294
295 let mut entities = Vec::new();
296 for entry in std::fs::read_dir(&dir)? {
297 let entry = entry?;
298 let path = entry.path();
299 if path.extension().and_then(|e| e.to_str()) == Some("yaml")
300 && let Some(stem) = path.file_stem().and_then(|s| s.to_str())
301 {
302 entities.push(stem.to_string());
303 }
304 }
305 entities.sort();
306 Ok(entities)
307 }
308
309 pub fn delete_entity(
311 &self,
312 entity_type: EntityType,
313 entity_id: &str,
314 author: &str,
315 ) -> Result<String, NapError> {
316 let path = self.manifest_path(entity_type, entity_id);
317 if !path.exists() {
318 return Err(NapError::ManifestNotFound(path.display().to_string()));
319 }
320
321 std::fs::remove_file(&path)?;
322
323 let message = format!("Delete {entity_type} '{entity_id}'");
324 let hash = self.vcs.commit(&self.root, &message, author)?;
325 info!(entity_type = %entity_type, entity_id = %entity_id, "deleted entity");
326 Ok(hash)
327 }
328
329 pub fn create_branch(&self, name: &str) -> Result<(), NapError> {
331 self.vcs.create_branch(&self.root, name)
332 }
333
334 pub fn switch_branch(&self, name: &str) -> Result<(), NapError> {
336 self.vcs.switch_branch(&self.root, name)
337 }
338
339 pub fn create_tag(&self, name: &str) -> Result<(), NapError> {
341 self.vcs.create_tag(&self.root, name)
342 }
343
344 pub fn list_branches(&self) -> Result<Vec<String>, NapError> {
346 self.vcs.list_branches(&self.root)
347 }
348
349 pub fn list_tags(&self) -> Result<Vec<String>, NapError> {
351 self.vcs.list_tags(&self.root)
352 }
353
354 pub fn revert_commit(&self, commit_hash: &str, author: &str) -> Result<String, NapError> {
361 let new_hash = self.vcs.revert(&self.root, commit_hash)?;
362
363 for et in EntityType::subdirectory_types() {
366 if let Ok(ids) = self.list_entities(*et) {
367 for id in &ids {
368 if let Ok(mut manifest) = self.read_manifest(*et, id) {
369 manifest.head = Some(new_hash.clone());
370 self.write_manifest(&manifest).ok();
371 }
372 }
373 }
374 }
375
376 info!(
377 commit = %commit_hash,
378 revert = %new_hash,
379 author = %author,
380 "commit reverted"
381 );
382
383 Ok(new_hash)
384 }
385
386 pub fn head_hash(&self) -> Result<String, NapError> {
388 self.vcs.head_hash(&self.root)
389 }
390
391 pub fn resolve_branch_head(&self, branch: &str) -> Result<String, NapError> {
393 self.vcs.resolve_branch_head(&self.root, branch)
394 }
395
396 pub fn add_remote(&self, name: &str, url: &str) -> Result<(), NapError> {
400 self.vcs.add_remote(&self.root, name, url)
401 }
402
403 pub fn remove_remote(&self, name: &str) -> Result<(), NapError> {
405 self.vcs.remove_remote(&self.root, name)
406 }
407
408 pub fn list_remotes(&self) -> Result<Vec<(String, String)>, NapError> {
410 self.vcs.list_remotes(&self.root)
411 }
412
413 pub fn push(&self, remote: Option<&str>, branch: Option<&str>) -> Result<(), NapError> {
415 self.vcs.push(&self.root, remote, branch)
416 }
417
418 pub fn pull(&self, remote: Option<&str>, branch: Option<&str>) -> Result<(), NapError> {
420 self.vcs.pull(&self.root, remote, branch)
421 }
422
423 pub fn vcs(&self) -> &dyn VcsBackend {
425 self.vcs.as_ref()
426 }
427}
428
429#[cfg(test)]
432mod mock_backend {
433 use std::collections::HashMap;
434 use std::path::Path;
435 use std::sync::Mutex;
436 use std::sync::atomic::{AtomicU64, Ordering};
437
438 use crate::error::NapError;
439 use crate::vcs::{CommitInfo, VcsBackend};
440
441 pub(crate) struct MockBackend {
445 counter: AtomicU64,
447 commits: Mutex<Vec<CommitInfo>>,
449 remotes: Mutex<HashMap<String, String>>,
451 branches: Mutex<Vec<String>>,
453 current_branch: Mutex<String>,
455 tags: Mutex<Vec<String>>,
457 }
458
459 impl MockBackend {
460 pub fn new() -> Self {
461 Self {
462 counter: AtomicU64::new(1),
463 commits: Mutex::new(Vec::new()),
464 remotes: Mutex::new(HashMap::new()),
465 branches: Mutex::new(vec!["main".to_string()]),
466 current_branch: Mutex::new("main".to_string()),
467 tags: Mutex::new(Vec::new()),
468 }
469 }
470 }
471
472 impl VcsBackend for MockBackend {
473 fn init(&self, _path: &Path) -> Result<(), NapError> {
474 Ok(())
475 }
476
477 fn commit(&self, _path: &Path, message: &str, author: &str) -> Result<String, NapError> {
478 let n = self.counter.fetch_add(1, Ordering::SeqCst);
479 let hash = format!("{:064x}", n);
481 let mut commits = self.commits.lock().unwrap();
482 let parent = commits.last().map(|c| c.id.clone());
483 let info = CommitInfo {
484 id: hash.clone(),
485 parent,
486 author: author.to_string(),
487 message: message.to_string(),
488 timestamp: chrono::Utc::now().to_rfc3339(),
489 };
490 commits.push(info);
491 Ok(hash)
492 }
493
494 fn read_file_at_ref(
495 &self,
496 repo_path: &Path,
497 file_path: &str,
498 _reference: Option<&str>,
499 ) -> Result<String, NapError> {
500 let full_path = repo_path.join(file_path);
501 std::fs::read_to_string(&full_path)
502 .map_err(|e| NapError::Other(format!("mock: read failed: {}", e)))
503 }
504
505 fn log(
506 &self,
507 _path: &Path,
508 _file: Option<&str>,
509 _limit: usize,
510 ) -> Result<Vec<CommitInfo>, NapError> {
511 let commits = self.commits.lock().unwrap();
512 Ok(commits.clone())
513 }
514
515 fn create_branch(&self, _path: &Path, name: &str) -> Result<(), NapError> {
516 self.branches.lock().unwrap().push(name.to_string());
517 Ok(())
518 }
519
520 fn switch_branch(&self, _path: &Path, name: &str) -> Result<(), NapError> {
521 *self.current_branch.lock().unwrap() = name.to_string();
522 Ok(())
523 }
524
525 fn create_tag(&self, _path: &Path, name: &str) -> Result<(), NapError> {
526 let mut tags = self.tags.lock().unwrap();
527 if !tags.contains(&name.to_string()) {
528 tags.push(name.to_string());
529 }
530 Ok(())
531 }
532
533 fn current_branch(&self, _path: &Path) -> Result<String, NapError> {
534 Ok(self.current_branch.lock().unwrap().clone())
535 }
536
537 fn head_hash(&self, _path: &Path) -> Result<String, NapError> {
538 let commits = self.commits.lock().unwrap();
539 commits
540 .last()
541 .map(|c| c.id.clone())
542 .ok_or_else(|| NapError::VcsError("no commits".to_string()))
543 }
544
545 fn resolve_branch_head(&self, _path: &Path, _branch: &str) -> Result<String, NapError> {
546 let commits = self.commits.lock().unwrap();
549 commits
550 .last()
551 .map(|c| c.id.clone())
552 .ok_or_else(|| NapError::VcsError("no commits on branch".to_string()))
553 }
554
555 fn revert(&self, _repo_path: &Path, commit_hash: &str) -> Result<String, NapError> {
556 let n = self.counter.fetch_add(1, Ordering::SeqCst);
557 let hash = format!("{:064x}", n);
558 let info = CommitInfo {
559 id: hash.clone(),
560 parent: Some(commit_hash.to_string()),
561 author: "revert".to_string(),
562 message: format!("revert {}", commit_hash),
563 timestamp: chrono::Utc::now().to_rfc3339(),
564 };
565 self.commits.lock().unwrap().push(info);
566 Ok(hash)
567 }
568
569 fn list_branches(&self, _path: &Path) -> Result<Vec<String>, NapError> {
570 Ok(self.branches.lock().unwrap().clone())
571 }
572
573 fn list_tags(&self, _path: &Path) -> Result<Vec<String>, NapError> {
574 Ok(self.tags.lock().unwrap().clone())
575 }
576
577 fn add_remote(&self, _path: &Path, name: &str, url: &str) -> Result<(), NapError> {
578 self.remotes
579 .lock()
580 .unwrap()
581 .insert(name.to_string(), url.to_string());
582 Ok(())
583 }
584
585 fn remove_remote(&self, _path: &Path, name: &str) -> Result<(), NapError> {
586 self.remotes.lock().unwrap().remove(name);
587 Ok(())
588 }
589
590 fn list_remotes(&self, _path: &Path) -> Result<Vec<(String, String)>, NapError> {
591 let remotes = self.remotes.lock().unwrap();
592 Ok(remotes
593 .iter()
594 .map(|(k, v)| (k.clone(), v.clone()))
595 .collect())
596 }
597
598 fn push(
599 &self,
600 _path: &Path,
601 _remote: Option<&str>,
602 _branch: Option<&str>,
603 ) -> Result<(), NapError> {
604 Ok(())
605 }
606
607 fn pull(
608 &self,
609 _path: &Path,
610 _remote: Option<&str>,
611 _branch: Option<&str>,
612 ) -> Result<(), NapError> {
613 Ok(())
614 }
615 }
616}
617
618#[cfg(test)]
619mod tests {
620 use super::*;
621 use mock_backend::MockBackend;
622 use tempfile::TempDir;
623
624 fn make_repo(tmp: &TempDir) -> Repository {
625 Repository::init(tmp.path(), "testverse", Box::new(MockBackend::new())).unwrap()
626 }
627
628 #[test]
629 fn test_init_creates_structure() {
630 let tmp = TempDir::new().unwrap();
631 let repo = make_repo(&tmp);
632
633 assert!(repo.root.join(".nap").exists());
634 assert!(repo.root.join("universe.yaml").exists());
635 assert!(repo.root.join("characters").exists());
636 assert!(repo.root.join("locations").exists());
637 assert!(repo.root.join("scenes").exists());
638 assert!(repo.root.join("props").exists());
639 }
640
641 #[test]
642 fn test_create_and_read_entity() {
643 let tmp = TempDir::new().unwrap();
644 let repo = make_repo(&tmp);
645
646 let (manifest, _hash) = repo
647 .create_entity(EntityType::Character, "hero", "The Hero", "test-author")
648 .unwrap();
649
650 assert_eq!(manifest.name, "The Hero");
651 assert_eq!(manifest.entity_type, EntityType::Character);
652
653 let read_back = repo.read_manifest(EntityType::Character, "hero").unwrap();
655 assert_eq!(read_back.name, "The Hero");
656 }
657
658 #[test]
659 fn test_list_entities() {
660 let tmp = TempDir::new().unwrap();
661 let repo = make_repo(&tmp);
662
663 repo.create_entity(EntityType::Character, "alice", "Alice", "author")
664 .unwrap();
665 repo.create_entity(EntityType::Character, "bob", "Bob", "author")
666 .unwrap();
667
668 let chars = repo.list_entities(EntityType::Character).unwrap();
669 assert_eq!(chars, vec!["alice", "bob"]);
670 }
671
672 #[test]
673 fn test_commit_manifest_updates() {
674 let tmp = TempDir::new().unwrap();
675 let repo = make_repo(&tmp);
676
677 let (mut manifest, _) = repo
678 .create_entity(EntityType::Character, "hero", "The Hero", "author")
679 .unwrap();
680
681 manifest.set_property("species", serde_yaml::Value::String("elf".to_string()));
683 let changes = vec![Change::set("properties.species", None, "elf".to_string())];
684 let commit = repo
685 .commit_manifest(&mut manifest, "set species to elf", "author", changes)
686 .unwrap();
687
688 assert!(!commit.id.is_empty());
689 assert_eq!(commit.message, "set species to elf");
690
691 let read_back = repo.read_manifest(EntityType::Character, "hero").unwrap();
693 assert!(read_back.version >= 2);
694 }
695
696 #[test]
697 fn test_history() {
698 let tmp = TempDir::new().unwrap();
699 let repo = make_repo(&tmp);
700
701 let (mut manifest, _) = repo
702 .create_entity(EntityType::Character, "hero", "The Hero", "author")
703 .unwrap();
704
705 manifest.set_property(
706 "name",
707 serde_yaml::Value::String("Updated Hero".to_string()),
708 );
709 repo.commit_manifest(&mut manifest, "update name", "author", vec![])
710 .unwrap();
711
712 let hist = repo.history(EntityType::Character, "hero", 10).unwrap();
713 assert!(hist.len() >= 2);
714 }
715
716 #[test]
717 fn test_revert_commit() {
718 let tmp = TempDir::new().unwrap();
719 let repo = make_repo(&tmp);
720
721 let (mut manifest, _) = repo
723 .create_entity(EntityType::Character, "hero", "The Hero", "author")
724 .unwrap();
725 assert_eq!(manifest.name, "The Hero");
726
727 manifest.set_property("species", serde_yaml::Value::String("elf".to_string()));
729 let changes = vec![Change::set("properties.species", None, "elf".to_string())];
730 let _commit = repo
731 .commit_manifest(&mut manifest, "set species to elf", "author", changes)
732 .unwrap();
733
734 let read_back = repo.read_manifest(EntityType::Character, "hero").unwrap();
736 assert_eq!(
737 read_back.properties.get("species").and_then(|v| v.as_str()),
738 Some("elf")
739 );
740
741 let vcs_hash = read_back
745 .head
746 .as_ref()
747 .expect("head should be set after commit");
748
749 let revert_hash = repo.revert_commit(vcs_hash, "author").unwrap();
751 assert!(!revert_hash.is_empty());
752
753 let after_revert = repo.read_manifest(EntityType::Character, "hero").unwrap();
755 assert_eq!(after_revert.head.as_deref(), Some(revert_hash.as_str()));
756
757 let hist = repo.history(EntityType::Character, "hero", 10).unwrap();
759 assert!(hist.iter().any(|c| c.id == revert_hash));
760 }
761
762 #[test]
763 fn test_remote_operations() {
764 let tmp = TempDir::new().unwrap();
765 let repo = make_repo(&tmp);
766
767 repo.add_remote("origin", "git@github.com:user/repo.git")
768 .unwrap();
769 let remotes = repo.list_remotes().unwrap();
770 assert_eq!(remotes.len(), 1);
771 assert_eq!(remotes[0].0, "origin");
772
773 repo.remove_remote("origin").unwrap();
774 let remotes = repo.list_remotes().unwrap();
775 assert!(remotes.is_empty());
776 }
777}