1use std::path::PathBuf;
22use std::sync::Arc;
23
24use memstead_schema::Schema;
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#[allow(clippy::large_enum_variant)]
46#[derive(Debug, thiserror::Error)]
47pub enum FromArchiveBytesError {
48 #[error("archive validation: {0}")]
51 Validation(#[from] crate::validator::ValidationError),
52 #[error("invalid published config: {0}")]
56 InvalidConfig(String),
57 #[error(
62 "unsupported archive format {declared} — this engine accepts formats {accepted:?}; \
63 re-export the mem with a current engine or upgrade this one"
64 )]
65 UnsupportedFormat {
66 declared: u32,
68 accepted: &'static [u32],
70 },
71 #[error("embedded schema failed to load: {0}")]
74 EmbeddedSchemaInvalid(String),
75 #[error(transparent)]
78 Engine(#[from] EngineError),
79}
80
81impl Engine {
82 pub fn from_archive_bytes(bytes: Vec<u8>) -> Result<Self, FromArchiveBytesError> {
96 Self::from_archive_bytes_with_limits(bytes, &ValidatorLimits::DEFAULT)
97 }
98
99 pub fn from_archive_bytes_with_limits(
104 bytes: Vec<u8>,
105 limits: &ValidatorLimits,
106 ) -> Result<Self, FromArchiveBytesError> {
107 let entries = extract_entries(&bytes, limits)?;
108 let ArchiveEntries {
109 config_bytes,
110 schema_files,
111 ..
112 } = &entries;
113
114 let published: memstead_schema::PublishedMemConfig =
115 serde_json::from_slice(config_bytes)
116 .map_err(|e| FromArchiveBytesError::InvalidConfig(e.to_string()))?;
117
118 if !memstead_schema::published_format_accepted(published.format) {
123 return Err(FromArchiveBytesError::UnsupportedFormat {
124 declared: published.format,
125 accepted: memstead_schema::PUBLISHED_MEM_FORMATS_ACCEPTED,
126 });
127 }
128
129 let extra_schemas = load_embedded_schemas(schema_files)?;
130
131 let mount = Mount {
132 mem: published.name.clone(),
133 schema: Some(published.schema.clone()),
134 storage: MountStorage::Archive {
135 path: PathBuf::new(),
136 },
137 capability: MountCapability::ReadOnly,
138 lifecycle: MountLifecycle::Eager,
139 cross_linkable: false,
140 migration_target: None,
141 };
142 let backend: Box<dyn MemBackend> = Box::new(ArchiveBackend::from_bytes(bytes));
143
144 let engine = Self::from_mounts_inner(vec![(mount, backend)], extra_schemas, Vec::new())?;
145 Ok(engine)
146 }
147
148 pub fn export_mem_to_bytes(&self, mem_name: &str) -> Result<Vec<u8>, EngineError> {
167 let mount = self
168 .mounts
169 .iter()
170 .find(|m| m.mount.mem == mem_name)
171 .ok_or_else(|| self.unknown_mem_error(mem_name))?;
172 let config = self.mem_config_for(mem_name).ok_or_else(|| {
173 EngineError::InvalidInput(format!(
174 "mem '{mem_name}' has no loaded MemConfig — cannot export"
175 ))
176 })?;
177 if config.version.is_none() {
178 return Err(EngineError::MemConfigIncomplete {
179 mem: mem_name.to_string(),
180 missing_fields: vec!["version".to_string()],
181 });
182 }
183 let workspace_root = self.workspace_root.as_deref();
184 let fixed_schemas_dir = workspace_root.map(|r| r.join(".memstead").join("schemas"));
186 let workspace_schemas_dir = fixed_schemas_dir.as_deref();
187 match &mount.mount.storage {
188 MountStorage::Folder { path } => crate::ops::export::export_mem_to_bytes(
189 path,
190 config,
191 workspace_root,
192 workspace_schemas_dir,
193 mem_name,
194 self.ref_schema_source_for(config),
195 )
196 .map(|out| out.bytes)
197 .map_err(|e| {
198 EngineError::Backend(crate::backend::BackendError::Other(format!(
199 "export_mem_to_bytes: {e}"
200 )))
201 }),
202 MountStorage::Archive { .. } => {
203 Err(EngineError::Backend(crate::backend::BackendError::Sealed))
204 }
205 MountStorage::GitBranch { gitdir, branch } => {
206 let hook = self.git_branch_ops.as_ref().ok_or_else(|| {
207 EngineError::Backend(crate::backend::BackendError::Other(
208 "git-branch export hook not installed (full flavour not loaded)"
209 .to_string(),
210 ))
211 })?;
212 let provenance_bytes = mount
217 .backend
218 .read_provenance(None)
219 .ok()
220 .and_then(|records| crate::ops::export::build_archive_provenance(&records))
221 .and_then(|prov| prov.to_archive_bytes().ok());
222 let anchors_bytes = mount.backend.read_anchors_sidecar().ok().flatten();
225 (hook.export_to_bytes)(
226 gitdir,
227 branch,
228 mem_name,
229 config,
230 workspace_root,
231 workspace_schemas_dir,
232 provenance_bytes.as_deref(),
233 anchors_bytes.as_deref(),
234 )
235 .map(|out| out.bytes)
236 .map_err(EngineError::Backend)
237 }
238 MountStorage::InMemory => {
244 let backend = mount.backend.as_ref();
245 let rels = backend.list_entities().map_err(EngineError::Backend)?;
246 let mut md_entries: Vec<(std::path::PathBuf, Vec<u8>)> =
247 Vec::with_capacity(rels.len());
248 for rel in rels {
249 if let Some(bytes) = backend.read_entity(&rel).map_err(EngineError::Backend)? {
250 md_entries.push((rel, bytes));
251 }
252 }
253 let provenance = backend
257 .read_provenance(None)
258 .ok()
259 .and_then(|records| crate::ops::export::build_archive_provenance(&records));
260 let anchors_bytes = backend
264 .read_anchors_sidecar()
265 .map_err(EngineError::Backend)?;
266 crate::ops::export::export_entries_to_bytes(
267 config,
268 workspace_root,
269 workspace_schemas_dir,
270 mem_name,
271 md_entries,
272 provenance.as_ref(),
273 anchors_bytes.as_deref(),
274 self.ref_schema_source_for(config),
275 )
276 .map(|out| out.bytes)
277 .map_err(|e| {
278 EngineError::Backend(crate::backend::BackendError::Other(format!(
279 "export_mem_to_bytes: {e}"
280 )))
281 })
282 }
283 }
284 }
285}
286
287pub(crate) fn load_embedded_schemas(
293 schema_files: &[SchemaFile],
294) -> Result<Vec<Arc<Schema>>, FromArchiveBytesError> {
295 if schema_files.is_empty() {
296 return Ok(Vec::new());
297 }
298 let mut manifest: Option<&str> = None;
299 let mut types: Vec<(String, String)> = Vec::new();
300 for sf in schema_files {
301 if sf.archive_path == ".memstead/schema/schema.yaml" {
302 manifest = Some(&sf.content);
303 } else if let Some(rest) = sf.archive_path.strip_prefix(".memstead/schema/types/")
304 && let Some(stem) = rest.strip_suffix(".yaml")
305 {
306 types.push((stem.to_string(), sf.content.clone()));
307 }
308 }
309 let Some(manifest_yaml) = manifest else {
310 return Err(FromArchiveBytesError::EmbeddedSchemaInvalid(
311 "embedded schema package present but `.memstead/schema/schema.yaml` missing"
312 .to_string(),
313 ));
314 };
315 let marker_path = format!(
318 ".memstead/schema/{}",
319 memstead_schema::loader::SCHEMA_FORMAT_MARKER_FILE
320 );
321 let format = if schema_files.iter().any(|sf| sf.archive_path == marker_path) {
322 memstead_schema::MetadataPolarityFormat::RequiredOptIn
323 } else {
324 memstead_schema::MetadataPolarityFormat::Legacy
325 };
326 let schema =
327 memstead_schema::load_schema_from_memory_with_format(manifest_yaml, &types, format)
328 .map_err(|e| FromArchiveBytesError::EmbeddedSchemaInvalid(e.to_string()))?;
329 Ok(vec![Arc::new(schema)])
330}
331
332#[cfg(test)]
333mod tests {
334 use super::*;
335 use std::path::Path;
336 use tempfile::TempDir;
337
338 use crate::backend::{BackendError, MemBackend};
339 use crate::engine::test_helpers::{cli_actor, empty_create_args, folder_mount};
340 use crate::storage::FilesystemMemWriter;
341 use crate::workspace::{Mount, MountCapability, MountLifecycle, MountStorage};
342
343 fn folder_mem_with_entities(tmp: &TempDir, titles: &[&str]) -> (Engine, std::path::PathBuf) {
346 let mem_dir = tmp.path().join("specs");
347 std::fs::create_dir_all(mem_dir.join(".memstead")).unwrap();
348 let config_body = r#"{
349 "format": 1,
350 "schema": "default@1.0.0",
351 "version": "1.0.0"
352 }"#;
353 std::fs::write(mem_dir.join(".memstead").join("config.json"), config_body).unwrap();
354
355 let writer = FilesystemMemWriter::new(mem_dir.clone());
356 let mut engine = Engine::from_mounts(vec![(
357 folder_mount("specs", mem_dir.clone()),
358 Box::new(writer) as Box<dyn MemBackend>,
359 )])
360 .unwrap();
361 let (actor, client) = cli_actor();
362 for t in titles {
363 engine
364 .create_entity(empty_create_args("specs", t), actor, Some(&client), None)
365 .unwrap();
366 }
367 (engine, mem_dir)
368 }
369
370 #[test]
371 fn export_to_bytes_produces_bytes_that_extract_cleanly() {
372 let tmp = TempDir::new().unwrap();
373 let (engine, _mem) = folder_mem_with_entities(&tmp, &["Alpha", "Beta"]);
374 let bytes = engine.export_mem_to_bytes("specs").unwrap();
375 assert!(!bytes.is_empty(), "export bytes must be non-empty");
376 let entries = extract_entries(&bytes, &ValidatorLimits::DEFAULT).unwrap();
379 assert_eq!(entries.markdown_files.len(), 2);
380 let mut names: Vec<_> = entries
381 .markdown_files
382 .iter()
383 .map(|m| m.path.clone())
384 .collect();
385 names.sort();
386 assert_eq!(names, vec!["alpha.md".to_string(), "beta.md".to_string()]);
387 }
388
389 #[test]
394 fn export_round_trips_title_and_subject() {
395 let tmp = TempDir::new().unwrap();
396 let mem_dir = tmp.path().join("specs");
397 std::fs::create_dir_all(mem_dir.join(".memstead")).unwrap();
398 std::fs::write(
399 mem_dir.join(".memstead").join("config.json"),
400 serde_json::json!({
401 "format": 1,
402 "schema": "default@1.0.0",
403 "version": "1.0.0",
404 "title": "Einrichtungsbezogene Impfpflicht Deutschland",
405 "subject": {
406 "scope": "Die einrichtungsbezogene Impfpflicht — Rechtslage und Vollzug",
407 "method": "Primärquellen, händisch geprüft",
408 "exclusions": ["Länderverordnungen nach 2023", "Presseberichte", "Άλλα θέματα"],
409 },
410 })
411 .to_string(),
412 )
413 .unwrap();
414 let writer = FilesystemMemWriter::new(mem_dir.clone());
415 let mut engine = Engine::from_mounts(vec![(
416 folder_mount("specs", mem_dir.clone()),
417 Box::new(writer) as Box<dyn MemBackend>,
418 )])
419 .unwrap();
420 let (actor, client) = cli_actor();
421 engine
422 .create_entity(
423 empty_create_args("specs", "Alpha"),
424 actor,
425 Some(&client),
426 None,
427 )
428 .unwrap();
429
430 let bytes = engine.export_mem_to_bytes("specs").unwrap();
431 let validated =
433 crate::validator::validate_and_normalize_archive(&bytes).expect("archive re-validates");
434 let cfg = &validated.config;
435 assert_eq!(cfg.format, memstead_schema::PUBLISHED_MEM_FORMAT);
436 assert_eq!(
437 cfg.title.as_deref(),
438 Some("Einrichtungsbezogene Impfpflicht Deutschland")
439 );
440 let subject = cfg.subject.as_ref().expect("subject rides the archive");
441 assert_eq!(
442 subject.scope,
443 "Die einrichtungsbezogene Impfpflicht — Rechtslage und Vollzug"
444 );
445 assert_eq!(
446 subject.method.as_deref(),
447 Some("Primärquellen, händisch geprüft")
448 );
449 assert_eq!(
450 subject.exclusions,
451 vec![
452 "Länderverordnungen nach 2023",
453 "Presseberichte",
454 "Άλλα θέματα"
455 ],
456 "exclusions preserved in order, non-ASCII intact"
457 );
458
459 std::fs::write(
461 mem_dir.join(".memstead").join("config.json"),
462 serde_json::json!({
463 "format": 1,
464 "schema": "default@1.0.0",
465 "version": "1.0.0",
466 "subject": { "scope": "Nur der Rahmen", "exclusions": [] },
467 })
468 .to_string(),
469 )
470 .unwrap();
471 engine.reload_each_writable_mem().unwrap();
472 let bytes = engine.export_mem_to_bytes("specs").unwrap();
473 let validated =
474 crate::validator::validate_and_normalize_archive(&bytes).expect("re-validates");
475 let subject = validated.config.subject.as_ref().expect("subject present");
476 assert_eq!(subject.scope, "Nur der Rahmen");
477 assert_eq!(subject.method, None);
478 assert!(subject.exclusions.is_empty());
479 assert_eq!(validated.config.title, None, "unset title stays unset");
480 }
481
482 #[test]
488 fn export_carries_provenance_that_install_reads_back() {
489 let tmp = TempDir::new().unwrap();
490 let mem_dir = tmp.path().join("specs");
491 std::fs::create_dir_all(mem_dir.join(".memstead")).unwrap();
492 std::fs::write(
493 mem_dir.join(".memstead").join("config.json"),
494 r#"{"format":1,"schema":"default@1.0.0","version":"1.0.0"}"#,
495 )
496 .unwrap();
497 let writer = FilesystemMemWriter::new(mem_dir.clone());
498 let mut engine = Engine::from_mounts(vec![(
499 folder_mount("specs", mem_dir.clone()),
500 Box::new(writer) as Box<dyn MemBackend>,
501 )])
502 .unwrap();
503 let (actor, client) = cli_actor();
504 engine
506 .create_entity(
507 empty_create_args("specs", "Alpha"),
508 actor,
509 Some(&client),
510 Some("why alpha exists"),
511 )
512 .unwrap();
513 engine
514 .create_entity(
515 empty_create_args("specs", "Beta"),
516 actor,
517 Some(&client),
518 None,
519 )
520 .unwrap();
521
522 let bytes = engine.export_mem_to_bytes("specs").unwrap();
523 let entries = extract_entries(&bytes, &ValidatorLimits::DEFAULT).unwrap();
525 assert!(
526 entries.provenance_bytes.is_some(),
527 "export must embed the provenance payload"
528 );
529
530 let validated =
534 crate::validator::validate_and_normalize_archive(&bytes).expect("archive re-validates");
535 let canonical_entries =
536 extract_entries(&validated.canonical_bytes, &ValidatorLimits::DEFAULT).unwrap();
537 assert!(
538 canonical_entries.provenance_bytes.is_some(),
539 "normalize must preserve provenance through the canonical re-pack (publish store path)"
540 );
541
542 let installed = Engine::from_archive_bytes(bytes).unwrap();
544 let prov = installed
545 .archive_provenance_for("specs")
546 .expect("installed mem exposes provenance");
547 assert_eq!(
548 prov.entity("alpha").and_then(|r| r.rationale.as_deref()),
549 Some("why alpha exists"),
550 "noted entity's rationale matches the source"
551 );
552 assert_eq!(
553 prov.entity("alpha").and_then(|r| r.kind.as_deref()),
554 Some("create"),
555 );
556 assert!(
557 prov.entity("beta").is_none(),
558 "entity authored without a note is absent — no fabricated provenance"
559 );
560 }
561
562 fn inject_zip_member(archive: &[u8], name: &str, content: &[u8]) -> Vec<u8> {
568 use std::io::{Read, Write};
569 let mut src = zip::ZipArchive::new(std::io::Cursor::new(archive)).unwrap();
570 let mut out = Vec::new();
571 {
572 let mut w = zip::ZipWriter::new(std::io::Cursor::new(&mut out));
573 let opts = zip::write::SimpleFileOptions::default()
574 .compression_method(zip::CompressionMethod::Deflated);
575 for i in 0..src.len() {
576 let mut f = src.by_index(i).unwrap();
577 let fname = f.name().to_string();
578 let mut buf = Vec::new();
579 f.read_to_end(&mut buf).unwrap();
580 w.start_file(fname, opts).unwrap();
581 w.write_all(&buf).unwrap();
582 }
583 w.start_file(name, opts).unwrap();
584 w.write_all(content).unwrap();
585 w.finish().unwrap();
586 }
587 out
588 }
589
590 fn rewrite_zip_member(archive: &[u8], name: &str, content: &[u8]) -> Vec<u8> {
593 use std::io::{Read, Write};
594 let mut src = zip::ZipArchive::new(std::io::Cursor::new(archive)).unwrap();
595 let mut out = Vec::new();
596 {
597 let mut w = zip::ZipWriter::new(std::io::Cursor::new(&mut out));
598 let opts = zip::write::SimpleFileOptions::default()
599 .compression_method(zip::CompressionMethod::Deflated);
600 for i in 0..src.len() {
601 let mut f = src.by_index(i).unwrap();
602 let fname = f.name().to_string();
603 let mut buf = Vec::new();
604 f.read_to_end(&mut buf).unwrap();
605 w.start_file(fname.clone(), opts).unwrap();
606 if fname == name {
607 w.write_all(content).unwrap();
608 } else {
609 w.write_all(&buf).unwrap();
610 }
611 }
612 w.finish().unwrap();
613 }
614 out
615 }
616
617 #[test]
623 fn byte_hydration_refuses_unknown_archive_format() {
624 let tmp = TempDir::new().unwrap();
625 let (mut engine, _dir) = folder_mem_with_entities(&tmp, &["Alpha"]);
626 let _ = &mut engine;
627 let exported = engine.export_mem_to_bytes("specs").unwrap();
628
629 let ok = Engine::from_archive_bytes(exported.clone()).expect("valid archive hydrates");
631 assert!(
632 ok.get_entity(&crate::EntityId("specs--alpha".into()))
633 .is_some()
634 );
635
636 let entries = extract_entries(&exported, &ValidatorLimits::DEFAULT).unwrap();
638 let mut cfg: serde_json::Value = serde_json::from_slice(&entries.config_bytes).unwrap();
639 cfg["format"] = serde_json::json!(99);
640 let tampered = rewrite_zip_member(
641 &exported,
642 ".memstead/config.json",
643 serde_json::to_string(&cfg).unwrap().as_bytes(),
644 );
645
646 let err =
647 Engine::from_archive_bytes(tampered).expect_err("format 99 must refuse, never hydrate");
648 match err {
649 FromArchiveBytesError::UnsupportedFormat { declared, accepted } => {
650 assert_eq!(declared, 99);
651 assert!(!accepted.is_empty());
652 }
653 other => panic!("expected UnsupportedFormat, got {other:?}"),
654 }
655 }
656
657 #[test]
662 fn anchors_member_survives_canonical_repack_and_install() {
663 let tmp = TempDir::new().unwrap();
664 let (mut engine, _dir) = folder_mem_with_entities(&tmp, &["Alpha"]);
665 let _ = &mut engine;
666 let exported = engine.export_mem_to_bytes("specs").unwrap();
667
668 let anchors = br#"{"version":1,"entities":{"specs--alpha":[{"artifact":"src/lib.rs","grain":"file","class":"anchored","hash_stability":"stable","hash":"h1"}]}}"#;
669 let with_anchors = inject_zip_member(&exported, ".memstead/anchors.json", anchors);
670
671 let entries = extract_entries(&with_anchors, &ValidatorLimits::DEFAULT).unwrap();
673 assert_eq!(entries.anchors_bytes.as_deref(), Some(&anchors[..]));
674
675 let validated = crate::validator::validate_and_normalize_archive(&with_anchors)
677 .expect("archive with anchors re-validates");
678 let canonical =
679 extract_entries(&validated.canonical_bytes, &ValidatorLimits::DEFAULT).unwrap();
680 assert_eq!(
681 canonical.anchors_bytes.as_deref(),
682 Some(&anchors[..]),
683 "normalize must preserve the anchors member through the canonical re-pack"
684 );
685
686 let installed = Engine::from_archive_bytes(validated.canonical_bytes).unwrap();
688 let ids = installed.entity_anchors(&crate::EntityId::new("specs", "alpha"));
689 assert_eq!(ids.len(), 1);
690 assert_eq!(ids[0].artifact, "src/lib.rs");
691 }
692
693 #[test]
699 fn export_embeds_anchors_that_install_reads_back() {
700 let tmp = TempDir::new().unwrap();
701 let mem_dir = tmp.path().join("specs");
702 std::fs::create_dir_all(mem_dir.join(".memstead")).unwrap();
703 std::fs::write(
704 mem_dir.join(".memstead").join("config.json"),
705 r#"{"format":1,"schema":"default@1.0.0","version":"1.0.0"}"#,
706 )
707 .unwrap();
708 let writer = FilesystemMemWriter::new(mem_dir.clone());
709 let mut engine = Engine::from_mounts(vec![(
710 folder_mount("specs", mem_dir.clone()),
711 Box::new(writer) as Box<dyn MemBackend>,
712 )])
713 .unwrap();
714 let (actor, client) = cli_actor();
715
716 let mut alpha = empty_create_args("specs", "Alpha");
718 alpha.anchors = vec![crate::anchor::AnchorInput {
719 artifact: Some("src/lib.rs".to_string()),
720 grain: Some("file".to_string()),
721 class: Some("anchored".to_string()),
722 hash: Some("h1".to_string()),
723 hash_stability: Some("stable".to_string()),
724 ..Default::default()
725 }];
726 engine
727 .create_entity(alpha, actor, Some(&client), None)
728 .unwrap();
729 engine
730 .create_entity(
731 empty_create_args("specs", "Beta"),
732 actor,
733 Some(&client),
734 None,
735 )
736 .unwrap();
737
738 let bytes = engine.export_mem_to_bytes("specs").unwrap();
741 let entries = extract_entries(&bytes, &ValidatorLimits::DEFAULT).unwrap();
742 assert!(
743 entries.anchors_bytes.is_some(),
744 "export must embed the anchors sidecar when the mem has anchors"
745 );
746
747 let validated =
749 crate::validator::validate_and_normalize_archive(&bytes).expect("archive re-validates");
750 let canonical =
751 extract_entries(&validated.canonical_bytes, &ValidatorLimits::DEFAULT).unwrap();
752 assert!(
753 canonical.anchors_bytes.is_some(),
754 "normalize must preserve the exported anchors member"
755 );
756
757 let installed = Engine::from_archive_bytes(validated.canonical_bytes).unwrap();
759 let alpha_anchors = installed.entity_anchors(&crate::EntityId::new("specs", "alpha"));
760 assert_eq!(alpha_anchors.len(), 1);
761 assert_eq!(alpha_anchors[0].artifact, "src/lib.rs");
762 assert_eq!(alpha_anchors[0].hash.as_deref(), Some("h1"));
763 assert!(
765 installed
766 .entity_anchors(&crate::EntityId::new("specs", "beta"))
767 .is_empty(),
768 "an entity with no anchors exposes none after install"
769 );
770 }
771
772 #[test]
781 fn redacted_archive_survives_repack_and_install() {
782 use crate::filesystem::publish::redact_archive_anchors;
783
784 let tmp = TempDir::new().unwrap();
785 let mem_dir = tmp.path().join("specs");
786 std::fs::create_dir_all(mem_dir.join(".memstead")).unwrap();
787 std::fs::write(
788 mem_dir.join(".memstead").join("config.json"),
789 r#"{"format":1,"schema":"default@1.0.0","version":"1.0.0"}"#,
790 )
791 .unwrap();
792 let writer = FilesystemMemWriter::new(mem_dir.clone());
793 let mut engine = Engine::from_mounts(vec![(
794 folder_mount("specs", mem_dir.clone()),
795 Box::new(writer) as Box<dyn MemBackend>,
796 )])
797 .unwrap();
798 let (actor, client) = cli_actor();
799 let mut alpha = empty_create_args("specs", "Alpha");
800 alpha.anchors = vec![crate::anchor::AnchorInput {
801 artifact: Some("src/secret/module.rs".to_string()),
802 grain: Some("file".to_string()),
803 class: Some("anchored".to_string()),
804 hash: Some("h1".to_string()),
805 hash_stability: Some("stable".to_string()),
806 ..Default::default()
807 }];
808 engine
809 .create_entity(alpha, actor, Some(&client), None)
810 .unwrap();
811
812 let bytes = engine.export_mem_to_bytes("specs").unwrap();
813 let redacted = redact_archive_anchors(&bytes).unwrap();
814 assert_ne!(redacted, bytes, "redaction changes the anchors member");
815
816 let validated = crate::validator::validate_and_normalize_archive(&redacted)
818 .expect("redacted archive validates");
819 let canonical =
820 extract_entries(&validated.canonical_bytes, &ValidatorLimits::DEFAULT).unwrap();
821 let sidecar = crate::anchor::AnchorSidecar::from_bytes(
822 canonical.anchors_bytes.as_deref().expect("member survives"),
823 )
824 .unwrap();
825 let anchors = sidecar.get("specs--alpha");
826 assert_eq!(anchors.len(), 1, "anchor count unchanged");
827 assert_eq!(
828 anchors[0].artifact,
829 crate::anchor::REDACTED_ARTIFACT_SENTINEL
830 );
831 assert_eq!(
832 anchors[0].hash.as_deref(),
833 Some("h1"),
834 "trust metadata kept"
835 );
836
837 let installed = Engine::from_archive_bytes(validated.canonical_bytes).unwrap();
839 let read_back = installed.entity_anchors(&crate::EntityId::new("specs", "alpha"));
840 assert_eq!(read_back.len(), 1);
841 assert_eq!(
842 read_back[0].artifact,
843 crate::anchor::REDACTED_ARTIFACT_SENTINEL
844 );
845 assert!(
846 !String::from_utf8_lossy(&redacted).contains("src/secret/module.rs"),
847 "the artifact path must not survive anywhere in the package"
848 );
849
850 let local =
854 std::fs::read_to_string(mem_dir.join(".memstead").join("anchors.json")).unwrap();
855 assert!(
856 local.contains("src/secret/module.rs"),
857 "local sidecar bytes untouched by a redacted publish"
858 );
859 assert_eq!(
860 engine.entity_anchors(&crate::EntityId::new("specs", "alpha"))[0].artifact,
861 "src/secret/module.rs"
862 );
863
864 let mem_dir2 = tmp.path().join("plain");
866 std::fs::create_dir_all(mem_dir2.join(".memstead")).unwrap();
867 std::fs::write(
868 mem_dir2.join(".memstead").join("config.json"),
869 r#"{"format":1,"schema":"default@1.0.0","version":"1.0.0"}"#,
870 )
871 .unwrap();
872 let writer2 = FilesystemMemWriter::new(mem_dir2.clone());
873 let mut engine2 = Engine::from_mounts(vec![(
874 folder_mount("plain", mem_dir2.clone()),
875 Box::new(writer2) as Box<dyn MemBackend>,
876 )])
877 .unwrap();
878 engine2
879 .create_entity(
880 empty_create_args("plain", "Only"),
881 actor,
882 Some(&client),
883 None,
884 )
885 .unwrap();
886 let plain_bytes = engine2.export_mem_to_bytes("plain").unwrap();
887 assert_eq!(
888 redact_archive_anchors(&plain_bytes).unwrap(),
889 plain_bytes,
890 "no anchors member ⇒ byte-identical passthrough"
891 );
892 }
893
894 #[test]
899 fn in_memory_mem_export_round_trips_anchors() {
900 use crate::storage::InMemoryBackend;
901 let backend = InMemoryBackend::new();
904 backend
905 .write_mem_config(br#"{"version":"0.1.0","schema":"default@1.0.0"}"#)
906 .unwrap();
907 let mount = Mount {
908 mem: "sketch".to_string(),
909 schema: Some("default@1.0.0".parse().unwrap()),
910 storage: MountStorage::InMemory,
911 capability: MountCapability::Write,
912 lifecycle: MountLifecycle::Eager,
913 cross_linkable: false,
914 migration_target: None,
915 };
916 let mut engine =
917 Engine::from_mounts(vec![(mount, Box::new(backend) as Box<dyn MemBackend>)]).unwrap();
918 let (actor, client) = cli_actor();
919
920 let mut args = empty_create_args("sketch", "Idea");
921 args.anchors = vec![crate::anchor::AnchorInput {
922 artifact: Some("notes/idea.md".to_string()),
923 grain: Some("file".to_string()),
924 class: Some("informed-by".to_string()),
925 ..Default::default()
926 }];
927 engine
928 .create_entity(args, actor, Some(&client), None)
929 .unwrap();
930
931 let bytes = engine.export_mem_to_bytes("sketch").unwrap();
932 let entries = extract_entries(&bytes, &ValidatorLimits::DEFAULT).unwrap();
933 assert!(
934 entries.anchors_bytes.is_some(),
935 "in-memory session export must carry the anchors sidecar"
936 );
937
938 let reimported = Engine::from_archive_bytes(bytes).unwrap();
939 let anchors = reimported.entity_anchors(&crate::EntityId::new("sketch", "idea"));
940 assert_eq!(anchors.len(), 1);
941 assert_eq!(anchors[0].artifact, "notes/idea.md");
942 assert_eq!(
943 anchors[0].class,
944 crate::anchor::AnchorProvenanceClass::InformedBy
945 );
946 }
947
948 #[test]
955 fn provenance_bearing_archive_stays_within_publish_budget() {
956 const PUBLISH_BODY_LIMIT: usize = 2 * 1024 * 1024;
957 let tmp = TempDir::new().unwrap();
958 let mem_dir = tmp.path().join("specs");
959 std::fs::create_dir_all(mem_dir.join(".memstead")).unwrap();
960 std::fs::write(
961 mem_dir.join(".memstead").join("config.json"),
962 r#"{"format":1,"schema":"default@1.0.0","version":"1.0.0"}"#,
963 )
964 .unwrap();
965 let writer = FilesystemMemWriter::new(mem_dir.clone());
966 let mut engine = Engine::from_mounts(vec![(
967 folder_mount("specs", mem_dir.clone()),
968 Box::new(writer) as Box<dyn MemBackend>,
969 )])
970 .unwrap();
971 let (actor, client) = cli_actor();
972 let note = "x".repeat(280);
975 for i in 0..60 {
976 engine
977 .create_entity(
978 empty_create_args("specs", &format!("Entity {i}")),
979 actor,
980 Some(&client),
981 Some(¬e),
982 )
983 .unwrap();
984 }
985 let bytes = engine.export_mem_to_bytes("specs").unwrap();
986 assert!(
987 bytes.len() < PUBLISH_BODY_LIMIT,
988 "archive ({} B) must stay under the 2 MB publish limit",
989 bytes.len()
990 );
991 let entries = extract_entries(&bytes, &ValidatorLimits::DEFAULT).unwrap();
992 let prov = entries.provenance_bytes.expect("provenance present");
993 assert!(
996 prov.len() < PUBLISH_BODY_LIMIT / 4,
997 "provenance payload ({} B) is a small fraction of the budget",
998 prov.len()
999 );
1000 let parsed = memstead_schema::ArchiveProvenance::from_archive_bytes(&prov).unwrap();
1001 assert_eq!(
1002 parsed.entities.len(),
1003 60,
1004 "every noted entity has provenance"
1005 );
1006 }
1007
1008 #[test]
1016 fn export_warns_on_cross_mem_edge_that_install_refuses() {
1017 let tmp = TempDir::new().unwrap();
1018 let (engine, mem_dir) = folder_mem_with_entities(&tmp, &[]);
1019 let md = "\
1023---
1024type: spec
1025created_date: 2026-01-15
1026last_modified: 2026-01-15
1027level: M0
1028---
1029# Broker
1030
1031## Identity
1032
1033A
1034
1035## Purpose
1036
1037B
1038
1039## Specifies
1040
1041C
1042
1043## Constraints
1044
1045D
1046
1047## Rationale
1048
1049E
1050
1051## Relationships
1052
1053- **USES**: [[other--thing]]
1054";
1055 std::fs::write(mem_dir.join("broker.md"), md).unwrap();
1056
1057 let out = tmp.path().join("specs.mem");
1060 let result = engine.export_mem("specs", &out).unwrap();
1061 assert!(out.is_file(), "archive must still be produced");
1062 assert_eq!(
1063 result.dangling_cross_mem_edges.len(),
1064 1,
1065 "export must surface the cross-mem edge: {:?}",
1066 result.dangling_cross_mem_edges
1067 );
1068 let edge = &result.dangling_cross_mem_edges[0];
1069 assert_eq!(edge.entity_path, "broker.md");
1070 assert_eq!(edge.target_id, "other--thing");
1071 assert_eq!(edge.target_mem, "other");
1072
1073 let bytes = std::fs::read(&out).unwrap();
1076 let err = crate::validator::validate_and_normalize_archive(&bytes).unwrap_err();
1077 assert!(
1078 matches!(
1079 err,
1080 crate::validator::ValidationError::CrossMemRelationship { .. }
1081 ),
1082 "install-side strict validation must refuse the same edge: {err:?}",
1083 );
1084 }
1085
1086 #[test]
1092 fn self_contained_repack_drops_cross_mem_rows_and_passes_install_validation() {
1093 let tmp = TempDir::new().unwrap();
1094 let (engine, mem_dir) = folder_mem_with_entities(&tmp, &["Alpha"]);
1095 let md = "\
1096---
1097type: spec
1098created_date: 2026-01-15
1099last_modified: 2026-01-15
1100level: M0
1101---
1102# Broker
1103
1104## Identity
1105
1106Talks to [[other:thing]] over the wire.
1107
1108## Purpose
1109
1110B
1111
1112## Specifies
1113
1114C
1115
1116## Constraints
1117
1118D
1119
1120## Rationale
1121
1122E
1123
1124## Relationships
1125
1126- **USES**: [[other--thing]]
1127- **REFERENCES**: [[alpha]]
1128";
1129 std::fs::write(mem_dir.join("broker.md"), md).unwrap();
1130 let out = tmp.path().join("specs.mem");
1131 engine.export_mem("specs", &out).unwrap();
1132 let bytes = std::fs::read(&out).unwrap();
1133 assert!(
1134 crate::validator::validate_and_normalize_archive(&bytes).is_err(),
1135 "precondition: the raw export is refused"
1136 );
1137
1138 let sealed = crate::validator::make_archive_self_contained(&bytes).unwrap();
1139 assert_eq!(sealed.dropped.len(), 1, "{:?}", sealed.dropped);
1140 assert_eq!(sealed.dropped[0].entity_path, "broker.md");
1141 assert_eq!(sealed.dropped[0].target_id, "other--thing");
1142 assert_eq!(sealed.dropped[0].target_mem, "other");
1143 let validated = crate::validator::validate_and_normalize_archive(&sealed.bytes)
1145 .expect("the self-contained archive passes strict validation");
1146 assert!(validated.dangling_cross_mem_edges.is_empty());
1147 let broker = validated
1148 .entities
1149 .iter()
1150 .find(|e| e.id.as_ref() == "specs--broker")
1151 .expect("broker travels");
1152 assert_eq!(
1153 broker
1154 .relationships
1155 .iter()
1156 .map(|r| r.target.as_ref())
1157 .collect::<Vec<_>>(),
1158 vec!["specs--alpha"],
1159 "only the same-mem row survives"
1160 );
1161 assert!(
1162 broker
1163 .sections
1164 .values()
1165 .any(|b| b.contains("[[other:thing]]")),
1166 "body wiki-links are never touched"
1167 );
1168 let hydrated = Engine::from_archive_bytes(sealed.bytes).unwrap();
1169 assert_eq!(
1170 hydrated.store().all_entities().filter(|e| !e.stub).count(),
1171 2
1172 );
1173
1174 std::fs::remove_file(mem_dir.join("broker.md")).unwrap();
1176 let clean_engine = Engine::from_mounts(vec![(
1177 folder_mount("specs", mem_dir.clone()),
1178 Box::new(FilesystemMemWriter::new(mem_dir.clone())) as Box<dyn MemBackend>,
1179 )])
1180 .unwrap();
1181 let clean_out = tmp.path().join("clean.mem");
1182 clean_engine.export_mem("specs", &clean_out).unwrap();
1183 let clean =
1184 crate::validator::make_archive_self_contained(&std::fs::read(&clean_out).unwrap())
1185 .unwrap();
1186 assert!(clean.dropped.is_empty());
1187 assert!(crate::validator::validate_and_normalize_archive(&clean.bytes).is_ok());
1188 }
1189
1190 #[test]
1193 fn export_self_contained_mem_warns_nothing() {
1194 let tmp = TempDir::new().unwrap();
1195 let (engine, _mem) = folder_mem_with_entities(&tmp, &["Alpha", "Beta"]);
1196 let out = tmp.path().join("specs.mem");
1197 let result = engine.export_mem("specs", &out).unwrap();
1198 assert!(
1199 result.dangling_cross_mem_edges.is_empty(),
1200 "self-contained export must warn nothing: {:?}",
1201 result.dangling_cross_mem_edges
1202 );
1203 }
1204
1205 #[test]
1206 fn export_empty_mem_produces_valid_hydratable_archive() {
1207 let tmp = TempDir::new().unwrap();
1208 let (engine, _mem) = folder_mem_with_entities(&tmp, &[]);
1209 let bytes = engine.export_mem_to_bytes("specs").unwrap();
1210 let entries = extract_entries(&bytes, &ValidatorLimits::DEFAULT).unwrap();
1212 assert!(entries.markdown_files.is_empty());
1213 let hydrated = Engine::from_archive_bytes(bytes).unwrap();
1215 assert_eq!(hydrated.mem_names(), vec!["specs"]);
1216 assert!(hydrated.store().is_empty());
1217 }
1218
1219 #[test]
1220 fn export_unknown_mem_returns_unknown_mem_error() {
1221 let tmp = TempDir::new().unwrap();
1222 let (engine, _mem) = folder_mem_with_entities(&tmp, &[]);
1223 let err = engine.export_mem_to_bytes("missing").unwrap_err();
1224 match err {
1225 EngineError::UnknownMem(v) => assert_eq!(v, "missing"),
1226 other => panic!("expected UnknownMem, got {other:?}"),
1227 }
1228 }
1229
1230 #[test]
1231 fn export_archive_backend_returns_sealed() {
1232 let tmp = TempDir::new().unwrap();
1237 let (engine, _mem) = folder_mem_with_entities(&tmp, &["Alpha"]);
1238 let bytes = engine.export_mem_to_bytes("specs").unwrap();
1239
1240 let archive_path = tmp.path().join("ext.mem");
1241 std::fs::write(&archive_path, &bytes).unwrap();
1242 let archive_engine = Engine::from_mounts(vec![(
1243 Mount {
1244 mem: "ext".to_string(),
1245 schema: Some(memstead_schema::SchemaRef::new(
1246 "default",
1247 semver::Version::new(1, 0, 0),
1248 )),
1249 storage: MountStorage::Archive {
1250 path: archive_path.clone(),
1251 },
1252 capability: MountCapability::ReadOnly,
1253 lifecycle: MountLifecycle::Lazy,
1254 cross_linkable: false,
1255 migration_target: None,
1256 },
1257 Box::new(crate::storage::ArchiveBackend::new(archive_path)) as Box<dyn MemBackend>,
1258 )])
1259 .unwrap();
1260 let err = archive_engine.export_mem_to_bytes("ext").unwrap_err();
1261 assert!(matches!(err, EngineError::Backend(BackendError::Sealed)));
1262 }
1263
1264 #[test]
1265 fn from_archive_bytes_refuses_non_zip_with_validation_error() {
1266 let err = Engine::from_archive_bytes(b"not a zip at all".to_vec()).unwrap_err();
1267 match err {
1268 FromArchiveBytesError::Validation(crate::validator::ValidationError::Zip(_)) => {}
1269 other => panic!("expected Validation(Zip(_)), got {other:?}"),
1270 }
1271 }
1272
1273 #[test]
1274 fn from_archive_bytes_refuses_oversized_with_size_cap() {
1275 let tmp = TempDir::new().unwrap();
1276 let (engine, _mem) = folder_mem_with_entities(&tmp, &["Alpha"]);
1277 let bytes = engine.export_mem_to_bytes("specs").unwrap();
1278
1279 let mut limits = ValidatorLimits::DEFAULT;
1280 limits.max_compressed_archive = 1;
1281 let err = Engine::from_archive_bytes_with_limits(bytes, &limits).unwrap_err();
1282 match err {
1283 FromArchiveBytesError::Validation(
1284 crate::validator::ValidationError::SizeCapExceeded { .. },
1285 ) => {}
1286 other => panic!("expected Validation(SizeCapExceeded), got {other:?}"),
1287 }
1288 }
1289
1290 #[test]
1291 fn hydrated_engine_answers_reads_and_refuses_writes() {
1292 let tmp = TempDir::new().unwrap();
1293 let (engine, _mem) = folder_mem_with_entities(&tmp, &["Hello", "World"]);
1294 let bytes = engine.export_mem_to_bytes("specs").unwrap();
1295 let mut hydrated = Engine::from_archive_bytes(bytes).unwrap();
1296
1297 let hello = hydrated
1299 .get_entity(&crate::EntityId::new("specs", "hello"))
1300 .expect("hello entity must round-trip");
1301 assert_eq!(hello.title, "Hello");
1302 let world = hydrated
1303 .get_entity(&crate::EntityId::new("specs", "world"))
1304 .expect("world entity must round-trip");
1305 assert_eq!(world.title, "World");
1306
1307 let (actor, client) = cli_actor();
1310 let err = hydrated
1311 .create_entity(
1312 empty_create_args("specs", "Forbidden"),
1313 actor,
1314 Some(&client),
1315 None,
1316 )
1317 .unwrap_err();
1318 assert!(matches!(err, EngineError::ReadOnlyMount(v) if v == "specs"));
1319 }
1320
1321 #[test]
1322 fn round_trip_preserves_entities_and_relations() {
1323 let tmp = TempDir::new().unwrap();
1327 let mem_dir = tmp.path().join("specs");
1328 std::fs::create_dir_all(mem_dir.join(".memstead")).unwrap();
1329 std::fs::write(
1330 mem_dir.join(".memstead").join("config.json"),
1331 r#"{"format":1,"schema":"default@1.0.0","version":"1.0.0"}"#,
1332 )
1333 .unwrap();
1334 let writer = FilesystemMemWriter::new(mem_dir.clone());
1335 let mut source = Engine::from_mounts(vec![(
1336 folder_mount("specs", mem_dir),
1337 Box::new(writer) as Box<dyn MemBackend>,
1338 )])
1339 .unwrap();
1340 let (actor, client) = cli_actor();
1341 let src = source
1342 .create_entity(
1343 empty_create_args("specs", "Source"),
1344 actor,
1345 Some(&client),
1346 None,
1347 )
1348 .unwrap();
1349 let tgt = source
1350 .create_entity(
1351 empty_create_args("specs", "Target"),
1352 actor,
1353 Some(&client),
1354 None,
1355 )
1356 .unwrap();
1357 source
1358 .relate_entity(
1359 crate::engine::RelateEntityArgs {
1360 source: src.id.clone(),
1361 expected_hash: Some(src.content_hash.clone()),
1362 rel_type: "USES".to_string(),
1363 target: tgt.id.clone(),
1364 remove: false,
1365 description: None,
1366 dry_run: false,
1367 },
1368 actor,
1369 Some(&client),
1370 None,
1371 )
1372 .unwrap();
1373
1374 let bytes = source.export_mem_to_bytes("specs").unwrap();
1375 let hydrated = Engine::from_archive_bytes(bytes).unwrap();
1376
1377 let mut src_ids: Vec<String> = source
1379 .store()
1380 .all_entities()
1381 .map(|e| e.id.to_string())
1382 .collect();
1383 let mut hyd_ids: Vec<String> = hydrated
1384 .store()
1385 .all_entities()
1386 .map(|e| e.id.to_string())
1387 .collect();
1388 src_ids.sort();
1389 hyd_ids.sort();
1390 assert_eq!(src_ids, hyd_ids);
1391
1392 for id_str in &src_ids {
1394 let (mem, slug) = id_str.split_once("--").expect("ids carry `<mem>--<slug>`");
1395 let id = crate::EntityId::new(mem, slug);
1396 let s = source.get_entity(&id).unwrap();
1397 let h = hydrated.get_entity(&id).unwrap();
1398 assert_eq!(s.title, h.title, "title differs for {id_str}");
1399 assert_eq!(s.entity_type, h.entity_type, "type differs for {id_str}");
1400 }
1401
1402 let src_edges: Vec<_> = source
1404 .store()
1405 .outgoing(&src.id)
1406 .iter()
1407 .map(|e| (e.rel_type.clone(), e.target.clone()))
1408 .collect();
1409 let hyd_edges: Vec<_> = hydrated
1410 .store()
1411 .outgoing(&src.id)
1412 .iter()
1413 .map(|e| (e.rel_type.clone(), e.target.clone()))
1414 .collect();
1415 assert_eq!(src_edges, hyd_edges);
1416 }
1417
1418 #[test]
1419 fn export_then_hydrate_then_re_export_yields_byte_equivalent_archive() {
1420 let tmp = TempDir::new().unwrap();
1426 let (engine, _mem) = folder_mem_with_entities(&tmp, &["Alpha"]);
1427 let bytes1 = engine.export_mem_to_bytes("specs").unwrap();
1428 let bytes2 = engine.export_mem_to_bytes("specs").unwrap();
1429 assert_eq!(bytes1, bytes2, "export bytes must be deterministic");
1430 }
1431
1432 fn assert_state_equivalent(source: &Engine, hydrated: &Engine, mem: &str) {
1439 let mut src_ids: Vec<String> = source
1440 .store()
1441 .all_entities()
1442 .filter(|e| e.mem == mem)
1443 .map(|e| e.id.to_string())
1444 .collect();
1445 let mut hyd_ids: Vec<String> = hydrated
1446 .store()
1447 .all_entities()
1448 .filter(|e| e.mem == mem)
1449 .map(|e| e.id.to_string())
1450 .collect();
1451 src_ids.sort();
1452 hyd_ids.sort();
1453 assert_eq!(src_ids, hyd_ids, "entity id set differs for mem {mem}");
1454
1455 for id_str in &src_ids {
1456 let (v, slug) = id_str.split_once("--").expect("ids carry `<mem>--<slug>`");
1457 let id = crate::EntityId::new(v, slug);
1458 let s = source.get_entity(&id).expect("source entity present");
1459 let h = hydrated.get_entity(&id).expect("hydrated entity present");
1460 assert_eq!(s.title, h.title, "title differs for {id_str}");
1461 assert_eq!(s.entity_type, h.entity_type, "type differs for {id_str}");
1462 assert_eq!(s.metadata, h.metadata, "metadata differs for {id_str}");
1463 assert_eq!(s.sections, h.sections, "sections differ for {id_str}");
1464 assert_eq!(
1465 s.content_hash, h.content_hash,
1466 "content_hash differs for {id_str}",
1467 );
1468
1469 let mut src_edges: Vec<_> = source
1470 .store()
1471 .outgoing(&id)
1472 .iter()
1473 .map(|e| (e.rel_type.clone(), e.target.to_string()))
1474 .collect();
1475 let mut hyd_edges: Vec<_> = hydrated
1476 .store()
1477 .outgoing(&id)
1478 .iter()
1479 .map(|e| (e.rel_type.clone(), e.target.to_string()))
1480 .collect();
1481 src_edges.sort();
1482 hyd_edges.sort();
1483 assert_eq!(src_edges, hyd_edges, "edges differ for {id_str}");
1484 }
1485 }
1486
1487 fn round_trip(source: &Engine, mem: &str) -> Engine {
1491 let bytes = source.export_mem_to_bytes(mem).unwrap();
1492 extract_entries(&bytes, &ValidatorLimits::DEFAULT).unwrap();
1496 let hydrated = Engine::from_archive_bytes(bytes).unwrap();
1497 assert_state_equivalent(source, &hydrated, mem);
1498 hydrated
1499 }
1500
1501 #[test]
1502 fn fixture_sweep_round_trip_empty() {
1503 let tmp = TempDir::new().unwrap();
1504 let (engine, _mem) = folder_mem_with_entities(&tmp, &[]);
1505 let hydrated = round_trip(&engine, "specs");
1506 assert!(hydrated.store().is_empty());
1507 }
1508
1509 #[test]
1510 fn fixture_sweep_round_trip_single_entity() {
1511 let tmp = TempDir::new().unwrap();
1512 let (engine, _mem) = folder_mem_with_entities(&tmp, &["Solo"]);
1513 round_trip(&engine, "specs");
1514 }
1515
1516 #[test]
1517 fn fixture_sweep_round_trip_multi_entity_no_relations() {
1518 let tmp = TempDir::new().unwrap();
1519 let (engine, _mem) = folder_mem_with_entities(&tmp, &["A One", "A Two", "A Three"]);
1520 round_trip(&engine, "specs");
1521 }
1522
1523 #[test]
1524 fn fixture_sweep_round_trip_entity_with_metadata_and_sections() {
1525 let tmp = TempDir::new().unwrap();
1526 let mem_dir = tmp.path().join("specs");
1527 std::fs::create_dir_all(mem_dir.join(".memstead")).unwrap();
1528 std::fs::write(
1529 mem_dir.join(".memstead").join("config.json"),
1530 r#"{"format":1,"schema":"default@1.0.0","version":"1.0.0"}"#,
1531 )
1532 .unwrap();
1533 let writer = FilesystemMemWriter::new(mem_dir.clone());
1534 let mut engine = Engine::from_mounts(vec![(
1535 folder_mount("specs", mem_dir),
1536 Box::new(writer) as Box<dyn MemBackend>,
1537 )])
1538 .unwrap();
1539 let (actor, client) = cli_actor();
1540
1541 let mut sections = indexmap::IndexMap::new();
1542 sections.insert("identity".to_string(), "A rich body.".to_string());
1543 sections.insert(
1544 "purpose".to_string(),
1545 "To exercise the archive round-trip.".to_string(),
1546 );
1547 sections.insert(
1548 "rationale".to_string(),
1549 "Because the spec said so.".to_string(),
1550 );
1551
1552 let mut metadata: indexmap::IndexMap<String, String> = indexmap::IndexMap::new();
1553 metadata.insert("level".to_string(), "M0".to_string());
1554
1555 engine
1556 .create_entity(
1557 crate::engine::CreateEntityArgs {
1558 anchors: Vec::new(),
1559 mem: "specs".to_string(),
1560 title: "Rich".to_string(),
1561 entity_type: "spec".to_string(),
1562 sections,
1563 metadata,
1564 relations: Vec::new(),
1565 dry_run: false,
1566 },
1567 actor,
1568 Some(&client),
1569 None,
1570 )
1571 .unwrap();
1572 round_trip(&engine, "specs");
1573 }
1574
1575 #[test]
1576 fn fixture_sweep_round_trip_multi_entity_with_relations() {
1577 let tmp = TempDir::new().unwrap();
1578 let mem_dir = tmp.path().join("specs");
1579 std::fs::create_dir_all(mem_dir.join(".memstead")).unwrap();
1580 std::fs::write(
1581 mem_dir.join(".memstead").join("config.json"),
1582 r#"{"format":1,"schema":"default@1.0.0","version":"1.0.0"}"#,
1583 )
1584 .unwrap();
1585 let writer = FilesystemMemWriter::new(mem_dir.clone());
1586 let mut engine = Engine::from_mounts(vec![(
1587 folder_mount("specs", mem_dir),
1588 Box::new(writer) as Box<dyn MemBackend>,
1589 )])
1590 .unwrap();
1591 let (actor, client) = cli_actor();
1592 let src = engine
1593 .create_entity(
1594 empty_create_args("specs", "Source"),
1595 actor,
1596 Some(&client),
1597 None,
1598 )
1599 .unwrap();
1600 let mid = engine
1601 .create_entity(
1602 empty_create_args("specs", "Middle"),
1603 actor,
1604 Some(&client),
1605 None,
1606 )
1607 .unwrap();
1608 let tgt = engine
1609 .create_entity(
1610 empty_create_args("specs", "Target"),
1611 actor,
1612 Some(&client),
1613 None,
1614 )
1615 .unwrap();
1616 engine
1619 .relate_entity(
1620 crate::engine::RelateEntityArgs {
1621 source: src.id.clone(),
1622 expected_hash: Some(src.content_hash.clone()),
1623 rel_type: "USES".to_string(),
1624 target: mid.id.clone(),
1625 remove: false,
1626 description: None,
1627 dry_run: false,
1628 },
1629 actor,
1630 Some(&client),
1631 None,
1632 )
1633 .unwrap();
1634 let src_after = engine
1635 .get_entity(&src.id)
1636 .expect("source must still resolve");
1637 engine
1638 .relate_entity(
1639 crate::engine::RelateEntityArgs {
1640 source: src.id.clone(),
1641 expected_hash: Some(src_after.content_hash.clone()),
1642 rel_type: "PART_OF".to_string(),
1643 target: tgt.id.clone(),
1644 remove: false,
1645 description: None,
1646 dry_run: false,
1647 },
1648 actor,
1649 Some(&client),
1650 None,
1651 )
1652 .unwrap();
1653 round_trip(&engine, "specs");
1654 }
1655
1656 #[test]
1657 fn read_entity_path_works_against_byte_backed_archive() {
1658 let tmp = TempDir::new().unwrap();
1661 let (engine, _mem) = folder_mem_with_entities(&tmp, &["First", "Second"]);
1662 let bytes = engine.export_mem_to_bytes("specs").unwrap();
1663 let hydrated = Engine::from_archive_bytes(bytes).unwrap();
1664 let first = hydrated
1665 .get_entity(&crate::EntityId::new("specs", "first"))
1666 .expect("first must hydrate");
1667 assert_eq!(first.title, "First");
1668 let backend = ArchiveBackend::from_bytes(Vec::new());
1671 let _: Option<&Path> = backend.archive_path();
1672 }
1673}