Skip to main content

nap_core/
repository.rs

1//! Repository — filesystem layout and manifest CRUD.
2//!
3//! A NAP repository is a self-contained directory of entities.
4//! Repository structure:
5//!
6//! ```text
7//! starwars/               ← repository root
8//! ├── repository.yaml     ← repository metadata (name, description, nap config)
9//! ├── character/          ← entity type (has .entity-type marker)
10//! │   ├── .entity-type    ← marker file
11//! │   ├── lukeskywalker.yaml
12//! │   └── darthvader.yaml
13//! ├── location/
14//! │   ├── .entity-type
15//! │   └── tatooine.yaml
16//! └── scene/
17//!     ├── .entity-type
18//!     └── cantina-scene.yaml
19//! ```
20
21use 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::resolver::ResolveConfig;
29use crate::types::EntityType;
30use crate::uri::NapUri;
31use crate::vcs::VcsBackend;
32
33/// Marker filename for entity type directories.
34pub const ENTITY_TYPE_MARKER: &str = ".entity-type";
35
36/// A NAP repository.
37pub struct Repository {
38    /// Filesystem path to the repository root.
39    pub root: PathBuf,
40    /// The repository name (derived from directory name).
41    pub repository: String,
42    /// The VCS backend (Lore).
43    vcs: Box<dyn VcsBackend>,
44}
45
46impl Repository {
47    /// Open an existing NAP repository at the given path.
48    pub fn open(path: &Path, vcs: Box<dyn VcsBackend>) -> Result<Self, NapError> {
49        // Check for repository.yaml or repository.yaml to identify valid repository
50        if !path.join("repository.yaml").exists() && !path.join("repository.yaml").exists() {
51            return Err(NapError::RepositoryNotFound(path.display().to_string()));
52        }
53
54        let repository = path
55            .file_name()
56            .and_then(|n| n.to_str())
57            .unwrap_or("unknown")
58            .to_string();
59
60        debug!(
61            path = %path.display(),
62            repository = %repository,
63            "opened NAP repository"
64        );
65
66        Ok(Self {
67            root: path.to_path_buf(),
68            repository,
69            vcs,
70        })
71    }
72
73    /// Read the resolve configuration from repository.yaml (or repository.yaml) metadata.
74    pub fn read_resolve_config(&self) -> ResolveConfig {
75        // Prefer repository.yaml, fall back to repository.yaml
76        let repo_yaml_path = self.root.join("repository.yaml");
77        let universe_yaml_path = self.root.join("repository.yaml");
78        let config_path = if repo_yaml_path.exists() {
79            repo_yaml_path
80        } else if universe_yaml_path.exists() {
81            universe_yaml_path
82        } else {
83            debug!("no repository.yaml or repository.yaml found, using default ResolveConfig");
84            return ResolveConfig::default();
85        };
86
87        let yaml_content = match std::fs::read_to_string(&config_path) {
88            Ok(content) => content,
89            Err(e) => {
90                debug!(
91                    path = %config_path.display(),
92                    error = %e,
93                    "failed to read config, using default ResolveConfig"
94                );
95                return ResolveConfig::default();
96            }
97        };
98
99        // Parse YAML and extract nap metadata
100        let parsed: serde_yaml::Value = match serde_yaml::from_str(&yaml_content) {
101            Ok(value) => value,
102            Err(e) => {
103                debug!(
104                    path = %config_path.display(),
105                    error = %e,
106                    "failed to parse config, using default ResolveConfig"
107                );
108                return ResolveConfig::default();
109            }
110        };
111
112        // Extract default_branch from nap metadata
113        let default_branch = parsed
114            .get("metadata")
115            .and_then(|metadata| metadata.get("nap"))
116            .and_then(|nap| nap.get("default_branch"))
117            .and_then(|branch| branch.as_str())
118            .map(|s| s.to_string());
119
120        // Auto-create nap metadata if missing
121        if default_branch.is_none() {
122            debug!(
123                path = %config_path.display(),
124                "nap metadata not found, auto-creating with default_branch = 'main'"
125            );
126
127            // Read the existing manifest
128            let mut manifest = match Manifest::from_file(&config_path) {
129                Ok(m) => m,
130                Err(e) => {
131                    debug!(
132                        path = %config_path.display(),
133                        error = %e,
134                        "failed to read manifest, using default ResolveConfig"
135                    );
136                    return ResolveConfig::default();
137                }
138            };
139
140            // Add nap metadata
141            manifest.metadata.insert(
142                "nap".to_string(),
143                serde_yaml::to_value(serde_json::json!({
144                    "default_branch": "main"
145                }))
146                .unwrap(),
147            );
148
149            // Write back to file
150            if let Err(e) = manifest.to_file(&config_path) {
151                debug!(
152                    path = %config_path.display(),
153                    error = %e,
154                    "failed to write nap metadata, using default ResolveConfig"
155                );
156                return ResolveConfig::default();
157            }
158
159            return ResolveConfig {
160                default_branch: Some("main".to_string()),
161            };
162        }
163
164        ResolveConfig { default_branch }
165    }
166
167    /// Initialize a new NAP repository.
168    pub fn init(path: &Path, repository: &str, vcs: Box<dyn VcsBackend>) -> Result<Self, NapError> {
169        let repo_root = path.to_path_buf();
170        if repo_root.join("repository.yaml").exists() || repo_root.join("repository.yaml").exists()
171        {
172            return Err(NapError::RepositoryAlreadyExists(
173                repo_root.display().to_string(),
174            ));
175        }
176
177        info!(
178            path = %repo_root.display(),
179            repository = %repository,
180            "initializing NAP repository"
181        );
182
183        // Create directory structure
184        std::fs::create_dir_all(&repo_root)?;
185
186        // Create repository.yaml with [nap] metadata
187        let mut repo_manifest = Manifest::new(
188            repository,
189            EntityType::new("world"),
190            repository,
191            &format!("{repository} Repository"),
192        );
193
194        // Add nap configuration to metadata
195        repo_manifest.metadata.insert(
196            "nap".to_string(),
197            serde_yaml::to_value(serde_json::json!({
198                "default_branch": "main"
199            }))
200            .unwrap(),
201        );
202
203        repo_manifest.to_file(&repo_root.join("repository.yaml"))?;
204
205        // Initialize VCS
206        vcs.init(&repo_root)?;
207
208        // Initial commit
209        vcs.commit(
210            &repo_root,
211            &format!("Initialize {repository} repository"),
212            "nap-init",
213        )?;
214
215        info!(
216            path = %repo_root.display(),
217            repository = %repository,
218            "NAP repository initialized successfully"
219        );
220
221        Ok(Self {
222            root: repo_root,
223            repository: repository.to_string(),
224            vcs,
225        })
226    }
227
228    /// Get the full filesystem path to an entity's manifest file.
229    pub fn manifest_path(&self, entity_type: &EntityType, entity_id: &str) -> PathBuf {
230        let uri = NapUri::new(&self.repository, entity_type.clone(), entity_id);
231        self.root.join(uri.manifest_path())
232    }
233
234    /// Read a manifest from the repository.
235    pub fn read_manifest(
236        &self,
237        entity_type: &EntityType,
238        entity_id: &str,
239    ) -> Result<Manifest, NapError> {
240        let path = self.manifest_path(entity_type, entity_id);
241        debug!(
242            path = %path.display(),
243            entity_type = %entity_type,
244            entity_id = %entity_id,
245            "reading manifest"
246        );
247        Manifest::from_file(&path)
248    }
249
250    /// Read a manifest at a specific VCS ref (commit, branch, tag).
251    pub fn read_manifest_at_ref(
252        &self,
253        entity_type: &EntityType,
254        entity_id: &str,
255        reference: &str,
256    ) -> Result<Manifest, NapError> {
257        let uri = NapUri::new(&self.repository, entity_type.clone(), entity_id);
258        let file_path = uri.manifest_path();
259
260        debug!(
261            file_path = %file_path,
262            reference = %reference,
263            "reading manifest at ref"
264        );
265
266        let content = self
267            .vcs
268            .read_file_at_ref(&self.root, &file_path, Some(reference))?;
269        Manifest::from_yaml(&content)
270    }
271
272    /// Write a manifest to the repository (does NOT commit).
273    pub fn write_manifest(&self, manifest: &Manifest) -> Result<PathBuf, NapError> {
274        let uri: NapUri = manifest.id.parse()?;
275        let entity_type = uri.entity_type.clone();
276
277        // Ensure the entity type directory and marker exist
278        self.ensure_entity_type_dir(&entity_type)?;
279
280        let path = self.root.join(uri.manifest_path());
281
282        debug!(
283            path = %path.display(),
284            manifest_id = %manifest.id,
285            "writing manifest"
286        );
287
288        manifest.to_file(&path)?;
289        Ok(path)
290    }
291
292    /// Ensure an entity type directory exists with its marker file.
293    fn ensure_entity_type_dir(&self, entity_type: &EntityType) -> Result<(), NapError> {
294        let dir = self.root.join(entity_type.directory_name());
295        if !dir.exists() {
296            std::fs::create_dir_all(&dir)?;
297            // Create .entity-type marker file
298            let marker = dir.join(ENTITY_TYPE_MARKER);
299            std::fs::write(&marker, "")?;
300            debug!(
301                entity_type = %entity_type,
302                path = %dir.display(),
303                "created entity type directory with marker"
304            );
305        }
306        Ok(())
307    }
308
309    /// Create a new entity manifest and commit it.
310    pub fn create_entity(
311        &self,
312        entity_type: &EntityType,
313        entity_id: &str,
314        name: &str,
315        author: &str,
316    ) -> Result<(Manifest, String), NapError> {
317        // Ensure entity type directory exists
318        self.ensure_entity_type_dir(entity_type)?;
319
320        let mut manifest = Manifest::new(&self.repository, entity_type.clone(), entity_id, name);
321
322        // Check if entity already exists (idempotency guard)
323        let path = self.manifest_path(entity_type, entity_id);
324        if path.exists() {
325            return Err(NapError::Other(format!(
326                "entity '{entity_id}' of type '{entity_type}' already exists"
327            )));
328        }
329
330        // Validate against schema before writing
331        crate::schema::validate_manifest(&manifest)
332            .map_err(|errors| NapError::ManifestValidationError(errors.join("; ")))?;
333
334        // Write the manifest
335        self.write_manifest(&manifest)?;
336
337        // Commit via VCS
338        let commit_message = format!("Create {entity_type} '{name}' ({entity_id})");
339        let commit_hash = self.vcs.commit(&self.root, &commit_message, author)?;
340
341        // Update manifest with head pointer
342        manifest.head = Some(commit_hash.clone());
343        manifest.bump_version();
344        self.write_manifest(&manifest)?;
345
346        info!(
347            manifest_id = %manifest.id,
348            commit_hash = %commit_hash,
349            "created entity"
350        );
351
352        Ok((manifest, commit_hash))
353    }
354
355    /// Update an existing manifest and commit the changes.
356    pub fn commit_manifest(
357        &self,
358        manifest: &mut Manifest,
359        message: &str,
360        author: &str,
361        changes: Vec<Change>,
362    ) -> Result<Commit, NapError> {
363        // Validate against schema before writing
364        crate::schema::validate_manifest(manifest)
365            .map_err(|errors| NapError::ManifestValidationError(errors.join("; ")))?;
366
367        // Bump version
368        manifest.bump_version();
369
370        // Write updated manifest (without new head — we don't know it yet)
371        self.write_manifest(manifest)?;
372
373        // Compute manifest hash
374        let manifest_hash = manifest.content_hash()?.as_str().to_string();
375
376        // VCS commit — produces the new HEAD hash
377        let vcs_hash = self.vcs.commit(&self.root, message, author)?;
378
379        // Create NAP commit object with the now-known VCS hash
380        let nap_commit = Commit::new(
381            manifest.head.clone(),
382            author,
383            message,
384            &manifest_hash,
385            changes,
386        );
387
388        // Update head pointer and write again (leaves working tree dirty,
389        // same pattern as create_entity)
390        manifest.head = Some(vcs_hash.clone());
391        self.write_manifest(manifest)?;
392
393        debug!(
394            manifest_id = %manifest.id,
395            version = manifest.version,
396            nap_commit_id = %nap_commit.id,
397            vcs_hash = %vcs_hash,
398            "manifest committed"
399        );
400
401        Ok(nap_commit)
402    }
403
404    /// Get the commit history for a specific entity.
405    pub fn history(
406        &self,
407        entity_type: &EntityType,
408        entity_id: &str,
409        limit: usize,
410    ) -> Result<Vec<crate::vcs::CommitInfo>, NapError> {
411        let uri = NapUri::new(&self.repository, entity_type.clone(), entity_id);
412        let file_path = uri.manifest_path();
413        self.vcs.log(&self.root, Some(&file_path), limit)
414    }
415
416    /// List all entity IDs of a given type in the repository.
417    pub fn list_entities(&self, entity_type: &EntityType) -> Result<Vec<String>, NapError> {
418        let dir = self.root.join(entity_type.directory_name());
419        if !dir.exists() {
420            return Err(NapError::EntityTypeNotFound(entity_type.to_string()));
421        }
422
423        let mut entities = Vec::new();
424        for entry in std::fs::read_dir(&dir)? {
425            let entry = entry?;
426            let path = entry.path();
427            if path.extension().and_then(|e| e.to_str()) == Some("yaml")
428                && let Some(stem) = path.file_stem().and_then(|s| s.to_str())
429            {
430                entities.push(stem.to_string());
431            }
432        }
433        entities.sort();
434        Ok(entities)
435    }
436
437    /// Discover all entity types in this repository.
438    ///
439    /// Scans the repository root for directories containing a `.entity-type`
440    /// marker file OR directories containing at least one `.yaml` file
441    /// (implicit discovery for backward compatibility).
442    pub fn list_entity_types(&self) -> Result<Vec<EntityType>, NapError> {
443        let mut types = Vec::new();
444        for entry in std::fs::read_dir(&self.root)? {
445            let entry = entry?;
446            let path = entry.path();
447            if !path.is_dir() {
448                continue;
449            }
450            // Skip hidden directories (.nap, .git, etc.)
451            let dir_name = match path.file_name().and_then(|n| n.to_str()) {
452                Some(name) => name,
453                None => continue,
454            };
455            if dir_name.starts_with('.') || dir_name == "target" {
456                continue;
457            }
458
459            // Check for .entity-type marker file (explicit)
460            if path.join(ENTITY_TYPE_MARKER).exists() {
461                types.push(EntityType::new(dir_name));
462                continue;
463            }
464
465            // Implicit: directory contains at least one .yaml file
466            let has_yaml = std::fs::read_dir(&path)
467                .ok()
468                .and_then(|entries| {
469                    entries
470                        .filter_map(|e| e.ok())
471                        .find(|e| e.path().extension().and_then(|ext| ext.to_str()) == Some("yaml"))
472                        .map(|_| true)
473                })
474                .unwrap_or(false);
475
476            if has_yaml {
477                types.push(EntityType::new(dir_name));
478            }
479        }
480        types.sort_by(|a, b| a.as_str().cmp(b.as_str()));
481        Ok(types)
482    }
483
484    /// Delete an entity manifest and commit the deletion.
485    pub fn delete_entity(
486        &self,
487        entity_type: &EntityType,
488        entity_id: &str,
489        author: &str,
490    ) -> Result<String, NapError> {
491        let path = self.manifest_path(entity_type, entity_id);
492        if !path.exists() {
493            return Err(NapError::ManifestNotFound(path.display().to_string()));
494        }
495
496        std::fs::remove_file(&path)?;
497
498        let message = format!("Delete {entity_type} '{entity_id}'");
499        let hash = self.vcs.commit(&self.root, &message, author)?;
500        info!(entity_type = %entity_type, entity_id = %entity_id, "deleted entity");
501        Ok(hash)
502    }
503
504    /// Create a branch in the underlying VCS.
505    pub fn create_branch(&self, name: &str) -> Result<(), NapError> {
506        self.vcs.create_branch(&self.root, name)
507    }
508
509    /// Switch to a branch.
510    pub fn switch_branch(&self, name: &str) -> Result<(), NapError> {
511        self.vcs.switch_branch(&self.root, name)
512    }
513
514    /// Create a tag.
515    pub fn create_tag(&self, name: &str) -> Result<(), NapError> {
516        self.vcs.create_tag(&self.root, name)
517    }
518
519    /// List branches.
520    pub fn list_branches(&self) -> Result<Vec<String>, NapError> {
521        self.vcs.list_branches(&self.root)
522    }
523
524    /// List tags.
525    pub fn list_tags(&self) -> Result<Vec<String>, NapError> {
526        self.vcs.list_tags(&self.root)
527    }
528
529    /// Revert a commit by creating a new VCS commit that undoes the specified one.
530    pub fn revert_commit(&self, commit_hash: &str, author: &str) -> Result<String, NapError> {
531        let new_hash = self.vcs.revert(&self.root, commit_hash)?;
532
533        // Re-read all entity manifests and update their `head` pointer
534        for entity_type in self.list_entity_types()? {
535            if let Ok(ids) = self.list_entities(&entity_type) {
536                for id in &ids {
537                    if let Ok(mut manifest) = self.read_manifest(&entity_type, id) {
538                        manifest.head = Some(new_hash.clone());
539                        self.write_manifest(&manifest)?;
540                    }
541                }
542            }
543        }
544
545        info!(
546            commit = %commit_hash,
547            revert = %new_hash,
548            author = %author,
549            "commit reverted"
550        );
551
552        Ok(new_hash)
553    }
554
555    /// Get current HEAD hash.
556    pub fn head_hash(&self) -> Result<String, NapError> {
557        self.vcs.head_hash(&self.root)
558    }
559
560    /// Resolve the most recent commit hash on a given branch.
561    pub fn resolve_branch_head(&self, branch: &str) -> Result<String, NapError> {
562        self.vcs.resolve_branch_head(&self.root, branch)
563    }
564
565    // ── Remote operations ─────────────────────────────────────────
566
567    /// Add a remote to the repository.
568    pub fn add_remote(&self, name: &str, url: &str) -> Result<(), NapError> {
569        self.vcs.add_remote(&self.root, name, url)
570    }
571
572    /// Remove a remote from the repository.
573    pub fn remove_remote(&self, name: &str) -> Result<(), NapError> {
574        self.vcs.remove_remote(&self.root, name)
575    }
576
577    /// List remotes as `(name, url)` pairs.
578    pub fn list_remotes(&self) -> Result<Vec<(String, String)>, NapError> {
579        self.vcs.list_remotes(&self.root)
580    }
581
582    /// Push the current branch to a remote.
583    pub fn push(&self, remote: Option<&str>, branch: Option<&str>) -> Result<(), NapError> {
584        self.vcs.push(&self.root, remote, branch)
585    }
586
587    /// Pull the current branch from a remote.
588    pub fn pull(&self, remote: Option<&str>, branch: Option<&str>) -> Result<(), NapError> {
589        self.vcs.pull(&self.root, remote, branch)
590    }
591
592    /// Access the VCS backend (for the resolver to read files at specific refs).
593    pub fn vcs(&self) -> &dyn VcsBackend {
594        self.vcs.as_ref()
595    }
596}
597
598#[cfg(test)]
599mod tests {
600    use super::*;
601    use crate::test_utils::{MockBackend, mock_repo};
602    use tempfile::TempDir;
603
604    #[test]
605    fn test_mock_backend_contract() {
606        crate::test_utils::contract::run_repository_contract(MockBackend::new());
607    }
608
609    #[test]
610    fn test_init_creates_structure() {
611        let tmp = TempDir::new().unwrap();
612        let repo = mock_repo(&tmp);
613
614        assert!(repo.root.join("repository.yaml").exists());
615    }
616
617    #[test]
618    fn test_create_and_read_entity() {
619        let tmp = TempDir::new().unwrap();
620        let repo = mock_repo(&tmp);
621
622        let (manifest, _hash) = repo
623            .create_entity(
624                &EntityType::new("character"),
625                "hero",
626                "The Hero",
627                "test-author",
628            )
629            .unwrap();
630
631        assert_eq!(manifest.name, "The Hero");
632        assert_eq!(manifest.entity_type.as_str(), "character");
633
634        // Read it back
635        let read_back = repo
636            .read_manifest(&EntityType::new("character"), "hero")
637            .unwrap();
638        assert_eq!(read_back.name, "The Hero");
639    }
640
641    #[test]
642    fn test_create_entity_auto_creates_type_directory() {
643        let tmp = TempDir::new().unwrap();
644        let repo = mock_repo(&tmp);
645
646        // Create entity of a custom type
647        repo.create_entity(&EntityType::new("pokemon"), "pikachu", "Pikachu", "test")
648            .unwrap();
649
650        // Verify the type directory and marker exist
651        assert!(repo.root.join("pokemon").exists());
652        assert!(repo.root.join("pokemon").join(".entity-type").exists());
653        assert!(repo.root.join("pokemon/pikachu.yaml").exists());
654    }
655
656    #[test]
657    fn test_list_entity_types() {
658        let tmp = TempDir::new().unwrap();
659        let repo = mock_repo(&tmp);
660
661        // Create entities of different types
662        repo.create_entity(&EntityType::new("character"), "hero", "Hero", "author")
663            .unwrap();
664        repo.create_entity(&EntityType::new("location"), "village", "Village", "author")
665            .unwrap();
666        repo.create_entity(&EntityType::new("pokemon"), "pikachu", "Pikachu", "author")
667            .unwrap();
668
669        let types = repo.list_entity_types().unwrap();
670        assert!(types.contains(&EntityType::new("character")));
671        assert!(types.contains(&EntityType::new("location")));
672        assert!(types.contains(&EntityType::new("pokemon")));
673    }
674
675    #[test]
676    fn test_list_entities() {
677        let tmp = TempDir::new().unwrap();
678        let repo = mock_repo(&tmp);
679
680        repo.create_entity(&EntityType::new("character"), "alice", "Alice", "author")
681            .unwrap();
682        repo.create_entity(&EntityType::new("character"), "bob", "Bob", "author")
683            .unwrap();
684
685        let chars = repo.list_entities(&EntityType::new("character")).unwrap();
686        assert_eq!(chars, vec!["alice", "bob"]);
687    }
688
689    #[test]
690    fn test_commit_manifest_updates() {
691        let tmp = TempDir::new().unwrap();
692        let repo = mock_repo(&tmp);
693
694        let (mut manifest, _) = repo
695            .create_entity(&EntityType::new("character"), "hero", "The Hero", "author")
696            .unwrap();
697
698        // Modify and commit
699        manifest.set_property("species", serde_yaml::Value::String("elf".to_string()));
700        let changes = vec![Change::set("properties.species", None, "elf".to_string())];
701        let commit = repo
702            .commit_manifest(&mut manifest, "set species to elf", "author", changes)
703            .unwrap();
704
705        assert!(!commit.id.is_empty());
706        assert_eq!(commit.message, "set species to elf");
707
708        // Verify version incremented
709        let read_back = repo
710            .read_manifest(&EntityType::new("character"), "hero")
711            .unwrap();
712        assert!(read_back.version >= 2);
713    }
714
715    #[test]
716    fn test_history() {
717        let tmp = TempDir::new().unwrap();
718        let repo = mock_repo(&tmp);
719
720        let (mut manifest, _) = repo
721            .create_entity(&EntityType::new("character"), "hero", "The Hero", "author")
722            .unwrap();
723
724        manifest.set_property(
725            "name",
726            serde_yaml::Value::String("Updated Hero".to_string()),
727        );
728        repo.commit_manifest(&mut manifest, "update name", "author", vec![])
729            .unwrap();
730
731        let hist = repo
732            .history(&EntityType::new("character"), "hero", 10)
733            .unwrap();
734        assert!(hist.len() >= 2);
735    }
736
737    #[test]
738    fn test_revert_commit() {
739        let tmp = TempDir::new().unwrap();
740        let repo = mock_repo(&tmp);
741
742        // Create entity and note its name
743        let (mut manifest, _) = repo
744            .create_entity(&EntityType::new("character"), "hero", "The Hero", "author")
745            .unwrap();
746        assert_eq!(manifest.name, "The Hero");
747
748        // Modify and commit
749        manifest.set_property("species", serde_yaml::Value::String("elf".to_string()));
750        let changes = vec![Change::set("properties.species", None, "elf".to_string())];
751        let _commit = repo
752            .commit_manifest(&mut manifest, "set species to elf", "author", changes)
753            .unwrap();
754
755        // Verify the property was set
756        let read_back = repo
757            .read_manifest(&EntityType::new("character"), "hero")
758            .unwrap();
759        assert_eq!(
760            read_back.properties.get("species").and_then(|v| v.as_str()),
761            Some("elf")
762        );
763
764        // Get the VCS commit hash from the manifest's head pointer.
765        let vcs_hash = read_back
766            .head
767            .as_ref()
768            .expect("head should be set after commit");
769
770        // Revert that VCS commit
771        let revert_hash = repo.revert_commit(vcs_hash, "author").unwrap();
772        assert!(!revert_hash.is_empty());
773
774        // Verify the manifest head was updated to the revert commit
775        let after_revert = repo
776            .read_manifest(&EntityType::new("character"), "hero")
777            .unwrap();
778        assert_eq!(after_revert.head.as_deref(), Some(revert_hash.as_str()));
779
780        // Verify the revert appears in history
781        let hist = repo
782            .history(&EntityType::new("character"), "hero", 10)
783            .unwrap();
784        assert!(hist.iter().any(|c| c.id == revert_hash));
785    }
786}
787
788// ── Integration tests: Repository + LoreBackend ─────────────────────
789// These require a running Lore server. Run with:
790//   cargo test --features lore-integration
791#[cfg(all(test, feature = "lore-integration"))]
792mod lore_integration_tests {
793    use super::*;
794    use crate::vcs_lore::LoreBackend;
795    use std::time::{SystemTime, UNIX_EPOCH};
796    use tempfile::TempDir;
797
798    fn unique_suffix() -> u64 {
799        SystemTime::now()
800            .duration_since(UNIX_EPOCH)
801            .unwrap()
802            .as_nanos() as u64
803    }
804
805    fn setup_lore_repo() -> (TempDir, Repository) {
806        let repository = format!("ri-{}", unique_suffix());
807        let tmp = TempDir::new().unwrap();
808        let repo_path = tmp.path().join(&repository);
809        let repo =
810            Repository::init(&repo_path, &repository, Box::new(LoreBackend::from_env())).unwrap();
811        (tmp, repo)
812    }
813
814    #[test]
815    fn test_lore_init_creates_structure() {
816        let (_tmp, repo) = setup_lore_repo();
817        assert!(repo.root.join(".nap").exists());
818        assert!(repo.root.join("repository.yaml").exists());
819    }
820
821    #[test]
822    fn test_lore_create_and_read_entity() {
823        let (_tmp, repo) = setup_lore_repo();
824
825        let (manifest, _hash) = repo
826            .create_entity(
827                &EntityType::new("character"),
828                "hero",
829                "Test Hero",
830                "integration-test",
831            )
832            .unwrap();
833        assert_eq!(manifest.name, "Test Hero");
834
835        let read_back = repo
836            .read_manifest(&EntityType::new("character"), "hero")
837            .unwrap();
838        assert_eq!(read_back.name, "Test Hero");
839    }
840
841    #[test]
842    fn test_lore_commit_and_branch() {
843        let (_tmp, repo) = setup_lore_repo();
844
845        let (mut manifest, _) = repo
846            .create_entity(
847                &EntityType::new("character"),
848                "hero",
849                "Test Hero",
850                "integration-test",
851            )
852            .unwrap();
853
854        manifest.set_property("species", serde_yaml::Value::String("human".to_string()));
855        let changes = vec![Change::set("properties.species", None, "human".to_string())];
856        repo.commit_manifest(&mut manifest, "add species", "integration-test", changes)
857            .unwrap();
858
859        let read_back = repo
860            .read_manifest(&EntityType::new("character"), "hero")
861            .unwrap();
862        assert_eq!(read_back.version, 2);
863
864        repo.create_branch("feature-branch").unwrap();
865        let branches = repo.list_branches().unwrap();
866        assert!(branches.contains(&"feature-branch".to_string()));
867    }
868
869    #[test]
870    fn test_lore_delete_entity() {
871        let (_tmp, repo) = setup_lore_repo();
872
873        repo.create_entity(
874            &EntityType::new("character"),
875            "hero",
876            "Test Hero",
877            "integration-test",
878        )
879        .unwrap();
880
881        repo.delete_entity(&EntityType::new("character"), "hero", "integration-test")
882            .unwrap();
883
884        let entities = repo.list_entities(&EntityType::new("character")).unwrap();
885        assert!(!entities.contains(&"hero".to_string()));
886    }
887
888    #[test]
889    fn test_lore_history() {
890        let (_tmp, repo) = setup_lore_repo();
891
892        let (mut manifest, _) = repo
893            .create_entity(
894                &EntityType::new("character"),
895                "hero",
896                "Test Hero",
897                "integration-test",
898            )
899            .unwrap();
900
901        manifest.set_property(
902            "name",
903            serde_yaml::Value::String("Updated Hero".to_string()),
904        );
905        repo.commit_manifest(&mut manifest, "update name", "integration-test", vec![])
906            .unwrap();
907
908        let hist = repo
909            .history(&EntityType::new("character"), "hero", 10)
910            .unwrap();
911        assert!(hist.len() >= 2);
912    }
913}