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