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