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