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 let anchors_bytes = mount.backend.read_anchors_sidecar().ok().flatten();
195 (hook.export_to_bytes)(
196 gitdir,
197 branch,
198 mem_name,
199 config,
200 workspace_root,
201 workspace_schemas_dir,
202 provenance_bytes.as_deref(),
203 anchors_bytes.as_deref(),
204 )
205 .map(|out| out.bytes)
206 .map_err(EngineError::Backend)
207 }
208 MountStorage::InMemory => {
214 let backend = mount.backend.as_ref();
215 let rels = backend.list_entities().map_err(EngineError::Backend)?;
216 let mut md_entries: Vec<(std::path::PathBuf, Vec<u8>)> =
217 Vec::with_capacity(rels.len());
218 for rel in rels {
219 if let Some(bytes) = backend.read_entity(&rel).map_err(EngineError::Backend)? {
220 md_entries.push((rel, bytes));
221 }
222 }
223 let provenance = backend
227 .read_provenance(None)
228 .ok()
229 .and_then(|records| crate::ops::export::build_archive_provenance(&records));
230 let anchors_bytes = backend
234 .read_anchors_sidecar()
235 .map_err(EngineError::Backend)?;
236 crate::ops::export::export_entries_to_bytes(
237 config,
238 workspace_root,
239 workspace_schemas_dir,
240 mem_name,
241 md_entries,
242 provenance.as_ref(),
243 anchors_bytes.as_deref(),
244 )
245 .map(|out| out.bytes)
246 .map_err(|e| {
247 EngineError::Backend(crate::backend::BackendError::Other(format!(
248 "export_mem_to_bytes: {e}"
249 )))
250 })
251 }
252 }
253 }
254}
255
256pub(crate) fn load_embedded_schemas(
262 schema_files: &[SchemaFile],
263) -> Result<Vec<Arc<Schema>>, FromArchiveBytesError> {
264 if schema_files.is_empty() {
265 return Ok(Vec::new());
266 }
267 let mut manifest: Option<&str> = None;
268 let mut types: Vec<(String, String)> = Vec::new();
269 for sf in schema_files {
270 if sf.archive_path == ".memstead/schema/schema.yaml" {
271 manifest = Some(&sf.content);
272 } else if let Some(rest) = sf.archive_path.strip_prefix(".memstead/schema/types/")
273 && let Some(stem) = rest.strip_suffix(".yaml")
274 {
275 types.push((stem.to_string(), sf.content.clone()));
276 }
277 }
278 let Some(manifest_yaml) = manifest else {
279 return Err(FromArchiveBytesError::EmbeddedSchemaInvalid(
280 "embedded schema package present but `.memstead/schema/schema.yaml` missing"
281 .to_string(),
282 ));
283 };
284 let schema = load_schema_from_memory(manifest_yaml, &types)
285 .map_err(|e| FromArchiveBytesError::EmbeddedSchemaInvalid(e.to_string()))?;
286 Ok(vec![Arc::new(schema)])
287}
288
289#[cfg(test)]
290mod tests {
291 use super::*;
292 use std::path::Path;
293 use tempfile::TempDir;
294
295 use crate::backend::{BackendError, MemBackend};
296 use crate::engine::test_helpers::{cli_actor, empty_create_args, folder_mount};
297 use crate::storage::FilesystemMemWriter;
298 use crate::workspace::{Mount, MountCapability, MountLifecycle, MountStorage};
299
300 fn folder_mem_with_entities(tmp: &TempDir, titles: &[&str]) -> (Engine, std::path::PathBuf) {
303 let mem_dir = tmp.path().join("specs");
304 std::fs::create_dir_all(mem_dir.join(".memstead")).unwrap();
305 let config_body = r#"{
306 "format": 1,
307 "schema": "default@1.0.0",
308 "version": "1.0.0"
309 }"#;
310 std::fs::write(mem_dir.join(".memstead").join("config.json"), config_body).unwrap();
311
312 let writer = FilesystemMemWriter::new(mem_dir.clone());
313 let mut engine = Engine::from_mounts(vec![(
314 folder_mount("specs", mem_dir.clone()),
315 Box::new(writer) as Box<dyn MemBackend>,
316 )])
317 .unwrap();
318 let (actor, client) = cli_actor();
319 for t in titles {
320 engine
321 .create_entity(empty_create_args("specs", t), actor, Some(&client), None)
322 .unwrap();
323 }
324 (engine, mem_dir)
325 }
326
327 #[test]
328 fn export_to_bytes_produces_bytes_that_extract_cleanly() {
329 let tmp = TempDir::new().unwrap();
330 let (engine, _mem) = folder_mem_with_entities(&tmp, &["Alpha", "Beta"]);
331 let bytes = engine.export_mem_to_bytes("specs").unwrap();
332 assert!(!bytes.is_empty(), "export bytes must be non-empty");
333 let entries = extract_entries(&bytes, &ValidatorLimits::DEFAULT).unwrap();
336 assert_eq!(entries.markdown_files.len(), 2);
337 let mut names: Vec<_> = entries
338 .markdown_files
339 .iter()
340 .map(|m| m.path.clone())
341 .collect();
342 names.sort();
343 assert_eq!(names, vec!["alpha.md".to_string(), "beta.md".to_string()]);
344 }
345
346 #[test]
352 fn export_carries_provenance_that_install_reads_back() {
353 let tmp = TempDir::new().unwrap();
354 let mem_dir = tmp.path().join("specs");
355 std::fs::create_dir_all(mem_dir.join(".memstead")).unwrap();
356 std::fs::write(
357 mem_dir.join(".memstead").join("config.json"),
358 r#"{"format":1,"schema":"default@1.0.0","version":"1.0.0"}"#,
359 )
360 .unwrap();
361 let writer = FilesystemMemWriter::new(mem_dir.clone());
362 let mut engine = Engine::from_mounts(vec![(
363 folder_mount("specs", mem_dir.clone()),
364 Box::new(writer) as Box<dyn MemBackend>,
365 )])
366 .unwrap();
367 let (actor, client) = cli_actor();
368 engine
370 .create_entity(
371 empty_create_args("specs", "Alpha"),
372 actor,
373 Some(&client),
374 Some("why alpha exists"),
375 )
376 .unwrap();
377 engine
378 .create_entity(
379 empty_create_args("specs", "Beta"),
380 actor,
381 Some(&client),
382 None,
383 )
384 .unwrap();
385
386 let bytes = engine.export_mem_to_bytes("specs").unwrap();
387 let entries = extract_entries(&bytes, &ValidatorLimits::DEFAULT).unwrap();
389 assert!(
390 entries.provenance_bytes.is_some(),
391 "export must embed the provenance payload"
392 );
393
394 let validated =
398 crate::validator::validate_and_normalize_archive(&bytes).expect("archive re-validates");
399 let canonical_entries =
400 extract_entries(&validated.canonical_bytes, &ValidatorLimits::DEFAULT).unwrap();
401 assert!(
402 canonical_entries.provenance_bytes.is_some(),
403 "normalize must preserve provenance through the canonical re-pack (publish store path)"
404 );
405
406 let installed = Engine::from_archive_bytes(bytes).unwrap();
408 let prov = installed
409 .archive_provenance_for("specs")
410 .expect("installed mem exposes provenance");
411 assert_eq!(
412 prov.entity("alpha").and_then(|r| r.rationale.as_deref()),
413 Some("why alpha exists"),
414 "noted entity's rationale matches the source"
415 );
416 assert_eq!(
417 prov.entity("alpha").and_then(|r| r.kind.as_deref()),
418 Some("create"),
419 );
420 assert!(
421 prov.entity("beta").is_none(),
422 "entity authored without a note is absent — no fabricated provenance"
423 );
424 }
425
426 fn inject_zip_member(archive: &[u8], name: &str, content: &[u8]) -> Vec<u8> {
432 use std::io::{Read, Write};
433 let mut src = zip::ZipArchive::new(std::io::Cursor::new(archive)).unwrap();
434 let mut out = Vec::new();
435 {
436 let mut w = zip::ZipWriter::new(std::io::Cursor::new(&mut out));
437 let opts = zip::write::SimpleFileOptions::default()
438 .compression_method(zip::CompressionMethod::Deflated);
439 for i in 0..src.len() {
440 let mut f = src.by_index(i).unwrap();
441 let fname = f.name().to_string();
442 let mut buf = Vec::new();
443 f.read_to_end(&mut buf).unwrap();
444 w.start_file(fname, opts).unwrap();
445 w.write_all(&buf).unwrap();
446 }
447 w.start_file(name, opts).unwrap();
448 w.write_all(content).unwrap();
449 w.finish().unwrap();
450 }
451 out
452 }
453
454 #[test]
459 fn anchors_member_survives_canonical_repack_and_install() {
460 let tmp = TempDir::new().unwrap();
461 let (mut engine, _dir) = folder_mem_with_entities(&tmp, &["Alpha"]);
462 let _ = &mut engine;
463 let exported = engine.export_mem_to_bytes("specs").unwrap();
464
465 let anchors = br#"{"version":1,"entities":{"specs--alpha":[{"artifact":"src/lib.rs","grain":"file","class":"anchored","hash_stability":"stable","hash":"h1"}]}}"#;
466 let with_anchors = inject_zip_member(&exported, ".memstead/anchors.json", anchors);
467
468 let entries = extract_entries(&with_anchors, &ValidatorLimits::DEFAULT).unwrap();
470 assert_eq!(entries.anchors_bytes.as_deref(), Some(&anchors[..]));
471
472 let validated = crate::validator::validate_and_normalize_archive(&with_anchors)
474 .expect("archive with anchors re-validates");
475 let canonical =
476 extract_entries(&validated.canonical_bytes, &ValidatorLimits::DEFAULT).unwrap();
477 assert_eq!(
478 canonical.anchors_bytes.as_deref(),
479 Some(&anchors[..]),
480 "normalize must preserve the anchors member through the canonical re-pack"
481 );
482
483 let installed = Engine::from_archive_bytes(validated.canonical_bytes).unwrap();
485 let ids = installed.entity_anchors(&crate::EntityId::new("specs", "alpha"));
486 assert_eq!(ids.len(), 1);
487 assert_eq!(ids[0].artifact, "src/lib.rs");
488 }
489
490 #[test]
496 fn export_embeds_anchors_that_install_reads_back() {
497 let tmp = TempDir::new().unwrap();
498 let mem_dir = tmp.path().join("specs");
499 std::fs::create_dir_all(mem_dir.join(".memstead")).unwrap();
500 std::fs::write(
501 mem_dir.join(".memstead").join("config.json"),
502 r#"{"format":1,"schema":"default@1.0.0","version":"1.0.0"}"#,
503 )
504 .unwrap();
505 let writer = FilesystemMemWriter::new(mem_dir.clone());
506 let mut engine = Engine::from_mounts(vec![(
507 folder_mount("specs", mem_dir.clone()),
508 Box::new(writer) as Box<dyn MemBackend>,
509 )])
510 .unwrap();
511 let (actor, client) = cli_actor();
512
513 let mut alpha = empty_create_args("specs", "Alpha");
515 alpha.anchors = vec![crate::anchor::AnchorInput {
516 artifact: Some("src/lib.rs".to_string()),
517 grain: Some("file".to_string()),
518 class: Some("anchored".to_string()),
519 hash: Some("h1".to_string()),
520 hash_stability: Some("stable".to_string()),
521 ..Default::default()
522 }];
523 engine
524 .create_entity(alpha, actor, Some(&client), None)
525 .unwrap();
526 engine
527 .create_entity(
528 empty_create_args("specs", "Beta"),
529 actor,
530 Some(&client),
531 None,
532 )
533 .unwrap();
534
535 let bytes = engine.export_mem_to_bytes("specs").unwrap();
538 let entries = extract_entries(&bytes, &ValidatorLimits::DEFAULT).unwrap();
539 assert!(
540 entries.anchors_bytes.is_some(),
541 "export must embed the anchors sidecar when the mem has anchors"
542 );
543
544 let validated =
546 crate::validator::validate_and_normalize_archive(&bytes).expect("archive re-validates");
547 let canonical =
548 extract_entries(&validated.canonical_bytes, &ValidatorLimits::DEFAULT).unwrap();
549 assert!(
550 canonical.anchors_bytes.is_some(),
551 "normalize must preserve the exported anchors member"
552 );
553
554 let installed = Engine::from_archive_bytes(validated.canonical_bytes).unwrap();
556 let alpha_anchors = installed.entity_anchors(&crate::EntityId::new("specs", "alpha"));
557 assert_eq!(alpha_anchors.len(), 1);
558 assert_eq!(alpha_anchors[0].artifact, "src/lib.rs");
559 assert_eq!(alpha_anchors[0].hash.as_deref(), Some("h1"));
560 assert!(
562 installed
563 .entity_anchors(&crate::EntityId::new("specs", "beta"))
564 .is_empty(),
565 "an entity with no anchors exposes none after install"
566 );
567 }
568
569 #[test]
574 fn in_memory_mem_export_round_trips_anchors() {
575 use crate::storage::InMemoryBackend;
576 let backend = InMemoryBackend::new();
579 backend
580 .write_mem_config(br#"{"version":"0.1.0","schema":"default@1.0.0"}"#)
581 .unwrap();
582 let mount = Mount {
583 mem: "sketch".to_string(),
584 schema: Some("default@1.0.0".parse().unwrap()),
585 storage: MountStorage::InMemory,
586 capability: MountCapability::Write,
587 lifecycle: MountLifecycle::Eager,
588 cross_linkable: false,
589 migration_target: None,
590 };
591 let mut engine =
592 Engine::from_mounts(vec![(mount, Box::new(backend) as Box<dyn MemBackend>)]).unwrap();
593 let (actor, client) = cli_actor();
594
595 let mut args = empty_create_args("sketch", "Idea");
596 args.anchors = vec![crate::anchor::AnchorInput {
597 artifact: Some("notes/idea.md".to_string()),
598 grain: Some("file".to_string()),
599 class: Some("informed-by".to_string()),
600 ..Default::default()
601 }];
602 engine
603 .create_entity(args, actor, Some(&client), None)
604 .unwrap();
605
606 let bytes = engine.export_mem_to_bytes("sketch").unwrap();
607 let entries = extract_entries(&bytes, &ValidatorLimits::DEFAULT).unwrap();
608 assert!(
609 entries.anchors_bytes.is_some(),
610 "in-memory session export must carry the anchors sidecar"
611 );
612
613 let reimported = Engine::from_archive_bytes(bytes).unwrap();
614 let anchors = reimported.entity_anchors(&crate::EntityId::new("sketch", "idea"));
615 assert_eq!(anchors.len(), 1);
616 assert_eq!(anchors[0].artifact, "notes/idea.md");
617 assert_eq!(
618 anchors[0].class,
619 crate::anchor::AnchorProvenanceClass::InformedBy
620 );
621 }
622
623 #[test]
630 fn provenance_bearing_archive_stays_within_publish_budget() {
631 const PUBLISH_BODY_LIMIT: usize = 2 * 1024 * 1024;
632 let tmp = TempDir::new().unwrap();
633 let mem_dir = tmp.path().join("specs");
634 std::fs::create_dir_all(mem_dir.join(".memstead")).unwrap();
635 std::fs::write(
636 mem_dir.join(".memstead").join("config.json"),
637 r#"{"format":1,"schema":"default@1.0.0","version":"1.0.0"}"#,
638 )
639 .unwrap();
640 let writer = FilesystemMemWriter::new(mem_dir.clone());
641 let mut engine = Engine::from_mounts(vec![(
642 folder_mount("specs", mem_dir.clone()),
643 Box::new(writer) as Box<dyn MemBackend>,
644 )])
645 .unwrap();
646 let (actor, client) = cli_actor();
647 let note = "x".repeat(280);
650 for i in 0..60 {
651 engine
652 .create_entity(
653 empty_create_args("specs", &format!("Entity {i}")),
654 actor,
655 Some(&client),
656 Some(¬e),
657 )
658 .unwrap();
659 }
660 let bytes = engine.export_mem_to_bytes("specs").unwrap();
661 assert!(
662 bytes.len() < PUBLISH_BODY_LIMIT,
663 "archive ({} B) must stay under the 2 MB publish limit",
664 bytes.len()
665 );
666 let entries = extract_entries(&bytes, &ValidatorLimits::DEFAULT).unwrap();
667 let prov = entries.provenance_bytes.expect("provenance present");
668 assert!(
671 prov.len() < PUBLISH_BODY_LIMIT / 4,
672 "provenance payload ({} B) is a small fraction of the budget",
673 prov.len()
674 );
675 let parsed = memstead_schema::ArchiveProvenance::from_archive_bytes(&prov).unwrap();
676 assert_eq!(
677 parsed.entities.len(),
678 60,
679 "every noted entity has provenance"
680 );
681 }
682
683 #[test]
691 fn export_warns_on_cross_mem_edge_that_install_refuses() {
692 let tmp = TempDir::new().unwrap();
693 let (engine, mem_dir) = folder_mem_with_entities(&tmp, &[]);
694 let md = "\
698---
699type: spec
700created_date: 2026-01-15
701last_modified: 2026-01-15
702level: M0
703---
704# Broker
705
706## Identity
707
708A
709
710## Purpose
711
712B
713
714## Specifies
715
716C
717
718## Constraints
719
720D
721
722## Rationale
723
724E
725
726## Relationships
727
728- **USES**: [[other--thing]]
729";
730 std::fs::write(mem_dir.join("broker.md"), md).unwrap();
731
732 let out = tmp.path().join("specs.mem");
735 let result = engine.export_mem("specs", &out).unwrap();
736 assert!(out.is_file(), "archive must still be produced");
737 assert_eq!(
738 result.dangling_cross_mem_edges.len(),
739 1,
740 "export must surface the cross-mem edge: {:?}",
741 result.dangling_cross_mem_edges
742 );
743 let edge = &result.dangling_cross_mem_edges[0];
744 assert_eq!(edge.entity_path, "broker.md");
745 assert_eq!(edge.target_id, "other--thing");
746 assert_eq!(edge.target_mem, "other");
747
748 let bytes = std::fs::read(&out).unwrap();
751 let err = crate::validator::validate_and_normalize_archive(&bytes).unwrap_err();
752 assert!(
753 matches!(
754 err,
755 crate::validator::ValidationError::CrossMemRelationship { .. }
756 ),
757 "install-side strict validation must refuse the same edge: {err:?}",
758 );
759 }
760
761 #[test]
764 fn export_self_contained_mem_warns_nothing() {
765 let tmp = TempDir::new().unwrap();
766 let (engine, _mem) = folder_mem_with_entities(&tmp, &["Alpha", "Beta"]);
767 let out = tmp.path().join("specs.mem");
768 let result = engine.export_mem("specs", &out).unwrap();
769 assert!(
770 result.dangling_cross_mem_edges.is_empty(),
771 "self-contained export must warn nothing: {:?}",
772 result.dangling_cross_mem_edges
773 );
774 }
775
776 #[test]
777 fn export_empty_mem_produces_valid_hydratable_archive() {
778 let tmp = TempDir::new().unwrap();
779 let (engine, _mem) = folder_mem_with_entities(&tmp, &[]);
780 let bytes = engine.export_mem_to_bytes("specs").unwrap();
781 let entries = extract_entries(&bytes, &ValidatorLimits::DEFAULT).unwrap();
783 assert!(entries.markdown_files.is_empty());
784 let hydrated = Engine::from_archive_bytes(bytes).unwrap();
786 assert_eq!(hydrated.mem_names(), vec!["specs"]);
787 assert!(hydrated.store().is_empty());
788 }
789
790 #[test]
791 fn export_unknown_mem_returns_unknown_mem_error() {
792 let tmp = TempDir::new().unwrap();
793 let (engine, _mem) = folder_mem_with_entities(&tmp, &[]);
794 let err = engine.export_mem_to_bytes("missing").unwrap_err();
795 match err {
796 EngineError::UnknownMem(v) => assert_eq!(v, "missing"),
797 other => panic!("expected UnknownMem, got {other:?}"),
798 }
799 }
800
801 #[test]
802 fn export_archive_backend_returns_sealed() {
803 let tmp = TempDir::new().unwrap();
808 let (engine, _mem) = folder_mem_with_entities(&tmp, &["Alpha"]);
809 let bytes = engine.export_mem_to_bytes("specs").unwrap();
810
811 let archive_path = tmp.path().join("ext.mem");
812 std::fs::write(&archive_path, &bytes).unwrap();
813 let archive_engine = Engine::from_mounts(vec![(
814 Mount {
815 mem: "ext".to_string(),
816 schema: Some(memstead_schema::SchemaRef::new(
817 "default",
818 semver::Version::new(1, 0, 0),
819 )),
820 storage: MountStorage::Archive {
821 path: archive_path.clone(),
822 },
823 capability: MountCapability::ReadOnly,
824 lifecycle: MountLifecycle::Lazy,
825 cross_linkable: false,
826 migration_target: None,
827 },
828 Box::new(crate::storage::ArchiveBackend::new(archive_path)) as Box<dyn MemBackend>,
829 )])
830 .unwrap();
831 let err = archive_engine.export_mem_to_bytes("ext").unwrap_err();
832 assert!(matches!(err, EngineError::Backend(BackendError::Sealed)));
833 }
834
835 #[test]
836 fn from_archive_bytes_refuses_non_zip_with_validation_error() {
837 let err = Engine::from_archive_bytes(b"not a zip at all".to_vec()).unwrap_err();
838 match err {
839 FromArchiveBytesError::Validation(crate::validator::ValidationError::Zip(_)) => {}
840 other => panic!("expected Validation(Zip(_)), got {other:?}"),
841 }
842 }
843
844 #[test]
845 fn from_archive_bytes_refuses_oversized_with_size_cap() {
846 let tmp = TempDir::new().unwrap();
847 let (engine, _mem) = folder_mem_with_entities(&tmp, &["Alpha"]);
848 let bytes = engine.export_mem_to_bytes("specs").unwrap();
849
850 let mut limits = ValidatorLimits::DEFAULT;
851 limits.max_compressed_archive = 1;
852 let err = Engine::from_archive_bytes_with_limits(bytes, &limits).unwrap_err();
853 match err {
854 FromArchiveBytesError::Validation(
855 crate::validator::ValidationError::SizeCapExceeded { .. },
856 ) => {}
857 other => panic!("expected Validation(SizeCapExceeded), got {other:?}"),
858 }
859 }
860
861 #[test]
862 fn hydrated_engine_answers_reads_and_refuses_writes() {
863 let tmp = TempDir::new().unwrap();
864 let (engine, _mem) = folder_mem_with_entities(&tmp, &["Hello", "World"]);
865 let bytes = engine.export_mem_to_bytes("specs").unwrap();
866 let mut hydrated = Engine::from_archive_bytes(bytes).unwrap();
867
868 let hello = hydrated
870 .get_entity(&crate::EntityId::new("specs", "hello"))
871 .expect("hello entity must round-trip");
872 assert_eq!(hello.title, "Hello");
873 let world = hydrated
874 .get_entity(&crate::EntityId::new("specs", "world"))
875 .expect("world entity must round-trip");
876 assert_eq!(world.title, "World");
877
878 let (actor, client) = cli_actor();
881 let err = hydrated
882 .create_entity(
883 empty_create_args("specs", "Forbidden"),
884 actor,
885 Some(&client),
886 None,
887 )
888 .unwrap_err();
889 assert!(matches!(err, EngineError::ReadOnlyMount(v) if v == "specs"));
890 }
891
892 #[test]
893 fn round_trip_preserves_entities_and_relations() {
894 let tmp = TempDir::new().unwrap();
898 let mem_dir = tmp.path().join("specs");
899 std::fs::create_dir_all(mem_dir.join(".memstead")).unwrap();
900 std::fs::write(
901 mem_dir.join(".memstead").join("config.json"),
902 r#"{"format":1,"schema":"default@1.0.0","version":"1.0.0"}"#,
903 )
904 .unwrap();
905 let writer = FilesystemMemWriter::new(mem_dir.clone());
906 let mut source = Engine::from_mounts(vec![(
907 folder_mount("specs", mem_dir),
908 Box::new(writer) as Box<dyn MemBackend>,
909 )])
910 .unwrap();
911 let (actor, client) = cli_actor();
912 let src = source
913 .create_entity(
914 empty_create_args("specs", "Source"),
915 actor,
916 Some(&client),
917 None,
918 )
919 .unwrap();
920 let tgt = source
921 .create_entity(
922 empty_create_args("specs", "Target"),
923 actor,
924 Some(&client),
925 None,
926 )
927 .unwrap();
928 source
929 .relate_entity(
930 crate::engine::RelateEntityArgs {
931 source: src.id.clone(),
932 expected_hash: Some(src.content_hash.clone()),
933 rel_type: "USES".to_string(),
934 target: tgt.id.clone(),
935 remove: false,
936 description: None,
937 },
938 actor,
939 Some(&client),
940 None,
941 )
942 .unwrap();
943
944 let bytes = source.export_mem_to_bytes("specs").unwrap();
945 let hydrated = Engine::from_archive_bytes(bytes).unwrap();
946
947 let mut src_ids: Vec<String> = source
949 .store()
950 .all_entities()
951 .map(|e| e.id.to_string())
952 .collect();
953 let mut hyd_ids: Vec<String> = hydrated
954 .store()
955 .all_entities()
956 .map(|e| e.id.to_string())
957 .collect();
958 src_ids.sort();
959 hyd_ids.sort();
960 assert_eq!(src_ids, hyd_ids);
961
962 for id_str in &src_ids {
964 let (mem, slug) = id_str.split_once("--").expect("ids carry `<mem>--<slug>`");
965 let id = crate::EntityId::new(mem, slug);
966 let s = source.get_entity(&id).unwrap();
967 let h = hydrated.get_entity(&id).unwrap();
968 assert_eq!(s.title, h.title, "title differs for {id_str}");
969 assert_eq!(s.entity_type, h.entity_type, "type differs for {id_str}");
970 }
971
972 let src_edges: Vec<_> = source
974 .store()
975 .outgoing(&src.id)
976 .iter()
977 .map(|e| (e.rel_type.clone(), e.target.clone()))
978 .collect();
979 let hyd_edges: Vec<_> = hydrated
980 .store()
981 .outgoing(&src.id)
982 .iter()
983 .map(|e| (e.rel_type.clone(), e.target.clone()))
984 .collect();
985 assert_eq!(src_edges, hyd_edges);
986 }
987
988 #[test]
989 fn export_then_hydrate_then_re_export_yields_byte_equivalent_archive() {
990 let tmp = TempDir::new().unwrap();
996 let (engine, _mem) = folder_mem_with_entities(&tmp, &["Alpha"]);
997 let bytes1 = engine.export_mem_to_bytes("specs").unwrap();
998 let bytes2 = engine.export_mem_to_bytes("specs").unwrap();
999 assert_eq!(bytes1, bytes2, "export bytes must be deterministic");
1000 }
1001
1002 fn assert_state_equivalent(source: &Engine, hydrated: &Engine, mem: &str) {
1009 let mut src_ids: Vec<String> = source
1010 .store()
1011 .all_entities()
1012 .filter(|e| e.mem == mem)
1013 .map(|e| e.id.to_string())
1014 .collect();
1015 let mut hyd_ids: Vec<String> = hydrated
1016 .store()
1017 .all_entities()
1018 .filter(|e| e.mem == mem)
1019 .map(|e| e.id.to_string())
1020 .collect();
1021 src_ids.sort();
1022 hyd_ids.sort();
1023 assert_eq!(src_ids, hyd_ids, "entity id set differs for mem {mem}");
1024
1025 for id_str in &src_ids {
1026 let (v, slug) = id_str.split_once("--").expect("ids carry `<mem>--<slug>`");
1027 let id = crate::EntityId::new(v, slug);
1028 let s = source.get_entity(&id).expect("source entity present");
1029 let h = hydrated.get_entity(&id).expect("hydrated entity present");
1030 assert_eq!(s.title, h.title, "title differs for {id_str}");
1031 assert_eq!(s.entity_type, h.entity_type, "type differs for {id_str}");
1032 assert_eq!(s.metadata, h.metadata, "metadata differs for {id_str}");
1033 assert_eq!(s.sections, h.sections, "sections differ for {id_str}");
1034 assert_eq!(
1035 s.content_hash, h.content_hash,
1036 "content_hash differs for {id_str}",
1037 );
1038
1039 let mut src_edges: Vec<_> = source
1040 .store()
1041 .outgoing(&id)
1042 .iter()
1043 .map(|e| (e.rel_type.clone(), e.target.to_string()))
1044 .collect();
1045 let mut hyd_edges: Vec<_> = hydrated
1046 .store()
1047 .outgoing(&id)
1048 .iter()
1049 .map(|e| (e.rel_type.clone(), e.target.to_string()))
1050 .collect();
1051 src_edges.sort();
1052 hyd_edges.sort();
1053 assert_eq!(src_edges, hyd_edges, "edges differ for {id_str}");
1054 }
1055 }
1056
1057 fn round_trip(source: &Engine, mem: &str) -> Engine {
1061 let bytes = source.export_mem_to_bytes(mem).unwrap();
1062 extract_entries(&bytes, &ValidatorLimits::DEFAULT).unwrap();
1066 let hydrated = Engine::from_archive_bytes(bytes).unwrap();
1067 assert_state_equivalent(source, &hydrated, mem);
1068 hydrated
1069 }
1070
1071 #[test]
1072 fn fixture_sweep_round_trip_empty() {
1073 let tmp = TempDir::new().unwrap();
1074 let (engine, _mem) = folder_mem_with_entities(&tmp, &[]);
1075 let hydrated = round_trip(&engine, "specs");
1076 assert!(hydrated.store().is_empty());
1077 }
1078
1079 #[test]
1080 fn fixture_sweep_round_trip_single_entity() {
1081 let tmp = TempDir::new().unwrap();
1082 let (engine, _mem) = folder_mem_with_entities(&tmp, &["Solo"]);
1083 round_trip(&engine, "specs");
1084 }
1085
1086 #[test]
1087 fn fixture_sweep_round_trip_multi_entity_no_relations() {
1088 let tmp = TempDir::new().unwrap();
1089 let (engine, _mem) = folder_mem_with_entities(&tmp, &["A One", "A Two", "A Three"]);
1090 round_trip(&engine, "specs");
1091 }
1092
1093 #[test]
1094 fn fixture_sweep_round_trip_entity_with_metadata_and_sections() {
1095 let tmp = TempDir::new().unwrap();
1096 let mem_dir = tmp.path().join("specs");
1097 std::fs::create_dir_all(mem_dir.join(".memstead")).unwrap();
1098 std::fs::write(
1099 mem_dir.join(".memstead").join("config.json"),
1100 r#"{"format":1,"schema":"default@1.0.0","version":"1.0.0"}"#,
1101 )
1102 .unwrap();
1103 let writer = FilesystemMemWriter::new(mem_dir.clone());
1104 let mut engine = Engine::from_mounts(vec![(
1105 folder_mount("specs", mem_dir),
1106 Box::new(writer) as Box<dyn MemBackend>,
1107 )])
1108 .unwrap();
1109 let (actor, client) = cli_actor();
1110
1111 let mut sections = indexmap::IndexMap::new();
1112 sections.insert("identity".to_string(), "A rich body.".to_string());
1113 sections.insert(
1114 "purpose".to_string(),
1115 "To exercise the archive round-trip.".to_string(),
1116 );
1117 sections.insert(
1118 "rationale".to_string(),
1119 "Because the spec said so.".to_string(),
1120 );
1121
1122 let mut metadata: indexmap::IndexMap<String, String> = indexmap::IndexMap::new();
1123 metadata.insert("level".to_string(), "M0".to_string());
1124
1125 engine
1126 .create_entity(
1127 crate::engine::CreateEntityArgs {
1128 anchors: Vec::new(),
1129 mem: "specs".to_string(),
1130 title: "Rich".to_string(),
1131 entity_type: "spec".to_string(),
1132 sections,
1133 metadata,
1134 relations: Vec::new(),
1135 dry_run: false,
1136 },
1137 actor,
1138 Some(&client),
1139 None,
1140 )
1141 .unwrap();
1142 round_trip(&engine, "specs");
1143 }
1144
1145 #[test]
1146 fn fixture_sweep_round_trip_multi_entity_with_relations() {
1147 let tmp = TempDir::new().unwrap();
1148 let mem_dir = tmp.path().join("specs");
1149 std::fs::create_dir_all(mem_dir.join(".memstead")).unwrap();
1150 std::fs::write(
1151 mem_dir.join(".memstead").join("config.json"),
1152 r#"{"format":1,"schema":"default@1.0.0","version":"1.0.0"}"#,
1153 )
1154 .unwrap();
1155 let writer = FilesystemMemWriter::new(mem_dir.clone());
1156 let mut engine = Engine::from_mounts(vec![(
1157 folder_mount("specs", mem_dir),
1158 Box::new(writer) as Box<dyn MemBackend>,
1159 )])
1160 .unwrap();
1161 let (actor, client) = cli_actor();
1162 let src = engine
1163 .create_entity(
1164 empty_create_args("specs", "Source"),
1165 actor,
1166 Some(&client),
1167 None,
1168 )
1169 .unwrap();
1170 let mid = engine
1171 .create_entity(
1172 empty_create_args("specs", "Middle"),
1173 actor,
1174 Some(&client),
1175 None,
1176 )
1177 .unwrap();
1178 let tgt = engine
1179 .create_entity(
1180 empty_create_args("specs", "Target"),
1181 actor,
1182 Some(&client),
1183 None,
1184 )
1185 .unwrap();
1186 engine
1189 .relate_entity(
1190 crate::engine::RelateEntityArgs {
1191 source: src.id.clone(),
1192 expected_hash: Some(src.content_hash.clone()),
1193 rel_type: "USES".to_string(),
1194 target: mid.id.clone(),
1195 remove: false,
1196 description: None,
1197 },
1198 actor,
1199 Some(&client),
1200 None,
1201 )
1202 .unwrap();
1203 let src_after = engine
1204 .get_entity(&src.id)
1205 .expect("source must still resolve");
1206 engine
1207 .relate_entity(
1208 crate::engine::RelateEntityArgs {
1209 source: src.id.clone(),
1210 expected_hash: Some(src_after.content_hash.clone()),
1211 rel_type: "PART_OF".to_string(),
1212 target: tgt.id.clone(),
1213 remove: false,
1214 description: None,
1215 },
1216 actor,
1217 Some(&client),
1218 None,
1219 )
1220 .unwrap();
1221 round_trip(&engine, "specs");
1222 }
1223
1224 #[test]
1225 fn read_entity_path_works_against_byte_backed_archive() {
1226 let tmp = TempDir::new().unwrap();
1229 let (engine, _mem) = folder_mem_with_entities(&tmp, &["First", "Second"]);
1230 let bytes = engine.export_mem_to_bytes("specs").unwrap();
1231 let hydrated = Engine::from_archive_bytes(bytes).unwrap();
1232 let first = hydrated
1233 .get_entity(&crate::EntityId::new("specs", "first"))
1234 .expect("first must hydrate");
1235 assert_eq!(first.title, "First");
1236 let backend = ArchiveBackend::from_bytes(Vec::new());
1239 let _: Option<&Path> = backend.archive_path();
1240 }
1241}