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