use std::fmt;
use std::path::PathBuf;
use crate::backend::BackendError;
use crate::entity::EntityId;
use crate::entity::id::SlugError;
use crate::entity::parser::ParseError;
use crate::runtime_validator::{MissingRequiredField, ValidationError};
pub const INLINE_LIST_CAP: usize = 3;
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct BlockedReferrer {
pub from_mem: String,
pub to_mem: String,
pub count: usize,
}
impl fmt::Display for BlockedReferrer {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(
f,
"{} → {} ({} referrer{})",
self.from_mem,
self.to_mem,
self.count,
if self.count == 1 { "" } else { "s" }
)
}
}
fn format_blocked_referrers(items: &[BlockedReferrer]) -> String {
format_inline_list_overflow(items, "blocked_referrers")
}
pub fn format_inline_list_overflow<T: fmt::Display>(items: &[T], field: &str) -> String {
if items.is_empty() {
return String::new();
}
let head: Vec<String> = items
.iter()
.take(INLINE_LIST_CAP)
.map(|i| i.to_string())
.collect();
let inline = head.join(", ");
if items.len() > INLINE_LIST_CAP {
let extra = items.len() - INLINE_LIST_CAP;
format!("{inline} +{extra} more — see details.{field}")
} else {
inline
}
}
#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize)]
pub struct SchemaSourceDiagnostic {
pub source: &'static str,
pub versions_found: Vec<String>,
pub pinned_version_match: bool,
#[serde(skip_serializing_if = "Option::is_none")]
pub status: Option<&'static str>,
}
impl SchemaSourceDiagnostic {
pub fn for_failed_pin(
name: &str,
requested: &semver::Version,
consulted: &[std::sync::Arc<memstead_schema::Schema>],
) -> Vec<Self> {
use std::collections::BTreeSet;
let builtin: BTreeSet<semver::Version> = memstead_schema::builtins::load_builtin_schemas()
.map(|set| {
set.iter()
.filter(|s| s.manifest.name == name)
.map(|s| s.version.clone())
.collect()
})
.unwrap_or_default();
let local: BTreeSet<semver::Version> = consulted
.iter()
.filter(|s| s.manifest.name == name)
.map(|s| s.version.clone())
.filter(|v| !builtin.contains(v))
.collect();
let to_strings =
|set: &BTreeSet<semver::Version>| set.iter().map(|v| v.to_string()).collect::<Vec<_>>();
vec![
Self {
source: "local_storage",
pinned_version_match: local.contains(requested),
versions_found: to_strings(&local),
status: None,
},
Self {
source: "builtin",
pinned_version_match: builtin.contains(requested),
versions_found: to_strings(&builtin),
status: None,
},
Self {
source: "remote",
versions_found: Vec::new(),
pinned_version_match: false,
status: Some("not_configured"),
},
]
}
}
#[derive(Debug, thiserror::Error)]
pub enum EngineError {
#[error("duplicate mem in mount list: {0}")]
DuplicateMem(String),
#[error("unknown mem: {0}")]
UnknownMem(String),
#[error("mem {0} is mounted read-only; mutations rejected")]
ReadOnlyMount(String),
#[error(
"unknown entity type '{name}' in schema '{schema_ref}'. Declared types: [{}]{}",
declared.join(", "),
suggestion.as_deref().map(|s| format!(". Did you mean '{s}'?")).unwrap_or_default()
)]
UnknownType {
name: String,
schema_ref: String,
declared: Vec<String>,
suggestion: Option<String>,
},
#[error("title is invalid: {0}")]
InvalidTitle(#[from] SlugError),
#[error("entity already exists: {id}")]
AlreadyExists { id: String },
#[error("entity not found: {id}")]
NotFound { id: String },
#[error("{}", _hash_mismatch_msg(id, current, *is_stub))]
HashMismatch {
id: String,
current: String,
is_stub: bool,
},
#[error(
"entity {id} has {n} incoming reference(s) in write mems ({inline}); remove them first via memstead_relate --remove or memstead_update",
n = referrers.len(),
inline = format_inline_list_overflow(referrers, "referrers"),
)]
HasIncomingRefs {
id: String,
referrers: Vec<ReferrerInfo>,
},
#[error(
"mem `{mem}` has {n} incoming reference(s) in write mems ({inline}); remove them first via memstead_relate --remove or memstead_update",
n = referrers.len(),
inline = format_inline_list_overflow(referrers, "referrers"),
)]
MemHasIncomingRefs {
mem: String,
referrers: Vec<ReferrerInfo>,
},
#[error(
"cross-mem link from mem `{from_mem}` to mem `{to_mem}` is not allowed by the workspace `[cross_mem_links]` policy"
)]
CrossMemLinkNotAllowed { from_mem: String, to_mem: String },
#[error(
"cross-mem relate target {target_id} is absent in read-only mem `{target_mem}` — auto-stub is unavailable across the read-only boundary; the target must exist before relating"
)]
CrossMemTargetNotFound {
target_id: String,
target_mem: String,
},
#[error(
"cross-mem edge {rel_type} from `{from_id}` (schema {source_schema}) to `{to_id}` (schema {target_schema}) is not declared in {source_schema}'s `cross_mem_relationships:` section"
)]
CrossMemEdgeNotDeclared {
source_schema: String,
target_schema: String,
rel_type: String,
from_id: String,
to_id: String,
},
#[error(
"repair input refused for {id}: the entity currently passes the conformance check — {recovery}"
)]
RepairNotNeeded { id: String, recovery: String },
#[error(
"rename would not change the id of {id} — new title {new_title:?} produces the same slug"
)]
RenameNoOp { id: String, new_title: String },
#[error(
"no mutation content for {id} — payload carries an id but every mutation map is empty (recognised keys: sections, append_sections, patch_sections, metadata, metadata_unset, declare_relations, relations_unset)"
)]
EmptyUpdate { id: String },
#[error(
"rename blocked: cross-mem rewrite from referrer mem(s) into `{from_mem}` is not permitted by `[cross_mem_links]` — blocked: {} — grant the missing direction or rewrite the blocked referrers manually",
format_blocked_referrers(blocked_referrers)
)]
RenameBlockedByCrossMemPolicy {
from_mem: String,
blocked_referrers: Vec<BlockedReferrer>,
},
#[error(
"post-mutation body of {from_id} has {n} wiki-link(s) without a backing relation ({inline}) — declare the relation(s) via memstead_relate first (REFERENCES or a more specific rel-type), then retry",
n = missing.len(),
inline = format_inline_list_overflow(missing, "missing"),
)]
WikiLinkWithoutRelation {
from_id: String,
missing: Vec<MissingWikiLink>,
},
#[error(
"cannot remove {rel_type} {from_id} → {to_id}: source body still contains wiki-link(s) to the target in section(s) {inline} — drop them via memstead_update before removing the relation",
inline = format_inline_list_overflow(body_links, "body_links"),
)]
RelationHasBodyLinks {
from_id: String,
to_id: String,
rel_type: String,
body_links: Vec<String>,
},
#[error(
"rename partial-failure: mem `{failed_mem}` aborted with cause {failure_cause:?} after {committed_mems:?} already committed — reload and retry, or reconcile manually"
)]
RenamePartialFailure {
committed_mems: Vec<String>,
failed_mem: String,
failure_cause: String,
},
#[error("source entity {id} is a stub — promote it to a real entity via memstead_create first")]
StubCannotRelate { id: String },
#[error("entity {id} is a stub — promote it to a real entity via memstead_create first")]
StubNotUpdatable { id: String },
#[error(
"entity {id} is a stub — promote it to a real entity via memstead_create before renaming"
)]
StubNotRenamable { id: String },
#[error("entity id '{id}' is malformed: {reason}")]
InvalidEntityId { id: String, reason: String },
#[error("body wiki-link target '{raw}' in section '{section}' is not slug-form: {reason}")]
InvalidWikiLinkTarget {
raw: String,
suggested: Option<String>,
section: String,
link_source: String,
reason: String,
},
#[error(
"body wiki-link mem prefix '{raw}' in section '{section}' is not a valid mem name: {reason}"
)]
InvalidWikiLinkMem {
raw: String,
section: String,
reason: String,
},
#[error("conflicting section modes for {section}: {modes:?}")]
ConflictingSectionModes { section: String, modes: Vec<String> },
#[error(
"creating edge {rel_type} from '{from}' to '{to}' would close a cycle in the {rel_type} subgraph"
)]
RelationshipCycle {
rel_type: String,
from: EntityId,
to: EntityId,
existing_path: Vec<EntityId>,
path_truncated: bool,
},
#[error("metadata keys appear in both set and unset: {keys:?}")]
SetAndUnsetConflict { keys: Vec<String> },
#[error("{}", _required_field_unset_msg(field, entity_type, *on_create))]
RequiredFieldUnset {
field: String,
entity_type: String,
field_description: Option<String>,
enum_values: Vec<String>,
type_write_rules: Vec<String>,
on_create: bool,
missing: Vec<MissingRequiredField>,
},
#[error("missing {missing_count} required section(s) for type '{entity_type}'")]
MissingRequiredSection {
entity_type: String,
missing_count: usize,
sections: Vec<crate::runtime_validator::MissingRequiredSection>,
type_guidance: std::collections::BTreeMap<String, Vec<String>>,
},
#[error("patch target section is empty: {section}")]
PatchSectionEmpty { section: String },
#[error("patch `old` substring not found in {section}")]
PatchOldNotFound {
section: String,
current_content: String,
truncated: bool,
},
#[error("schema validation: {0}")]
Validation(#[from] ValidationError),
#[error("parse-after-write failed: {0}")]
ParseAfterWrite(String),
#[error("parse error: {0}")]
Parse(#[from] ParseError),
#[error(transparent)]
Backend(#[from] BackendError),
#[error("mem {mem}: schema pin {pin:?} did not resolve in any schema source")]
SchemaNotFound {
mem: String,
pin: String,
sources: Vec<SchemaSourceDiagnostic>,
},
#[error("built-in schema catalogue failed to load: {0}")]
SchemaResolverInit(String),
#[error("mem error: {0}")]
Mem(String),
#[error("mem name collision: {name} is already registered ({source_origin})")]
MemNameCollision { name: String, source_origin: String },
#[error("invalid input: {0}")]
InvalidInput(String),
#[error("unknown remote: {0}")]
UnknownRemote(String),
#[error(
"mem `{mem}`'s local branch has diverged from `{remote_ref}` — pull cannot fast-forward without losing local commits; rebase / replay first or run memstead branch-reset"
)]
LocalDivergence { mem: String, remote_ref: String },
#[error(
"push to remote `{remote}` for mem `{mem}` is not a fast-forward; rebase / replay locally or pass `force: true` to overwrite the remote"
)]
NonFastForward { mem: String, remote: String },
#[error(
"mem `{mem}` failed pre-push schema validation; remote `{remote}` was not contacted: {detail}"
)]
LocalInvalidState {
mem: String,
remote: String,
detail: String,
},
#[error(
"mem `{mem}` would fail schema validation at `{ref_name}` — {n} violation(s); fix the remote or replay locally first",
n = violations.len(),
)]
SchemaViolationInFetch {
mem: String,
ref_name: String,
violations: Vec<String>,
},
#[error(
"branch_reset refused: {} pushed commit(s) would be discarded ({}); pick a target that preserves the pushed segment or push the pre-reset state under a different branch first",
pushed_shas.len(),
pushed_shas.join(", "),
)]
PushedCommitsProtected {
mem: String,
target_sha: String,
pushed_shas: Vec<String>,
},
#[error("unknown ref: {0}")]
UnknownRef(String),
#[error("rename_similarity {requested} outside allowed range [{allowed_min}, {allowed_max}]")]
RenameSimilarityOutOfRange {
requested: f32,
allowed_min: f32,
allowed_max: f32,
},
#[error(
"commit cursor '{since}' is not a known commit in mem '{mem}' — pass a commit_sha from a prior mutation, or the empty-tree sentinel to re-seed"
)]
InvalidChangesCursor { mem: String, since: String },
#[error(
"mem `{mem}` config is missing required field(s) {missing_fields:?} — \
set via `memstead mem set-version {mem} <version>` (e.g. 0.1.0)"
)]
MemConfigIncomplete {
mem: String,
missing_fields: Vec<String>,
},
#[error(
"rel-type `{rel_type}` declares `per_edge_description: required` — \
{from_id} → {to_id} needs a description; re-issue with \
`--description \"<text>\"`."
)]
MissingRequiredDescription {
rel_type: String,
from_id: String,
to_id: String,
},
#[error(
"rel-type `{rel_type}` declares `per_edge_description: forbidden` — \
{from_id} → {to_id} cannot carry a description; drop the \
`--description` argument."
)]
DescriptionNotPermitted {
rel_type: String,
from_id: String,
to_id: String,
},
#[error(
"rel-type `{rel_type}` declares `manual_authoring: forbidden` — \
{from_id} → {to_id} cannot be authored explicitly; this rel-type \
is reserved for engine-emitted synthesis via the body-link → \
relation alias path. {guidance}"
)]
RelationManualAuthoringForbidden {
rel_type: String,
from_id: String,
to_id: String,
guidance: String,
},
#[error(
"full-text search is unavailable in this engine build (wasm32); \
route search queries to the bridge's memstead_search endpoint"
)]
SearchUnavailable,
#[error(
"mem `{mem}` is on backend `{active_backend}`; `memstead export --format markdown` \
is supported only on backends [{}] — use `--format mem` to produce a portable \
`.mem` archive instead",
supported_backends.join(", ")
)]
MarkdownExportUnsupportedBackend {
mem: String,
active_backend: String,
supported_backends: Vec<String>,
},
}
#[derive(Debug, Clone, serde::Serialize)]
pub struct ReferrerInfo {
pub from_id: String,
pub rel_types: Vec<String>,
pub mem: String,
}
impl fmt::Display for ReferrerInfo {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
if self.rel_types.len() <= 1 {
f.write_str(&self.from_id)
} else {
write!(
f,
"{} ×{} [{}]",
self.from_id,
self.rel_types.len(),
self.rel_types.join(", ")
)
}
}
}
#[derive(Debug, Clone, serde::Serialize, PartialEq, Eq)]
pub struct MissingWikiLink {
pub section_key: String,
pub target_id: String,
}
impl fmt::Display for MissingWikiLink {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "{}→{}", self.section_key, self.target_id)
}
}
impl EngineError {
pub fn code(&self) -> &'static str {
match self {
EngineError::DuplicateMem(_) => "DUPLICATE_MEM",
EngineError::UnknownMem(_) => "UNKNOWN_MEM",
EngineError::UnknownRef(_) => "UNKNOWN_REF",
EngineError::UnknownRemote(_) => "UNKNOWN_REMOTE",
EngineError::LocalDivergence { .. } => "LOCAL_DIVERGENCE",
EngineError::NonFastForward { .. } => "NON_FAST_FORWARD",
EngineError::LocalInvalidState { .. } => "LOCAL_INVALID_STATE",
EngineError::SchemaViolationInFetch { .. } => "SCHEMA_VIOLATION_IN_FETCH",
EngineError::PushedCommitsProtected { .. } => "PUSHED_COMMITS_PROTECTED",
EngineError::ReadOnlyMount(_) => "READ_ONLY_MOUNT",
EngineError::UnknownType { .. } => "UNKNOWN_ENTITY_TYPE",
EngineError::InvalidTitle(_) => "INVALID_TITLE",
EngineError::AlreadyExists { .. } => "ENTITY_ALREADY_EXISTS",
EngineError::NotFound { .. } => "ENTITY_NOT_FOUND",
EngineError::HashMismatch { .. } => "HASH_MISMATCH",
EngineError::HasIncomingRefs { .. } => "HAS_INCOMING_REFS",
EngineError::MemHasIncomingRefs { .. } => "MEM_HAS_INCOMING_REFS",
EngineError::CrossMemLinkNotAllowed { .. } => "CROSS_MEM_LINK_NOT_ALLOWED",
EngineError::CrossMemTargetNotFound { .. } => "CROSS_MEM_TARGET_NOT_FOUND",
EngineError::CrossMemEdgeNotDeclared { .. } => "CROSS_MEM_EDGE_NOT_DECLARED",
EngineError::RepairNotNeeded { .. } => "REPAIR_NOT_NEEDED",
EngineError::RenameNoOp { .. } => "RENAME_NO_OP",
EngineError::EmptyUpdate { .. } => "EMPTY_UPDATE",
EngineError::RenameBlockedByCrossMemPolicy { .. } => {
"RENAME_BLOCKED_BY_CROSS_MEM_POLICY"
}
EngineError::RenamePartialFailure { .. } => "RENAME_PARTIAL_FAILURE",
EngineError::RelationHasBodyLinks { .. } => "RELATION_HAS_BODY_LINKS",
EngineError::WikiLinkWithoutRelation { .. } => "WIKILINK_WITHOUT_RELATION",
EngineError::StubCannotRelate { .. } => "STUB_CANNOT_RELATE",
EngineError::StubNotUpdatable { .. } => "STUB_NOT_UPDATABLE",
EngineError::StubNotRenamable { .. } => "STUB_NOT_RENAMABLE",
EngineError::InvalidEntityId { .. } => "INVALID_ENTITY_ID",
EngineError::InvalidWikiLinkTarget { .. } => "INVALID_WIKI_LINK_TARGET",
EngineError::InvalidWikiLinkMem { .. } => "INVALID_MEM_NAME",
EngineError::ConflictingSectionModes { .. } => "CONFLICTING_SECTION_MODES",
EngineError::RelationshipCycle { .. } => "RELATIONSHIP_CYCLE",
EngineError::SetAndUnsetConflict { .. } => "SET_AND_UNSET_CONFLICT",
EngineError::RequiredFieldUnset { .. } => "REQUIRED_FIELD_UNSET",
EngineError::MissingRequiredSection { .. } => "MISSING_REQUIRED_SECTION",
EngineError::PatchSectionEmpty { .. } => "PATCH_SECTION_EMPTY",
EngineError::PatchOldNotFound { .. } => "PATCH_OLD_NOT_FOUND",
EngineError::Validation(v) => v.code(),
EngineError::ParseAfterWrite(_) => "PARSE_ERROR",
EngineError::Parse(_) => "PARSE_ERROR",
EngineError::Backend(_) => "MEM_ERROR",
EngineError::SchemaNotFound { .. } => "SCHEMA_NOT_FOUND",
EngineError::SchemaResolverInit(_) => "SCHEMA_RESOLVER_INIT_FAILED",
EngineError::Mem(_) => "MEM_ERROR",
EngineError::MemNameCollision { .. } => "MEM_NAME_COLLISION",
EngineError::InvalidInput(_) => "INVALID_INPUT",
EngineError::RenameSimilarityOutOfRange { .. } => "INVALID_INPUT",
EngineError::InvalidChangesCursor { .. } => "INVALID_CURSOR",
EngineError::MemConfigIncomplete { .. } => "MEM_CONFIG_INCOMPLETE",
EngineError::MissingRequiredDescription { .. } => "MISSING_REQUIRED_DESCRIPTION",
EngineError::DescriptionNotPermitted { .. } => "DESCRIPTION_NOT_PERMITTED",
EngineError::RelationManualAuthoringForbidden { .. } => {
"RELATION_MANUAL_AUTHORING_FORBIDDEN"
}
EngineError::SearchUnavailable => "SEARCH_UNAVAILABLE_IN_WASM",
EngineError::MarkdownExportUnsupportedBackend { .. } => {
"MARKDOWN_EXPORT_UNSUPPORTED_BACKEND"
}
}
}
pub fn details(&self) -> serde_json::Value {
match self {
EngineError::NotFound { id } => serde_json::json!({ "id": id }),
EngineError::RepairNotNeeded { id, recovery } => {
serde_json::json!({ "id": id, "recovery": recovery })
}
EngineError::UnknownType {
name,
schema_ref,
declared,
suggestion,
} => serde_json::json!({
"name": name,
"schema_ref": schema_ref,
"declared": declared,
"suggestion": suggestion,
}),
EngineError::HashMismatch {
id,
current,
is_stub,
} => serde_json::json!({
"id": id,
"current": current,
"is_stub": is_stub,
}),
EngineError::HasIncomingRefs { id, referrers } => {
let referrers_json: Vec<_> = referrers
.iter()
.map(|r| {
serde_json::json!({
"from_id": r.from_id,
"rel_types": r.rel_types,
"mem": r.mem,
})
})
.collect();
serde_json::json!({ "id": id, "referrers": referrers_json })
}
EngineError::MemHasIncomingRefs { mem, referrers } => {
let referrers_json: Vec<_> = referrers
.iter()
.map(|r| {
serde_json::json!({
"from_id": r.from_id,
"rel_types": r.rel_types,
"mem": r.mem,
})
})
.collect();
serde_json::json!({ "mem": mem, "referrers": referrers_json })
}
EngineError::WikiLinkWithoutRelation { from_id, missing } => serde_json::json!({
"from_id": from_id,
"missing": missing,
}),
EngineError::RelationHasBodyLinks {
from_id,
to_id,
rel_type,
body_links,
} => {
serde_json::json!({
"from_id": from_id,
"to_id": to_id,
"rel_type": rel_type,
"body_links": body_links,
})
}
EngineError::InvalidEntityId { id, reason } => {
serde_json::json!({ "id": id, "reason": reason })
}
EngineError::InvalidWikiLinkTarget {
raw,
suggested,
section,
link_source,
reason,
} => {
let proposed_slug = suggested
.as_ref()
.filter(|s| !s.contains(':') && !s.contains("--"));
serde_json::json!({
"raw": raw,
"suggested": suggested,
"proposed_slug": proposed_slug,
"section": section,
"source": link_source,
"reason": reason,
})
}
EngineError::InvalidWikiLinkMem {
raw,
section,
reason,
} => {
serde_json::json!({ "raw": raw, "section": section, "reason": reason })
}
EngineError::ConflictingSectionModes { section, modes } => {
serde_json::json!({ "section": section, "modes": modes })
}
EngineError::SetAndUnsetConflict { keys } => serde_json::json!({ "keys": keys }),
EngineError::RequiredFieldUnset {
field,
entity_type,
field_description,
enum_values,
type_write_rules,
on_create: _,
missing,
} => {
let missing_json: Vec<_> = missing
.iter()
.map(|m| {
serde_json::json!({
"field": m.key,
"description": m.description,
"enum_values": m.enum_values,
"write_rules": type_write_rules,
})
})
.collect();
serde_json::json!({
"field": field,
"entity_type": entity_type,
"field_description": field_description,
"enum_values": enum_values,
"type_write_rules": type_write_rules,
"missing": missing_json,
})
}
EngineError::MissingRequiredSection {
entity_type,
missing_count,
sections,
type_guidance,
} => {
let sections_json: Vec<_> = sections
.iter()
.map(|s| {
serde_json::json!({
"entity_type": s.entity_type,
"key": s.key,
"heading": s.heading,
"write_rules": s.write_rules,
})
})
.collect();
serde_json::json!({
"entity_type": entity_type,
"missing_count": missing_count,
"sections": sections_json,
"type_guidance": type_guidance,
})
}
EngineError::PatchSectionEmpty { section } => serde_json::json!({ "section": section }),
EngineError::PatchOldNotFound {
section,
current_content,
truncated,
} => {
serde_json::json!({
"section": section,
"current_content": current_content,
"truncated": truncated,
})
}
EngineError::RelationshipCycle {
rel_type,
from,
to,
existing_path,
path_truncated,
} => {
let path_json: Vec<_> = existing_path.iter().map(|id| id.to_string()).collect();
serde_json::json!({
"rel_type": rel_type,
"from": from.to_string(),
"to": to.to_string(),
"existing_path": path_json,
"path_truncated": path_truncated,
})
}
EngineError::CrossMemLinkNotAllowed { from_mem, to_mem } => {
serde_json::json!({ "from_mem": from_mem, "to_mem": to_mem })
}
EngineError::EmptyUpdate { id } => {
serde_json::json!({
"id": id,
"recognised_keys": [
"sections", "append_sections", "patch_sections",
"metadata", "metadata_unset", "declare_relations", "relations_unset",
],
})
}
EngineError::RenameBlockedByCrossMemPolicy {
from_mem,
blocked_referrers,
} => {
let entries: Vec<_> = blocked_referrers
.iter()
.map(|r| {
serde_json::json!({
"from_mem": r.from_mem,
"to_mem": r.to_mem,
"count": r.count,
})
})
.collect();
serde_json::json!({
"from_mem": from_mem,
"blocked_referrers": entries,
})
}
EngineError::CrossMemTargetNotFound {
target_id,
target_mem,
} => {
serde_json::json!({ "target_id": target_id, "target_mem": target_mem })
}
EngineError::Validation(v) => v.details(),
EngineError::MissingRequiredDescription {
rel_type,
from_id,
to_id,
} => {
serde_json::json!({
"rel_type": rel_type,
"from_id": from_id,
"to_id": to_id,
})
}
EngineError::DescriptionNotPermitted {
rel_type,
from_id,
to_id,
} => {
serde_json::json!({
"rel_type": rel_type,
"from_id": from_id,
"to_id": to_id,
})
}
EngineError::RelationManualAuthoringForbidden {
rel_type,
from_id,
to_id,
guidance,
} => serde_json::json!({
"rel_type": rel_type,
"from_id": from_id,
"to_id": to_id,
"guidance": guidance,
}),
EngineError::MarkdownExportUnsupportedBackend {
mem,
active_backend,
supported_backends,
} => serde_json::json!({
"mem": mem,
"active_backend": active_backend,
"supported_backends": supported_backends,
}),
EngineError::InvalidChangesCursor { mem, since } => serde_json::json!({
"mem": mem,
"since": since,
}),
EngineError::SchemaNotFound { mem, pin, sources } => serde_json::json!({
"mem": mem,
"pin": pin,
"sources": sources,
}),
_ => serde_json::Value::Object(serde_json::Map::new()),
}
}
pub fn prose_render(&self) -> String {
match self {
EngineError::HasIncomingRefs { id, referrers } => {
let inline = render_referrers_inline(referrers);
format!(
"entity {id} has {n} incoming reference(s) in write mems ({inline}); remove them first via memstead_relate --remove or memstead_update",
n = referrers.len(),
)
}
EngineError::MemHasIncomingRefs { mem, referrers } => {
let inline = render_referrers_inline(referrers);
format!(
"mem `{mem}` has {n} incoming reference(s) in write mems ({inline}); remove them first via memstead_relate --remove or memstead_update",
n = referrers.len(),
)
}
EngineError::WikiLinkWithoutRelation { from_id, missing } => {
let inline = missing
.iter()
.map(|m| m.to_string())
.collect::<Vec<_>>()
.join(", ");
format!(
"post-mutation body of {from_id} has {n} wiki-link(s) without a backing relation ({inline}) — declare the relation(s) via memstead_relate first (REFERENCES or a more specific rel-type), then retry",
n = missing.len(),
)
}
EngineError::RelationHasBodyLinks {
from_id,
to_id,
rel_type,
body_links,
} => {
let inline = body_links.join(", ");
format!(
"cannot remove {rel_type} {from_id} → {to_id}: source body still contains wiki-link(s) to the target in section(s) {inline} — drop them via memstead_update before removing the relation"
)
}
EngineError::RelationshipCycle {
rel_type,
from,
to,
existing_path,
path_truncated,
} => {
let path_inline = if existing_path.is_empty() {
String::from("(unavailable)")
} else {
existing_path
.iter()
.map(|id| id.to_string())
.collect::<Vec<_>>()
.join(" → ")
};
let trunc = if *path_truncated {
" (path truncated)"
} else {
""
};
format!(
"creating edge {rel_type} from '{from}' to '{to}' would close a cycle in the {rel_type} subgraph — existing path: {path_inline}{trunc}; remove an edge along this path to break the cycle, then retry"
)
}
EngineError::RequiredFieldUnset {
field,
entity_type,
field_description,
enum_values,
type_write_rules,
on_create,
missing,
} => {
let desc_clause = field_description
.as_deref()
.map(|d| format!(" Field purpose: {d}."))
.unwrap_or_default();
let enum_clause = if enum_values.is_empty() {
String::new()
} else {
format!(" Allowed values: {}.", enum_values.join(", "))
};
let rules_clause = if type_write_rules.is_empty() {
String::new()
} else {
format!(" Type-level write_rules: {}.", type_write_rules.join("; "))
};
let lead = if *on_create {
format!(
"required metadata field '{field}' not provided — type '{entity_type}' declares the field as required and has no default for it"
)
} else {
format!("cannot unset required field '{field}' for type '{entity_type}'")
};
let tail_clause = if missing.len() > 1 {
let others: Vec<&str> =
missing.iter().skip(1).map(|m| m.key.as_str()).collect();
format!(" Also unset (declaration order): {}.", others.join(", "))
} else {
String::new()
};
format!("{lead}.{desc_clause}{enum_clause}{rules_clause}{tail_clause}")
}
EngineError::MissingRequiredSection {
entity_type,
missing_count,
sections,
type_guidance,
} => {
let mut out = format!(
"missing {missing_count} required section(s) for type '{entity_type}':"
);
for s in sections {
let rules = if s.write_rules.is_empty() {
String::new()
} else {
format!(" — write_rules: {}", s.write_rules.join("; "))
};
out.push_str(&format!("\n - '{}' ({}){rules}", s.key, s.heading));
}
if !type_guidance.is_empty() {
out.push_str("\nType guidance:");
for (etype, rules) in type_guidance {
if rules.is_empty() {
continue;
}
out.push_str(&format!("\n - {etype}: {}", rules.join("; ")));
}
}
out
}
EngineError::Validation(v) => v.prose_render(),
_ => self.to_string(),
}
}
}
fn render_referrers_inline(referrers: &[ReferrerInfo]) -> String {
referrers
.iter()
.map(|r| r.to_string())
.collect::<Vec<_>>()
.join(", ")
}
fn _required_field_unset_msg(field: &str, entity_type: &str, on_create: bool) -> String {
if on_create {
format!(
"required metadata field '{field}' not provided — type '{entity_type}' declares the field as required and has no default for it"
)
} else {
format!("cannot unset required field '{field}' for type '{entity_type}'")
}
}
fn _hash_mismatch_msg(id: &str, current: &str, is_stub: bool) -> String {
if is_stub {
format!(
"hash mismatch for {id} — entity is a stub (no content_hash); pass expected_hash: \"\" to operate on stubs"
)
} else {
format!("hash mismatch for {id} — entity was modified concurrently (current: {current})")
}
}
#[derive(Debug, thiserror::Error)]
pub enum BootError {
#[error("workspace at {0} is not initialised — run `memstead mem-repo init` first")]
NotInitialised(PathBuf),
#[error(transparent)]
Store(#[from] crate::workspace_store::StoreError),
#[error(transparent)]
Instantiate(#[from] crate::workspace_store::InstantiateError),
#[error(transparent)]
Engine(#[from] EngineError),
}
#[cfg(test)]
mod plan05_subsystem_tests {
use super::*;
#[test]
fn invalid_wiki_link_details_carry_proposed_slug_for_title_case() {
let err = EngineError::InvalidWikiLinkTarget {
raw: "Idempotency".to_string(),
suggested: Some("idempotency".to_string()),
section: "purpose".to_string(),
link_source: "body_link".to_string(),
reason: "slugs must be lowercase".to_string(),
};
let d = err.details();
assert_eq!(d["proposed_slug"], "idempotency");
assert_eq!(d["suggested"], "idempotency");
}
#[test]
fn schema_not_found_details_carry_fixed_order_source_diagnostics() {
let requested: semver::Version = "99.0.0".parse().unwrap();
let sources = SchemaSourceDiagnostic::for_failed_pin("default", &requested, &[]);
let err = EngineError::SchemaNotFound {
mem: "specs".to_string(),
pin: "default@99.0.0".to_string(),
sources,
};
assert_eq!(err.code(), "SCHEMA_NOT_FOUND");
let d = err.details();
assert_eq!(d["mem"], "specs");
assert_eq!(d["pin"], "default@99.0.0");
let src = d["sources"].as_array().expect("sources is an array");
let labels: Vec<&str> = src.iter().map(|s| s["source"].as_str().unwrap()).collect();
assert_eq!(labels, ["local_storage", "builtin", "remote"]);
let builtin = &src[1];
assert!(
builtin["versions_found"]
.as_array()
.unwrap()
.iter()
.any(|v| v == "1.0.0"),
"builtin must enumerate default@1.0.0, got {builtin:?}",
);
assert_eq!(builtin["pinned_version_match"], false);
assert_eq!(src[0]["versions_found"].as_array().unwrap().len(), 0);
assert_eq!(src[2]["status"], "not_configured");
assert!(
src[2].get("versions_found").is_some(),
"remote still ships an (empty) versions_found list",
);
}
#[test]
fn invalid_wiki_link_colon_form_suggestion_is_not_a_proposed_slug() {
let err = EngineError::InvalidWikiLinkTarget {
raw: "team/sub--thing".to_string(),
suggested: Some("team/sub:thing".to_string()),
section: "purpose".to_string(),
link_source: "body_link".to_string(),
reason: "ambiguous".to_string(),
};
let d = err.details();
assert!(
d["proposed_slug"].is_null(),
"colon-form must not be a proposed_slug: {d}"
);
assert_eq!(d["suggested"], "team/sub:thing");
}
#[test]
fn invalid_changes_cursor_code_and_details() {
let sha = "deadbeefdeadbeefdeadbeefdeadbeefdeadbeef";
let err = EngineError::InvalidChangesCursor {
mem: "specs".to_string(),
since: sha.to_string(),
};
assert_eq!(err.code(), "INVALID_CURSOR");
let d = err.details();
assert_eq!(d["mem"], "specs");
assert_eq!(
d["since"], sha,
"the offending SHA must ride untruncated in details"
);
}
}
#[cfg(test)]
mod inline_list_tests {
use super::*;
#[test]
fn empty_list_renders_empty_string() {
let items: Vec<String> = Vec::new();
assert_eq!(format_inline_list_overflow(&items, "x"), "");
}
#[test]
fn list_at_cap_renders_all_no_overflow_suffix() {
let items = vec!["a".to_string(), "b".to_string(), "c".to_string()];
assert_eq!(format_inline_list_overflow(&items, "x"), "a, b, c");
}
#[test]
fn list_under_cap_renders_all_no_overflow_suffix() {
let items = vec!["a".to_string(), "b".to_string()];
assert_eq!(format_inline_list_overflow(&items, "x"), "a, b");
}
#[test]
fn list_over_cap_appends_count_and_field_name() {
let items: Vec<String> = (0..23).map(|i| format!("id{i}")).collect();
let rendered = format_inline_list_overflow(&items, "referrers");
assert_eq!(rendered, "id0, id1, id2 +20 more — see details.referrers");
}
#[test]
fn list_six_items_truncates_to_three_plus_three() {
let items: Vec<String> = (0..6).map(|i| format!("t{i}")).collect();
let rendered = format_inline_list_overflow(&items, "missing");
assert_eq!(rendered, "t0, t1, t2 +3 more — see details.missing");
}
#[test]
fn has_incoming_refs_display_inlines_first_three_referrer_ids() {
let referrers: Vec<ReferrerInfo> = (0..23)
.map(|i| ReferrerInfo {
from_id: format!("specs--ref{i}"),
rel_types: vec!["USES".to_string()],
mem: "specs".to_string(),
})
.collect();
let err = EngineError::HasIncomingRefs {
id: "specs--hub".to_string(),
referrers,
};
let s = err.to_string();
assert!(
s.contains("specs--ref0, specs--ref1, specs--ref2"),
"got: {s}"
);
assert!(s.contains("+20 more — see details.referrers"), "got: {s}");
assert!(s.contains("23 incoming reference"), "got: {s}");
}
#[test]
fn wiki_link_without_relation_display_lists_all_when_under_cap() {
let missing = vec![
MissingWikiLink {
section_key: "specifies".to_string(),
target_id: "specs--a".to_string(),
},
MissingWikiLink {
section_key: "specifies".to_string(),
target_id: "specs--b".to_string(),
},
MissingWikiLink {
section_key: "rationale".to_string(),
target_id: "specs--c".to_string(),
},
];
let err = EngineError::WikiLinkWithoutRelation {
from_id: "specs--src".to_string(),
missing,
};
let s = err.to_string();
assert!(s.contains("specifies→specs--a"), "got: {s}");
assert!(s.contains("specifies→specs--b"), "got: {s}");
assert!(s.contains("rationale→specs--c"), "got: {s}");
assert!(!s.contains("more — see details"), "got: {s}");
}
#[test]
fn wiki_link_without_relation_display_truncates_at_cap_with_pointer() {
let missing: Vec<MissingWikiLink> = (0..6)
.map(|i| MissingWikiLink {
section_key: format!("s{i}"),
target_id: format!("specs--t{i}"),
})
.collect();
let err = EngineError::WikiLinkWithoutRelation {
from_id: "specs--src".to_string(),
missing,
};
let s = err.to_string();
assert!(
s.contains("s0→specs--t0, s1→specs--t1, s2→specs--t2"),
"got: {s}"
);
assert!(s.contains("+3 more — see details.missing"), "got: {s}");
}
#[test]
fn relation_has_body_links_display_inlines_section_keys() {
let err = EngineError::RelationHasBodyLinks {
from_id: "specs--src".to_string(),
to_id: "specs--dst".to_string(),
rel_type: "USES".to_string(),
body_links: vec!["specifies".to_string(), "rationale".to_string()],
};
let s = err.to_string();
assert!(s.contains("specifies, rationale"), "got: {s}");
assert!(!s.contains("more — see details"), "got: {s}");
}
#[test]
fn prose_render_has_incoming_refs_inlines_every_referrer() {
let referrers = (0..7)
.map(|i| ReferrerInfo {
from_id: format!("specs--r{i}"),
rel_types: vec!["DEPENDS_ON".to_string()],
mem: "specs".to_string(),
})
.collect();
let err = EngineError::HasIncomingRefs {
id: "specs--target".to_string(),
referrers,
};
let prose = err.prose_render();
for i in 0..7 {
assert!(
prose.contains(&format!("specs--r{i}")),
"every referrer must appear inline; missing r{i} in: {prose}"
);
}
assert!(!prose.contains("see details"), "got: {prose}");
let display = err.to_string();
assert!(
display.contains("+4 more — see details.referrers"),
"got: {display}"
);
}
#[test]
fn prose_render_required_field_unset_inlines_field_description_and_rules() {
let err = EngineError::RequiredFieldUnset {
field: "verified_on".to_string(),
entity_type: "requirement".to_string(),
field_description: Some("ISO-8601 date the requirement was last validated".to_string()),
enum_values: vec![],
type_write_rules: vec!["bump verified_on on every status change".to_string()],
on_create: false,
missing: Vec::new(),
};
let prose = err.prose_render();
assert!(
prose.contains("ISO-8601 date"),
"field_description missing: {prose}"
);
assert!(
prose.contains("bump verified_on"),
"type_write_rules missing: {prose}"
);
assert!(!prose.contains("see details"), "got: {prose}");
assert!(
prose.contains("cannot unset"),
"update-path wording must say 'cannot unset': {prose}"
);
}
#[test]
fn prose_render_required_field_unset_create_path_uses_not_provided_wording() {
let err = EngineError::RequiredFieldUnset {
field: "verified_on".to_string(),
entity_type: "requirement".to_string(),
field_description: Some("ISO-8601 date the requirement was last validated".to_string()),
enum_values: vec![],
type_write_rules: vec![],
on_create: true,
missing: Vec::new(),
};
let prose = err.prose_render();
assert!(
prose.contains("not provided"),
"create-path wording must say 'not provided': {prose}"
);
assert!(
!prose.contains("cannot unset"),
"create-path wording must NOT say 'cannot unset': {prose}"
);
let display = err.to_string();
assert!(
display.contains("not provided"),
"Display must match: {display}"
);
assert!(
!display.contains("cannot unset"),
"Display must match: {display}"
);
}
#[test]
fn details_required_field_unset_multi_field_envelope_shape() {
use crate::runtime_validator::MissingRequiredField;
let err = EngineError::RequiredFieldUnset {
field: "decided_on".to_string(),
entity_type: "decision".to_string(),
field_description: Some("Date the decision was accepted. ISO YYYY-MM-DD.".to_string()),
enum_values: vec![],
type_write_rules: vec!["status transitions: proposed → accepted".to_string()],
on_create: true,
missing: vec![
MissingRequiredField {
entity_type: "decision".to_string(),
key: "decided_on".to_string(),
description: "Date the decision was accepted. ISO YYYY-MM-DD.".to_string(),
enum_values: vec![],
},
MissingRequiredField {
entity_type: "decision".to_string(),
key: "deciders".to_string(),
description: "Who made the call. Comma-separated handles.".to_string(),
enum_values: vec![],
},
],
};
let details = err.details();
assert_eq!(details["field"].as_str(), Some("decided_on"));
let missing = details["missing"].as_array().expect("missing[] array");
assert_eq!(missing.len(), 2);
assert_eq!(missing[0]["field"].as_str(), Some("decided_on"));
assert_eq!(missing[1]["field"].as_str(), Some("deciders"));
assert_eq!(details["field"], missing[0]["field"]);
assert_eq!(missing[0]["write_rules"], details["type_write_rules"]);
let prose = err.prose_render();
assert!(prose.contains("decided_on"), "got: {prose}");
assert!(prose.contains("deciders"), "got: {prose}");
}
#[test]
fn details_required_field_unset_singular_shape_for_unset_path() {
let err = EngineError::RequiredFieldUnset {
field: "decided_on".to_string(),
entity_type: "decision".to_string(),
field_description: Some("…".to_string()),
enum_values: vec![],
type_write_rules: vec![],
on_create: false,
missing: Vec::new(),
};
let details = err.details();
assert_eq!(details["field"].as_str(), Some("decided_on"));
let missing = details["missing"]
.as_array()
.expect("missing[] array present");
assert!(missing.is_empty(), "unset-path missing[] must be empty");
assert_eq!(err.code(), "REQUIRED_FIELD_UNSET");
}
#[test]
fn prose_render_missing_required_section_enumerates_each_section_with_write_rules() {
use crate::runtime_validator::MissingRequiredSection;
let sections = vec![
MissingRequiredSection {
entity_type: "spec".to_string(),
key: "purpose".to_string(),
heading: "Purpose".to_string(),
write_rules: vec!["one-sentence statement of intent".to_string()],
},
MissingRequiredSection {
entity_type: "spec".to_string(),
key: "scope".to_string(),
heading: "Scope".to_string(),
write_rules: vec!["what is in and out of scope".to_string()],
},
];
let mut type_guidance: std::collections::BTreeMap<String, Vec<String>> = Default::default();
type_guidance.insert(
"spec".to_string(),
vec!["specs are immutable once stable".to_string()],
);
let err = EngineError::MissingRequiredSection {
entity_type: "spec".to_string(),
missing_count: 2,
sections,
type_guidance,
};
let prose = err.prose_render();
assert!(prose.contains("purpose"), "got: {prose}");
assert!(prose.contains("scope"), "got: {prose}");
assert!(
prose.contains("one-sentence statement of intent"),
"got: {prose}"
);
assert!(
prose.contains("specs are immutable once stable"),
"got: {prose}"
);
assert!(!prose.contains("see details"), "got: {prose}");
}
#[test]
fn prose_render_relationship_cycle_inlines_existing_path() {
use crate::entity::EntityId;
let path = vec![
EntityId::canonical("specs--a"),
EntityId::canonical("specs--b"),
EntityId::canonical("specs--c"),
EntityId::canonical("specs--a"),
];
let err = EngineError::RelationshipCycle {
rel_type: "PART_OF".to_string(),
from: EntityId::canonical("specs--a"),
to: EntityId::canonical("specs--c"),
existing_path: path,
path_truncated: false,
};
let prose = err.prose_render();
assert!(
prose.contains("specs--a → specs--b → specs--c → specs--a"),
"got: {prose}"
);
assert!(!prose.contains("see details"), "got: {prose}");
}
#[test]
fn prose_render_falls_back_to_display_for_trivial_variants() {
let err = EngineError::ReadOnlyMount("archive-2024".to_string());
assert_eq!(err.prose_render(), err.to_string());
}
}