1use std::path::PathBuf;
22use std::sync::Arc;
23
24use memstead_schema::{Schema, load_schema_from_memory};
25
26use crate::backend::MemBackend;
27use crate::storage::ArchiveBackend;
28use crate::validator::ValidatorLimits;
29use crate::validator::archive::{ArchiveEntries, SchemaFile, extract_entries};
30use crate::workspace::{Mount, MountCapability, MountLifecycle, MountStorage};
31
32use super::{Engine, EngineError};
33
34#[derive(Debug, thiserror::Error)]
43pub enum FromArchiveBytesError {
44 #[error("archive validation: {0}")]
47 Validation(#[from] crate::validator::ValidationError),
48 #[error("invalid published config: {0}")]
52 InvalidConfig(String),
53 #[error("embedded schema failed to load: {0}")]
56 EmbeddedSchemaInvalid(String),
57 #[error(transparent)]
60 Engine(#[from] EngineError),
61}
62
63impl Engine {
64 pub fn from_archive_bytes(bytes: Vec<u8>) -> Result<Self, FromArchiveBytesError> {
78 Self::from_archive_bytes_with_limits(bytes, &ValidatorLimits::DEFAULT)
79 }
80
81 pub fn from_archive_bytes_with_limits(
86 bytes: Vec<u8>,
87 limits: &ValidatorLimits,
88 ) -> Result<Self, FromArchiveBytesError> {
89 let entries = extract_entries(&bytes, limits)?;
90 let ArchiveEntries {
91 config_bytes,
92 schema_files,
93 ..
94 } = &entries;
95
96 let published: memstead_schema::PublishedMemConfig =
97 serde_json::from_slice(config_bytes)
98 .map_err(|e| FromArchiveBytesError::InvalidConfig(e.to_string()))?;
99
100 let extra_schemas = load_embedded_schemas(schema_files)?;
101
102 let mount = Mount {
103 mem: published.name.clone(),
104 schema: Some(published.schema.clone()),
105 storage: MountStorage::Archive {
106 path: PathBuf::new(),
107 },
108 capability: MountCapability::ReadOnly,
109 lifecycle: MountLifecycle::Eager,
110 cross_linkable: false,
111 migration_target: None,
112 };
113 let backend: Box<dyn MemBackend> = Box::new(ArchiveBackend::from_bytes(bytes));
114
115 let engine = Self::from_mounts_inner(vec![(mount, backend)], extra_schemas)?;
116 Ok(engine)
117 }
118
119 pub fn export_mem_to_bytes(&self, mem_name: &str) -> Result<Vec<u8>, EngineError> {
138 let mount = self
139 .mounts
140 .iter()
141 .find(|m| m.mount.mem == mem_name)
142 .ok_or_else(|| EngineError::UnknownMem(mem_name.to_string()))?;
143 let config = self.mem_config_for(mem_name).ok_or_else(|| {
144 EngineError::InvalidInput(format!(
145 "mem '{mem_name}' has no loaded MemConfig — cannot export"
146 ))
147 })?;
148 if config.version.is_none() {
149 return Err(EngineError::MemConfigIncomplete {
150 mem: mem_name.to_string(),
151 missing_fields: vec!["version".to_string()],
152 });
153 }
154 let workspace_root = self.workspace_root.as_deref();
155 let fixed_schemas_dir = workspace_root.map(|r| r.join(".memstead").join("schemas"));
157 let workspace_schemas_dir = fixed_schemas_dir.as_deref();
158 match &mount.mount.storage {
159 MountStorage::Folder { path } => crate::ops::export::export_mem_to_bytes(
160 path,
161 config,
162 workspace_root,
163 workspace_schemas_dir,
164 mem_name,
165 )
166 .map(|out| out.bytes)
167 .map_err(|e| {
168 EngineError::Backend(crate::backend::BackendError::Other(format!(
169 "export_mem_to_bytes: {e}"
170 )))
171 }),
172 MountStorage::Archive { .. } => {
173 Err(EngineError::Backend(crate::backend::BackendError::Sealed))
174 }
175 MountStorage::GitBranch { gitdir, branch } => {
176 let hook = self.git_branch_ops.as_ref().ok_or_else(|| {
177 EngineError::Backend(crate::backend::BackendError::Other(
178 "git-branch export hook not installed (full flavour not loaded)"
179 .to_string(),
180 ))
181 })?;
182 let provenance_bytes = mount
187 .backend
188 .read_provenance(None)
189 .ok()
190 .and_then(|records| crate::ops::export::build_archive_provenance(&records))
191 .and_then(|prov| prov.to_archive_bytes().ok());
192 (hook.export_to_bytes)(
193 gitdir,
194 branch,
195 mem_name,
196 config,
197 workspace_root,
198 workspace_schemas_dir,
199 provenance_bytes.as_deref(),
200 )
201 .map(|out| out.bytes)
202 .map_err(EngineError::Backend)
203 }
204 MountStorage::InMemory => {
210 let backend = mount.backend.as_ref();
211 let rels = backend.list_entities().map_err(EngineError::Backend)?;
212 let mut md_entries: Vec<(std::path::PathBuf, Vec<u8>)> =
213 Vec::with_capacity(rels.len());
214 for rel in rels {
215 if let Some(bytes) = backend.read_entity(&rel).map_err(EngineError::Backend)? {
216 md_entries.push((rel, bytes));
217 }
218 }
219 let provenance = backend
223 .read_provenance(None)
224 .ok()
225 .and_then(|records| crate::ops::export::build_archive_provenance(&records));
226 crate::ops::export::export_entries_to_bytes(
227 config,
228 workspace_root,
229 workspace_schemas_dir,
230 mem_name,
231 md_entries,
232 provenance.as_ref(),
233 )
234 .map(|out| out.bytes)
235 .map_err(|e| {
236 EngineError::Backend(crate::backend::BackendError::Other(format!(
237 "export_mem_to_bytes: {e}"
238 )))
239 })
240 }
241 }
242 }
243}
244
245pub(crate) fn load_embedded_schemas(
251 schema_files: &[SchemaFile],
252) -> Result<Vec<Arc<Schema>>, FromArchiveBytesError> {
253 if schema_files.is_empty() {
254 return Ok(Vec::new());
255 }
256 let mut manifest: Option<&str> = None;
257 let mut types: Vec<(String, String)> = Vec::new();
258 for sf in schema_files {
259 if sf.archive_path == ".memstead/schema/schema.yaml" {
260 manifest = Some(&sf.content);
261 } else if let Some(rest) = sf.archive_path.strip_prefix(".memstead/schema/types/")
262 && let Some(stem) = rest.strip_suffix(".yaml")
263 {
264 types.push((stem.to_string(), sf.content.clone()));
265 }
266 }
267 let Some(manifest_yaml) = manifest else {
268 return Err(FromArchiveBytesError::EmbeddedSchemaInvalid(
269 "embedded schema package present but `.memstead/schema/schema.yaml` missing"
270 .to_string(),
271 ));
272 };
273 let schema = load_schema_from_memory(manifest_yaml, &types)
274 .map_err(|e| FromArchiveBytesError::EmbeddedSchemaInvalid(e.to_string()))?;
275 Ok(vec![Arc::new(schema)])
276}
277
278#[cfg(test)]
279mod tests {
280 use super::*;
281 use std::path::Path;
282 use tempfile::TempDir;
283
284 use crate::backend::{BackendError, MemBackend};
285 use crate::engine::test_helpers::{cli_actor, empty_create_args, folder_mount};
286 use crate::storage::FilesystemMemWriter;
287 use crate::workspace::{Mount, MountCapability, MountLifecycle, MountStorage};
288
289 fn folder_mem_with_entities(tmp: &TempDir, titles: &[&str]) -> (Engine, std::path::PathBuf) {
292 let mem_dir = tmp.path().join("specs");
293 std::fs::create_dir_all(mem_dir.join(".memstead")).unwrap();
294 let config_body = r#"{
295 "format": 1,
296 "schema": "default@1.0.0",
297 "version": "1.0.0"
298 }"#;
299 std::fs::write(mem_dir.join(".memstead").join("config.json"), config_body).unwrap();
300
301 let writer = FilesystemMemWriter::new(mem_dir.clone());
302 let mut engine = Engine::from_mounts(vec![(
303 folder_mount("specs", mem_dir.clone()),
304 Box::new(writer) as Box<dyn MemBackend>,
305 )])
306 .unwrap();
307 let (actor, client) = cli_actor();
308 for t in titles {
309 engine
310 .create_entity(empty_create_args("specs", t), actor, Some(&client), None)
311 .unwrap();
312 }
313 (engine, mem_dir)
314 }
315
316 #[test]
317 fn export_to_bytes_produces_bytes_that_extract_cleanly() {
318 let tmp = TempDir::new().unwrap();
319 let (engine, _mem) = folder_mem_with_entities(&tmp, &["Alpha", "Beta"]);
320 let bytes = engine.export_mem_to_bytes("specs").unwrap();
321 assert!(!bytes.is_empty(), "export bytes must be non-empty");
322 let entries = extract_entries(&bytes, &ValidatorLimits::DEFAULT).unwrap();
325 assert_eq!(entries.markdown_files.len(), 2);
326 let mut names: Vec<_> = entries
327 .markdown_files
328 .iter()
329 .map(|m| m.path.clone())
330 .collect();
331 names.sort();
332 assert_eq!(names, vec!["alpha.md".to_string(), "beta.md".to_string()]);
333 }
334
335 #[test]
341 fn export_carries_provenance_that_install_reads_back() {
342 let tmp = TempDir::new().unwrap();
343 let mem_dir = tmp.path().join("specs");
344 std::fs::create_dir_all(mem_dir.join(".memstead")).unwrap();
345 std::fs::write(
346 mem_dir.join(".memstead").join("config.json"),
347 r#"{"format":1,"schema":"default@1.0.0","version":"1.0.0"}"#,
348 )
349 .unwrap();
350 let writer = FilesystemMemWriter::new(mem_dir.clone());
351 let mut engine = Engine::from_mounts(vec![(
352 folder_mount("specs", mem_dir.clone()),
353 Box::new(writer) as Box<dyn MemBackend>,
354 )])
355 .unwrap();
356 let (actor, client) = cli_actor();
357 engine
359 .create_entity(
360 empty_create_args("specs", "Alpha"),
361 actor,
362 Some(&client),
363 Some("why alpha exists"),
364 )
365 .unwrap();
366 engine
367 .create_entity(
368 empty_create_args("specs", "Beta"),
369 actor,
370 Some(&client),
371 None,
372 )
373 .unwrap();
374
375 let bytes = engine.export_mem_to_bytes("specs").unwrap();
376 let entries = extract_entries(&bytes, &ValidatorLimits::DEFAULT).unwrap();
378 assert!(
379 entries.provenance_bytes.is_some(),
380 "export must embed the provenance payload"
381 );
382
383 let validated =
387 crate::validator::validate_and_normalize_archive(&bytes).expect("archive re-validates");
388 let canonical_entries =
389 extract_entries(&validated.canonical_bytes, &ValidatorLimits::DEFAULT).unwrap();
390 assert!(
391 canonical_entries.provenance_bytes.is_some(),
392 "normalize must preserve provenance through the canonical re-pack (publish store path)"
393 );
394
395 let installed = Engine::from_archive_bytes(bytes).unwrap();
397 let prov = installed
398 .archive_provenance_for("specs")
399 .expect("installed mem exposes provenance");
400 assert_eq!(
401 prov.entity("alpha").and_then(|r| r.rationale.as_deref()),
402 Some("why alpha exists"),
403 "noted entity's rationale matches the source"
404 );
405 assert_eq!(
406 prov.entity("alpha").and_then(|r| r.kind.as_deref()),
407 Some("create"),
408 );
409 assert!(
410 prov.entity("beta").is_none(),
411 "entity authored without a note is absent — no fabricated provenance"
412 );
413 }
414
415 #[test]
422 fn provenance_bearing_archive_stays_within_publish_budget() {
423 const PUBLISH_BODY_LIMIT: usize = 2 * 1024 * 1024;
424 let tmp = TempDir::new().unwrap();
425 let mem_dir = tmp.path().join("specs");
426 std::fs::create_dir_all(mem_dir.join(".memstead")).unwrap();
427 std::fs::write(
428 mem_dir.join(".memstead").join("config.json"),
429 r#"{"format":1,"schema":"default@1.0.0","version":"1.0.0"}"#,
430 )
431 .unwrap();
432 let writer = FilesystemMemWriter::new(mem_dir.clone());
433 let mut engine = Engine::from_mounts(vec![(
434 folder_mount("specs", mem_dir.clone()),
435 Box::new(writer) as Box<dyn MemBackend>,
436 )])
437 .unwrap();
438 let (actor, client) = cli_actor();
439 let note = "x".repeat(280);
442 for i in 0..60 {
443 engine
444 .create_entity(
445 empty_create_args("specs", &format!("Entity {i}")),
446 actor,
447 Some(&client),
448 Some(¬e),
449 )
450 .unwrap();
451 }
452 let bytes = engine.export_mem_to_bytes("specs").unwrap();
453 assert!(
454 bytes.len() < PUBLISH_BODY_LIMIT,
455 "archive ({} B) must stay under the 2 MB publish limit",
456 bytes.len()
457 );
458 let entries = extract_entries(&bytes, &ValidatorLimits::DEFAULT).unwrap();
459 let prov = entries.provenance_bytes.expect("provenance present");
460 assert!(
463 prov.len() < PUBLISH_BODY_LIMIT / 4,
464 "provenance payload ({} B) is a small fraction of the budget",
465 prov.len()
466 );
467 let parsed = memstead_schema::ArchiveProvenance::from_archive_bytes(&prov).unwrap();
468 assert_eq!(
469 parsed.entities.len(),
470 60,
471 "every noted entity has provenance"
472 );
473 }
474
475 #[test]
483 fn export_warns_on_cross_mem_edge_that_install_refuses() {
484 let tmp = TempDir::new().unwrap();
485 let (engine, mem_dir) = folder_mem_with_entities(&tmp, &[]);
486 let md = "\
490---
491type: spec
492created_date: 2026-01-15
493last_modified: 2026-01-15
494level: M0
495---
496# Broker
497
498## Identity
499
500A
501
502## Purpose
503
504B
505
506## Specifies
507
508C
509
510## Constraints
511
512D
513
514## Rationale
515
516E
517
518## Relationships
519
520- **USES**: [[other--thing]]
521";
522 std::fs::write(mem_dir.join("broker.md"), md).unwrap();
523
524 let out = tmp.path().join("specs.mem");
527 let result = engine.export_mem("specs", &out).unwrap();
528 assert!(out.is_file(), "archive must still be produced");
529 assert_eq!(
530 result.dangling_cross_mem_edges.len(),
531 1,
532 "export must surface the cross-mem edge: {:?}",
533 result.dangling_cross_mem_edges
534 );
535 let edge = &result.dangling_cross_mem_edges[0];
536 assert_eq!(edge.entity_path, "broker.md");
537 assert_eq!(edge.target_id, "other--thing");
538 assert_eq!(edge.target_mem, "other");
539
540 let bytes = std::fs::read(&out).unwrap();
543 let err = crate::validator::validate_and_normalize_archive(&bytes).unwrap_err();
544 assert!(
545 matches!(
546 err,
547 crate::validator::ValidationError::CrossMemRelationship { .. }
548 ),
549 "install-side strict validation must refuse the same edge: {err:?}",
550 );
551 }
552
553 #[test]
556 fn export_self_contained_mem_warns_nothing() {
557 let tmp = TempDir::new().unwrap();
558 let (engine, _mem) = folder_mem_with_entities(&tmp, &["Alpha", "Beta"]);
559 let out = tmp.path().join("specs.mem");
560 let result = engine.export_mem("specs", &out).unwrap();
561 assert!(
562 result.dangling_cross_mem_edges.is_empty(),
563 "self-contained export must warn nothing: {:?}",
564 result.dangling_cross_mem_edges
565 );
566 }
567
568 #[test]
569 fn export_empty_mem_produces_valid_hydratable_archive() {
570 let tmp = TempDir::new().unwrap();
571 let (engine, _mem) = folder_mem_with_entities(&tmp, &[]);
572 let bytes = engine.export_mem_to_bytes("specs").unwrap();
573 let entries = extract_entries(&bytes, &ValidatorLimits::DEFAULT).unwrap();
575 assert!(entries.markdown_files.is_empty());
576 let hydrated = Engine::from_archive_bytes(bytes).unwrap();
578 assert_eq!(hydrated.mem_names(), vec!["specs"]);
579 assert!(hydrated.store().is_empty());
580 }
581
582 #[test]
583 fn export_unknown_mem_returns_unknown_mem_error() {
584 let tmp = TempDir::new().unwrap();
585 let (engine, _mem) = folder_mem_with_entities(&tmp, &[]);
586 let err = engine.export_mem_to_bytes("missing").unwrap_err();
587 match err {
588 EngineError::UnknownMem(v) => assert_eq!(v, "missing"),
589 other => panic!("expected UnknownMem, got {other:?}"),
590 }
591 }
592
593 #[test]
594 fn export_archive_backend_returns_sealed() {
595 let tmp = TempDir::new().unwrap();
600 let (engine, _mem) = folder_mem_with_entities(&tmp, &["Alpha"]);
601 let bytes = engine.export_mem_to_bytes("specs").unwrap();
602
603 let archive_path = tmp.path().join("ext.mem");
604 std::fs::write(&archive_path, &bytes).unwrap();
605 let archive_engine = Engine::from_mounts(vec![(
606 Mount {
607 mem: "ext".to_string(),
608 schema: Some(memstead_schema::SchemaRef::new(
609 "default",
610 semver::Version::new(1, 0, 0),
611 )),
612 storage: MountStorage::Archive {
613 path: archive_path.clone(),
614 },
615 capability: MountCapability::ReadOnly,
616 lifecycle: MountLifecycle::Lazy,
617 cross_linkable: false,
618 migration_target: None,
619 },
620 Box::new(crate::storage::ArchiveBackend::new(archive_path)) as Box<dyn MemBackend>,
621 )])
622 .unwrap();
623 let err = archive_engine.export_mem_to_bytes("ext").unwrap_err();
624 assert!(matches!(err, EngineError::Backend(BackendError::Sealed)));
625 }
626
627 #[test]
628 fn from_archive_bytes_refuses_non_zip_with_validation_error() {
629 let err = Engine::from_archive_bytes(b"not a zip at all".to_vec()).unwrap_err();
630 match err {
631 FromArchiveBytesError::Validation(crate::validator::ValidationError::Zip(_)) => {}
632 other => panic!("expected Validation(Zip(_)), got {other:?}"),
633 }
634 }
635
636 #[test]
637 fn from_archive_bytes_refuses_oversized_with_size_cap() {
638 let tmp = TempDir::new().unwrap();
639 let (engine, _mem) = folder_mem_with_entities(&tmp, &["Alpha"]);
640 let bytes = engine.export_mem_to_bytes("specs").unwrap();
641
642 let mut limits = ValidatorLimits::DEFAULT;
643 limits.max_compressed_archive = 1;
644 let err = Engine::from_archive_bytes_with_limits(bytes, &limits).unwrap_err();
645 match err {
646 FromArchiveBytesError::Validation(
647 crate::validator::ValidationError::SizeCapExceeded { .. },
648 ) => {}
649 other => panic!("expected Validation(SizeCapExceeded), got {other:?}"),
650 }
651 }
652
653 #[test]
654 fn hydrated_engine_answers_reads_and_refuses_writes() {
655 let tmp = TempDir::new().unwrap();
656 let (engine, _mem) = folder_mem_with_entities(&tmp, &["Hello", "World"]);
657 let bytes = engine.export_mem_to_bytes("specs").unwrap();
658 let mut hydrated = Engine::from_archive_bytes(bytes).unwrap();
659
660 let hello = hydrated
662 .get_entity(&crate::EntityId::new("specs", "hello"))
663 .expect("hello entity must round-trip");
664 assert_eq!(hello.title, "Hello");
665 let world = hydrated
666 .get_entity(&crate::EntityId::new("specs", "world"))
667 .expect("world entity must round-trip");
668 assert_eq!(world.title, "World");
669
670 let (actor, client) = cli_actor();
673 let err = hydrated
674 .create_entity(
675 empty_create_args("specs", "Forbidden"),
676 actor,
677 Some(&client),
678 None,
679 )
680 .unwrap_err();
681 assert!(matches!(err, EngineError::ReadOnlyMount(v) if v == "specs"));
682 }
683
684 #[test]
685 fn round_trip_preserves_entities_and_relations() {
686 let tmp = TempDir::new().unwrap();
690 let mem_dir = tmp.path().join("specs");
691 std::fs::create_dir_all(mem_dir.join(".memstead")).unwrap();
692 std::fs::write(
693 mem_dir.join(".memstead").join("config.json"),
694 r#"{"format":1,"schema":"default@1.0.0","version":"1.0.0"}"#,
695 )
696 .unwrap();
697 let writer = FilesystemMemWriter::new(mem_dir.clone());
698 let mut source = Engine::from_mounts(vec![(
699 folder_mount("specs", mem_dir),
700 Box::new(writer) as Box<dyn MemBackend>,
701 )])
702 .unwrap();
703 let (actor, client) = cli_actor();
704 let src = source
705 .create_entity(
706 empty_create_args("specs", "Source"),
707 actor,
708 Some(&client),
709 None,
710 )
711 .unwrap();
712 let tgt = source
713 .create_entity(
714 empty_create_args("specs", "Target"),
715 actor,
716 Some(&client),
717 None,
718 )
719 .unwrap();
720 source
721 .relate_entity(
722 crate::engine::RelateEntityArgs {
723 source: src.id.clone(),
724 expected_hash: Some(src.content_hash.clone()),
725 rel_type: "USES".to_string(),
726 target: tgt.id.clone(),
727 remove: false,
728 description: None,
729 },
730 actor,
731 Some(&client),
732 None,
733 )
734 .unwrap();
735
736 let bytes = source.export_mem_to_bytes("specs").unwrap();
737 let hydrated = Engine::from_archive_bytes(bytes).unwrap();
738
739 let mut src_ids: Vec<String> = source
741 .store()
742 .all_entities()
743 .map(|e| e.id.to_string())
744 .collect();
745 let mut hyd_ids: Vec<String> = hydrated
746 .store()
747 .all_entities()
748 .map(|e| e.id.to_string())
749 .collect();
750 src_ids.sort();
751 hyd_ids.sort();
752 assert_eq!(src_ids, hyd_ids);
753
754 for id_str in &src_ids {
756 let (mem, slug) = id_str.split_once("--").expect("ids carry `<mem>--<slug>`");
757 let id = crate::EntityId::new(mem, slug);
758 let s = source.get_entity(&id).unwrap();
759 let h = hydrated.get_entity(&id).unwrap();
760 assert_eq!(s.title, h.title, "title differs for {id_str}");
761 assert_eq!(s.entity_type, h.entity_type, "type differs for {id_str}");
762 }
763
764 let src_edges: Vec<_> = source
766 .store()
767 .outgoing(&src.id)
768 .iter()
769 .map(|e| (e.rel_type.clone(), e.target.clone()))
770 .collect();
771 let hyd_edges: Vec<_> = hydrated
772 .store()
773 .outgoing(&src.id)
774 .iter()
775 .map(|e| (e.rel_type.clone(), e.target.clone()))
776 .collect();
777 assert_eq!(src_edges, hyd_edges);
778 }
779
780 #[test]
781 fn export_then_hydrate_then_re_export_yields_byte_equivalent_archive() {
782 let tmp = TempDir::new().unwrap();
788 let (engine, _mem) = folder_mem_with_entities(&tmp, &["Alpha"]);
789 let bytes1 = engine.export_mem_to_bytes("specs").unwrap();
790 let bytes2 = engine.export_mem_to_bytes("specs").unwrap();
791 assert_eq!(bytes1, bytes2, "export bytes must be deterministic");
792 }
793
794 fn assert_state_equivalent(source: &Engine, hydrated: &Engine, mem: &str) {
801 let mut src_ids: Vec<String> = source
802 .store()
803 .all_entities()
804 .filter(|e| e.mem == mem)
805 .map(|e| e.id.to_string())
806 .collect();
807 let mut hyd_ids: Vec<String> = hydrated
808 .store()
809 .all_entities()
810 .filter(|e| e.mem == mem)
811 .map(|e| e.id.to_string())
812 .collect();
813 src_ids.sort();
814 hyd_ids.sort();
815 assert_eq!(src_ids, hyd_ids, "entity id set differs for mem {mem}");
816
817 for id_str in &src_ids {
818 let (v, slug) = id_str.split_once("--").expect("ids carry `<mem>--<slug>`");
819 let id = crate::EntityId::new(v, slug);
820 let s = source.get_entity(&id).expect("source entity present");
821 let h = hydrated.get_entity(&id).expect("hydrated entity present");
822 assert_eq!(s.title, h.title, "title differs for {id_str}");
823 assert_eq!(s.entity_type, h.entity_type, "type differs for {id_str}");
824 assert_eq!(s.metadata, h.metadata, "metadata differs for {id_str}");
825 assert_eq!(s.sections, h.sections, "sections differ for {id_str}");
826 assert_eq!(
827 s.content_hash, h.content_hash,
828 "content_hash differs for {id_str}",
829 );
830
831 let mut src_edges: Vec<_> = source
832 .store()
833 .outgoing(&id)
834 .iter()
835 .map(|e| (e.rel_type.clone(), e.target.to_string()))
836 .collect();
837 let mut hyd_edges: Vec<_> = hydrated
838 .store()
839 .outgoing(&id)
840 .iter()
841 .map(|e| (e.rel_type.clone(), e.target.to_string()))
842 .collect();
843 src_edges.sort();
844 hyd_edges.sort();
845 assert_eq!(src_edges, hyd_edges, "edges differ for {id_str}");
846 }
847 }
848
849 fn round_trip(source: &Engine, mem: &str) -> Engine {
853 let bytes = source.export_mem_to_bytes(mem).unwrap();
854 extract_entries(&bytes, &ValidatorLimits::DEFAULT).unwrap();
858 let hydrated = Engine::from_archive_bytes(bytes).unwrap();
859 assert_state_equivalent(source, &hydrated, mem);
860 hydrated
861 }
862
863 #[test]
864 fn fixture_sweep_round_trip_empty() {
865 let tmp = TempDir::new().unwrap();
866 let (engine, _mem) = folder_mem_with_entities(&tmp, &[]);
867 let hydrated = round_trip(&engine, "specs");
868 assert!(hydrated.store().is_empty());
869 }
870
871 #[test]
872 fn fixture_sweep_round_trip_single_entity() {
873 let tmp = TempDir::new().unwrap();
874 let (engine, _mem) = folder_mem_with_entities(&tmp, &["Solo"]);
875 round_trip(&engine, "specs");
876 }
877
878 #[test]
879 fn fixture_sweep_round_trip_multi_entity_no_relations() {
880 let tmp = TempDir::new().unwrap();
881 let (engine, _mem) = folder_mem_with_entities(&tmp, &["A One", "A Two", "A Three"]);
882 round_trip(&engine, "specs");
883 }
884
885 #[test]
886 fn fixture_sweep_round_trip_entity_with_metadata_and_sections() {
887 let tmp = TempDir::new().unwrap();
888 let mem_dir = tmp.path().join("specs");
889 std::fs::create_dir_all(mem_dir.join(".memstead")).unwrap();
890 std::fs::write(
891 mem_dir.join(".memstead").join("config.json"),
892 r#"{"format":1,"schema":"default@1.0.0","version":"1.0.0"}"#,
893 )
894 .unwrap();
895 let writer = FilesystemMemWriter::new(mem_dir.clone());
896 let mut engine = Engine::from_mounts(vec![(
897 folder_mount("specs", mem_dir),
898 Box::new(writer) as Box<dyn MemBackend>,
899 )])
900 .unwrap();
901 let (actor, client) = cli_actor();
902
903 let mut sections = indexmap::IndexMap::new();
904 sections.insert("identity".to_string(), "A rich body.".to_string());
905 sections.insert(
906 "purpose".to_string(),
907 "To exercise the archive round-trip.".to_string(),
908 );
909 sections.insert(
910 "rationale".to_string(),
911 "Because the spec said so.".to_string(),
912 );
913
914 let mut metadata: indexmap::IndexMap<String, String> = indexmap::IndexMap::new();
915 metadata.insert("level".to_string(), "M0".to_string());
916
917 engine
918 .create_entity(
919 crate::engine::CreateEntityArgs {
920 mem: "specs".to_string(),
921 title: "Rich".to_string(),
922 entity_type: "spec".to_string(),
923 sections,
924 metadata,
925 relations: Vec::new(),
926 dry_run: false,
927 },
928 actor,
929 Some(&client),
930 None,
931 )
932 .unwrap();
933 round_trip(&engine, "specs");
934 }
935
936 #[test]
937 fn fixture_sweep_round_trip_multi_entity_with_relations() {
938 let tmp = TempDir::new().unwrap();
939 let mem_dir = tmp.path().join("specs");
940 std::fs::create_dir_all(mem_dir.join(".memstead")).unwrap();
941 std::fs::write(
942 mem_dir.join(".memstead").join("config.json"),
943 r#"{"format":1,"schema":"default@1.0.0","version":"1.0.0"}"#,
944 )
945 .unwrap();
946 let writer = FilesystemMemWriter::new(mem_dir.clone());
947 let mut engine = Engine::from_mounts(vec![(
948 folder_mount("specs", mem_dir),
949 Box::new(writer) as Box<dyn MemBackend>,
950 )])
951 .unwrap();
952 let (actor, client) = cli_actor();
953 let src = engine
954 .create_entity(
955 empty_create_args("specs", "Source"),
956 actor,
957 Some(&client),
958 None,
959 )
960 .unwrap();
961 let mid = engine
962 .create_entity(
963 empty_create_args("specs", "Middle"),
964 actor,
965 Some(&client),
966 None,
967 )
968 .unwrap();
969 let tgt = engine
970 .create_entity(
971 empty_create_args("specs", "Target"),
972 actor,
973 Some(&client),
974 None,
975 )
976 .unwrap();
977 engine
980 .relate_entity(
981 crate::engine::RelateEntityArgs {
982 source: src.id.clone(),
983 expected_hash: Some(src.content_hash.clone()),
984 rel_type: "USES".to_string(),
985 target: mid.id.clone(),
986 remove: false,
987 description: None,
988 },
989 actor,
990 Some(&client),
991 None,
992 )
993 .unwrap();
994 let src_after = engine
995 .get_entity(&src.id)
996 .expect("source must still resolve");
997 engine
998 .relate_entity(
999 crate::engine::RelateEntityArgs {
1000 source: src.id.clone(),
1001 expected_hash: Some(src_after.content_hash.clone()),
1002 rel_type: "PART_OF".to_string(),
1003 target: tgt.id.clone(),
1004 remove: false,
1005 description: None,
1006 },
1007 actor,
1008 Some(&client),
1009 None,
1010 )
1011 .unwrap();
1012 round_trip(&engine, "specs");
1013 }
1014
1015 #[test]
1016 fn read_entity_path_works_against_byte_backed_archive() {
1017 let tmp = TempDir::new().unwrap();
1020 let (engine, _mem) = folder_mem_with_entities(&tmp, &["First", "Second"]);
1021 let bytes = engine.export_mem_to_bytes("specs").unwrap();
1022 let hydrated = Engine::from_archive_bytes(bytes).unwrap();
1023 let first = hydrated
1024 .get_entity(&crate::EntityId::new("specs", "first"))
1025 .expect("first must hydrate");
1026 assert_eq!(first.title, "First");
1027 let backend = ArchiveBackend::from_bytes(Vec::new());
1030 let _: Option<&Path> = backend.archive_path();
1031 }
1032}