use std::path::PathBuf;
use std::sync::Arc;
use memstead_schema::Schema;
use crate::backend::MemBackend;
use crate::storage::ArchiveBackend;
use crate::validator::ValidatorLimits;
use crate::validator::archive::{ArchiveEntries, SchemaFile, extract_entries};
use crate::workspace::{Mount, MountCapability, MountLifecycle, MountStorage};
use super::{Engine, EngineError};
#[allow(clippy::large_enum_variant)]
#[derive(Debug, thiserror::Error)]
pub enum FromArchiveBytesError {
#[error("archive validation: {0}")]
Validation(#[from] crate::validator::ValidationError),
#[error("invalid published config: {0}")]
InvalidConfig(String),
#[error("embedded schema failed to load: {0}")]
EmbeddedSchemaInvalid(String),
#[error(transparent)]
Engine(#[from] EngineError),
}
impl Engine {
pub fn from_archive_bytes(bytes: Vec<u8>) -> Result<Self, FromArchiveBytesError> {
Self::from_archive_bytes_with_limits(bytes, &ValidatorLimits::DEFAULT)
}
pub fn from_archive_bytes_with_limits(
bytes: Vec<u8>,
limits: &ValidatorLimits,
) -> Result<Self, FromArchiveBytesError> {
let entries = extract_entries(&bytes, limits)?;
let ArchiveEntries {
config_bytes,
schema_files,
..
} = &entries;
let published: memstead_schema::PublishedMemConfig =
serde_json::from_slice(config_bytes)
.map_err(|e| FromArchiveBytesError::InvalidConfig(e.to_string()))?;
let extra_schemas = load_embedded_schemas(schema_files)?;
let mount = Mount {
mem: published.name.clone(),
schema: Some(published.schema.clone()),
storage: MountStorage::Archive {
path: PathBuf::new(),
},
capability: MountCapability::ReadOnly,
lifecycle: MountLifecycle::Eager,
cross_linkable: false,
migration_target: None,
};
let backend: Box<dyn MemBackend> = Box::new(ArchiveBackend::from_bytes(bytes));
let engine = Self::from_mounts_inner(vec![(mount, backend)], extra_schemas, Vec::new())?;
Ok(engine)
}
pub fn export_mem_to_bytes(&self, mem_name: &str) -> Result<Vec<u8>, EngineError> {
let mount = self
.mounts
.iter()
.find(|m| m.mount.mem == mem_name)
.ok_or_else(|| self.unknown_mem_error(mem_name))?;
let config = self.mem_config_for(mem_name).ok_or_else(|| {
EngineError::InvalidInput(format!(
"mem '{mem_name}' has no loaded MemConfig — cannot export"
))
})?;
if config.version.is_none() {
return Err(EngineError::MemConfigIncomplete {
mem: mem_name.to_string(),
missing_fields: vec!["version".to_string()],
});
}
let workspace_root = self.workspace_root.as_deref();
let fixed_schemas_dir = workspace_root.map(|r| r.join(".memstead").join("schemas"));
let workspace_schemas_dir = fixed_schemas_dir.as_deref();
match &mount.mount.storage {
MountStorage::Folder { path } => crate::ops::export::export_mem_to_bytes(
path,
config,
workspace_root,
workspace_schemas_dir,
mem_name,
)
.map(|out| out.bytes)
.map_err(|e| {
EngineError::Backend(crate::backend::BackendError::Other(format!(
"export_mem_to_bytes: {e}"
)))
}),
MountStorage::Archive { .. } => {
Err(EngineError::Backend(crate::backend::BackendError::Sealed))
}
MountStorage::GitBranch { gitdir, branch } => {
let hook = self.git_branch_ops.as_ref().ok_or_else(|| {
EngineError::Backend(crate::backend::BackendError::Other(
"git-branch export hook not installed (full flavour not loaded)"
.to_string(),
))
})?;
let provenance_bytes = mount
.backend
.read_provenance(None)
.ok()
.and_then(|records| crate::ops::export::build_archive_provenance(&records))
.and_then(|prov| prov.to_archive_bytes().ok());
let anchors_bytes = mount.backend.read_anchors_sidecar().ok().flatten();
(hook.export_to_bytes)(
gitdir,
branch,
mem_name,
config,
workspace_root,
workspace_schemas_dir,
provenance_bytes.as_deref(),
anchors_bytes.as_deref(),
)
.map(|out| out.bytes)
.map_err(EngineError::Backend)
}
MountStorage::InMemory => {
let backend = mount.backend.as_ref();
let rels = backend.list_entities().map_err(EngineError::Backend)?;
let mut md_entries: Vec<(std::path::PathBuf, Vec<u8>)> =
Vec::with_capacity(rels.len());
for rel in rels {
if let Some(bytes) = backend.read_entity(&rel).map_err(EngineError::Backend)? {
md_entries.push((rel, bytes));
}
}
let provenance = backend
.read_provenance(None)
.ok()
.and_then(|records| crate::ops::export::build_archive_provenance(&records));
let anchors_bytes = backend
.read_anchors_sidecar()
.map_err(EngineError::Backend)?;
crate::ops::export::export_entries_to_bytes(
config,
workspace_root,
workspace_schemas_dir,
mem_name,
md_entries,
provenance.as_ref(),
anchors_bytes.as_deref(),
)
.map(|out| out.bytes)
.map_err(|e| {
EngineError::Backend(crate::backend::BackendError::Other(format!(
"export_mem_to_bytes: {e}"
)))
})
}
}
}
}
pub(crate) fn load_embedded_schemas(
schema_files: &[SchemaFile],
) -> Result<Vec<Arc<Schema>>, FromArchiveBytesError> {
if schema_files.is_empty() {
return Ok(Vec::new());
}
let mut manifest: Option<&str> = None;
let mut types: Vec<(String, String)> = Vec::new();
for sf in schema_files {
if sf.archive_path == ".memstead/schema/schema.yaml" {
manifest = Some(&sf.content);
} else if let Some(rest) = sf.archive_path.strip_prefix(".memstead/schema/types/")
&& let Some(stem) = rest.strip_suffix(".yaml")
{
types.push((stem.to_string(), sf.content.clone()));
}
}
let Some(manifest_yaml) = manifest else {
return Err(FromArchiveBytesError::EmbeddedSchemaInvalid(
"embedded schema package present but `.memstead/schema/schema.yaml` missing"
.to_string(),
));
};
let marker_path = format!(
".memstead/schema/{}",
memstead_schema::loader::SCHEMA_FORMAT_MARKER_FILE
);
let format = if schema_files.iter().any(|sf| sf.archive_path == marker_path) {
memstead_schema::MetadataPolarityFormat::RequiredOptIn
} else {
memstead_schema::MetadataPolarityFormat::Legacy
};
let schema =
memstead_schema::load_schema_from_memory_with_format(manifest_yaml, &types, format)
.map_err(|e| FromArchiveBytesError::EmbeddedSchemaInvalid(e.to_string()))?;
Ok(vec![Arc::new(schema)])
}
#[cfg(test)]
mod tests {
use super::*;
use std::path::Path;
use tempfile::TempDir;
use crate::backend::{BackendError, MemBackend};
use crate::engine::test_helpers::{cli_actor, empty_create_args, folder_mount};
use crate::storage::FilesystemMemWriter;
use crate::workspace::{Mount, MountCapability, MountLifecycle, MountStorage};
fn folder_mem_with_entities(tmp: &TempDir, titles: &[&str]) -> (Engine, std::path::PathBuf) {
let mem_dir = tmp.path().join("specs");
std::fs::create_dir_all(mem_dir.join(".memstead")).unwrap();
let config_body = r#"{
"format": 1,
"schema": "default@1.0.0",
"version": "1.0.0"
}"#;
std::fs::write(mem_dir.join(".memstead").join("config.json"), config_body).unwrap();
let writer = FilesystemMemWriter::new(mem_dir.clone());
let mut engine = Engine::from_mounts(vec![(
folder_mount("specs", mem_dir.clone()),
Box::new(writer) as Box<dyn MemBackend>,
)])
.unwrap();
let (actor, client) = cli_actor();
for t in titles {
engine
.create_entity(empty_create_args("specs", t), actor, Some(&client), None)
.unwrap();
}
(engine, mem_dir)
}
#[test]
fn export_to_bytes_produces_bytes_that_extract_cleanly() {
let tmp = TempDir::new().unwrap();
let (engine, _mem) = folder_mem_with_entities(&tmp, &["Alpha", "Beta"]);
let bytes = engine.export_mem_to_bytes("specs").unwrap();
assert!(!bytes.is_empty(), "export bytes must be non-empty");
let entries = extract_entries(&bytes, &ValidatorLimits::DEFAULT).unwrap();
assert_eq!(entries.markdown_files.len(), 2);
let mut names: Vec<_> = entries
.markdown_files
.iter()
.map(|m| m.path.clone())
.collect();
names.sort();
assert_eq!(names, vec!["alpha.md".to_string(), "beta.md".to_string()]);
}
#[test]
fn export_round_trips_title_and_subject() {
let tmp = TempDir::new().unwrap();
let mem_dir = tmp.path().join("specs");
std::fs::create_dir_all(mem_dir.join(".memstead")).unwrap();
std::fs::write(
mem_dir.join(".memstead").join("config.json"),
serde_json::json!({
"format": 1,
"schema": "default@1.0.0",
"version": "1.0.0",
"title": "Einrichtungsbezogene Impfpflicht Deutschland",
"subject": {
"scope": "Die einrichtungsbezogene Impfpflicht — Rechtslage und Vollzug",
"method": "Primärquellen, händisch geprüft",
"exclusions": ["Länderverordnungen nach 2023", "Presseberichte", "Άλλα θέματα"],
},
})
.to_string(),
)
.unwrap();
let writer = FilesystemMemWriter::new(mem_dir.clone());
let mut engine = Engine::from_mounts(vec![(
folder_mount("specs", mem_dir.clone()),
Box::new(writer) as Box<dyn MemBackend>,
)])
.unwrap();
let (actor, client) = cli_actor();
engine
.create_entity(
empty_create_args("specs", "Alpha"),
actor,
Some(&client),
None,
)
.unwrap();
let bytes = engine.export_mem_to_bytes("specs").unwrap();
let validated =
crate::validator::validate_and_normalize_archive(&bytes).expect("archive re-validates");
let cfg = &validated.config;
assert_eq!(cfg.format, memstead_schema::PUBLISHED_MEM_FORMAT);
assert_eq!(
cfg.title.as_deref(),
Some("Einrichtungsbezogene Impfpflicht Deutschland")
);
let subject = cfg.subject.as_ref().expect("subject rides the archive");
assert_eq!(
subject.scope,
"Die einrichtungsbezogene Impfpflicht — Rechtslage und Vollzug"
);
assert_eq!(
subject.method.as_deref(),
Some("Primärquellen, händisch geprüft")
);
assert_eq!(
subject.exclusions,
vec![
"Länderverordnungen nach 2023",
"Presseberichte",
"Άλλα θέματα"
],
"exclusions preserved in order, non-ASCII intact"
);
std::fs::write(
mem_dir.join(".memstead").join("config.json"),
serde_json::json!({
"format": 1,
"schema": "default@1.0.0",
"version": "1.0.0",
"subject": { "scope": "Nur der Rahmen", "exclusions": [] },
})
.to_string(),
)
.unwrap();
engine.reload_each_writable_mem().unwrap();
let bytes = engine.export_mem_to_bytes("specs").unwrap();
let validated =
crate::validator::validate_and_normalize_archive(&bytes).expect("re-validates");
let subject = validated.config.subject.as_ref().expect("subject present");
assert_eq!(subject.scope, "Nur der Rahmen");
assert_eq!(subject.method, None);
assert!(subject.exclusions.is_empty());
assert_eq!(validated.config.title, None, "unset title stays unset");
}
#[test]
fn export_carries_provenance_that_install_reads_back() {
let tmp = TempDir::new().unwrap();
let mem_dir = tmp.path().join("specs");
std::fs::create_dir_all(mem_dir.join(".memstead")).unwrap();
std::fs::write(
mem_dir.join(".memstead").join("config.json"),
r#"{"format":1,"schema":"default@1.0.0","version":"1.0.0"}"#,
)
.unwrap();
let writer = FilesystemMemWriter::new(mem_dir.clone());
let mut engine = Engine::from_mounts(vec![(
folder_mount("specs", mem_dir.clone()),
Box::new(writer) as Box<dyn MemBackend>,
)])
.unwrap();
let (actor, client) = cli_actor();
engine
.create_entity(
empty_create_args("specs", "Alpha"),
actor,
Some(&client),
Some("why alpha exists"),
)
.unwrap();
engine
.create_entity(
empty_create_args("specs", "Beta"),
actor,
Some(&client),
None,
)
.unwrap();
let bytes = engine.export_mem_to_bytes("specs").unwrap();
let entries = extract_entries(&bytes, &ValidatorLimits::DEFAULT).unwrap();
assert!(
entries.provenance_bytes.is_some(),
"export must embed the provenance payload"
);
let validated =
crate::validator::validate_and_normalize_archive(&bytes).expect("archive re-validates");
let canonical_entries =
extract_entries(&validated.canonical_bytes, &ValidatorLimits::DEFAULT).unwrap();
assert!(
canonical_entries.provenance_bytes.is_some(),
"normalize must preserve provenance through the canonical re-pack (publish store path)"
);
let installed = Engine::from_archive_bytes(bytes).unwrap();
let prov = installed
.archive_provenance_for("specs")
.expect("installed mem exposes provenance");
assert_eq!(
prov.entity("alpha").and_then(|r| r.rationale.as_deref()),
Some("why alpha exists"),
"noted entity's rationale matches the source"
);
assert_eq!(
prov.entity("alpha").and_then(|r| r.kind.as_deref()),
Some("create"),
);
assert!(
prov.entity("beta").is_none(),
"entity authored without a note is absent — no fabricated provenance"
);
}
fn inject_zip_member(archive: &[u8], name: &str, content: &[u8]) -> Vec<u8> {
use std::io::{Read, Write};
let mut src = zip::ZipArchive::new(std::io::Cursor::new(archive)).unwrap();
let mut out = Vec::new();
{
let mut w = zip::ZipWriter::new(std::io::Cursor::new(&mut out));
let opts = zip::write::SimpleFileOptions::default()
.compression_method(zip::CompressionMethod::Deflated);
for i in 0..src.len() {
let mut f = src.by_index(i).unwrap();
let fname = f.name().to_string();
let mut buf = Vec::new();
f.read_to_end(&mut buf).unwrap();
w.start_file(fname, opts).unwrap();
w.write_all(&buf).unwrap();
}
w.start_file(name, opts).unwrap();
w.write_all(content).unwrap();
w.finish().unwrap();
}
out
}
#[test]
fn anchors_member_survives_canonical_repack_and_install() {
let tmp = TempDir::new().unwrap();
let (mut engine, _dir) = folder_mem_with_entities(&tmp, &["Alpha"]);
let _ = &mut engine;
let exported = engine.export_mem_to_bytes("specs").unwrap();
let anchors = br#"{"version":1,"entities":{"specs--alpha":[{"artifact":"src/lib.rs","grain":"file","class":"anchored","hash_stability":"stable","hash":"h1"}]}}"#;
let with_anchors = inject_zip_member(&exported, ".memstead/anchors.json", anchors);
let entries = extract_entries(&with_anchors, &ValidatorLimits::DEFAULT).unwrap();
assert_eq!(entries.anchors_bytes.as_deref(), Some(&anchors[..]));
let validated = crate::validator::validate_and_normalize_archive(&with_anchors)
.expect("archive with anchors re-validates");
let canonical =
extract_entries(&validated.canonical_bytes, &ValidatorLimits::DEFAULT).unwrap();
assert_eq!(
canonical.anchors_bytes.as_deref(),
Some(&anchors[..]),
"normalize must preserve the anchors member through the canonical re-pack"
);
let installed = Engine::from_archive_bytes(validated.canonical_bytes).unwrap();
let ids = installed.entity_anchors(&crate::EntityId::new("specs", "alpha"));
assert_eq!(ids.len(), 1);
assert_eq!(ids[0].artifact, "src/lib.rs");
}
#[test]
fn export_embeds_anchors_that_install_reads_back() {
let tmp = TempDir::new().unwrap();
let mem_dir = tmp.path().join("specs");
std::fs::create_dir_all(mem_dir.join(".memstead")).unwrap();
std::fs::write(
mem_dir.join(".memstead").join("config.json"),
r#"{"format":1,"schema":"default@1.0.0","version":"1.0.0"}"#,
)
.unwrap();
let writer = FilesystemMemWriter::new(mem_dir.clone());
let mut engine = Engine::from_mounts(vec![(
folder_mount("specs", mem_dir.clone()),
Box::new(writer) as Box<dyn MemBackend>,
)])
.unwrap();
let (actor, client) = cli_actor();
let mut alpha = empty_create_args("specs", "Alpha");
alpha.anchors = vec![crate::anchor::AnchorInput {
artifact: Some("src/lib.rs".to_string()),
grain: Some("file".to_string()),
class: Some("anchored".to_string()),
hash: Some("h1".to_string()),
hash_stability: Some("stable".to_string()),
..Default::default()
}];
engine
.create_entity(alpha, actor, Some(&client), None)
.unwrap();
engine
.create_entity(
empty_create_args("specs", "Beta"),
actor,
Some(&client),
None,
)
.unwrap();
let bytes = engine.export_mem_to_bytes("specs").unwrap();
let entries = extract_entries(&bytes, &ValidatorLimits::DEFAULT).unwrap();
assert!(
entries.anchors_bytes.is_some(),
"export must embed the anchors sidecar when the mem has anchors"
);
let validated =
crate::validator::validate_and_normalize_archive(&bytes).expect("archive re-validates");
let canonical =
extract_entries(&validated.canonical_bytes, &ValidatorLimits::DEFAULT).unwrap();
assert!(
canonical.anchors_bytes.is_some(),
"normalize must preserve the exported anchors member"
);
let installed = Engine::from_archive_bytes(validated.canonical_bytes).unwrap();
let alpha_anchors = installed.entity_anchors(&crate::EntityId::new("specs", "alpha"));
assert_eq!(alpha_anchors.len(), 1);
assert_eq!(alpha_anchors[0].artifact, "src/lib.rs");
assert_eq!(alpha_anchors[0].hash.as_deref(), Some("h1"));
assert!(
installed
.entity_anchors(&crate::EntityId::new("specs", "beta"))
.is_empty(),
"an entity with no anchors exposes none after install"
);
}
#[test]
fn in_memory_mem_export_round_trips_anchors() {
use crate::storage::InMemoryBackend;
let backend = InMemoryBackend::new();
backend
.write_mem_config(br#"{"version":"0.1.0","schema":"default@1.0.0"}"#)
.unwrap();
let mount = Mount {
mem: "sketch".to_string(),
schema: Some("default@1.0.0".parse().unwrap()),
storage: MountStorage::InMemory,
capability: MountCapability::Write,
lifecycle: MountLifecycle::Eager,
cross_linkable: false,
migration_target: None,
};
let mut engine =
Engine::from_mounts(vec![(mount, Box::new(backend) as Box<dyn MemBackend>)]).unwrap();
let (actor, client) = cli_actor();
let mut args = empty_create_args("sketch", "Idea");
args.anchors = vec![crate::anchor::AnchorInput {
artifact: Some("notes/idea.md".to_string()),
grain: Some("file".to_string()),
class: Some("informed-by".to_string()),
..Default::default()
}];
engine
.create_entity(args, actor, Some(&client), None)
.unwrap();
let bytes = engine.export_mem_to_bytes("sketch").unwrap();
let entries = extract_entries(&bytes, &ValidatorLimits::DEFAULT).unwrap();
assert!(
entries.anchors_bytes.is_some(),
"in-memory session export must carry the anchors sidecar"
);
let reimported = Engine::from_archive_bytes(bytes).unwrap();
let anchors = reimported.entity_anchors(&crate::EntityId::new("sketch", "idea"));
assert_eq!(anchors.len(), 1);
assert_eq!(anchors[0].artifact, "notes/idea.md");
assert_eq!(
anchors[0].class,
crate::anchor::AnchorProvenanceClass::InformedBy
);
}
#[test]
fn provenance_bearing_archive_stays_within_publish_budget() {
const PUBLISH_BODY_LIMIT: usize = 2 * 1024 * 1024;
let tmp = TempDir::new().unwrap();
let mem_dir = tmp.path().join("specs");
std::fs::create_dir_all(mem_dir.join(".memstead")).unwrap();
std::fs::write(
mem_dir.join(".memstead").join("config.json"),
r#"{"format":1,"schema":"default@1.0.0","version":"1.0.0"}"#,
)
.unwrap();
let writer = FilesystemMemWriter::new(mem_dir.clone());
let mut engine = Engine::from_mounts(vec![(
folder_mount("specs", mem_dir.clone()),
Box::new(writer) as Box<dyn MemBackend>,
)])
.unwrap();
let (actor, client) = cli_actor();
let note = "x".repeat(280);
for i in 0..60 {
engine
.create_entity(
empty_create_args("specs", &format!("Entity {i}")),
actor,
Some(&client),
Some(¬e),
)
.unwrap();
}
let bytes = engine.export_mem_to_bytes("specs").unwrap();
assert!(
bytes.len() < PUBLISH_BODY_LIMIT,
"archive ({} B) must stay under the 2 MB publish limit",
bytes.len()
);
let entries = extract_entries(&bytes, &ValidatorLimits::DEFAULT).unwrap();
let prov = entries.provenance_bytes.expect("provenance present");
assert!(
prov.len() < PUBLISH_BODY_LIMIT / 4,
"provenance payload ({} B) is a small fraction of the budget",
prov.len()
);
let parsed = memstead_schema::ArchiveProvenance::from_archive_bytes(&prov).unwrap();
assert_eq!(
parsed.entities.len(),
60,
"every noted entity has provenance"
);
}
#[test]
fn export_warns_on_cross_mem_edge_that_install_refuses() {
let tmp = TempDir::new().unwrap();
let (engine, mem_dir) = folder_mem_with_entities(&tmp, &[]);
let md = "\
---
type: spec
created_date: 2026-01-15
last_modified: 2026-01-15
level: M0
---
# Broker
## Identity
A
## Purpose
B
## Specifies
C
## Constraints
D
## Rationale
E
## Relationships
- **USES**: [[other--thing]]
";
std::fs::write(mem_dir.join("broker.md"), md).unwrap();
let out = tmp.path().join("specs.mem");
let result = engine.export_mem("specs", &out).unwrap();
assert!(out.is_file(), "archive must still be produced");
assert_eq!(
result.dangling_cross_mem_edges.len(),
1,
"export must surface the cross-mem edge: {:?}",
result.dangling_cross_mem_edges
);
let edge = &result.dangling_cross_mem_edges[0];
assert_eq!(edge.entity_path, "broker.md");
assert_eq!(edge.target_id, "other--thing");
assert_eq!(edge.target_mem, "other");
let bytes = std::fs::read(&out).unwrap();
let err = crate::validator::validate_and_normalize_archive(&bytes).unwrap_err();
assert!(
matches!(
err,
crate::validator::ValidationError::CrossMemRelationship { .. }
),
"install-side strict validation must refuse the same edge: {err:?}",
);
}
#[test]
fn export_self_contained_mem_warns_nothing() {
let tmp = TempDir::new().unwrap();
let (engine, _mem) = folder_mem_with_entities(&tmp, &["Alpha", "Beta"]);
let out = tmp.path().join("specs.mem");
let result = engine.export_mem("specs", &out).unwrap();
assert!(
result.dangling_cross_mem_edges.is_empty(),
"self-contained export must warn nothing: {:?}",
result.dangling_cross_mem_edges
);
}
#[test]
fn export_empty_mem_produces_valid_hydratable_archive() {
let tmp = TempDir::new().unwrap();
let (engine, _mem) = folder_mem_with_entities(&tmp, &[]);
let bytes = engine.export_mem_to_bytes("specs").unwrap();
let entries = extract_entries(&bytes, &ValidatorLimits::DEFAULT).unwrap();
assert!(entries.markdown_files.is_empty());
let hydrated = Engine::from_archive_bytes(bytes).unwrap();
assert_eq!(hydrated.mem_names(), vec!["specs"]);
assert!(hydrated.store().is_empty());
}
#[test]
fn export_unknown_mem_returns_unknown_mem_error() {
let tmp = TempDir::new().unwrap();
let (engine, _mem) = folder_mem_with_entities(&tmp, &[]);
let err = engine.export_mem_to_bytes("missing").unwrap_err();
match err {
EngineError::UnknownMem(v) => assert_eq!(v, "missing"),
other => panic!("expected UnknownMem, got {other:?}"),
}
}
#[test]
fn export_archive_backend_returns_sealed() {
let tmp = TempDir::new().unwrap();
let (engine, _mem) = folder_mem_with_entities(&tmp, &["Alpha"]);
let bytes = engine.export_mem_to_bytes("specs").unwrap();
let archive_path = tmp.path().join("ext.mem");
std::fs::write(&archive_path, &bytes).unwrap();
let archive_engine = Engine::from_mounts(vec![(
Mount {
mem: "ext".to_string(),
schema: Some(memstead_schema::SchemaRef::new(
"default",
semver::Version::new(1, 0, 0),
)),
storage: MountStorage::Archive {
path: archive_path.clone(),
},
capability: MountCapability::ReadOnly,
lifecycle: MountLifecycle::Lazy,
cross_linkable: false,
migration_target: None,
},
Box::new(crate::storage::ArchiveBackend::new(archive_path)) as Box<dyn MemBackend>,
)])
.unwrap();
let err = archive_engine.export_mem_to_bytes("ext").unwrap_err();
assert!(matches!(err, EngineError::Backend(BackendError::Sealed)));
}
#[test]
fn from_archive_bytes_refuses_non_zip_with_validation_error() {
let err = Engine::from_archive_bytes(b"not a zip at all".to_vec()).unwrap_err();
match err {
FromArchiveBytesError::Validation(crate::validator::ValidationError::Zip(_)) => {}
other => panic!("expected Validation(Zip(_)), got {other:?}"),
}
}
#[test]
fn from_archive_bytes_refuses_oversized_with_size_cap() {
let tmp = TempDir::new().unwrap();
let (engine, _mem) = folder_mem_with_entities(&tmp, &["Alpha"]);
let bytes = engine.export_mem_to_bytes("specs").unwrap();
let mut limits = ValidatorLimits::DEFAULT;
limits.max_compressed_archive = 1;
let err = Engine::from_archive_bytes_with_limits(bytes, &limits).unwrap_err();
match err {
FromArchiveBytesError::Validation(
crate::validator::ValidationError::SizeCapExceeded { .. },
) => {}
other => panic!("expected Validation(SizeCapExceeded), got {other:?}"),
}
}
#[test]
fn hydrated_engine_answers_reads_and_refuses_writes() {
let tmp = TempDir::new().unwrap();
let (engine, _mem) = folder_mem_with_entities(&tmp, &["Hello", "World"]);
let bytes = engine.export_mem_to_bytes("specs").unwrap();
let mut hydrated = Engine::from_archive_bytes(bytes).unwrap();
let hello = hydrated
.get_entity(&crate::EntityId::new("specs", "hello"))
.expect("hello entity must round-trip");
assert_eq!(hello.title, "Hello");
let world = hydrated
.get_entity(&crate::EntityId::new("specs", "world"))
.expect("world entity must round-trip");
assert_eq!(world.title, "World");
let (actor, client) = cli_actor();
let err = hydrated
.create_entity(
empty_create_args("specs", "Forbidden"),
actor,
Some(&client),
None,
)
.unwrap_err();
assert!(matches!(err, EngineError::ReadOnlyMount(v) if v == "specs"));
}
#[test]
fn round_trip_preserves_entities_and_relations() {
let tmp = TempDir::new().unwrap();
let mem_dir = tmp.path().join("specs");
std::fs::create_dir_all(mem_dir.join(".memstead")).unwrap();
std::fs::write(
mem_dir.join(".memstead").join("config.json"),
r#"{"format":1,"schema":"default@1.0.0","version":"1.0.0"}"#,
)
.unwrap();
let writer = FilesystemMemWriter::new(mem_dir.clone());
let mut source = Engine::from_mounts(vec![(
folder_mount("specs", mem_dir),
Box::new(writer) as Box<dyn MemBackend>,
)])
.unwrap();
let (actor, client) = cli_actor();
let src = source
.create_entity(
empty_create_args("specs", "Source"),
actor,
Some(&client),
None,
)
.unwrap();
let tgt = source
.create_entity(
empty_create_args("specs", "Target"),
actor,
Some(&client),
None,
)
.unwrap();
source
.relate_entity(
crate::engine::RelateEntityArgs {
source: src.id.clone(),
expected_hash: Some(src.content_hash.clone()),
rel_type: "USES".to_string(),
target: tgt.id.clone(),
remove: false,
description: None,
dry_run: false,
},
actor,
Some(&client),
None,
)
.unwrap();
let bytes = source.export_mem_to_bytes("specs").unwrap();
let hydrated = Engine::from_archive_bytes(bytes).unwrap();
let mut src_ids: Vec<String> = source
.store()
.all_entities()
.map(|e| e.id.to_string())
.collect();
let mut hyd_ids: Vec<String> = hydrated
.store()
.all_entities()
.map(|e| e.id.to_string())
.collect();
src_ids.sort();
hyd_ids.sort();
assert_eq!(src_ids, hyd_ids);
for id_str in &src_ids {
let (mem, slug) = id_str.split_once("--").expect("ids carry `<mem>--<slug>`");
let id = crate::EntityId::new(mem, slug);
let s = source.get_entity(&id).unwrap();
let h = hydrated.get_entity(&id).unwrap();
assert_eq!(s.title, h.title, "title differs for {id_str}");
assert_eq!(s.entity_type, h.entity_type, "type differs for {id_str}");
}
let src_edges: Vec<_> = source
.store()
.outgoing(&src.id)
.iter()
.map(|e| (e.rel_type.clone(), e.target.clone()))
.collect();
let hyd_edges: Vec<_> = hydrated
.store()
.outgoing(&src.id)
.iter()
.map(|e| (e.rel_type.clone(), e.target.clone()))
.collect();
assert_eq!(src_edges, hyd_edges);
}
#[test]
fn export_then_hydrate_then_re_export_yields_byte_equivalent_archive() {
let tmp = TempDir::new().unwrap();
let (engine, _mem) = folder_mem_with_entities(&tmp, &["Alpha"]);
let bytes1 = engine.export_mem_to_bytes("specs").unwrap();
let bytes2 = engine.export_mem_to_bytes("specs").unwrap();
assert_eq!(bytes1, bytes2, "export bytes must be deterministic");
}
fn assert_state_equivalent(source: &Engine, hydrated: &Engine, mem: &str) {
let mut src_ids: Vec<String> = source
.store()
.all_entities()
.filter(|e| e.mem == mem)
.map(|e| e.id.to_string())
.collect();
let mut hyd_ids: Vec<String> = hydrated
.store()
.all_entities()
.filter(|e| e.mem == mem)
.map(|e| e.id.to_string())
.collect();
src_ids.sort();
hyd_ids.sort();
assert_eq!(src_ids, hyd_ids, "entity id set differs for mem {mem}");
for id_str in &src_ids {
let (v, slug) = id_str.split_once("--").expect("ids carry `<mem>--<slug>`");
let id = crate::EntityId::new(v, slug);
let s = source.get_entity(&id).expect("source entity present");
let h = hydrated.get_entity(&id).expect("hydrated entity present");
assert_eq!(s.title, h.title, "title differs for {id_str}");
assert_eq!(s.entity_type, h.entity_type, "type differs for {id_str}");
assert_eq!(s.metadata, h.metadata, "metadata differs for {id_str}");
assert_eq!(s.sections, h.sections, "sections differ for {id_str}");
assert_eq!(
s.content_hash, h.content_hash,
"content_hash differs for {id_str}",
);
let mut src_edges: Vec<_> = source
.store()
.outgoing(&id)
.iter()
.map(|e| (e.rel_type.clone(), e.target.to_string()))
.collect();
let mut hyd_edges: Vec<_> = hydrated
.store()
.outgoing(&id)
.iter()
.map(|e| (e.rel_type.clone(), e.target.to_string()))
.collect();
src_edges.sort();
hyd_edges.sort();
assert_eq!(src_edges, hyd_edges, "edges differ for {id_str}");
}
}
fn round_trip(source: &Engine, mem: &str) -> Engine {
let bytes = source.export_mem_to_bytes(mem).unwrap();
extract_entries(&bytes, &ValidatorLimits::DEFAULT).unwrap();
let hydrated = Engine::from_archive_bytes(bytes).unwrap();
assert_state_equivalent(source, &hydrated, mem);
hydrated
}
#[test]
fn fixture_sweep_round_trip_empty() {
let tmp = TempDir::new().unwrap();
let (engine, _mem) = folder_mem_with_entities(&tmp, &[]);
let hydrated = round_trip(&engine, "specs");
assert!(hydrated.store().is_empty());
}
#[test]
fn fixture_sweep_round_trip_single_entity() {
let tmp = TempDir::new().unwrap();
let (engine, _mem) = folder_mem_with_entities(&tmp, &["Solo"]);
round_trip(&engine, "specs");
}
#[test]
fn fixture_sweep_round_trip_multi_entity_no_relations() {
let tmp = TempDir::new().unwrap();
let (engine, _mem) = folder_mem_with_entities(&tmp, &["A One", "A Two", "A Three"]);
round_trip(&engine, "specs");
}
#[test]
fn fixture_sweep_round_trip_entity_with_metadata_and_sections() {
let tmp = TempDir::new().unwrap();
let mem_dir = tmp.path().join("specs");
std::fs::create_dir_all(mem_dir.join(".memstead")).unwrap();
std::fs::write(
mem_dir.join(".memstead").join("config.json"),
r#"{"format":1,"schema":"default@1.0.0","version":"1.0.0"}"#,
)
.unwrap();
let writer = FilesystemMemWriter::new(mem_dir.clone());
let mut engine = Engine::from_mounts(vec![(
folder_mount("specs", mem_dir),
Box::new(writer) as Box<dyn MemBackend>,
)])
.unwrap();
let (actor, client) = cli_actor();
let mut sections = indexmap::IndexMap::new();
sections.insert("identity".to_string(), "A rich body.".to_string());
sections.insert(
"purpose".to_string(),
"To exercise the archive round-trip.".to_string(),
);
sections.insert(
"rationale".to_string(),
"Because the spec said so.".to_string(),
);
let mut metadata: indexmap::IndexMap<String, String> = indexmap::IndexMap::new();
metadata.insert("level".to_string(), "M0".to_string());
engine
.create_entity(
crate::engine::CreateEntityArgs {
anchors: Vec::new(),
mem: "specs".to_string(),
title: "Rich".to_string(),
entity_type: "spec".to_string(),
sections,
metadata,
relations: Vec::new(),
dry_run: false,
},
actor,
Some(&client),
None,
)
.unwrap();
round_trip(&engine, "specs");
}
#[test]
fn fixture_sweep_round_trip_multi_entity_with_relations() {
let tmp = TempDir::new().unwrap();
let mem_dir = tmp.path().join("specs");
std::fs::create_dir_all(mem_dir.join(".memstead")).unwrap();
std::fs::write(
mem_dir.join(".memstead").join("config.json"),
r#"{"format":1,"schema":"default@1.0.0","version":"1.0.0"}"#,
)
.unwrap();
let writer = FilesystemMemWriter::new(mem_dir.clone());
let mut engine = Engine::from_mounts(vec![(
folder_mount("specs", mem_dir),
Box::new(writer) as Box<dyn MemBackend>,
)])
.unwrap();
let (actor, client) = cli_actor();
let src = engine
.create_entity(
empty_create_args("specs", "Source"),
actor,
Some(&client),
None,
)
.unwrap();
let mid = engine
.create_entity(
empty_create_args("specs", "Middle"),
actor,
Some(&client),
None,
)
.unwrap();
let tgt = engine
.create_entity(
empty_create_args("specs", "Target"),
actor,
Some(&client),
None,
)
.unwrap();
engine
.relate_entity(
crate::engine::RelateEntityArgs {
source: src.id.clone(),
expected_hash: Some(src.content_hash.clone()),
rel_type: "USES".to_string(),
target: mid.id.clone(),
remove: false,
description: None,
dry_run: false,
},
actor,
Some(&client),
None,
)
.unwrap();
let src_after = engine
.get_entity(&src.id)
.expect("source must still resolve");
engine
.relate_entity(
crate::engine::RelateEntityArgs {
source: src.id.clone(),
expected_hash: Some(src_after.content_hash.clone()),
rel_type: "PART_OF".to_string(),
target: tgt.id.clone(),
remove: false,
description: None,
dry_run: false,
},
actor,
Some(&client),
None,
)
.unwrap();
round_trip(&engine, "specs");
}
#[test]
fn read_entity_path_works_against_byte_backed_archive() {
let tmp = TempDir::new().unwrap();
let (engine, _mem) = folder_mem_with_entities(&tmp, &["First", "Second"]);
let bytes = engine.export_mem_to_bytes("specs").unwrap();
let hydrated = Engine::from_archive_bytes(bytes).unwrap();
let first = hydrated
.get_entity(&crate::EntityId::new("specs", "first"))
.expect("first must hydrate");
assert_eq!(first.title, "First");
let backend = ArchiveBackend::from_bytes(Vec::new());
let _: Option<&Path> = backend.archive_path();
}
}