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")
}
fn render_occupant(existing_title: &str, existing_is_stub: bool) -> String {
match (existing_is_stub, existing_title.is_empty()) {
(true, true) => "a stub".to_string(),
(true, false) => format!("a stub titled '{existing_title}'"),
(false, _) => format!("'{existing_title}'"),
}
}
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 '{mem}' is quarantined — it failed to attach at boot and serves nothing until \
repaired: [{reason_code}] {reason_message}. After repairing, run memstead_reload \
(or `memstead reload`) to bring it back into service."
)]
MemQuarantined {
mem: String,
reason_code: String,
reason_message: String,
},
#[error("mem {0} is mounted read-only; mutations rejected")]
ReadOnlyMount(String),
#[error("check not recorded: {reason}")]
CheckNotRecorded { reason: 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} — occupied by {}",
render_occupant(existing_title, *existing_is_stub)
)]
AlreadyExists {
id: String,
existing_title: String,
existing_is_stub: bool,
},
#[error(
"write refused: {entity_id} ({entity_type}) violates {n} block-tier declared constraint(s) — first: {first}",
n = violations.len(),
first = violations.first().map(|v| v.describe()).unwrap_or_default(),
)]
ConstraintUnsatisfied {
entity_type: String,
entity_id: String,
violations: Vec<crate::ops::health::UnsatisfiedConstraint>,
},
#[error("write refused: {entity_id} ({entity_type}) — {}", violation.describe())]
SectionFormatRefused {
entity_type: String,
entity_id: String,
violation: crate::section_format::SectionFormatViolation,
},
#[error(
"write refused: {entity_id} ({entity_type}) leaves {n} block-tier `required_outgoing` block(s) unsatisfied",
n = missing.len(),
)]
RequiredOutgoingUnsatisfied {
entity_type: String,
entity_id: String,
missing: Vec<crate::ops::MissingRequiredOutgoingBlock>,
},
#[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 link 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 linking"
)]
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("{}", schema_not_found_message(mem, pin, sources, install_hint))]
SchemaNotFound {
mem: String,
pin: String,
sources: Vec<SchemaSourceDiagnostic>,
install_hint: Option<String>,
},
#[error(
"mem {mem}: the schema {pin} embedded in the archive could not be loaded: {reason} — \
the package is inside the archive, so this is the publisher's to fix; nothing was \
staged or mounted"
)]
EmbeddedSchemaInvalid {
mem: String,
pin: String,
reason: String,
},
#[error("schema package '{name}@{version}' failed validation: {message}")]
SchemaPackageInvalid {
name: String,
version: String,
message: String,
},
#[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(
"branch_reset refused: '{mem}' has advanced past the observed head (expected {expected}, live {current}) — the span now contains foreign commits; reload and review the accumulated delta instead"
)]
BranchResetHeadMoved {
mem: String,
expected: String,
current: 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}' has no review mark — set one first, or read the full history via changes_since"
)]
ReviewMarkNotSet { mem: 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>,
},
#[error("invalid anchor: {0}")]
InvalidAnchor(#[from] crate::anchor::AnchorValidationError),
}
#[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::MemQuarantined { .. } => "MEM_QUARANTINED",
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::BranchResetHeadMoved { .. } => "BRANCH_RESET_HEAD_MOVED",
EngineError::ReadOnlyMount(_) => "READ_ONLY_MOUNT",
EngineError::CheckNotRecorded { .. } => "CHECK_NOT_RECORDED",
EngineError::UnknownType { .. } => "UNKNOWN_ENTITY_TYPE",
EngineError::InvalidTitle(_) => "INVALID_TITLE",
EngineError::AlreadyExists { .. } => "ENTITY_ALREADY_EXISTS",
EngineError::ConstraintUnsatisfied { .. } => "CONSTRAINT_UNSATISFIED",
EngineError::RequiredOutgoingUnsatisfied { .. } => "MISSING_REQUIRED_OUTGOING",
EngineError::SectionFormatRefused { violation, .. } => violation.code(),
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::EmbeddedSchemaInvalid { .. } => "EMBEDDED_SCHEMA_INVALID",
EngineError::SchemaPackageInvalid { .. } => "SCHEMA_VALIDATION_FAILED",
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::ReviewMarkNotSet { .. } => "REVIEW_MARK_NOT_SET",
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"
}
EngineError::InvalidAnchor(_) => crate::anchor::INVALID_ANCHOR_CODE,
}
}
pub fn details(&self) -> serde_json::Value {
match self {
EngineError::NotFound { id } => serde_json::json!({ "id": id }),
EngineError::AlreadyExists {
id,
existing_title,
existing_is_stub,
} => serde_json::json!({
"id": id,
"existing_title": existing_title,
"existing_is_stub": existing_is_stub,
}),
EngineError::MemQuarantined {
mem,
reason_code,
reason_message,
} => serde_json::json!({
"mem": mem,
"reason_code": reason_code,
"reason_message": reason_message,
}),
EngineError::ConstraintUnsatisfied {
entity_type,
entity_id,
violations,
} => serde_json::json!({
"entity_type": entity_type,
"entity_id": entity_id,
"violations": violations,
}),
EngineError::RequiredOutgoingUnsatisfied {
entity_type,
entity_id,
missing,
} => serde_json::json!({
"entity_type": entity_type,
"entity_id": entity_id,
"missing": missing,
}),
EngineError::SectionFormatRefused {
entity_type,
entity_id,
violation,
} => {
let mut v = serde_json::to_value(violation).unwrap_or_default();
if let Some(obj) = v.as_object_mut() {
obj.insert("entity_type".into(), serde_json::json!(entity_type));
obj.insert("entity_id".into(), serde_json::json!(entity_id));
}
v
}
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::ReviewMarkNotSet { mem } => serde_json::json!({ "mem": mem }),
EngineError::InvalidChangesCursor { mem, since } => serde_json::json!({
"mem": mem,
"since": since,
}),
EngineError::SchemaNotFound {
mem,
pin,
sources,
install_hint,
} => {
let mut details = serde_json::json!({
"mem": mem,
"pin": pin,
"sources": sources,
});
if let Some(path) = install_hint {
details["install_hint"] = serde_json::json!({
"authoring_package": path,
"command": format!("memstead schema install {path}"),
});
}
details
}
EngineError::SchemaPackageInvalid {
name,
version,
message,
} => serde_json::json!({
"schema": format!("{name}@{version}"),
"error": message,
}),
EngineError::InvalidAnchor(e) => {
serde_json::Value::Object(e.detail().into_iter().collect::<serde_json::Map<_, _>>())
}
_ => serde_json::Value::Object(serde_json::Map::new()),
}
}
pub fn prose_render(&self) -> String {
match self {
EngineError::SectionFormatRefused { violation, .. } => {
let base = self.to_string();
match violation.example() {
Some(example) => {
format!("{base}\nA conforming example:\n{}", example.trim_end())
}
None => base,
}
}
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 schema_not_found_message(
mem: &str,
pin: &str,
sources: &[SchemaSourceDiagnostic],
install_hint: &Option<String>,
) -> String {
let mut msg = format!("mem {mem}: schema pin {pin:?} did not resolve in any schema source");
if sources.is_empty() {
return msg;
}
let name = pin.split('@').next().unwrap_or(pin);
let trail: Vec<String> = sources
.iter()
.map(|s| {
if let Some(status) = s.status {
format!("{} ({status})", s.source)
} else if s.versions_found.is_empty() {
format!("{} (nothing for {name:?})", s.source)
} else {
format!("{} (holds {})", s.source, s.versions_found.join(", "))
}
})
.collect();
msg.push_str(&format!(" — searched {}", trail.join(", ")));
if sources.iter().any(|s| !s.versions_found.is_empty()) {
let best = sources
.iter()
.flat_map(|s| &s.versions_found)
.filter_map(|v| semver::Version::parse(v).ok())
.max();
msg.push_str(&format!(
"; the name {name:?} exists at the versions listed — the pinned version is wrong, \
or the pinned version was never installed"
));
if let Some(best) = best {
msg.push_str(&format!(
"; repin to an installed version: run: memstead mem set-schema {mem} {name}@{best}"
));
}
} else if let Some(path) = install_hint {
msg.push_str(&format!(
"; an authoring package named {name:?} exists at {path:?} but is not installed — \
run: memstead schema install {path}"
));
} else {
msg.push_str(&format!(
"; no source holds any version of {name:?} — author or obtain the schema package, \
then run: memstead schema install <package-dir>"
));
}
msg
}
impl EngineError {
pub fn with_schema_install_probe(self, workspace_root: Option<&std::path::Path>) -> Self {
let EngineError::SchemaNotFound {
mem,
pin,
sources,
install_hint,
} = self
else {
return self;
};
let hint = if install_hint.is_some() {
install_hint
} else if sources.is_empty() || sources.iter().any(|s| !s.versions_found.is_empty()) {
None
} else {
let name = pin.split('@').next().unwrap_or(&pin).to_string();
workspace_root.and_then(|root| probe_authoring_package(root, &name))
};
EngineError::SchemaNotFound {
mem,
pin,
sources,
install_hint: hint,
}
}
}
fn probe_authoring_package(root: &std::path::Path, name: &str) -> Option<String> {
let entries = std::fs::read_dir(root).ok()?;
for entry in entries.flatten() {
let path = entry.path();
if !path.is_dir() {
continue;
}
let dir_name = entry.file_name();
let dir_name = dir_name.to_string_lossy();
if dir_name.starts_with('.') || dir_name == "mem-repo" {
continue;
}
if !path.join("schema.yaml").is_file() {
continue;
}
if let Ok(schema) = memstead_schema::load_schema_from_dir(&path) {
let (loaded_name, _) = schema.id();
if loaded_name == name {
return Some(path.display().to_string());
}
}
}
None
}
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})")
}
}
#[allow(clippy::large_enum_variant)]
#[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),
}
impl BootError {
pub fn code(&self) -> &'static str {
match self {
BootError::NotInitialised(_) => "WORKSPACE_NOT_INITIALISED",
BootError::Store(e) => e.code(),
BootError::Instantiate(e) => e.code(),
BootError::Engine(e) => e.code(),
}
}
pub fn details(&self) -> serde_json::Value {
use crate::workspace_store::StoreError;
match self {
BootError::NotInitialised(path) => {
serde_json::json!({
"path": path.display().to_string(),
"hint": { "recovery_command": "memstead mem-repo init" },
})
}
BootError::Store(e) => match e {
StoreError::NotInitialised { path }
| StoreError::Io { path, .. }
| StoreError::Parse { path, .. }
| StoreError::FormatMismatch { path, .. }
| StoreError::LegacyLayout { path, .. }
| StoreError::UnknownBindingVersion { path, .. } => {
serde_json::json!({ "path": path.display().to_string() })
}
StoreError::LegacyProjectionStore { path } => serde_json::json!({
"path": path.display().to_string(),
"hint": { "recovery_command": "memstead projection migrate" },
}),
StoreError::Other(_) => serde_json::json!({}),
},
BootError::Instantiate(
crate::workspace_store::InstantiateError::GitBranchRequiresMemRepoFeature { mem },
) => serde_json::json!({ "mem": mem }),
BootError::Engine(e) => e.details(),
}
}
pub fn surface_message(&self, workspace_root: &std::path::Path) -> String {
format!("init engine at {}: {self}", workspace_root.display())
}
}
#[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,
install_hint: None,
};
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 schema_not_found_message_summarises_trail_and_distinguishes_wrong_version() {
let requested: semver::Version = "99.0.0".parse().unwrap();
let wrong_version = EngineError::SchemaNotFound {
mem: "specs".to_string(),
pin: "default@99.0.0".to_string(),
sources: SchemaSourceDiagnostic::for_failed_pin("default", &requested, &[]),
install_hint: None,
};
let msg = wrong_version.to_string();
assert!(msg.contains("searched local_storage"), "got: {msg}");
assert!(msg.contains("builtin (holds"), "got: {msg}");
assert!(msg.contains("remote (not_configured)"), "got: {msg}");
assert!(
msg.contains("the pinned version is wrong"),
"wrong-version case must be named in the message: {msg}"
);
assert!(
msg.contains("memstead mem set-schema specs default@1.3.0"),
"wrong-version case ends in the concrete repin command: {msg}"
);
let never: semver::Version = "1.0.0".parse().unwrap();
let never_installed = EngineError::SchemaNotFound {
mem: "specs".to_string(),
pin: "no-such-schema@1.0.0".to_string(),
sources: SchemaSourceDiagnostic::for_failed_pin("no-such-schema", &never, &[]),
install_hint: None,
};
let msg2 = never_installed.to_string();
assert!(
msg2.contains("nothing for \"no-such-schema\""),
"never-installed case names the empty sources: {msg2}"
);
assert!(
!msg2.contains("the pinned version is wrong"),
"never-installed must NOT claim a version mismatch: {msg2}"
);
assert!(
msg2.contains("memstead schema install <package-dir>"),
"never-installed (no probe) still names the install path: {msg2}"
);
assert_ne!(msg, msg2, "the two failures are distinguishable");
let internal = EngineError::SchemaNotFound {
mem: "specs".to_string(),
pin: "x@1.0.0".to_string(),
sources: Vec::new(),
install_hint: None,
};
assert_eq!(
internal.to_string(),
"mem specs: schema pin \"x@1.0.0\" did not resolve in any schema source",
);
}
#[test]
fn schema_install_probe_hints_only_for_uninstalled_authoring_package() {
let tmp = tempfile::TempDir::new().unwrap();
let src_pkg = std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
.join("../memstead-schema/examples/minimal");
let dst = tmp.path().join("recipe");
std::fs::create_dir_all(dst.join("types")).unwrap();
std::fs::copy(src_pkg.join("schema.yaml"), dst.join("schema.yaml")).unwrap();
for entry in std::fs::read_dir(src_pkg.join("types")).unwrap().flatten() {
std::fs::copy(entry.path(), dst.join("types").join(entry.file_name())).unwrap();
}
let requested: semver::Version = "0.1.0".parse().unwrap();
let not_found = || EngineError::SchemaNotFound {
mem: "specs".to_string(),
pin: "recipe@0.1.0".to_string(),
sources: SchemaSourceDiagnostic::for_failed_pin("recipe", &requested, &[]),
install_hint: None,
};
let hinted = not_found().with_schema_install_probe(Some(tmp.path()));
let msg = hinted.to_string();
assert!(
msg.contains("memstead schema install"),
"hint must name the install command: {msg}"
);
assert!(msg.contains("recipe"), "hint names the package: {msg}");
let d = hinted.details();
assert!(
d["install_hint"]["command"]
.as_str()
.unwrap()
.starts_with("memstead schema install"),
"details carry the hint: {d}"
);
let no_root = not_found().with_schema_install_probe(None);
let no_root_msg = no_root.to_string();
assert!(
no_root_msg.contains("memstead schema install <package-dir>"),
"generic install path without a probe hit: {no_root_msg}"
);
assert!(
!no_root_msg.contains(&tmp.path().display().to_string()),
"no concrete package path without a probe hit: {no_root_msg}"
);
let other_tmp = tempfile::TempDir::new().unwrap();
let absent = not_found().with_schema_install_probe(Some(other_tmp.path()));
let absent_msg = absent.to_string();
assert!(
absent_msg.contains("memstead schema install <package-dir>"),
"generic install path when no package exists: {absent_msg}"
);
assert!(
!absent_msg.contains(&other_tmp.path().display().to_string()),
"no concrete package path when no package exists: {absent_msg}"
);
let mismatch_req: semver::Version = "99.0.0".parse().unwrap();
let mismatch = EngineError::SchemaNotFound {
mem: "specs".to_string(),
pin: "default@99.0.0".to_string(),
sources: SchemaSourceDiagnostic::for_failed_pin("default", &mismatch_req, &[]),
install_hint: None,
}
.with_schema_install_probe(Some(tmp.path()));
let mismatch_msg = mismatch.to_string();
assert!(
!mismatch_msg.contains("schema install"),
"version mismatch must not hint install: {mismatch_msg}"
);
assert!(
mismatch_msg.contains("memstead mem set-schema specs default@1.3.0"),
"version mismatch hints version repair instead: {mismatch_msg}"
);
let other = EngineError::UnknownMem("specs".to_string())
.with_schema_install_probe(Some(tmp.path()));
assert_eq!(other.code(), "UNKNOWN_MEM");
}
#[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());
}
#[test]
fn already_exists_names_the_occupying_title_on_both_channels() {
let err = EngineError::AlreadyExists {
id: "muehle--bösenberg-grundstücks-gmbh-co-kg".to_string(),
existing_title: "Bösenberg Grundstücks GmbH Co KG".to_string(),
existing_is_stub: false,
};
assert!(
err.to_string()
.contains("occupied by 'Bösenberg Grundstücks GmbH Co KG'"),
"got: {err}"
);
let details = err.details();
assert_eq!(
details["existing_title"],
"Bösenberg Grundstücks GmbH Co KG"
);
assert_eq!(details["existing_is_stub"], false);
assert_eq!(details["id"], "muehle--bösenberg-grundstücks-gmbh-co-kg");
}
#[test]
fn already_exists_stub_occupant_never_renders_an_empty_title() {
let titled = EngineError::AlreadyExists {
id: "specs--x".to_string(),
existing_title: "X".to_string(),
existing_is_stub: true,
};
assert!(
titled.to_string().contains("a stub titled 'X'"),
"got: {titled}"
);
let untitled = EngineError::AlreadyExists {
id: "specs--x".to_string(),
existing_title: String::new(),
existing_is_stub: true,
};
let msg = untitled.to_string();
assert!(msg.contains("occupied by a stub"), "got: {msg}");
assert!(!msg.contains("''"), "empty title must not render: {msg}");
}
}