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