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]
1087 fn export_self_contained_mem_warns_nothing() {
1088 let tmp = TempDir::new().unwrap();
1089 let (engine, _mem) = folder_mem_with_entities(&tmp, &["Alpha", "Beta"]);
1090 let out = tmp.path().join("specs.mem");
1091 let result = engine.export_mem("specs", &out).unwrap();
1092 assert!(
1093 result.dangling_cross_mem_edges.is_empty(),
1094 "self-contained export must warn nothing: {:?}",
1095 result.dangling_cross_mem_edges
1096 );
1097 }
1098
1099 #[test]
1100 fn export_empty_mem_produces_valid_hydratable_archive() {
1101 let tmp = TempDir::new().unwrap();
1102 let (engine, _mem) = folder_mem_with_entities(&tmp, &[]);
1103 let bytes = engine.export_mem_to_bytes("specs").unwrap();
1104 let entries = extract_entries(&bytes, &ValidatorLimits::DEFAULT).unwrap();
1106 assert!(entries.markdown_files.is_empty());
1107 let hydrated = Engine::from_archive_bytes(bytes).unwrap();
1109 assert_eq!(hydrated.mem_names(), vec!["specs"]);
1110 assert!(hydrated.store().is_empty());
1111 }
1112
1113 #[test]
1114 fn export_unknown_mem_returns_unknown_mem_error() {
1115 let tmp = TempDir::new().unwrap();
1116 let (engine, _mem) = folder_mem_with_entities(&tmp, &[]);
1117 let err = engine.export_mem_to_bytes("missing").unwrap_err();
1118 match err {
1119 EngineError::UnknownMem(v) => assert_eq!(v, "missing"),
1120 other => panic!("expected UnknownMem, got {other:?}"),
1121 }
1122 }
1123
1124 #[test]
1125 fn export_archive_backend_returns_sealed() {
1126 let tmp = TempDir::new().unwrap();
1131 let (engine, _mem) = folder_mem_with_entities(&tmp, &["Alpha"]);
1132 let bytes = engine.export_mem_to_bytes("specs").unwrap();
1133
1134 let archive_path = tmp.path().join("ext.mem");
1135 std::fs::write(&archive_path, &bytes).unwrap();
1136 let archive_engine = Engine::from_mounts(vec![(
1137 Mount {
1138 mem: "ext".to_string(),
1139 schema: Some(memstead_schema::SchemaRef::new(
1140 "default",
1141 semver::Version::new(1, 0, 0),
1142 )),
1143 storage: MountStorage::Archive {
1144 path: archive_path.clone(),
1145 },
1146 capability: MountCapability::ReadOnly,
1147 lifecycle: MountLifecycle::Lazy,
1148 cross_linkable: false,
1149 migration_target: None,
1150 },
1151 Box::new(crate::storage::ArchiveBackend::new(archive_path)) as Box<dyn MemBackend>,
1152 )])
1153 .unwrap();
1154 let err = archive_engine.export_mem_to_bytes("ext").unwrap_err();
1155 assert!(matches!(err, EngineError::Backend(BackendError::Sealed)));
1156 }
1157
1158 #[test]
1159 fn from_archive_bytes_refuses_non_zip_with_validation_error() {
1160 let err = Engine::from_archive_bytes(b"not a zip at all".to_vec()).unwrap_err();
1161 match err {
1162 FromArchiveBytesError::Validation(crate::validator::ValidationError::Zip(_)) => {}
1163 other => panic!("expected Validation(Zip(_)), got {other:?}"),
1164 }
1165 }
1166
1167 #[test]
1168 fn from_archive_bytes_refuses_oversized_with_size_cap() {
1169 let tmp = TempDir::new().unwrap();
1170 let (engine, _mem) = folder_mem_with_entities(&tmp, &["Alpha"]);
1171 let bytes = engine.export_mem_to_bytes("specs").unwrap();
1172
1173 let mut limits = ValidatorLimits::DEFAULT;
1174 limits.max_compressed_archive = 1;
1175 let err = Engine::from_archive_bytes_with_limits(bytes, &limits).unwrap_err();
1176 match err {
1177 FromArchiveBytesError::Validation(
1178 crate::validator::ValidationError::SizeCapExceeded { .. },
1179 ) => {}
1180 other => panic!("expected Validation(SizeCapExceeded), got {other:?}"),
1181 }
1182 }
1183
1184 #[test]
1185 fn hydrated_engine_answers_reads_and_refuses_writes() {
1186 let tmp = TempDir::new().unwrap();
1187 let (engine, _mem) = folder_mem_with_entities(&tmp, &["Hello", "World"]);
1188 let bytes = engine.export_mem_to_bytes("specs").unwrap();
1189 let mut hydrated = Engine::from_archive_bytes(bytes).unwrap();
1190
1191 let hello = hydrated
1193 .get_entity(&crate::EntityId::new("specs", "hello"))
1194 .expect("hello entity must round-trip");
1195 assert_eq!(hello.title, "Hello");
1196 let world = hydrated
1197 .get_entity(&crate::EntityId::new("specs", "world"))
1198 .expect("world entity must round-trip");
1199 assert_eq!(world.title, "World");
1200
1201 let (actor, client) = cli_actor();
1204 let err = hydrated
1205 .create_entity(
1206 empty_create_args("specs", "Forbidden"),
1207 actor,
1208 Some(&client),
1209 None,
1210 )
1211 .unwrap_err();
1212 assert!(matches!(err, EngineError::ReadOnlyMount(v) if v == "specs"));
1213 }
1214
1215 #[test]
1216 fn round_trip_preserves_entities_and_relations() {
1217 let tmp = TempDir::new().unwrap();
1221 let mem_dir = tmp.path().join("specs");
1222 std::fs::create_dir_all(mem_dir.join(".memstead")).unwrap();
1223 std::fs::write(
1224 mem_dir.join(".memstead").join("config.json"),
1225 r#"{"format":1,"schema":"default@1.0.0","version":"1.0.0"}"#,
1226 )
1227 .unwrap();
1228 let writer = FilesystemMemWriter::new(mem_dir.clone());
1229 let mut source = Engine::from_mounts(vec![(
1230 folder_mount("specs", mem_dir),
1231 Box::new(writer) as Box<dyn MemBackend>,
1232 )])
1233 .unwrap();
1234 let (actor, client) = cli_actor();
1235 let src = source
1236 .create_entity(
1237 empty_create_args("specs", "Source"),
1238 actor,
1239 Some(&client),
1240 None,
1241 )
1242 .unwrap();
1243 let tgt = source
1244 .create_entity(
1245 empty_create_args("specs", "Target"),
1246 actor,
1247 Some(&client),
1248 None,
1249 )
1250 .unwrap();
1251 source
1252 .relate_entity(
1253 crate::engine::RelateEntityArgs {
1254 source: src.id.clone(),
1255 expected_hash: Some(src.content_hash.clone()),
1256 rel_type: "USES".to_string(),
1257 target: tgt.id.clone(),
1258 remove: false,
1259 description: None,
1260 dry_run: false,
1261 },
1262 actor,
1263 Some(&client),
1264 None,
1265 )
1266 .unwrap();
1267
1268 let bytes = source.export_mem_to_bytes("specs").unwrap();
1269 let hydrated = Engine::from_archive_bytes(bytes).unwrap();
1270
1271 let mut src_ids: Vec<String> = source
1273 .store()
1274 .all_entities()
1275 .map(|e| e.id.to_string())
1276 .collect();
1277 let mut hyd_ids: Vec<String> = hydrated
1278 .store()
1279 .all_entities()
1280 .map(|e| e.id.to_string())
1281 .collect();
1282 src_ids.sort();
1283 hyd_ids.sort();
1284 assert_eq!(src_ids, hyd_ids);
1285
1286 for id_str in &src_ids {
1288 let (mem, slug) = id_str.split_once("--").expect("ids carry `<mem>--<slug>`");
1289 let id = crate::EntityId::new(mem, slug);
1290 let s = source.get_entity(&id).unwrap();
1291 let h = hydrated.get_entity(&id).unwrap();
1292 assert_eq!(s.title, h.title, "title differs for {id_str}");
1293 assert_eq!(s.entity_type, h.entity_type, "type differs for {id_str}");
1294 }
1295
1296 let src_edges: Vec<_> = source
1298 .store()
1299 .outgoing(&src.id)
1300 .iter()
1301 .map(|e| (e.rel_type.clone(), e.target.clone()))
1302 .collect();
1303 let hyd_edges: Vec<_> = hydrated
1304 .store()
1305 .outgoing(&src.id)
1306 .iter()
1307 .map(|e| (e.rel_type.clone(), e.target.clone()))
1308 .collect();
1309 assert_eq!(src_edges, hyd_edges);
1310 }
1311
1312 #[test]
1313 fn export_then_hydrate_then_re_export_yields_byte_equivalent_archive() {
1314 let tmp = TempDir::new().unwrap();
1320 let (engine, _mem) = folder_mem_with_entities(&tmp, &["Alpha"]);
1321 let bytes1 = engine.export_mem_to_bytes("specs").unwrap();
1322 let bytes2 = engine.export_mem_to_bytes("specs").unwrap();
1323 assert_eq!(bytes1, bytes2, "export bytes must be deterministic");
1324 }
1325
1326 fn assert_state_equivalent(source: &Engine, hydrated: &Engine, mem: &str) {
1333 let mut src_ids: Vec<String> = source
1334 .store()
1335 .all_entities()
1336 .filter(|e| e.mem == mem)
1337 .map(|e| e.id.to_string())
1338 .collect();
1339 let mut hyd_ids: Vec<String> = hydrated
1340 .store()
1341 .all_entities()
1342 .filter(|e| e.mem == mem)
1343 .map(|e| e.id.to_string())
1344 .collect();
1345 src_ids.sort();
1346 hyd_ids.sort();
1347 assert_eq!(src_ids, hyd_ids, "entity id set differs for mem {mem}");
1348
1349 for id_str in &src_ids {
1350 let (v, slug) = id_str.split_once("--").expect("ids carry `<mem>--<slug>`");
1351 let id = crate::EntityId::new(v, slug);
1352 let s = source.get_entity(&id).expect("source entity present");
1353 let h = hydrated.get_entity(&id).expect("hydrated entity present");
1354 assert_eq!(s.title, h.title, "title differs for {id_str}");
1355 assert_eq!(s.entity_type, h.entity_type, "type differs for {id_str}");
1356 assert_eq!(s.metadata, h.metadata, "metadata differs for {id_str}");
1357 assert_eq!(s.sections, h.sections, "sections differ for {id_str}");
1358 assert_eq!(
1359 s.content_hash, h.content_hash,
1360 "content_hash differs for {id_str}",
1361 );
1362
1363 let mut src_edges: Vec<_> = source
1364 .store()
1365 .outgoing(&id)
1366 .iter()
1367 .map(|e| (e.rel_type.clone(), e.target.to_string()))
1368 .collect();
1369 let mut hyd_edges: Vec<_> = hydrated
1370 .store()
1371 .outgoing(&id)
1372 .iter()
1373 .map(|e| (e.rel_type.clone(), e.target.to_string()))
1374 .collect();
1375 src_edges.sort();
1376 hyd_edges.sort();
1377 assert_eq!(src_edges, hyd_edges, "edges differ for {id_str}");
1378 }
1379 }
1380
1381 fn round_trip(source: &Engine, mem: &str) -> Engine {
1385 let bytes = source.export_mem_to_bytes(mem).unwrap();
1386 extract_entries(&bytes, &ValidatorLimits::DEFAULT).unwrap();
1390 let hydrated = Engine::from_archive_bytes(bytes).unwrap();
1391 assert_state_equivalent(source, &hydrated, mem);
1392 hydrated
1393 }
1394
1395 #[test]
1396 fn fixture_sweep_round_trip_empty() {
1397 let tmp = TempDir::new().unwrap();
1398 let (engine, _mem) = folder_mem_with_entities(&tmp, &[]);
1399 let hydrated = round_trip(&engine, "specs");
1400 assert!(hydrated.store().is_empty());
1401 }
1402
1403 #[test]
1404 fn fixture_sweep_round_trip_single_entity() {
1405 let tmp = TempDir::new().unwrap();
1406 let (engine, _mem) = folder_mem_with_entities(&tmp, &["Solo"]);
1407 round_trip(&engine, "specs");
1408 }
1409
1410 #[test]
1411 fn fixture_sweep_round_trip_multi_entity_no_relations() {
1412 let tmp = TempDir::new().unwrap();
1413 let (engine, _mem) = folder_mem_with_entities(&tmp, &["A One", "A Two", "A Three"]);
1414 round_trip(&engine, "specs");
1415 }
1416
1417 #[test]
1418 fn fixture_sweep_round_trip_entity_with_metadata_and_sections() {
1419 let tmp = TempDir::new().unwrap();
1420 let mem_dir = tmp.path().join("specs");
1421 std::fs::create_dir_all(mem_dir.join(".memstead")).unwrap();
1422 std::fs::write(
1423 mem_dir.join(".memstead").join("config.json"),
1424 r#"{"format":1,"schema":"default@1.0.0","version":"1.0.0"}"#,
1425 )
1426 .unwrap();
1427 let writer = FilesystemMemWriter::new(mem_dir.clone());
1428 let mut engine = Engine::from_mounts(vec![(
1429 folder_mount("specs", mem_dir),
1430 Box::new(writer) as Box<dyn MemBackend>,
1431 )])
1432 .unwrap();
1433 let (actor, client) = cli_actor();
1434
1435 let mut sections = indexmap::IndexMap::new();
1436 sections.insert("identity".to_string(), "A rich body.".to_string());
1437 sections.insert(
1438 "purpose".to_string(),
1439 "To exercise the archive round-trip.".to_string(),
1440 );
1441 sections.insert(
1442 "rationale".to_string(),
1443 "Because the spec said so.".to_string(),
1444 );
1445
1446 let mut metadata: indexmap::IndexMap<String, String> = indexmap::IndexMap::new();
1447 metadata.insert("level".to_string(), "M0".to_string());
1448
1449 engine
1450 .create_entity(
1451 crate::engine::CreateEntityArgs {
1452 anchors: Vec::new(),
1453 mem: "specs".to_string(),
1454 title: "Rich".to_string(),
1455 entity_type: "spec".to_string(),
1456 sections,
1457 metadata,
1458 relations: Vec::new(),
1459 dry_run: false,
1460 },
1461 actor,
1462 Some(&client),
1463 None,
1464 )
1465 .unwrap();
1466 round_trip(&engine, "specs");
1467 }
1468
1469 #[test]
1470 fn fixture_sweep_round_trip_multi_entity_with_relations() {
1471 let tmp = TempDir::new().unwrap();
1472 let mem_dir = tmp.path().join("specs");
1473 std::fs::create_dir_all(mem_dir.join(".memstead")).unwrap();
1474 std::fs::write(
1475 mem_dir.join(".memstead").join("config.json"),
1476 r#"{"format":1,"schema":"default@1.0.0","version":"1.0.0"}"#,
1477 )
1478 .unwrap();
1479 let writer = FilesystemMemWriter::new(mem_dir.clone());
1480 let mut engine = Engine::from_mounts(vec![(
1481 folder_mount("specs", mem_dir),
1482 Box::new(writer) as Box<dyn MemBackend>,
1483 )])
1484 .unwrap();
1485 let (actor, client) = cli_actor();
1486 let src = engine
1487 .create_entity(
1488 empty_create_args("specs", "Source"),
1489 actor,
1490 Some(&client),
1491 None,
1492 )
1493 .unwrap();
1494 let mid = engine
1495 .create_entity(
1496 empty_create_args("specs", "Middle"),
1497 actor,
1498 Some(&client),
1499 None,
1500 )
1501 .unwrap();
1502 let tgt = engine
1503 .create_entity(
1504 empty_create_args("specs", "Target"),
1505 actor,
1506 Some(&client),
1507 None,
1508 )
1509 .unwrap();
1510 engine
1513 .relate_entity(
1514 crate::engine::RelateEntityArgs {
1515 source: src.id.clone(),
1516 expected_hash: Some(src.content_hash.clone()),
1517 rel_type: "USES".to_string(),
1518 target: mid.id.clone(),
1519 remove: false,
1520 description: None,
1521 dry_run: false,
1522 },
1523 actor,
1524 Some(&client),
1525 None,
1526 )
1527 .unwrap();
1528 let src_after = engine
1529 .get_entity(&src.id)
1530 .expect("source must still resolve");
1531 engine
1532 .relate_entity(
1533 crate::engine::RelateEntityArgs {
1534 source: src.id.clone(),
1535 expected_hash: Some(src_after.content_hash.clone()),
1536 rel_type: "PART_OF".to_string(),
1537 target: tgt.id.clone(),
1538 remove: false,
1539 description: None,
1540 dry_run: false,
1541 },
1542 actor,
1543 Some(&client),
1544 None,
1545 )
1546 .unwrap();
1547 round_trip(&engine, "specs");
1548 }
1549
1550 #[test]
1551 fn read_entity_path_works_against_byte_backed_archive() {
1552 let tmp = TempDir::new().unwrap();
1555 let (engine, _mem) = folder_mem_with_entities(&tmp, &["First", "Second"]);
1556 let bytes = engine.export_mem_to_bytes("specs").unwrap();
1557 let hydrated = Engine::from_archive_bytes(bytes).unwrap();
1558 let first = hydrated
1559 .get_entity(&crate::EntityId::new("specs", "first"))
1560 .expect("first must hydrate");
1561 assert_eq!(first.title, "First");
1562 let backend = ArchiveBackend::from_bytes(Vec::new());
1565 let _: Option<&Path> = backend.archive_path();
1566 }
1567}