use std::collections::HashMap;
use indexmap::IndexMap;
use crate::entity::{Entity, EntityId};
use crate::store::Store;
use super::EngineError;
pub mod create;
pub mod delete;
pub mod mem_sweep;
pub mod parse_recovery;
pub mod relate;
pub mod rename;
pub mod update;
pub(super) fn lookup_title_and_type(
store: &Store,
id: &EntityId,
) -> (Option<String>, Option<String>) {
match store.get(id) {
Some(e) => (Some(e.title.clone()), Some(e.entity_type.clone())),
None => (None, None),
}
}
pub const PATCH_OLD_NOT_FOUND_CONTENT_CAP: usize = 500;
pub const RELATIONSHIP_CYCLE_PATH_CAP: usize = 20;
pub(crate) fn unknown_type_error(schema: &memstead_schema::Schema, attempted: &str) -> EngineError {
let mut declared: Vec<String> = schema.types.keys().cloned().collect();
declared.sort();
let (sname, sver) = schema.id();
EngineError::UnknownType {
name: attempted.to_string(),
schema_ref: format!("{sname}@{sver}"),
declared,
suggestion: schema.suggest_type(attempted),
}
}
pub(crate) fn medium_type_wire(t: crate::pipeline::MediumType) -> &'static str {
use crate::pipeline::MediumType::*;
match t {
Codebase => "codebase",
Filesystem => "filesystem",
Graph => "graph",
Git => "git",
Web => "web",
}
}
impl super::Engine {
pub(crate) fn resolve_anchor_medium(&self, mem: &str) -> Option<(String, &'static str)> {
let mut sources = self
.pipeline_configs()
.bindings
.iter()
.filter(|r| r.mem == mem)
.flat_map(|r| r.config.sources.iter());
let first = sources.next()?;
if sources.next().is_some() {
return None;
}
let caps = crate::binding::medium_capabilities(first.medium_type);
Some((
medium_type_wire(first.medium_type).to_string(),
caps.anchor_namespace,
))
}
pub(crate) fn validate_anchor_inputs(
&self,
mem: &str,
inputs: &[crate::anchor::AnchorInput],
) -> Result<Vec<crate::anchor::Anchor>, EngineError> {
if inputs.is_empty() {
return Ok(Vec::new());
}
let medium = self.resolve_anchor_medium(mem);
let medium_ref = medium.as_ref().map(|(t, ns)| (t.as_str(), *ns));
let anchors: Vec<crate::anchor::Anchor> = inputs
.iter()
.map(|i| i.validate(medium_ref).map_err(EngineError::from))
.collect::<Result<_, _>>()?;
if anchors
.iter()
.any(|a| a.binding.is_some() && a.source.is_some())
&& let Some(root) = self.workspace_root()
&& let Ok(configs) = crate::pipeline_store::load_pipeline_configs(root)
{
for a in &anchors {
let (Some(binding_hash), Some(source)) = (&a.binding, &a.source) else {
continue;
};
let Some(record) = configs
.bindings
.iter()
.find(|r| crate::binding::hash_binding(&r.config) == *binding_hash)
else {
continue; };
let declared: Vec<String> = record
.config
.sources
.iter()
.map(|s| s.name.clone())
.collect();
if !declared.iter().any(|n| n == source) {
return Err(EngineError::from(
crate::anchor::AnchorValidationError::SourceNotDeclared {
got: source.clone(),
declared,
},
));
}
}
}
Ok(anchors)
}
pub(crate) fn validate_anchor_unsets(
inputs: &[crate::anchor::AnchorUnsetInput],
) -> Result<Vec<crate::anchor::AnchorUnset>, EngineError> {
inputs
.iter()
.map(|i| i.validate().map_err(EngineError::from))
.collect()
}
pub fn record_anchor_observed_hashes(
&mut self,
mem_name: &str,
observed: &[crate::anchor::ObservedArtifactHash],
note: Option<&str>,
) -> Result<usize, EngineError> {
if observed.is_empty() {
return Ok(0);
}
let mount_idx = self
.mounts
.iter()
.position(|m| m.mount.mem == mem_name)
.ok_or_else(|| self.unknown_mem_error(mem_name))?;
if self.mounts[mount_idx].mount.capability != crate::workspace::MountCapability::Write {
return Err(EngineError::ReadOnlyMount(mem_name.to_string()));
}
let _warnings = self.reload_if_stale(Some(mem_name));
let backend = self.mounts[mount_idx].backend.as_ref();
let mut sidecar = read_sidecar(backend)?;
let mut written = 0usize;
for obs in observed {
let Some(anchors) = sidecar.entities.get_mut(&obs.entity) else {
continue;
};
for a in anchors {
if a.class.is_hash_bearing() && a.hash.is_none() && a.artifact == obs.artifact {
a.hash = Some(obs.hash.clone());
written += 1;
}
}
}
if written == 0 {
return Ok(0);
}
backend.write_anchors_sidecar(&sidecar.to_bytes())?;
let ctx = crate::vcs::CommitContext {
actor: crate::vcs::Actor::Agent,
client: None,
tool: Some("record_anchor_observed_hashes"),
note: note.map(String::from),
role: self.current_role,
logical_operation_id: None,
entity_ids: None,
};
let commit_sha = backend.commit(
&format!("memstead: anchor-hash backfill ({written} anchor(s))"),
&ctx,
)?;
self.record_self_write(mount_idx, &commit_sha);
self.stamp_mutation_versions(mount_idx);
Ok(written)
}
pub(crate) fn stamp_mutation_versions(&mut self, mount_idx: usize) {
let Some(state) = self.mounts.get(mount_idx) else {
return;
};
let mem = state.mount.mem.clone();
let Some(schema) = self.schemas.get(&mem) else {
return;
};
let (name, version) = schema.id();
let stamp = memstead_schema::MutationStamp {
engine_version: crate::build_info::full_version().to_string(),
schema: format!("{name}@{version}"),
};
let Some(state) = self.mounts.get_mut(mount_idx) else {
return;
};
let Some(config) = state.mem_config.as_ref() else {
return;
};
if config.mutation_stamp.as_ref() == Some(&stamp) {
return;
}
let mut updated = config.clone();
updated.mutation_stamp = Some(stamp);
let Ok(mut bytes) = serde_json::to_vec_pretty(&updated) else {
return;
};
bytes.push(b'\n');
if state
.backend
.write_mem_config_with_note(&bytes, Some("engine version stamp"))
.is_ok()
{
state.mem_config = Some(updated);
}
}
}
pub(crate) fn stage_derivation_sidecar(
backend: &dyn crate::backend::MemBackend,
mutate: impl FnOnce(&mut crate::derivation::DerivationSidecar),
) -> Result<(), EngineError> {
let path = std::path::Path::new(crate::derivation::DERIVATION_SIDECAR_PATH);
let mut sidecar = match backend.read_entity(path)? {
Some(bytes) => crate::derivation::DerivationSidecar::from_bytes(&bytes).map_err(|e| {
EngineError::Backend(crate::backend::BackendError::Other(format!(
"derivations sidecar parse: {e}"
)))
})?,
None => crate::derivation::DerivationSidecar::default(),
};
mutate(&mut sidecar);
backend.write_entity(path, &sidecar.to_bytes())?;
Ok(())
}
pub(crate) fn rel_type_declares_derivation(
schema: &memstead_schema::Schema,
rel_type: &str,
) -> bool {
schema
.manifest
.relationships
.definitions
.iter()
.any(|d| d.name == rel_type && d.derivation)
}
pub(crate) fn stage_anchors_sidecar(
backend: &dyn crate::backend::MemBackend,
entity_id: &EntityId,
unsets: &[crate::anchor::AnchorUnset],
anchors: Vec<crate::anchor::Anchor>,
) -> Result<(), EngineError> {
let mut sidecar = match backend.read_anchors_sidecar()? {
Some(bytes) => crate::anchor::AnchorSidecar::from_bytes(&bytes).map_err(|e| {
EngineError::Backend(crate::backend::BackendError::Other(format!(
"anchors sidecar parse: {e}"
)))
})?,
None => crate::anchor::AnchorSidecar::default(),
};
sidecar.merge(entity_id.as_ref(), unsets, anchors);
backend.write_anchors_sidecar(&sidecar.to_bytes())?;
Ok(())
}
fn read_sidecar(
backend: &dyn crate::backend::MemBackend,
) -> Result<crate::anchor::AnchorSidecar, EngineError> {
match backend.read_anchors_sidecar()? {
Some(bytes) => crate::anchor::AnchorSidecar::from_bytes(&bytes).map_err(|e| {
EngineError::Backend(crate::backend::BackendError::Other(format!(
"anchors sidecar parse: {e}"
)))
}),
None => Ok(crate::anchor::AnchorSidecar::default()),
}
}
pub(crate) fn stage_anchors_removal(
backend: &dyn crate::backend::MemBackend,
entity_id: &EntityId,
) -> Result<bool, EngineError> {
let mut sidecar = read_sidecar(backend)?;
if sidecar.get(entity_id.as_ref()).is_empty() {
return Ok(false);
}
sidecar.remove(entity_id.as_ref());
backend.write_anchors_sidecar(&sidecar.to_bytes())?;
Ok(true)
}
pub(crate) fn stage_anchors_rename(
backend: &dyn crate::backend::MemBackend,
from: &EntityId,
to: &EntityId,
) -> Result<bool, EngineError> {
let mut sidecar = read_sidecar(backend)?;
if sidecar.get(from.as_ref()).is_empty() {
return Ok(false);
}
sidecar.rename(from.as_ref(), to.as_ref());
backend.write_anchors_sidecar(&sidecar.to_bytes())?;
Ok(true)
}
pub(super) fn iso_from_system_time(t: std::time::SystemTime) -> String {
let now = t.duration_since(std::time::UNIX_EPOCH).unwrap_or_default();
let secs = now.as_secs();
let days = secs / 86400;
let secs_of_day = secs % 86400;
let hh = secs_of_day / 3600;
let mm = (secs_of_day % 3600) / 60;
let ss = secs_of_day % 60;
let z = days + 719468;
let era = z / 146097;
let doe = z - era * 146097;
let yoe = (doe - doe / 1460 + doe / 36524 - doe / 146096) / 365;
let y = yoe + era * 400;
let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
let mp = (5 * doy + 2) / 153;
let d = doy - (153 * mp + 2) / 5 + 1;
let m = if mp < 10 { mp + 3 } else { mp - 9 };
let y = if m <= 2 { y + 1 } else { y };
format!("{y:04}-{m:02}-{d:02}T{hh:02}:{mm:02}:{ss:02}Z")
}
pub(super) fn gc_orphan_stubs(store: &mut Store) -> Vec<EntityId> {
let stub_ids: Vec<EntityId> = store
.all_entities()
.filter(|e| e.stub)
.map(|e| e.id.clone())
.collect();
gc_orphan_stubs_among(store, &stub_ids)
}
pub(super) fn gc_orphan_stubs_among<'a>(
store: &mut Store,
candidates: impl IntoIterator<Item = &'a EntityId>,
) -> Vec<EntityId> {
let mut removed: Vec<EntityId> = Vec::new();
let mut seen: std::collections::HashSet<&EntityId> = std::collections::HashSet::new();
for id in candidates {
if !seen.insert(id) {
continue;
}
if store.get(id).is_some_and(|e| e.stub) && store.incoming(id).is_empty() {
store.remove(id);
removed.push(id.clone());
}
}
removed
}
pub(super) fn validate_relation_target_grammar(target: &EntityId) -> Result<(), EngineError> {
if let Err(reason) = crate::entity::id::validate_mem_name_grammar(target.mem()) {
return Err(EngineError::InvalidEntityId {
id: target.to_string(),
reason,
});
}
if let Err(reason) = crate::entity::id::validate_id_path_grammar(target.path()) {
return Err(EngineError::InvalidEntityId {
id: target.to_string(),
reason,
});
}
Ok(())
}
pub(super) fn auto_stamp_timestamps(
entity: &mut Entity,
type_def: &memstead_schema::TypeDefinition,
today: &str,
) {
for field_def in &type_def.metadata_fields {
if field_def.auto_timestamp {
entity.metadata.insert(
field_def.key.clone(),
crate::entity::MetadataValue::String(today.to_string()),
);
}
}
}
pub(super) fn make_stub(id: &EntityId, kind: crate::entity::StubKind) -> Entity {
Entity {
id: id.clone(),
title: id.name().to_string(),
entity_type: String::new(),
mem: id.mem().to_string(),
file_path: String::new(),
metadata: IndexMap::new(),
sections: IndexMap::new(),
relationships: Vec::new(),
content_hash: String::new(),
stub: true,
stub_kind: Some(kind),
heading_spans: HashMap::new(),
raw_section_headings: Vec::new(),
}
}
pub(super) fn validate_cross_mem_add_policy(
engine: &super::Engine,
source_mem: &str,
target: &EntityId,
) -> Result<(), EngineError> {
let target_mem = target.mem();
if source_mem == target_mem {
return Ok(());
}
if !engine.cross_mem_link_allowed(source_mem, target_mem) {
return Err(EngineError::CrossMemLinkNotAllowed {
from_mem: source_mem.to_string(),
to_mem: target_mem.to_string(),
});
}
if let Some(mount) = engine.mount(target_mem)
&& mount.capability == crate::workspace::MountCapability::ReadOnly
&& !engine.store.contains(target)
{
return Err(EngineError::CrossMemTargetNotFound {
target_id: target.to_string(),
target_mem: target_mem.to_string(),
});
}
Ok(())
}
pub(super) enum EdgeRouteOutcome {
Ok,
OpenModeWarning(Box<crate::ops::WarningHint>),
}
#[allow(clippy::too_many_arguments)]
pub(super) fn route_edge_validation(
engine: &super::Engine,
rel_type: &str,
from_type: &str,
to_type: Option<&str>,
source_mem: &str,
target_mem: &str,
from_id: &EntityId,
to_id: &EntityId,
check_shape: bool,
) -> Result<EdgeRouteOutcome, EngineError> {
use crate::runtime_validator::{
CrossMemRelCheck, RelationshipCheck, validate_cross_mem_edge, validate_rel_shape,
validate_rel_type,
};
use memstead_schema::SchemaRef;
let source_schema = engine
.schemas
.get(source_mem)
.expect("schema present for every registered mount");
let target_schema_arc = if source_mem == target_mem {
None
} else {
engine.schemas.get(target_mem).cloned()
};
let target_schema_ref: Option<SchemaRef> = target_schema_arc.as_ref().map(|s| {
let (name, version) = s.id();
SchemaRef::new(name, version)
});
let cross_mem_different = match (&target_schema_ref, source_schema.id()) {
(Some(target), (src_name, _)) => target.name != src_name,
(None, _) => false,
};
if cross_mem_different {
let target_ref = target_schema_ref
.as_ref()
.expect("target_schema_ref is Some when cross_mem_different");
if !check_shape {
return Ok(EdgeRouteOutcome::Ok);
}
match validate_cross_mem_edge(
rel_type,
from_type,
to_type,
source_schema.as_ref(),
target_ref,
) {
CrossMemRelCheck::Ok => Ok(EdgeRouteOutcome::Ok),
CrossMemRelCheck::EdgeNotDeclared => {
let (src_name, src_version) = source_schema.id();
Err(EngineError::CrossMemEdgeNotDeclared {
source_schema: SchemaRef::new(src_name, src_version).as_display(),
target_schema: target_ref.as_display(),
rel_type: rel_type.to_string(),
from_id: from_id.to_string(),
to_id: to_id.to_string(),
})
}
CrossMemRelCheck::Invalid(v) => Err(EngineError::Validation(v)),
}
} else {
let warning_hint = match validate_rel_type(rel_type, source_schema.as_ref())? {
RelationshipCheck::Ok => None,
RelationshipCheck::OpenWarning(message) => {
Some(crate::ops::WarningHint::UndeclaredRelationshipOpen {
rel_type: rel_type.to_string(),
message,
})
}
};
if check_shape {
validate_rel_shape(rel_type, from_type, to_type, source_schema.as_ref())?;
}
Ok(match warning_hint {
Some(w) => EdgeRouteOutcome::OpenModeWarning(Box::new(w)),
None => EdgeRouteOutcome::Ok,
})
}
}
pub(super) fn validate_edge_acyclicity(
store: &Store,
schema: &memstead_schema::Schema,
from: &EntityId,
from_type: &str,
to: &EntityId,
rel_type: &str,
) -> Result<(), EngineError> {
if from == to && schema.type_refuses_self_loop(from_type, rel_type) {
return Err(EngineError::RelationshipCycle {
rel_type: rel_type.to_string(),
from: from.clone(),
to: to.clone(),
existing_path: vec![from.clone()],
path_truncated: false,
});
}
if schema.relationship_acyclic(rel_type)
&& let Some(path) = crate::graph::query::would_cycle(store, from, to, rel_type)
{
let truncated = path.len() > RELATIONSHIP_CYCLE_PATH_CAP;
let mut existing_path = path;
if truncated {
existing_path.truncate(RELATIONSHIP_CYCLE_PATH_CAP);
}
return Err(EngineError::RelationshipCycle {
rel_type: rel_type.to_string(),
from: from.clone(),
to: to.clone(),
existing_path,
path_truncated: truncated,
});
}
Ok(())
}
pub(super) fn validate_description_posture(
engine: &super::Engine,
rel_type: &str,
description: Option<&str>,
source_mem: &str,
target_mem: &str,
from_id: &EntityId,
to_id: &EntityId,
) -> Result<(), EngineError> {
use memstead_schema::{PerEdgeDescription, SchemaRef};
let source_schema = engine
.schemas
.get(source_mem)
.expect("schema present for every registered mount");
let target_schema_arc = if source_mem == target_mem {
None
} else {
engine.schemas.get(target_mem).cloned()
};
let target_schema_ref: Option<SchemaRef> = target_schema_arc.as_ref().map(|s| {
let (name, version) = s.id();
SchemaRef::new(name, version)
});
let cross_mem_different = match (&target_schema_ref, source_schema.id()) {
(Some(target), (src_name, _)) => target.name != src_name,
(None, _) => false,
};
let posture = if cross_mem_different {
let target_ref = target_schema_ref
.as_ref()
.expect("target_schema_ref is Some when cross_mem_different");
source_schema
.cross_mem_entries(&target_ref.name)
.iter()
.find_map(|entry| entry.definitions.iter().find(|d| d.name == rel_type))
.map(|d| d.per_edge_description)
} else {
source_schema
.relationship_def(rel_type)
.map(|d| d.per_edge_description)
};
match posture {
Some(PerEdgeDescription::Required) if description.is_none() => {
Err(EngineError::MissingRequiredDescription {
rel_type: rel_type.to_string(),
from_id: from_id.to_string(),
to_id: to_id.to_string(),
})
}
Some(PerEdgeDescription::Forbidden) if description.is_some() => {
Err(EngineError::DescriptionNotPermitted {
rel_type: rel_type.to_string(),
from_id: from_id.to_string(),
to_id: to_id.to_string(),
})
}
_ => Ok(()),
}
}
pub(super) fn validate_manual_authoring_posture(
engine: &super::Engine,
rel_type: &str,
source_mem: &str,
from_id: &EntityId,
to_id: &EntityId,
) -> Result<(), EngineError> {
use memstead_schema::ManualAuthoring;
let source_schema = engine
.schemas
.get(source_mem)
.expect("schema present for every registered mount");
let posture = source_schema.relationship_manual_authoring(rel_type);
if matches!(posture, ManualAuthoring::Forbidden) {
let guidance = source_schema
.relationship_when_to_use(rel_type)
.unwrap_or_default();
return Err(EngineError::RelationManualAuthoringForbidden {
rel_type: rel_type.to_string(),
from_id: from_id.to_string(),
to_id: to_id.to_string(),
guidance,
});
}
Ok(())
}
pub(super) fn synthesise_alias_relations(
engine: &super::Engine,
prev_body_targets: &std::collections::HashSet<EntityId>,
next: &mut Entity,
) -> Result<(Vec<crate::entity::Relationship>, bool), super::EngineError> {
let schema = engine
.schemas
.get(next.mem.as_str())
.expect("schema present for every registered mount");
let Some(pointer) = schema.alias_target_rel_type().map(str::to_string) else {
return Ok((Vec::new(), false));
};
let mut next_targets: std::collections::HashSet<EntityId> = std::collections::HashSet::new();
for (section_key, body) in next.sections.iter() {
let ids = crate::entity::parser::extract_inline_links(body, &next.mem)
.map_err(|errs| map_wiki_link_errors(section_key, errs))?;
next_targets.extend(ids);
}
next.relationships.retain(|r| {
!(r.rel_type == pointer
&& prev_body_targets.contains(&r.target)
&& !next_targets.contains(&r.target))
});
let existing: std::collections::HashSet<(String, EntityId)> = next
.relationships
.iter()
.map(|r| (r.rel_type.clone(), r.target.clone()))
.collect();
let mut emitted: Vec<crate::entity::Relationship> = Vec::new();
let mut already_synthesised: std::collections::HashSet<EntityId> =
std::collections::HashSet::new();
let mut self_link_ignored = false;
for (section_key, body) in next.sections.iter() {
let ids = crate::entity::parser::extract_inline_links(body, &next.mem)
.map_err(|errs| map_wiki_link_errors(section_key, errs))?;
for target in ids {
if target == next.id {
self_link_ignored = true;
continue;
}
let key = (pointer.clone(), target.clone());
if existing.contains(&key) || already_synthesised.contains(&target) {
continue;
}
validate_cross_mem_add_policy(engine, &next.mem, &target)?;
let rel = crate::entity::Relationship::new(pointer.clone(), target.clone());
next.relationships.push(rel.clone());
already_synthesised.insert(target);
emitted.push(rel);
}
}
Ok((emitted, self_link_ignored))
}
pub(super) fn map_wiki_link_errors(
section_key: &str,
errors: Vec<crate::entity::id::WikiLinkError>,
) -> EngineError {
use crate::entity::id::WikiLinkError;
let first = errors
.into_iter()
.next()
.expect("map_wiki_link_errors called with non-empty error list");
match first {
WikiLinkError::InvalidTarget {
raw,
suggested,
reason,
} => EngineError::InvalidWikiLinkTarget {
raw,
suggested,
section: section_key.to_string(),
link_source: "body_link".to_string(),
reason,
},
WikiLinkError::InvalidMemName { raw, reason } => EngineError::InvalidWikiLinkMem {
raw,
section: section_key.to_string(),
reason,
},
}
}
pub(super) fn collect_body_link_targets(entity: &Entity) -> std::collections::HashSet<EntityId> {
entity
.sections
.iter()
.flat_map(|(_, body)| {
crate::entity::parser::extract_inline_links_lenient(body, &entity.mem)
})
.collect()
}
pub(super) fn scan_wikilinks_without_relation(
next: &Entity,
) -> Result<Vec<(String, EntityId)>, EngineError> {
let explicit_targets: std::collections::HashSet<EntityId> = next
.relationships
.iter()
.map(|r| r.target.clone())
.collect();
let mut missing: Vec<(String, EntityId)> = Vec::new();
for (section_key, body) in next.sections.iter() {
let ids = crate::entity::parser::extract_inline_links(body, &next.mem)
.map_err(|errs| map_wiki_link_errors(section_key, errs))?;
for target in ids {
if target == next.id {
continue;
}
if !explicit_targets.contains(&target)
&& !missing
.iter()
.any(|(k, t)| k == section_key && t == &target)
{
missing.push((section_key.clone(), target));
}
}
}
Ok(missing)
}
#[cfg(test)]
mod tests {
use tempfile::TempDir;
use crate::backend::MemBackend;
use crate::engine::test_helpers::*;
use crate::engine::{CreateEntityArgs, Engine, UpdateEntityArgs};
use crate::storage::FilesystemMemWriter;
use crate::vcs::CommitContext;
use indexmap::IndexMap;
#[test]
fn with_ctx_wrappers_delegate_to_explicit_forms() {
let tmp = TempDir::new().unwrap();
let mem_dir = tmp.path().to_path_buf();
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 ctx = CommitContext::internal();
let create_args = CreateEntityArgs {
anchors: Vec::new(),
mem: "specs".to_string(),
title: "Seed".to_string(),
entity_type: "spec".to_string(),
sections: IndexMap::from_iter([
("identity".to_string(), "seed identity".to_string()),
("purpose".to_string(), "seed purpose".to_string()),
]),
metadata: IndexMap::new(),
relations: Vec::new(),
dry_run: false,
};
let created = engine.create_entity_with_ctx(create_args, &ctx).unwrap();
assert_eq!(created.title, "Seed");
assert!(engine.store().get(&created.id).is_some());
let update_args = UpdateEntityArgs {
anchors: Vec::new(),
id: created.id.clone(),
expected_hash: Some(created.content_hash.clone()),
sections: IndexMap::from_iter([("identity".to_string(), "updated".to_string())]),
append_sections: IndexMap::new(),
patch_sections: IndexMap::new(),
metadata: IndexMap::new(),
metadata_unset: Vec::new(),
dry_run: false,
declare_relations: Vec::new(),
relations_unset: Vec::new(),
anchors_unset: Vec::new(),
};
let updated = engine.update_entity_with_ctx(update_args, &ctx).unwrap();
assert!(
!updated.commit_sha.is_empty()
|| (updated.modified_sections.replaced.is_empty()
&& updated.modified_sections.appended.is_empty()
&& updated.modified_sections.patched.is_empty())
);
let renamed = engine
.rename_entity_with_ctx(&created.id, "Renamed", &updated.content_hash, &ctx)
.unwrap();
assert_ne!(renamed.old_id, renamed.new_id);
assert!(engine.store().get(&renamed.new_id).is_some());
let deleted = engine
.delete_entity_with_ctx(&renamed.new_id, &renamed.content_hash, &ctx)
.unwrap();
assert_eq!(deleted.id, renamed.new_id);
assert!(engine.store().get(&renamed.new_id).is_none());
}
fn write_config(dir: &std::path::Path, stamp: Option<memstead_schema::MutationStamp>) {
let meta = dir.join(memstead_schema::MEM_META_DIR);
std::fs::create_dir_all(&meta).unwrap();
let mut config: memstead_schema::MemConfig =
serde_json::from_str(r#"{"schema": "default@1.0.0"}"#).unwrap();
config.mutation_stamp = stamp;
std::fs::write(
meta.join("config.json"),
serde_json::to_vec_pretty(&config).unwrap(),
)
.unwrap();
}
fn stamped_engine_fixture(mem_dir: std::path::PathBuf) -> Engine {
Engine::from_mounts(vec![(
folder_mount("specs", mem_dir.clone()),
Box::new(FilesystemMemWriter::new(mem_dir)) as Box<dyn MemBackend>,
)])
.unwrap()
}
fn disk_stamp(dir: &std::path::Path) -> Option<memstead_schema::MutationStamp> {
let bytes =
std::fs::read(dir.join(memstead_schema::MEM_META_DIR).join("config.json")).unwrap();
let config: memstead_schema::MemConfig = serde_json::from_slice(&bytes).unwrap();
config.mutation_stamp
}
fn spec_create_args(title: &str) -> CreateEntityArgs {
CreateEntityArgs {
anchors: Vec::new(),
mem: "specs".to_string(),
title: title.to_string(),
entity_type: "spec".to_string(),
sections: IndexMap::from_iter([
("identity".to_string(), "seed identity".to_string()),
("purpose".to_string(), "seed purpose".to_string()),
]),
metadata: IndexMap::new(),
relations: Vec::new(),
dry_run: false,
}
}
#[test]
fn mutation_writes_version_stamp_and_read_only_load_does_not() {
let tmp = TempDir::new().unwrap();
let mem_dir = tmp.path().to_path_buf();
write_config(&mem_dir, None);
drop(stamped_engine_fixture(mem_dir.clone()));
assert!(
disk_stamp(&mem_dir).is_none(),
"a read-only load must not write a stamp"
);
let mut engine = stamped_engine_fixture(mem_dir.clone());
engine
.create_entity_with_ctx(spec_create_args("Seed"), &CommitContext::internal())
.unwrap();
let stamp = disk_stamp(&mem_dir).expect("mutation must write the stamp");
assert_eq!(stamp.engine_version, crate::build_info::full_version());
assert_eq!(stamp.schema, "default@1.0.0");
let mut engine = stamped_engine_fixture(mem_dir.clone());
engine
.create_entity_with_ctx(spec_create_args("Second"), &CommitContext::internal())
.unwrap();
let again = disk_stamp(&mem_dir).expect("stamp survives");
assert_eq!(again, stamp);
}
#[test]
fn boot_skew_warning_fires_only_on_disagreeing_stamp() {
use crate::ops::WarningHint;
let tmp = TempDir::new().unwrap();
let mem_dir = tmp.path().to_path_buf();
write_config(
&mem_dir,
Some(memstead_schema::MutationStamp {
engine_version: "0.0.1".to_string(),
schema: "default@1.0.0".to_string(),
}),
);
let engine = stamped_engine_fixture(mem_dir);
let skew: Vec<_> = engine
.load_warnings()
.iter()
.filter(|w| matches!(w, WarningHint::EngineVersionSkew { .. }))
.collect();
assert_eq!(skew.len(), 1, "one skewed mem, one warning: {skew:?}");
if let WarningHint::EngineVersionSkew {
mem,
stamped_engine,
running_engine,
stamped_schema,
} = skew[0]
{
assert_eq!(mem, "specs");
assert_eq!(stamped_engine, "0.0.1");
assert_eq!(running_engine, crate::build_info::full_version());
assert_eq!(stamped_schema, "default@1.0.0");
}
let health = engine.health();
assert!(
health
.warnings
.iter()
.any(|w| w.code() == "ENGINE_VERSION_SKEW"),
"health() must surface the skew without an include gate: {:?}",
health.warnings,
);
let tmp = TempDir::new().unwrap();
let mem_dir = tmp.path().to_path_buf();
write_config(
&mem_dir,
Some(memstead_schema::MutationStamp {
engine_version: crate::build_info::full_version().to_string(),
schema: "default@1.0.0".to_string(),
}),
);
let engine = stamped_engine_fixture(mem_dir);
assert!(
!engine
.load_warnings()
.iter()
.any(|w| matches!(w, WarningHint::EngineVersionSkew { .. })),
"a matching stamp is not skew"
);
let tmp = TempDir::new().unwrap();
let mem_dir = tmp.path().to_path_buf();
write_config(&mem_dir, None);
let engine = stamped_engine_fixture(mem_dir);
assert!(
!engine
.load_warnings()
.iter()
.any(|w| matches!(w, WarningHint::EngineVersionSkew { .. })),
"a stamp-less (pre-plan) mem boots without warning noise"
);
}
}