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 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 mediums = self
.pipeline_configs()
.mediums
.iter()
.filter(|r| r.mem == mem);
let first = mediums.next()?;
if mediums.next().is_some() {
return None;
}
let caps = crate::binding::medium_capabilities(first.config.medium_type);
Some((
medium_type_wire(first.config.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));
inputs
.iter()
.map(|i| i.validate(medium_ref).map_err(EngineError::from))
.collect()
}
}
pub(crate) fn stage_anchors_sidecar(
backend: &dyn crate::backend::MemBackend,
entity_id: &EntityId,
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.set(entity_id.as_ref(), 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 today_iso() -> String {
let now = std::time::SystemTime::now()
.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(),
}
}
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_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_entry(&target_ref.name)
.and_then(|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(),
};
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());
}
}