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("embedded schema failed to load: {0}")]
60 EmbeddedSchemaInvalid(String),
61 #[error(transparent)]
64 Engine(#[from] EngineError),
65}
66
67impl Engine {
68 pub fn from_archive_bytes(bytes: Vec<u8>) -> Result<Self, FromArchiveBytesError> {
82 Self::from_archive_bytes_with_limits(bytes, &ValidatorLimits::DEFAULT)
83 }
84
85 pub fn from_archive_bytes_with_limits(
90 bytes: Vec<u8>,
91 limits: &ValidatorLimits,
92 ) -> Result<Self, FromArchiveBytesError> {
93 let entries = extract_entries(&bytes, limits)?;
94 let ArchiveEntries {
95 config_bytes,
96 schema_files,
97 ..
98 } = &entries;
99
100 let published: memstead_schema::PublishedMemConfig =
101 serde_json::from_slice(config_bytes)
102 .map_err(|e| FromArchiveBytesError::InvalidConfig(e.to_string()))?;
103
104 let extra_schemas = load_embedded_schemas(schema_files)?;
105
106 let mount = Mount {
107 mem: published.name.clone(),
108 schema: Some(published.schema.clone()),
109 storage: MountStorage::Archive {
110 path: PathBuf::new(),
111 },
112 capability: MountCapability::ReadOnly,
113 lifecycle: MountLifecycle::Eager,
114 cross_linkable: false,
115 migration_target: None,
116 };
117 let backend: Box<dyn MemBackend> = Box::new(ArchiveBackend::from_bytes(bytes));
118
119 let engine = Self::from_mounts_inner(vec![(mount, backend)], extra_schemas, Vec::new())?;
120 Ok(engine)
121 }
122
123 pub fn export_mem_to_bytes(&self, mem_name: &str) -> Result<Vec<u8>, EngineError> {
142 let mount = self
143 .mounts
144 .iter()
145 .find(|m| m.mount.mem == mem_name)
146 .ok_or_else(|| self.unknown_mem_error(mem_name))?;
147 let config = self.mem_config_for(mem_name).ok_or_else(|| {
148 EngineError::InvalidInput(format!(
149 "mem '{mem_name}' has no loaded MemConfig — cannot export"
150 ))
151 })?;
152 if config.version.is_none() {
153 return Err(EngineError::MemConfigIncomplete {
154 mem: mem_name.to_string(),
155 missing_fields: vec!["version".to_string()],
156 });
157 }
158 let workspace_root = self.workspace_root.as_deref();
159 let fixed_schemas_dir = workspace_root.map(|r| r.join(".memstead").join("schemas"));
161 let workspace_schemas_dir = fixed_schemas_dir.as_deref();
162 match &mount.mount.storage {
163 MountStorage::Folder { path } => crate::ops::export::export_mem_to_bytes(
164 path,
165 config,
166 workspace_root,
167 workspace_schemas_dir,
168 mem_name,
169 )
170 .map(|out| out.bytes)
171 .map_err(|e| {
172 EngineError::Backend(crate::backend::BackendError::Other(format!(
173 "export_mem_to_bytes: {e}"
174 )))
175 }),
176 MountStorage::Archive { .. } => {
177 Err(EngineError::Backend(crate::backend::BackendError::Sealed))
178 }
179 MountStorage::GitBranch { gitdir, branch } => {
180 let hook = self.git_branch_ops.as_ref().ok_or_else(|| {
181 EngineError::Backend(crate::backend::BackendError::Other(
182 "git-branch export hook not installed (full flavour not loaded)"
183 .to_string(),
184 ))
185 })?;
186 let provenance_bytes = mount
191 .backend
192 .read_provenance(None)
193 .ok()
194 .and_then(|records| crate::ops::export::build_archive_provenance(&records))
195 .and_then(|prov| prov.to_archive_bytes().ok());
196 let anchors_bytes = mount.backend.read_anchors_sidecar().ok().flatten();
199 (hook.export_to_bytes)(
200 gitdir,
201 branch,
202 mem_name,
203 config,
204 workspace_root,
205 workspace_schemas_dir,
206 provenance_bytes.as_deref(),
207 anchors_bytes.as_deref(),
208 )
209 .map(|out| out.bytes)
210 .map_err(EngineError::Backend)
211 }
212 MountStorage::InMemory => {
218 let backend = mount.backend.as_ref();
219 let rels = backend.list_entities().map_err(EngineError::Backend)?;
220 let mut md_entries: Vec<(std::path::PathBuf, Vec<u8>)> =
221 Vec::with_capacity(rels.len());
222 for rel in rels {
223 if let Some(bytes) = backend.read_entity(&rel).map_err(EngineError::Backend)? {
224 md_entries.push((rel, bytes));
225 }
226 }
227 let provenance = backend
231 .read_provenance(None)
232 .ok()
233 .and_then(|records| crate::ops::export::build_archive_provenance(&records));
234 let anchors_bytes = backend
238 .read_anchors_sidecar()
239 .map_err(EngineError::Backend)?;
240 crate::ops::export::export_entries_to_bytes(
241 config,
242 workspace_root,
243 workspace_schemas_dir,
244 mem_name,
245 md_entries,
246 provenance.as_ref(),
247 anchors_bytes.as_deref(),
248 )
249 .map(|out| out.bytes)
250 .map_err(|e| {
251 EngineError::Backend(crate::backend::BackendError::Other(format!(
252 "export_mem_to_bytes: {e}"
253 )))
254 })
255 }
256 }
257 }
258}
259
260pub(crate) fn load_embedded_schemas(
266 schema_files: &[SchemaFile],
267) -> Result<Vec<Arc<Schema>>, FromArchiveBytesError> {
268 if schema_files.is_empty() {
269 return Ok(Vec::new());
270 }
271 let mut manifest: Option<&str> = None;
272 let mut types: Vec<(String, String)> = Vec::new();
273 for sf in schema_files {
274 if sf.archive_path == ".memstead/schema/schema.yaml" {
275 manifest = Some(&sf.content);
276 } else if let Some(rest) = sf.archive_path.strip_prefix(".memstead/schema/types/")
277 && let Some(stem) = rest.strip_suffix(".yaml")
278 {
279 types.push((stem.to_string(), sf.content.clone()));
280 }
281 }
282 let Some(manifest_yaml) = manifest else {
283 return Err(FromArchiveBytesError::EmbeddedSchemaInvalid(
284 "embedded schema package present but `.memstead/schema/schema.yaml` missing"
285 .to_string(),
286 ));
287 };
288 let marker_path = format!(
291 ".memstead/schema/{}",
292 memstead_schema::loader::SCHEMA_FORMAT_MARKER_FILE
293 );
294 let format = if schema_files.iter().any(|sf| sf.archive_path == marker_path) {
295 memstead_schema::MetadataPolarityFormat::RequiredOptIn
296 } else {
297 memstead_schema::MetadataPolarityFormat::Legacy
298 };
299 let schema =
300 memstead_schema::load_schema_from_memory_with_format(manifest_yaml, &types, format)
301 .map_err(|e| FromArchiveBytesError::EmbeddedSchemaInvalid(e.to_string()))?;
302 Ok(vec![Arc::new(schema)])
303}
304
305#[cfg(test)]
306mod tests {
307 use super::*;
308 use std::path::Path;
309 use tempfile::TempDir;
310
311 use crate::backend::{BackendError, MemBackend};
312 use crate::engine::test_helpers::{cli_actor, empty_create_args, folder_mount};
313 use crate::storage::FilesystemMemWriter;
314 use crate::workspace::{Mount, MountCapability, MountLifecycle, MountStorage};
315
316 fn folder_mem_with_entities(tmp: &TempDir, titles: &[&str]) -> (Engine, std::path::PathBuf) {
319 let mem_dir = tmp.path().join("specs");
320 std::fs::create_dir_all(mem_dir.join(".memstead")).unwrap();
321 let config_body = r#"{
322 "format": 1,
323 "schema": "default@1.0.0",
324 "version": "1.0.0"
325 }"#;
326 std::fs::write(mem_dir.join(".memstead").join("config.json"), config_body).unwrap();
327
328 let writer = FilesystemMemWriter::new(mem_dir.clone());
329 let mut engine = Engine::from_mounts(vec![(
330 folder_mount("specs", mem_dir.clone()),
331 Box::new(writer) as Box<dyn MemBackend>,
332 )])
333 .unwrap();
334 let (actor, client) = cli_actor();
335 for t in titles {
336 engine
337 .create_entity(empty_create_args("specs", t), actor, Some(&client), None)
338 .unwrap();
339 }
340 (engine, mem_dir)
341 }
342
343 #[test]
344 fn export_to_bytes_produces_bytes_that_extract_cleanly() {
345 let tmp = TempDir::new().unwrap();
346 let (engine, _mem) = folder_mem_with_entities(&tmp, &["Alpha", "Beta"]);
347 let bytes = engine.export_mem_to_bytes("specs").unwrap();
348 assert!(!bytes.is_empty(), "export bytes must be non-empty");
349 let entries = extract_entries(&bytes, &ValidatorLimits::DEFAULT).unwrap();
352 assert_eq!(entries.markdown_files.len(), 2);
353 let mut names: Vec<_> = entries
354 .markdown_files
355 .iter()
356 .map(|m| m.path.clone())
357 .collect();
358 names.sort();
359 assert_eq!(names, vec!["alpha.md".to_string(), "beta.md".to_string()]);
360 }
361
362 #[test]
367 fn export_round_trips_title_and_subject() {
368 let tmp = TempDir::new().unwrap();
369 let mem_dir = tmp.path().join("specs");
370 std::fs::create_dir_all(mem_dir.join(".memstead")).unwrap();
371 std::fs::write(
372 mem_dir.join(".memstead").join("config.json"),
373 serde_json::json!({
374 "format": 1,
375 "schema": "default@1.0.0",
376 "version": "1.0.0",
377 "title": "Einrichtungsbezogene Impfpflicht Deutschland",
378 "subject": {
379 "scope": "Die einrichtungsbezogene Impfpflicht — Rechtslage und Vollzug",
380 "method": "Primärquellen, händisch geprüft",
381 "exclusions": ["Länderverordnungen nach 2023", "Presseberichte", "Άλλα θέματα"],
382 },
383 })
384 .to_string(),
385 )
386 .unwrap();
387 let writer = FilesystemMemWriter::new(mem_dir.clone());
388 let mut engine = Engine::from_mounts(vec![(
389 folder_mount("specs", mem_dir.clone()),
390 Box::new(writer) as Box<dyn MemBackend>,
391 )])
392 .unwrap();
393 let (actor, client) = cli_actor();
394 engine
395 .create_entity(
396 empty_create_args("specs", "Alpha"),
397 actor,
398 Some(&client),
399 None,
400 )
401 .unwrap();
402
403 let bytes = engine.export_mem_to_bytes("specs").unwrap();
404 let validated =
406 crate::validator::validate_and_normalize_archive(&bytes).expect("archive re-validates");
407 let cfg = &validated.config;
408 assert_eq!(cfg.format, memstead_schema::PUBLISHED_MEM_FORMAT);
409 assert_eq!(
410 cfg.title.as_deref(),
411 Some("Einrichtungsbezogene Impfpflicht Deutschland")
412 );
413 let subject = cfg.subject.as_ref().expect("subject rides the archive");
414 assert_eq!(
415 subject.scope,
416 "Die einrichtungsbezogene Impfpflicht — Rechtslage und Vollzug"
417 );
418 assert_eq!(
419 subject.method.as_deref(),
420 Some("Primärquellen, händisch geprüft")
421 );
422 assert_eq!(
423 subject.exclusions,
424 vec![
425 "Länderverordnungen nach 2023",
426 "Presseberichte",
427 "Άλλα θέματα"
428 ],
429 "exclusions preserved in order, non-ASCII intact"
430 );
431
432 std::fs::write(
434 mem_dir.join(".memstead").join("config.json"),
435 serde_json::json!({
436 "format": 1,
437 "schema": "default@1.0.0",
438 "version": "1.0.0",
439 "subject": { "scope": "Nur der Rahmen", "exclusions": [] },
440 })
441 .to_string(),
442 )
443 .unwrap();
444 engine.reload_each_writable_mem().unwrap();
445 let bytes = engine.export_mem_to_bytes("specs").unwrap();
446 let validated =
447 crate::validator::validate_and_normalize_archive(&bytes).expect("re-validates");
448 let subject = validated.config.subject.as_ref().expect("subject present");
449 assert_eq!(subject.scope, "Nur der Rahmen");
450 assert_eq!(subject.method, None);
451 assert!(subject.exclusions.is_empty());
452 assert_eq!(validated.config.title, None, "unset title stays unset");
453 }
454
455 #[test]
461 fn export_carries_provenance_that_install_reads_back() {
462 let tmp = TempDir::new().unwrap();
463 let mem_dir = tmp.path().join("specs");
464 std::fs::create_dir_all(mem_dir.join(".memstead")).unwrap();
465 std::fs::write(
466 mem_dir.join(".memstead").join("config.json"),
467 r#"{"format":1,"schema":"default@1.0.0","version":"1.0.0"}"#,
468 )
469 .unwrap();
470 let writer = FilesystemMemWriter::new(mem_dir.clone());
471 let mut engine = Engine::from_mounts(vec![(
472 folder_mount("specs", mem_dir.clone()),
473 Box::new(writer) as Box<dyn MemBackend>,
474 )])
475 .unwrap();
476 let (actor, client) = cli_actor();
477 engine
479 .create_entity(
480 empty_create_args("specs", "Alpha"),
481 actor,
482 Some(&client),
483 Some("why alpha exists"),
484 )
485 .unwrap();
486 engine
487 .create_entity(
488 empty_create_args("specs", "Beta"),
489 actor,
490 Some(&client),
491 None,
492 )
493 .unwrap();
494
495 let bytes = engine.export_mem_to_bytes("specs").unwrap();
496 let entries = extract_entries(&bytes, &ValidatorLimits::DEFAULT).unwrap();
498 assert!(
499 entries.provenance_bytes.is_some(),
500 "export must embed the provenance payload"
501 );
502
503 let validated =
507 crate::validator::validate_and_normalize_archive(&bytes).expect("archive re-validates");
508 let canonical_entries =
509 extract_entries(&validated.canonical_bytes, &ValidatorLimits::DEFAULT).unwrap();
510 assert!(
511 canonical_entries.provenance_bytes.is_some(),
512 "normalize must preserve provenance through the canonical re-pack (publish store path)"
513 );
514
515 let installed = Engine::from_archive_bytes(bytes).unwrap();
517 let prov = installed
518 .archive_provenance_for("specs")
519 .expect("installed mem exposes provenance");
520 assert_eq!(
521 prov.entity("alpha").and_then(|r| r.rationale.as_deref()),
522 Some("why alpha exists"),
523 "noted entity's rationale matches the source"
524 );
525 assert_eq!(
526 prov.entity("alpha").and_then(|r| r.kind.as_deref()),
527 Some("create"),
528 );
529 assert!(
530 prov.entity("beta").is_none(),
531 "entity authored without a note is absent — no fabricated provenance"
532 );
533 }
534
535 fn inject_zip_member(archive: &[u8], name: &str, content: &[u8]) -> Vec<u8> {
541 use std::io::{Read, Write};
542 let mut src = zip::ZipArchive::new(std::io::Cursor::new(archive)).unwrap();
543 let mut out = Vec::new();
544 {
545 let mut w = zip::ZipWriter::new(std::io::Cursor::new(&mut out));
546 let opts = zip::write::SimpleFileOptions::default()
547 .compression_method(zip::CompressionMethod::Deflated);
548 for i in 0..src.len() {
549 let mut f = src.by_index(i).unwrap();
550 let fname = f.name().to_string();
551 let mut buf = Vec::new();
552 f.read_to_end(&mut buf).unwrap();
553 w.start_file(fname, opts).unwrap();
554 w.write_all(&buf).unwrap();
555 }
556 w.start_file(name, opts).unwrap();
557 w.write_all(content).unwrap();
558 w.finish().unwrap();
559 }
560 out
561 }
562
563 #[test]
568 fn anchors_member_survives_canonical_repack_and_install() {
569 let tmp = TempDir::new().unwrap();
570 let (mut engine, _dir) = folder_mem_with_entities(&tmp, &["Alpha"]);
571 let _ = &mut engine;
572 let exported = engine.export_mem_to_bytes("specs").unwrap();
573
574 let anchors = br#"{"version":1,"entities":{"specs--alpha":[{"artifact":"src/lib.rs","grain":"file","class":"anchored","hash_stability":"stable","hash":"h1"}]}}"#;
575 let with_anchors = inject_zip_member(&exported, ".memstead/anchors.json", anchors);
576
577 let entries = extract_entries(&with_anchors, &ValidatorLimits::DEFAULT).unwrap();
579 assert_eq!(entries.anchors_bytes.as_deref(), Some(&anchors[..]));
580
581 let validated = crate::validator::validate_and_normalize_archive(&with_anchors)
583 .expect("archive with anchors re-validates");
584 let canonical =
585 extract_entries(&validated.canonical_bytes, &ValidatorLimits::DEFAULT).unwrap();
586 assert_eq!(
587 canonical.anchors_bytes.as_deref(),
588 Some(&anchors[..]),
589 "normalize must preserve the anchors member through the canonical re-pack"
590 );
591
592 let installed = Engine::from_archive_bytes(validated.canonical_bytes).unwrap();
594 let ids = installed.entity_anchors(&crate::EntityId::new("specs", "alpha"));
595 assert_eq!(ids.len(), 1);
596 assert_eq!(ids[0].artifact, "src/lib.rs");
597 }
598
599 #[test]
605 fn export_embeds_anchors_that_install_reads_back() {
606 let tmp = TempDir::new().unwrap();
607 let mem_dir = tmp.path().join("specs");
608 std::fs::create_dir_all(mem_dir.join(".memstead")).unwrap();
609 std::fs::write(
610 mem_dir.join(".memstead").join("config.json"),
611 r#"{"format":1,"schema":"default@1.0.0","version":"1.0.0"}"#,
612 )
613 .unwrap();
614 let writer = FilesystemMemWriter::new(mem_dir.clone());
615 let mut engine = Engine::from_mounts(vec![(
616 folder_mount("specs", mem_dir.clone()),
617 Box::new(writer) as Box<dyn MemBackend>,
618 )])
619 .unwrap();
620 let (actor, client) = cli_actor();
621
622 let mut alpha = empty_create_args("specs", "Alpha");
624 alpha.anchors = vec![crate::anchor::AnchorInput {
625 artifact: Some("src/lib.rs".to_string()),
626 grain: Some("file".to_string()),
627 class: Some("anchored".to_string()),
628 hash: Some("h1".to_string()),
629 hash_stability: Some("stable".to_string()),
630 ..Default::default()
631 }];
632 engine
633 .create_entity(alpha, actor, Some(&client), None)
634 .unwrap();
635 engine
636 .create_entity(
637 empty_create_args("specs", "Beta"),
638 actor,
639 Some(&client),
640 None,
641 )
642 .unwrap();
643
644 let bytes = engine.export_mem_to_bytes("specs").unwrap();
647 let entries = extract_entries(&bytes, &ValidatorLimits::DEFAULT).unwrap();
648 assert!(
649 entries.anchors_bytes.is_some(),
650 "export must embed the anchors sidecar when the mem has anchors"
651 );
652
653 let validated =
655 crate::validator::validate_and_normalize_archive(&bytes).expect("archive re-validates");
656 let canonical =
657 extract_entries(&validated.canonical_bytes, &ValidatorLimits::DEFAULT).unwrap();
658 assert!(
659 canonical.anchors_bytes.is_some(),
660 "normalize must preserve the exported anchors member"
661 );
662
663 let installed = Engine::from_archive_bytes(validated.canonical_bytes).unwrap();
665 let alpha_anchors = installed.entity_anchors(&crate::EntityId::new("specs", "alpha"));
666 assert_eq!(alpha_anchors.len(), 1);
667 assert_eq!(alpha_anchors[0].artifact, "src/lib.rs");
668 assert_eq!(alpha_anchors[0].hash.as_deref(), Some("h1"));
669 assert!(
671 installed
672 .entity_anchors(&crate::EntityId::new("specs", "beta"))
673 .is_empty(),
674 "an entity with no anchors exposes none after install"
675 );
676 }
677
678 #[test]
683 fn in_memory_mem_export_round_trips_anchors() {
684 use crate::storage::InMemoryBackend;
685 let backend = InMemoryBackend::new();
688 backend
689 .write_mem_config(br#"{"version":"0.1.0","schema":"default@1.0.0"}"#)
690 .unwrap();
691 let mount = Mount {
692 mem: "sketch".to_string(),
693 schema: Some("default@1.0.0".parse().unwrap()),
694 storage: MountStorage::InMemory,
695 capability: MountCapability::Write,
696 lifecycle: MountLifecycle::Eager,
697 cross_linkable: false,
698 migration_target: None,
699 };
700 let mut engine =
701 Engine::from_mounts(vec![(mount, Box::new(backend) as Box<dyn MemBackend>)]).unwrap();
702 let (actor, client) = cli_actor();
703
704 let mut args = empty_create_args("sketch", "Idea");
705 args.anchors = vec![crate::anchor::AnchorInput {
706 artifact: Some("notes/idea.md".to_string()),
707 grain: Some("file".to_string()),
708 class: Some("informed-by".to_string()),
709 ..Default::default()
710 }];
711 engine
712 .create_entity(args, actor, Some(&client), None)
713 .unwrap();
714
715 let bytes = engine.export_mem_to_bytes("sketch").unwrap();
716 let entries = extract_entries(&bytes, &ValidatorLimits::DEFAULT).unwrap();
717 assert!(
718 entries.anchors_bytes.is_some(),
719 "in-memory session export must carry the anchors sidecar"
720 );
721
722 let reimported = Engine::from_archive_bytes(bytes).unwrap();
723 let anchors = reimported.entity_anchors(&crate::EntityId::new("sketch", "idea"));
724 assert_eq!(anchors.len(), 1);
725 assert_eq!(anchors[0].artifact, "notes/idea.md");
726 assert_eq!(
727 anchors[0].class,
728 crate::anchor::AnchorProvenanceClass::InformedBy
729 );
730 }
731
732 #[test]
739 fn provenance_bearing_archive_stays_within_publish_budget() {
740 const PUBLISH_BODY_LIMIT: usize = 2 * 1024 * 1024;
741 let tmp = TempDir::new().unwrap();
742 let mem_dir = tmp.path().join("specs");
743 std::fs::create_dir_all(mem_dir.join(".memstead")).unwrap();
744 std::fs::write(
745 mem_dir.join(".memstead").join("config.json"),
746 r#"{"format":1,"schema":"default@1.0.0","version":"1.0.0"}"#,
747 )
748 .unwrap();
749 let writer = FilesystemMemWriter::new(mem_dir.clone());
750 let mut engine = Engine::from_mounts(vec![(
751 folder_mount("specs", mem_dir.clone()),
752 Box::new(writer) as Box<dyn MemBackend>,
753 )])
754 .unwrap();
755 let (actor, client) = cli_actor();
756 let note = "x".repeat(280);
759 for i in 0..60 {
760 engine
761 .create_entity(
762 empty_create_args("specs", &format!("Entity {i}")),
763 actor,
764 Some(&client),
765 Some(¬e),
766 )
767 .unwrap();
768 }
769 let bytes = engine.export_mem_to_bytes("specs").unwrap();
770 assert!(
771 bytes.len() < PUBLISH_BODY_LIMIT,
772 "archive ({} B) must stay under the 2 MB publish limit",
773 bytes.len()
774 );
775 let entries = extract_entries(&bytes, &ValidatorLimits::DEFAULT).unwrap();
776 let prov = entries.provenance_bytes.expect("provenance present");
777 assert!(
780 prov.len() < PUBLISH_BODY_LIMIT / 4,
781 "provenance payload ({} B) is a small fraction of the budget",
782 prov.len()
783 );
784 let parsed = memstead_schema::ArchiveProvenance::from_archive_bytes(&prov).unwrap();
785 assert_eq!(
786 parsed.entities.len(),
787 60,
788 "every noted entity has provenance"
789 );
790 }
791
792 #[test]
800 fn export_warns_on_cross_mem_edge_that_install_refuses() {
801 let tmp = TempDir::new().unwrap();
802 let (engine, mem_dir) = folder_mem_with_entities(&tmp, &[]);
803 let md = "\
807---
808type: spec
809created_date: 2026-01-15
810last_modified: 2026-01-15
811level: M0
812---
813# Broker
814
815## Identity
816
817A
818
819## Purpose
820
821B
822
823## Specifies
824
825C
826
827## Constraints
828
829D
830
831## Rationale
832
833E
834
835## Relationships
836
837- **USES**: [[other--thing]]
838";
839 std::fs::write(mem_dir.join("broker.md"), md).unwrap();
840
841 let out = tmp.path().join("specs.mem");
844 let result = engine.export_mem("specs", &out).unwrap();
845 assert!(out.is_file(), "archive must still be produced");
846 assert_eq!(
847 result.dangling_cross_mem_edges.len(),
848 1,
849 "export must surface the cross-mem edge: {:?}",
850 result.dangling_cross_mem_edges
851 );
852 let edge = &result.dangling_cross_mem_edges[0];
853 assert_eq!(edge.entity_path, "broker.md");
854 assert_eq!(edge.target_id, "other--thing");
855 assert_eq!(edge.target_mem, "other");
856
857 let bytes = std::fs::read(&out).unwrap();
860 let err = crate::validator::validate_and_normalize_archive(&bytes).unwrap_err();
861 assert!(
862 matches!(
863 err,
864 crate::validator::ValidationError::CrossMemRelationship { .. }
865 ),
866 "install-side strict validation must refuse the same edge: {err:?}",
867 );
868 }
869
870 #[test]
873 fn export_self_contained_mem_warns_nothing() {
874 let tmp = TempDir::new().unwrap();
875 let (engine, _mem) = folder_mem_with_entities(&tmp, &["Alpha", "Beta"]);
876 let out = tmp.path().join("specs.mem");
877 let result = engine.export_mem("specs", &out).unwrap();
878 assert!(
879 result.dangling_cross_mem_edges.is_empty(),
880 "self-contained export must warn nothing: {:?}",
881 result.dangling_cross_mem_edges
882 );
883 }
884
885 #[test]
886 fn export_empty_mem_produces_valid_hydratable_archive() {
887 let tmp = TempDir::new().unwrap();
888 let (engine, _mem) = folder_mem_with_entities(&tmp, &[]);
889 let bytes = engine.export_mem_to_bytes("specs").unwrap();
890 let entries = extract_entries(&bytes, &ValidatorLimits::DEFAULT).unwrap();
892 assert!(entries.markdown_files.is_empty());
893 let hydrated = Engine::from_archive_bytes(bytes).unwrap();
895 assert_eq!(hydrated.mem_names(), vec!["specs"]);
896 assert!(hydrated.store().is_empty());
897 }
898
899 #[test]
900 fn export_unknown_mem_returns_unknown_mem_error() {
901 let tmp = TempDir::new().unwrap();
902 let (engine, _mem) = folder_mem_with_entities(&tmp, &[]);
903 let err = engine.export_mem_to_bytes("missing").unwrap_err();
904 match err {
905 EngineError::UnknownMem(v) => assert_eq!(v, "missing"),
906 other => panic!("expected UnknownMem, got {other:?}"),
907 }
908 }
909
910 #[test]
911 fn export_archive_backend_returns_sealed() {
912 let tmp = TempDir::new().unwrap();
917 let (engine, _mem) = folder_mem_with_entities(&tmp, &["Alpha"]);
918 let bytes = engine.export_mem_to_bytes("specs").unwrap();
919
920 let archive_path = tmp.path().join("ext.mem");
921 std::fs::write(&archive_path, &bytes).unwrap();
922 let archive_engine = Engine::from_mounts(vec![(
923 Mount {
924 mem: "ext".to_string(),
925 schema: Some(memstead_schema::SchemaRef::new(
926 "default",
927 semver::Version::new(1, 0, 0),
928 )),
929 storage: MountStorage::Archive {
930 path: archive_path.clone(),
931 },
932 capability: MountCapability::ReadOnly,
933 lifecycle: MountLifecycle::Lazy,
934 cross_linkable: false,
935 migration_target: None,
936 },
937 Box::new(crate::storage::ArchiveBackend::new(archive_path)) as Box<dyn MemBackend>,
938 )])
939 .unwrap();
940 let err = archive_engine.export_mem_to_bytes("ext").unwrap_err();
941 assert!(matches!(err, EngineError::Backend(BackendError::Sealed)));
942 }
943
944 #[test]
945 fn from_archive_bytes_refuses_non_zip_with_validation_error() {
946 let err = Engine::from_archive_bytes(b"not a zip at all".to_vec()).unwrap_err();
947 match err {
948 FromArchiveBytesError::Validation(crate::validator::ValidationError::Zip(_)) => {}
949 other => panic!("expected Validation(Zip(_)), got {other:?}"),
950 }
951 }
952
953 #[test]
954 fn from_archive_bytes_refuses_oversized_with_size_cap() {
955 let tmp = TempDir::new().unwrap();
956 let (engine, _mem) = folder_mem_with_entities(&tmp, &["Alpha"]);
957 let bytes = engine.export_mem_to_bytes("specs").unwrap();
958
959 let mut limits = ValidatorLimits::DEFAULT;
960 limits.max_compressed_archive = 1;
961 let err = Engine::from_archive_bytes_with_limits(bytes, &limits).unwrap_err();
962 match err {
963 FromArchiveBytesError::Validation(
964 crate::validator::ValidationError::SizeCapExceeded { .. },
965 ) => {}
966 other => panic!("expected Validation(SizeCapExceeded), got {other:?}"),
967 }
968 }
969
970 #[test]
971 fn hydrated_engine_answers_reads_and_refuses_writes() {
972 let tmp = TempDir::new().unwrap();
973 let (engine, _mem) = folder_mem_with_entities(&tmp, &["Hello", "World"]);
974 let bytes = engine.export_mem_to_bytes("specs").unwrap();
975 let mut hydrated = Engine::from_archive_bytes(bytes).unwrap();
976
977 let hello = hydrated
979 .get_entity(&crate::EntityId::new("specs", "hello"))
980 .expect("hello entity must round-trip");
981 assert_eq!(hello.title, "Hello");
982 let world = hydrated
983 .get_entity(&crate::EntityId::new("specs", "world"))
984 .expect("world entity must round-trip");
985 assert_eq!(world.title, "World");
986
987 let (actor, client) = cli_actor();
990 let err = hydrated
991 .create_entity(
992 empty_create_args("specs", "Forbidden"),
993 actor,
994 Some(&client),
995 None,
996 )
997 .unwrap_err();
998 assert!(matches!(err, EngineError::ReadOnlyMount(v) if v == "specs"));
999 }
1000
1001 #[test]
1002 fn round_trip_preserves_entities_and_relations() {
1003 let tmp = TempDir::new().unwrap();
1007 let mem_dir = tmp.path().join("specs");
1008 std::fs::create_dir_all(mem_dir.join(".memstead")).unwrap();
1009 std::fs::write(
1010 mem_dir.join(".memstead").join("config.json"),
1011 r#"{"format":1,"schema":"default@1.0.0","version":"1.0.0"}"#,
1012 )
1013 .unwrap();
1014 let writer = FilesystemMemWriter::new(mem_dir.clone());
1015 let mut source = Engine::from_mounts(vec![(
1016 folder_mount("specs", mem_dir),
1017 Box::new(writer) as Box<dyn MemBackend>,
1018 )])
1019 .unwrap();
1020 let (actor, client) = cli_actor();
1021 let src = source
1022 .create_entity(
1023 empty_create_args("specs", "Source"),
1024 actor,
1025 Some(&client),
1026 None,
1027 )
1028 .unwrap();
1029 let tgt = source
1030 .create_entity(
1031 empty_create_args("specs", "Target"),
1032 actor,
1033 Some(&client),
1034 None,
1035 )
1036 .unwrap();
1037 source
1038 .relate_entity(
1039 crate::engine::RelateEntityArgs {
1040 source: src.id.clone(),
1041 expected_hash: Some(src.content_hash.clone()),
1042 rel_type: "USES".to_string(),
1043 target: tgt.id.clone(),
1044 remove: false,
1045 description: None,
1046 dry_run: false,
1047 },
1048 actor,
1049 Some(&client),
1050 None,
1051 )
1052 .unwrap();
1053
1054 let bytes = source.export_mem_to_bytes("specs").unwrap();
1055 let hydrated = Engine::from_archive_bytes(bytes).unwrap();
1056
1057 let mut src_ids: Vec<String> = source
1059 .store()
1060 .all_entities()
1061 .map(|e| e.id.to_string())
1062 .collect();
1063 let mut hyd_ids: Vec<String> = hydrated
1064 .store()
1065 .all_entities()
1066 .map(|e| e.id.to_string())
1067 .collect();
1068 src_ids.sort();
1069 hyd_ids.sort();
1070 assert_eq!(src_ids, hyd_ids);
1071
1072 for id_str in &src_ids {
1074 let (mem, slug) = id_str.split_once("--").expect("ids carry `<mem>--<slug>`");
1075 let id = crate::EntityId::new(mem, slug);
1076 let s = source.get_entity(&id).unwrap();
1077 let h = hydrated.get_entity(&id).unwrap();
1078 assert_eq!(s.title, h.title, "title differs for {id_str}");
1079 assert_eq!(s.entity_type, h.entity_type, "type differs for {id_str}");
1080 }
1081
1082 let src_edges: Vec<_> = source
1084 .store()
1085 .outgoing(&src.id)
1086 .iter()
1087 .map(|e| (e.rel_type.clone(), e.target.clone()))
1088 .collect();
1089 let hyd_edges: Vec<_> = hydrated
1090 .store()
1091 .outgoing(&src.id)
1092 .iter()
1093 .map(|e| (e.rel_type.clone(), e.target.clone()))
1094 .collect();
1095 assert_eq!(src_edges, hyd_edges);
1096 }
1097
1098 #[test]
1099 fn export_then_hydrate_then_re_export_yields_byte_equivalent_archive() {
1100 let tmp = TempDir::new().unwrap();
1106 let (engine, _mem) = folder_mem_with_entities(&tmp, &["Alpha"]);
1107 let bytes1 = engine.export_mem_to_bytes("specs").unwrap();
1108 let bytes2 = engine.export_mem_to_bytes("specs").unwrap();
1109 assert_eq!(bytes1, bytes2, "export bytes must be deterministic");
1110 }
1111
1112 fn assert_state_equivalent(source: &Engine, hydrated: &Engine, mem: &str) {
1119 let mut src_ids: Vec<String> = source
1120 .store()
1121 .all_entities()
1122 .filter(|e| e.mem == mem)
1123 .map(|e| e.id.to_string())
1124 .collect();
1125 let mut hyd_ids: Vec<String> = hydrated
1126 .store()
1127 .all_entities()
1128 .filter(|e| e.mem == mem)
1129 .map(|e| e.id.to_string())
1130 .collect();
1131 src_ids.sort();
1132 hyd_ids.sort();
1133 assert_eq!(src_ids, hyd_ids, "entity id set differs for mem {mem}");
1134
1135 for id_str in &src_ids {
1136 let (v, slug) = id_str.split_once("--").expect("ids carry `<mem>--<slug>`");
1137 let id = crate::EntityId::new(v, slug);
1138 let s = source.get_entity(&id).expect("source entity present");
1139 let h = hydrated.get_entity(&id).expect("hydrated entity present");
1140 assert_eq!(s.title, h.title, "title differs for {id_str}");
1141 assert_eq!(s.entity_type, h.entity_type, "type differs for {id_str}");
1142 assert_eq!(s.metadata, h.metadata, "metadata differs for {id_str}");
1143 assert_eq!(s.sections, h.sections, "sections differ for {id_str}");
1144 assert_eq!(
1145 s.content_hash, h.content_hash,
1146 "content_hash differs for {id_str}",
1147 );
1148
1149 let mut src_edges: Vec<_> = source
1150 .store()
1151 .outgoing(&id)
1152 .iter()
1153 .map(|e| (e.rel_type.clone(), e.target.to_string()))
1154 .collect();
1155 let mut hyd_edges: Vec<_> = hydrated
1156 .store()
1157 .outgoing(&id)
1158 .iter()
1159 .map(|e| (e.rel_type.clone(), e.target.to_string()))
1160 .collect();
1161 src_edges.sort();
1162 hyd_edges.sort();
1163 assert_eq!(src_edges, hyd_edges, "edges differ for {id_str}");
1164 }
1165 }
1166
1167 fn round_trip(source: &Engine, mem: &str) -> Engine {
1171 let bytes = source.export_mem_to_bytes(mem).unwrap();
1172 extract_entries(&bytes, &ValidatorLimits::DEFAULT).unwrap();
1176 let hydrated = Engine::from_archive_bytes(bytes).unwrap();
1177 assert_state_equivalent(source, &hydrated, mem);
1178 hydrated
1179 }
1180
1181 #[test]
1182 fn fixture_sweep_round_trip_empty() {
1183 let tmp = TempDir::new().unwrap();
1184 let (engine, _mem) = folder_mem_with_entities(&tmp, &[]);
1185 let hydrated = round_trip(&engine, "specs");
1186 assert!(hydrated.store().is_empty());
1187 }
1188
1189 #[test]
1190 fn fixture_sweep_round_trip_single_entity() {
1191 let tmp = TempDir::new().unwrap();
1192 let (engine, _mem) = folder_mem_with_entities(&tmp, &["Solo"]);
1193 round_trip(&engine, "specs");
1194 }
1195
1196 #[test]
1197 fn fixture_sweep_round_trip_multi_entity_no_relations() {
1198 let tmp = TempDir::new().unwrap();
1199 let (engine, _mem) = folder_mem_with_entities(&tmp, &["A One", "A Two", "A Three"]);
1200 round_trip(&engine, "specs");
1201 }
1202
1203 #[test]
1204 fn fixture_sweep_round_trip_entity_with_metadata_and_sections() {
1205 let tmp = TempDir::new().unwrap();
1206 let mem_dir = tmp.path().join("specs");
1207 std::fs::create_dir_all(mem_dir.join(".memstead")).unwrap();
1208 std::fs::write(
1209 mem_dir.join(".memstead").join("config.json"),
1210 r#"{"format":1,"schema":"default@1.0.0","version":"1.0.0"}"#,
1211 )
1212 .unwrap();
1213 let writer = FilesystemMemWriter::new(mem_dir.clone());
1214 let mut engine = Engine::from_mounts(vec![(
1215 folder_mount("specs", mem_dir),
1216 Box::new(writer) as Box<dyn MemBackend>,
1217 )])
1218 .unwrap();
1219 let (actor, client) = cli_actor();
1220
1221 let mut sections = indexmap::IndexMap::new();
1222 sections.insert("identity".to_string(), "A rich body.".to_string());
1223 sections.insert(
1224 "purpose".to_string(),
1225 "To exercise the archive round-trip.".to_string(),
1226 );
1227 sections.insert(
1228 "rationale".to_string(),
1229 "Because the spec said so.".to_string(),
1230 );
1231
1232 let mut metadata: indexmap::IndexMap<String, String> = indexmap::IndexMap::new();
1233 metadata.insert("level".to_string(), "M0".to_string());
1234
1235 engine
1236 .create_entity(
1237 crate::engine::CreateEntityArgs {
1238 anchors: Vec::new(),
1239 mem: "specs".to_string(),
1240 title: "Rich".to_string(),
1241 entity_type: "spec".to_string(),
1242 sections,
1243 metadata,
1244 relations: Vec::new(),
1245 dry_run: false,
1246 },
1247 actor,
1248 Some(&client),
1249 None,
1250 )
1251 .unwrap();
1252 round_trip(&engine, "specs");
1253 }
1254
1255 #[test]
1256 fn fixture_sweep_round_trip_multi_entity_with_relations() {
1257 let tmp = TempDir::new().unwrap();
1258 let mem_dir = tmp.path().join("specs");
1259 std::fs::create_dir_all(mem_dir.join(".memstead")).unwrap();
1260 std::fs::write(
1261 mem_dir.join(".memstead").join("config.json"),
1262 r#"{"format":1,"schema":"default@1.0.0","version":"1.0.0"}"#,
1263 )
1264 .unwrap();
1265 let writer = FilesystemMemWriter::new(mem_dir.clone());
1266 let mut engine = Engine::from_mounts(vec![(
1267 folder_mount("specs", mem_dir),
1268 Box::new(writer) as Box<dyn MemBackend>,
1269 )])
1270 .unwrap();
1271 let (actor, client) = cli_actor();
1272 let src = engine
1273 .create_entity(
1274 empty_create_args("specs", "Source"),
1275 actor,
1276 Some(&client),
1277 None,
1278 )
1279 .unwrap();
1280 let mid = engine
1281 .create_entity(
1282 empty_create_args("specs", "Middle"),
1283 actor,
1284 Some(&client),
1285 None,
1286 )
1287 .unwrap();
1288 let tgt = engine
1289 .create_entity(
1290 empty_create_args("specs", "Target"),
1291 actor,
1292 Some(&client),
1293 None,
1294 )
1295 .unwrap();
1296 engine
1299 .relate_entity(
1300 crate::engine::RelateEntityArgs {
1301 source: src.id.clone(),
1302 expected_hash: Some(src.content_hash.clone()),
1303 rel_type: "USES".to_string(),
1304 target: mid.id.clone(),
1305 remove: false,
1306 description: None,
1307 dry_run: false,
1308 },
1309 actor,
1310 Some(&client),
1311 None,
1312 )
1313 .unwrap();
1314 let src_after = engine
1315 .get_entity(&src.id)
1316 .expect("source must still resolve");
1317 engine
1318 .relate_entity(
1319 crate::engine::RelateEntityArgs {
1320 source: src.id.clone(),
1321 expected_hash: Some(src_after.content_hash.clone()),
1322 rel_type: "PART_OF".to_string(),
1323 target: tgt.id.clone(),
1324 remove: false,
1325 description: None,
1326 dry_run: false,
1327 },
1328 actor,
1329 Some(&client),
1330 None,
1331 )
1332 .unwrap();
1333 round_trip(&engine, "specs");
1334 }
1335
1336 #[test]
1337 fn read_entity_path_works_against_byte_backed_archive() {
1338 let tmp = TempDir::new().unwrap();
1341 let (engine, _mem) = folder_mem_with_entities(&tmp, &["First", "Second"]);
1342 let bytes = engine.export_mem_to_bytes("specs").unwrap();
1343 let hydrated = Engine::from_archive_bytes(bytes).unwrap();
1344 let first = hydrated
1345 .get_entity(&crate::EntityId::new("specs", "first"))
1346 .expect("first must hydrate");
1347 assert_eq!(first.title, "First");
1348 let backend = ArchiveBackend::from_bytes(Vec::new());
1351 let _: Option<&Path> = backend.archive_path();
1352 }
1353}