use std::sync::OnceLock;
use indexmap::IndexMap;
use memstead_schema::{
CrossMemRelationshipEntry, FieldType, RelationshipDef, RelationshipMode, Schema, TypeDefinition,
};
use regex::Regex;
use crate::entity::MetadataValue;
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct RelationshipHint {
pub name: String,
pub when_to_use: Option<String>,
}
impl std::fmt::Display for RelationshipHint {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str(&self.name)
}
}
#[derive(Debug, Clone, thiserror::Error)]
pub enum ValidationError {
#[error("unknown section '{key}' for type '{entity_type}'")]
UnknownSection {
key: String,
entity_type: String,
declared: Vec<String>,
suggestion: Option<String>,
},
#[error("unknown metadata field '{key}' for type '{entity_type}'")]
UnknownMetadata {
key: String,
entity_type: String,
declared: Vec<String>,
suggestion: Option<String>,
},
#[error("invalid value '{value}' for field '{field}' on type '{entity_type}'")]
InvalidEnumValue {
field: String,
value: String,
allowed: Vec<String>,
field_description: Option<String>,
suggestion: Option<String>,
type_write_rules: Vec<String>,
entity_type: String,
},
#[error("cannot change read-only field '{field}' via update")]
ReadOnlyField { field: String },
#[error("section '{section}' is not updatable for type '{entity_type}'")]
SectionNotUpdatable {
section: String,
entity_type: String,
},
#[error("invalid relationship type '{input}'")]
InvalidRelationshipType {
input: String,
allowed: Vec<RelationshipHint>,
suggestion: Option<String>,
},
#[error(
"relationship '{rel_type}' from type '{from_type}' to type '{to_type}' violates declared shape"
)]
InvalidRelationshipShape {
rel_type: String,
from_type: String,
to_type: String,
allowed_source_types: Vec<String>,
allowed_target_types: Vec<String>,
suggestion: Option<RelationshipHint>,
},
#[error(
"section '{section}' content contains an embedded reserved (`# ` / `## `) heading line '{embedded_heading}' — \
the compose-then-reparse pipeline would split the value at that heading; use `### ` or \
deeper for sub-headings"
)]
SectionContentInvalid {
section: String,
embedded_heading: String,
},
#[error(
"section '{section}' content contains a disallowed control character U+{codepoint:04X} \
at byte offset {byte_offset} — only tab and newline are permitted in section bodies"
)]
SectionContentControlByte {
section: String,
control_char: char,
codepoint: u32,
byte_offset: usize,
},
#[error(
"invalid value '{value}' for field '{field}' on type '{entity_type}' — expected {expected_type}"
)]
InvalidFieldValue {
field: String,
value: String,
expected_type: String,
expected_format: Option<String>,
field_description: Option<String>,
entity_type: String,
},
}
impl ValidationError {
pub fn code(&self) -> &'static str {
match self {
ValidationError::UnknownSection { .. } => "UNKNOWN_SECTION",
ValidationError::UnknownMetadata { .. } => "UNKNOWN_METADATA_FIELD",
ValidationError::InvalidEnumValue { .. } => "INVALID_ENUM_VALUE",
ValidationError::ReadOnlyField { .. } => "READ_ONLY_FIELD",
ValidationError::SectionNotUpdatable { .. } => "SECTION_NOT_UPDATABLE",
ValidationError::InvalidRelationshipType { .. } => "INVALID_REL_TYPE",
ValidationError::InvalidRelationshipShape { .. } => "INVALID_REL_SHAPE",
ValidationError::SectionContentInvalid { .. } => "SECTION_CONTENT_INVALID",
ValidationError::SectionContentControlByte { .. } => "SECTION_CONTENT_INVALID",
ValidationError::InvalidFieldValue { .. } => "INVALID_FIELD_VALUE",
}
}
pub fn details(&self) -> serde_json::Value {
match self {
ValidationError::UnknownSection {
key,
entity_type,
declared,
suggestion,
} => serde_json::json!({
"key": key,
"entity_type": entity_type,
"declared": declared,
"suggestion": suggestion,
}),
ValidationError::UnknownMetadata {
key,
entity_type,
declared,
suggestion,
} => serde_json::json!({
"key": key,
"entity_type": entity_type,
"declared": declared,
"suggestion": suggestion,
}),
ValidationError::InvalidEnumValue {
field,
value,
allowed,
field_description,
suggestion,
type_write_rules,
entity_type,
} => serde_json::json!({
"field": field,
"value": value,
"allowed": allowed,
"field_description": field_description,
"suggestion": suggestion,
"type_write_rules": type_write_rules,
"entity_type": entity_type,
}),
ValidationError::ReadOnlyField { field } => serde_json::json!({
"field": field,
}),
ValidationError::SectionNotUpdatable {
section,
entity_type,
} => serde_json::json!({
"section": section,
"entity_type": entity_type,
}),
ValidationError::InvalidRelationshipType {
input,
allowed,
suggestion,
} => {
let allowed_json: Vec<serde_json::Value> = allowed
.iter()
.map(|h| {
serde_json::json!({
"name": h.name,
"when_to_use": h.when_to_use,
})
})
.collect();
serde_json::json!({
"input": input,
"allowed": allowed_json,
"suggestion": suggestion,
})
}
ValidationError::InvalidRelationshipShape {
rel_type,
from_type,
to_type,
allowed_source_types,
allowed_target_types,
suggestion,
} => {
let suggestion_json = suggestion.as_ref().map(|h| {
serde_json::json!({
"name": h.name,
"when_to_use": h.when_to_use,
})
});
let mut details = serde_json::Map::new();
details.insert(
"rel_type".into(),
serde_json::Value::String(rel_type.clone()),
);
details.insert(
"from_type".into(),
serde_json::Value::String(from_type.clone()),
);
details.insert("to_type".into(), serde_json::Value::String(to_type.clone()));
if !allowed_source_types.is_empty() {
details.insert(
"allowed_source_types".into(),
serde_json::json!(allowed_source_types),
);
}
if !allowed_target_types.is_empty() {
details.insert(
"allowed_target_types".into(),
serde_json::json!(allowed_target_types),
);
}
details.insert("suggestion".into(), serde_json::json!(suggestion_json));
serde_json::Value::Object(details)
}
ValidationError::SectionContentInvalid {
section,
embedded_heading,
} => serde_json::json!({
"section": section,
"embedded_heading": embedded_heading,
}),
ValidationError::SectionContentControlByte {
section,
control_char,
codepoint,
byte_offset,
} => serde_json::json!({
"section": section,
"control_char": control_char.to_string(),
"codepoint": codepoint,
"byte_offset": byte_offset,
}),
ValidationError::InvalidFieldValue {
field,
value,
expected_type,
expected_format,
field_description,
entity_type,
} => serde_json::json!({
"field": field,
"value": value,
"expected_type": expected_type,
"expected_format": expected_format,
"field_description": field_description,
"entity_type": entity_type,
}),
}
}
pub fn prose_render(&self) -> String {
match self {
ValidationError::UnknownSection {
key,
entity_type,
declared,
suggestion,
} => {
let declared_inline = if declared.is_empty() {
"(none)".to_string()
} else {
declared.join(", ")
};
let suggestion_clause = suggestion
.as_deref()
.map(|s| format!(" Did you mean '{s}'?"))
.unwrap_or_default();
format!(
"unknown section '{key}' for type '{entity_type}' — declared sections: {declared_inline}.{suggestion_clause}"
)
}
ValidationError::UnknownMetadata {
key,
entity_type,
declared,
suggestion,
} => {
let declared_inline = if declared.is_empty() {
"(none)".to_string()
} else {
declared.join(", ")
};
let suggestion_clause = suggestion
.as_deref()
.map(|s| format!(" Did you mean '{s}'?"))
.unwrap_or_default();
format!(
"unknown metadata field '{key}' for type '{entity_type}' — declared fields: {declared_inline}.{suggestion_clause}"
)
}
ValidationError::InvalidEnumValue {
field,
value,
allowed,
field_description,
suggestion,
type_write_rules,
entity_type,
} => {
let allowed_inline = if allowed.is_empty() {
"(none)".to_string()
} else {
allowed.join(", ")
};
let desc_clause = field_description
.as_deref()
.map(|d| format!(" Field purpose: {d}."))
.unwrap_or_default();
let suggestion_clause = suggestion
.as_deref()
.map(|s| format!(" Did you mean '{s}'?"))
.unwrap_or_default();
let rules_clause = if type_write_rules.is_empty() {
String::new()
} else {
format!(" Type-level write_rules: {}.", type_write_rules.join("; "))
};
format!(
"invalid value '{value}' for field '{field}' on type '{entity_type}' — allowed: {allowed_inline}.{desc_clause}{suggestion_clause}{rules_clause}"
)
}
ValidationError::ReadOnlyField { field } => {
format!("cannot change read-only field '{field}' via update")
}
ValidationError::SectionNotUpdatable {
section,
entity_type,
} => format!("section '{section}' is not updatable for type '{entity_type}'"),
ValidationError::InvalidRelationshipType {
input,
allowed,
suggestion,
} => {
let allowed_inline = if allowed.is_empty() {
"(none)".to_string()
} else {
allowed
.iter()
.map(|h| h.name.clone())
.collect::<Vec<_>>()
.join(", ")
};
let suggestion_clause = suggestion
.as_deref()
.map(|s| format!(" Did you mean '{s}'?"))
.unwrap_or_default();
format!(
"invalid relationship type '{input}' — must be one of the schema's declared types: {allowed_inline}.{suggestion_clause}"
)
}
ValidationError::InvalidRelationshipShape {
rel_type,
from_type,
to_type,
allowed_source_types,
allowed_target_types,
suggestion,
} => {
let sources_inline = if allowed_source_types.is_empty() {
"any".to_string()
} else {
allowed_source_types.join(", ")
};
let targets_inline = if allowed_target_types.is_empty() {
"any".to_string()
} else {
allowed_target_types.join(", ")
};
let suggestion_clause = suggestion
.as_ref()
.map(|h| format!(" Suggested rel-type: '{}'.", h.name))
.unwrap_or_default();
format!(
"relationship '{rel_type}' from type '{from_type}' to type '{to_type}' violates declared shape — allowed sources: {sources_inline}; allowed targets: {targets_inline}.{suggestion_clause}"
)
}
ValidationError::SectionContentInvalid {
section,
embedded_heading,
} => format!(
"section '{section}' content contains an embedded reserved (`# ` / `## `) heading line '{embedded_heading}' — use `### ` or deeper for sub-headings"
),
ValidationError::SectionContentControlByte {
section,
codepoint,
byte_offset,
..
} => format!(
"section '{section}' content contains a disallowed control character U+{codepoint:04X} at byte offset {byte_offset} — \
only tab (U+0009) and newline (U+000A) are permitted in section bodies. Remove the control character: it would break \
the diffable-markdown invariant (a NUL makes git treat the file as binary and text tooling truncates at it)."
),
ValidationError::InvalidFieldValue {
field,
value,
expected_type,
expected_format,
field_description,
entity_type,
} => {
let format_clause = expected_format
.as_deref()
.map(|f| format!(" Expected format: {f}."))
.unwrap_or_default();
let desc_clause = field_description
.as_deref()
.map(|d| format!(" Field purpose: {d}."))
.unwrap_or_default();
format!(
"invalid value '{value}' for field '{field}' on type '{entity_type}' — \
not a valid {expected_type}.{format_clause}{desc_clause}"
)
}
}
}
}
pub const READ_ONLY_METADATA_KEYS: &[&str] = &["mem", "id", "type"];
pub fn validate_reserved_metadata_key(key: &str) -> Result<(), ValidationError> {
if READ_ONLY_METADATA_KEYS.contains(&key) {
return Err(ValidationError::ReadOnlyField {
field: key.to_string(),
});
}
Ok(())
}
pub fn validate_writable_metadata_key(
key: &str,
schema: &TypeDefinition,
) -> Result<(), ValidationError> {
validate_reserved_metadata_key(key)?;
if let Some(field) = schema.metadata_field(key)
&& (field.init_timestamp || field.auto_timestamp)
{
return Err(ValidationError::ReadOnlyField {
field: key.to_string(),
});
}
Ok(())
}
pub fn validate_unsettable_metadata_key(
key: &str,
schema: &TypeDefinition,
) -> Result<(), ValidationError> {
if let Some(field) = schema.metadata_field(key)
&& (field.init_timestamp || field.auto_timestamp)
{
return Err(ValidationError::ReadOnlyField {
field: key.to_string(),
});
}
Ok(())
}
pub fn validate_updatable_section(
section: &str,
schema: &TypeDefinition,
) -> Result<(), ValidationError> {
if section == "relationships" {
return Err(ValidationError::SectionNotUpdatable {
section: section.to_string(),
entity_type: schema.name.clone(),
});
}
if !schema.updatable_fields.is_empty() && !schema.updatable_fields.iter().any(|f| f == section)
{
return Err(ValidationError::SectionNotUpdatable {
section: section.to_string(),
entity_type: schema.name.clone(),
});
}
Ok(())
}
#[derive(Debug, Clone)]
pub struct MissingRequiredSection {
pub entity_type: String,
pub key: String,
pub heading: String,
pub write_rules: Vec<String>,
}
pub fn validate_section_content<'a>(
sections: impl Iterator<Item = (&'a str, &'a str)>,
) -> Result<(), ValidationError> {
for (key, value) in sections {
if let Some((byte_offset, ch)) = value
.char_indices()
.find(|(_, c)| c.is_control() && *c != '\t' && *c != '\n')
{
return Err(ValidationError::SectionContentControlByte {
section: key.to_string(),
control_char: ch,
codepoint: ch as u32,
byte_offset,
});
}
for line in value.lines() {
if (line.starts_with("## ") && line.len() > 3)
|| (line.starts_with("# ") && line.len() > 2)
{
return Err(ValidationError::SectionContentInvalid {
section: key.to_string(),
embedded_heading: line.to_string(),
});
}
}
}
Ok(())
}
pub fn validate_section_keys<'a>(
provided: impl Iterator<Item = &'a str>,
schema: &TypeDefinition,
) -> Result<(), ValidationError> {
let mut declared: Vec<String> = schema.sections.iter().map(|s| s.key.clone()).collect();
declared.sort();
let declared_set: std::collections::HashSet<&str> =
schema.sections.iter().map(|s| s.key.as_str()).collect();
let catch_all_key = schema.catch_all_section().map(|s| s.key.clone());
for key in provided {
if key == "relationships" {
continue;
}
if declared_set.contains(key) {
continue;
}
let suggestion = schema
.suggest_section(key)
.or_else(|| catch_all_key.clone());
return Err(ValidationError::UnknownSection {
key: key.to_string(),
entity_type: schema.name.clone(),
declared: declared.clone(),
suggestion,
});
}
Ok(())
}
pub fn parse_metadata_value(
key: &str,
value: &str,
schema: &TypeDefinition,
) -> Result<MetadataValue, ValidationError> {
let Some(field_def) = schema.metadata_field(key) else {
let mut declared: Vec<String> = schema
.metadata_fields
.iter()
.map(|f| f.key.clone())
.collect();
declared.sort();
return Err(ValidationError::UnknownMetadata {
key: key.to_string(),
entity_type: schema.name.clone(),
declared,
suggestion: schema.suggest_metadata_field(key),
});
};
if let Some(ref allowed) = field_def.enum_values
&& !allowed.iter().any(|v| v == value)
{
let suggestion = nearest_str_match(value, allowed);
return Err(ValidationError::InvalidEnumValue {
field: key.to_string(),
value: value.to_string(),
allowed: allowed.clone(),
field_description: Some(field_def.description.clone()),
suggestion,
type_write_rules: schema.write_rules.clone(),
entity_type: schema.name.clone(),
});
}
Ok(match field_def.field_type {
FieldType::Boolean => MetadataValue::Bool(value == "true" || value == "1"),
FieldType::Number => {
if let Ok(n) = value.parse::<i64>() {
MetadataValue::Integer(n)
} else if let Ok(f) = value.parse::<f64>() {
MetadataValue::Float(f)
} else {
return Err(ValidationError::InvalidFieldValue {
field: key.to_string(),
value: value.to_string(),
expected_type: "Number".to_string(),
expected_format: Some("an integer or decimal number".to_string()),
field_description: Some(field_def.description.clone()),
entity_type: schema.name.clone(),
});
}
}
FieldType::Date => {
if !is_date_shaped(value) {
return Err(ValidationError::InvalidFieldValue {
field: key.to_string(),
value: value.to_string(),
expected_type: "Date".to_string(),
expected_format: Some("YYYY-MM-DD or YYYY-MM-DDTHH:MM:SSZ".to_string()),
field_description: Some(field_def.description.clone()),
entity_type: schema.name.clone(),
});
}
MetadataValue::String(value.to_string())
}
_ => MetadataValue::String(value.to_string()),
})
}
pub fn is_date_shaped(s: &str) -> bool {
static RE: OnceLock<Regex> = OnceLock::new();
RE.get_or_init(|| Regex::new(r"^\d{4}-\d{2}-\d{2}(T\d{2}:\d{2}:\d{2}Z)?$").unwrap())
.is_match(s)
}
#[derive(Debug, Clone)]
pub struct MissingRequiredField {
pub entity_type: String,
pub key: String,
pub description: String,
pub enum_values: Vec<String>,
}
pub fn missing_required_fields(
schema: &TypeDefinition,
supplied: &IndexMap<String, String>,
) -> Vec<MissingRequiredField> {
schema
.metadata_fields
.iter()
.filter(|f| {
!READ_ONLY_METADATA_KEYS.contains(&f.key.as_str())
&& f.is_required()
&& f.default_value.is_none()
&& !f.init_timestamp
&& !f.auto_timestamp
&& !supplied.contains_key(f.key.as_str())
})
.map(|f| MissingRequiredField {
entity_type: schema.name.clone(),
key: f.key.clone(),
description: f.description.clone(),
enum_values: f.enum_values.clone().unwrap_or_default(),
})
.collect()
}
pub fn missing_required_sections(
schema: &TypeDefinition,
sections: &IndexMap<String, String>,
) -> Vec<MissingRequiredSection> {
schema
.required_sections()
.filter_map(|sec| {
let is_empty = sections
.get(sec.key.as_str())
.is_none_or(|c| c.trim().is_empty());
is_empty.then(|| MissingRequiredSection {
entity_type: schema.name.clone(),
key: sec.key.clone(),
heading: sec.heading.clone(),
write_rules: sec.write_rules.clone(),
})
})
.collect()
}
#[derive(Debug, Clone)]
pub enum RelationshipCheck {
Ok,
OpenWarning(String),
}
pub fn validate_rel_type(
rel_type: &str,
schema: &Schema,
) -> Result<RelationshipCheck, ValidationError> {
if schema.relationship_known(rel_type) {
return Ok(RelationshipCheck::Ok);
}
match schema.mode() {
RelationshipMode::Strict => {
let allowed = declared_relationship_hints(schema);
let candidate_names: Vec<String> = allowed.iter().map(|h| h.name.clone()).collect();
let suggestion = nearest_str_match(rel_type, &candidate_names);
Err(ValidationError::InvalidRelationshipType {
input: rel_type.to_string(),
allowed,
suggestion,
})
}
RelationshipMode::Open => {
let declared: Vec<String> = declared_relationship_hints(schema)
.into_iter()
.map(|h| h.name)
.collect();
let suggestion = schema
.suggest_relationship(rel_type)
.map(|s| format!(" Did you mean '{s}'?"))
.unwrap_or_default();
let (schema_name, schema_version) = schema.id();
Ok(RelationshipCheck::OpenWarning(format!(
"relationship '{rel_type}' is not declared in schema \
'{schema_name}@{schema_version}' (mode: open). \
Accepted with default weight. Declared: [{}].{suggestion}",
declared.join(", "),
)))
}
}
}
pub fn validate_rel_shape(
rel_type: &str,
from_type: &str,
to_type: Option<&str>,
schema: &Schema,
) -> Result<(), ValidationError> {
let Some(def) = schema.relationship_def(rel_type) else {
return Ok(());
};
let source_ok = def.source_types.is_empty() || def.source_types.iter().any(|t| t == from_type);
let target_ok = def.target_types.is_empty()
|| to_type.is_none_or(|t| def.target_types.iter().any(|d| d == t));
if source_ok && target_ok {
return Ok(());
}
let to_for_err = to_type.unwrap_or("<unknown>").to_string();
let suggestion = suggest_shape_admitting(from_type, to_type, schema);
Err(ValidationError::InvalidRelationshipShape {
rel_type: rel_type.to_string(),
from_type: from_type.to_string(),
to_type: to_for_err,
allowed_source_types: def.source_types.clone(),
allowed_target_types: def.target_types.clone(),
suggestion,
})
}
#[derive(Debug, Clone)]
pub enum CrossMemRelCheck {
Ok,
EdgeNotDeclared,
Invalid(ValidationError),
}
pub fn validate_cross_mem_edge(
rel_type: &str,
from_type: &str,
to_type: Option<&str>,
source_schema: &Schema,
target_schema_ref: &memstead_schema::SchemaRef,
) -> CrossMemRelCheck {
let entries = source_schema.cross_mem_entries(&target_schema_ref.name);
if entries.is_empty() {
return CrossMemRelCheck::EdgeNotDeclared;
}
let Some(def) = entries
.iter()
.find_map(|entry| entry.definitions.iter().find(|d| d.name == rel_type))
else {
if !entries.iter().any(|e| e.to_schema != "*") {
return CrossMemRelCheck::EdgeNotDeclared;
}
let allowed: Vec<RelationshipHint> = cross_mem_entries_hints(&entries);
let candidate_names: Vec<String> = allowed.iter().map(|h| h.name.clone()).collect();
let suggestion = nearest_str_match(rel_type, &candidate_names);
return CrossMemRelCheck::Invalid(ValidationError::InvalidRelationshipType {
input: rel_type.to_string(),
allowed,
suggestion,
});
};
let source_ok = def.source_types.is_empty() || def.source_types.iter().any(|t| t == from_type);
let target_ok = def.target_types.is_empty()
|| to_type.is_none_or(|t| def.target_types.iter().any(|d| d == t));
if source_ok && target_ok {
return CrossMemRelCheck::Ok;
}
let to_for_err = to_type.unwrap_or("<unknown>").to_string();
let suggestion = entries
.iter()
.find_map(|entry| cross_mem_suggest_shape(entry, from_type, to_type));
CrossMemRelCheck::Invalid(ValidationError::InvalidRelationshipShape {
rel_type: rel_type.to_string(),
from_type: from_type.to_string(),
to_type: to_for_err,
allowed_source_types: def.source_types.clone(),
allowed_target_types: def.target_types.clone(),
suggestion,
})
}
fn cross_mem_entries_hints(entries: &[&CrossMemRelationshipEntry]) -> Vec<RelationshipHint> {
let mut out: Vec<RelationshipHint> = Vec::new();
for entry in entries {
for hint in cross_mem_entry_hints(entry) {
if !out.iter().any(|h| h.name == hint.name) {
out.push(hint);
}
}
}
out.sort_by(|a, b| a.name.cmp(&b.name));
out
}
fn cross_mem_entry_hints(entry: &CrossMemRelationshipEntry) -> Vec<RelationshipHint> {
let mut out: Vec<RelationshipHint> = entry
.definitions
.iter()
.filter(|d| d.name != "_default")
.map(|d| RelationshipHint {
name: d.name.clone(),
when_to_use: d.when_to_use.clone(),
})
.collect();
out.sort_by(|a, b| a.name.cmp(&b.name));
out
}
fn cross_mem_suggest_shape(
entry: &CrossMemRelationshipEntry,
from_type: &str,
to_type: Option<&str>,
) -> Option<RelationshipHint> {
entry
.definitions
.iter()
.filter(|d| d.name != "_default")
.find(|d| cross_mem_def_admits(d, from_type, to_type))
.map(|d| RelationshipHint {
name: d.name.clone(),
when_to_use: d.when_to_use.clone(),
})
}
fn cross_mem_def_admits(d: &RelationshipDef, from_type: &str, to_type: Option<&str>) -> bool {
let src_ok = d.source_types.is_empty() || d.source_types.iter().any(|t| t == from_type);
let tgt_ok =
d.target_types.is_empty() || to_type.is_none_or(|t| d.target_types.iter().any(|x| x == t));
src_ok && tgt_ok
}
fn suggest_shape_admitting(
from_type: &str,
to_type: Option<&str>,
schema: &Schema,
) -> Option<RelationshipHint> {
schema
.manifest
.relationships
.definitions
.iter()
.filter(|d| d.name != "_default")
.find(|d| {
let src_ok = d.source_types.is_empty() || d.source_types.iter().any(|t| t == from_type);
let tgt_ok = d.target_types.is_empty()
|| to_type.is_none_or(|t| d.target_types.iter().any(|x| x == t));
src_ok && tgt_ok
})
.map(|d| RelationshipHint {
name: d.name.clone(),
when_to_use: d.when_to_use.clone(),
})
}
fn declared_relationship_hints(schema: &Schema) -> Vec<RelationshipHint> {
let mut out: Vec<RelationshipHint> = schema
.manifest
.relationships
.definitions
.iter()
.filter(|d| d.name != "_default")
.map(|d| RelationshipHint {
name: d.name.clone(),
when_to_use: d.when_to_use.clone(),
})
.collect();
out.sort_by(|a, b| a.name.cmp(&b.name));
out
}
fn nearest_str_match(needle: &str, candidates: &[String]) -> Option<String> {
let noise_floor = (needle.chars().count() / 2).max(1);
let mut best: Option<(usize, String)> = None;
for cand in candidates {
let d = strsim::levenshtein(needle, cand);
if d == 0 || d > noise_floor {
continue;
}
match &best {
Some((bd, _)) if *bd <= d => {}
_ => best = Some((d, cand.clone())),
}
}
best.map(|(_, name)| name)
}
#[cfg(test)]
mod tests {
use super::*;
fn shape_test_schema() -> std::sync::Arc<Schema> {
let manifest_yaml = r#"name: tests-rel-shape
version: 0.1.0
description: rel-shape test schema
when_to_use: tests
types:
- step
- decision
- note
relationships:
mode: strict
definitions:
- name: PART_OF
description: parent containment
default_weight: 3.0
acyclic: true
- name: USES
description: shape-free reference
default_weight: 1.0
- name: EXECUTES
description: step carries out decision
default_weight: 2.5
source_types: [step]
target_types: [decision]
- name: _default
description: fallback
default_weight: 1.0
community:
resolution: 1.0
seed: 42
"#;
let body_section = r#"sections:
- key: body
heading: Body
required: true
search_weight: 10.0
catch_all: true
write_rules: []
metadata_fields: []
title_weight: 100.0
text_fields:
- body
hierarchy_relationship: PART_OF
no_self_loop_relationships: []
updatable_fields:
- title
- body
health_required_fields:
- body
staleness_threshold_days: 90
write_rules: []
"#;
let make_type =
|name: &str| format!("name: {name}\ndescription: t\nwhen_to_use: Here\n{body_section}");
std::sync::Arc::new(
memstead_schema::load_schema_from_memory(
manifest_yaml,
&[
("step".to_string(), make_type("step")),
("decision".to_string(), make_type("decision")),
("note".to_string(), make_type("note")),
],
)
.expect("test schema must load"),
)
}
#[test]
fn rel_shape_admits_pair_in_declared_source_target() {
let schema = shape_test_schema();
assert!(validate_rel_shape("EXECUTES", "step", Some("decision"), &schema).is_ok());
}
#[test]
fn rel_shape_rejects_violating_source() {
let schema = shape_test_schema();
let err = validate_rel_shape("EXECUTES", "note", Some("decision"), &schema).unwrap_err();
match err {
ValidationError::InvalidRelationshipShape {
rel_type,
from_type,
to_type,
allowed_source_types,
allowed_target_types,
..
} => {
assert_eq!(rel_type, "EXECUTES");
assert_eq!(from_type, "note");
assert_eq!(to_type, "decision");
assert_eq!(allowed_source_types, vec!["step".to_string()]);
assert_eq!(allowed_target_types, vec!["decision".to_string()]);
}
other => panic!("expected InvalidRelationshipShape, got {other:?}"),
}
}
#[test]
fn rel_shape_rejects_violating_target() {
let schema = shape_test_schema();
let err = validate_rel_shape("EXECUTES", "step", Some("note"), &schema).unwrap_err();
assert!(matches!(
err,
ValidationError::InvalidRelationshipShape { .. }
));
}
#[test]
fn rel_shape_admits_shape_free_relationship() {
let schema = shape_test_schema();
assert!(validate_rel_shape("USES", "note", Some("step"), &schema).is_ok());
}
#[test]
fn rel_shape_skips_target_check_when_target_type_unknown() {
let schema = shape_test_schema();
assert!(validate_rel_shape("EXECUTES", "step", None, &schema).is_ok());
}
#[test]
fn rel_shape_no_op_for_unknown_rel_name() {
let schema = shape_test_schema();
assert!(validate_rel_shape("MADE_UP", "step", Some("decision"), &schema).is_ok());
}
fn cross_mem_source_schema() -> std::sync::Arc<Schema> {
let manifest_yaml = r#"name: source-cv
version: 0.1.0
description: cross-mem source schema
when_to_use: tests
types:
- step
- decision
relationships:
mode: strict
definitions:
- name: IMPLEMENTS
description: intra-mem only
default_weight: 1.0
- name: _default
description: fallback
default_weight: 1.0
cross_mem_relationships:
- to_schema: other
definitions:
- name: ADDRESSES
description: outbound shape-pinned
default_weight: 1.0
source_types: [step]
target_types: [requirement]
- name: MENTIONS
description: outbound shape-free
default_weight: 0.5
community:
resolution: 1.0
seed: 42
"#;
let body_section = r#"sections:
- key: body
heading: Body
required: true
search_weight: 10.0
catch_all: true
write_rules: []
metadata_fields: []
title_weight: 100.0
text_fields:
- body
hierarchy_relationship: _default
no_self_loop_relationships: []
updatable_fields:
- title
- body
health_required_fields:
- body
staleness_threshold_days: 90
write_rules: []
"#;
let make_type =
|name: &str| format!("name: {name}\ndescription: t\nwhen_to_use: Here\n{body_section}");
std::sync::Arc::new(
memstead_schema::load_schema_from_memory(
manifest_yaml,
&[
("step".to_string(), make_type("step")),
("decision".to_string(), make_type("decision")),
],
)
.expect("cross-mem source schema must load"),
)
}
fn other_target_ref() -> memstead_schema::SchemaRef {
memstead_schema::SchemaRef::new("other", semver::Version::new(1, 0, 0))
}
#[test]
fn cross_mem_admits_declared_shape() {
let src = cross_mem_source_schema();
let target = other_target_ref();
match validate_cross_mem_edge("ADDRESSES", "step", Some("requirement"), &src, &target) {
CrossMemRelCheck::Ok => {}
other => panic!("expected Ok, got {other:?}"),
}
}
#[test]
fn cross_mem_no_matching_entry_returns_edge_not_declared() {
let src = cross_mem_source_schema();
let target = memstead_schema::SchemaRef::new("docs", semver::Version::new(0, 1, 0));
match validate_cross_mem_edge("ADDRESSES", "step", Some("page"), &src, &target) {
CrossMemRelCheck::EdgeNotDeclared => {}
other => panic!("expected EdgeNotDeclared, got {other:?}"),
}
}
#[test]
fn cross_mem_entry_matches_any_target_version() {
let src = cross_mem_source_schema();
for version in [
semver::Version::new(1, 0, 0),
semver::Version::new(1, 1, 0),
semver::Version::new(2, 5, 0),
] {
let target = memstead_schema::SchemaRef::new("other", version.clone());
match validate_cross_mem_edge("ADDRESSES", "step", Some("requirement"), &src, &target) {
CrossMemRelCheck::Ok => {}
other => panic!("expected Ok against other@{version}, got {other:?}"),
}
}
}
#[test]
fn cross_mem_unknown_rel_type_returns_invalid_rel_type() {
let src = cross_mem_source_schema();
let target = other_target_ref();
match validate_cross_mem_edge("IMPLEMENTS", "step", Some("requirement"), &src, &target) {
CrossMemRelCheck::Invalid(ValidationError::InvalidRelationshipType {
input,
allowed,
..
}) => {
assert_eq!(input, "IMPLEMENTS");
let names: Vec<String> = allowed.into_iter().map(|h| h.name).collect();
assert!(names.iter().any(|n| n == "ADDRESSES"));
assert!(names.iter().any(|n| n == "MENTIONS"));
assert!(!names.iter().any(|n| n == "IMPLEMENTS"));
}
other => panic!("expected Invalid(InvalidRelationshipType), got {other:?}"),
}
}
#[test]
fn cross_mem_shape_mismatch_returns_invalid_rel_shape() {
let src = cross_mem_source_schema();
let target = other_target_ref();
match validate_cross_mem_edge("ADDRESSES", "decision", Some("requirement"), &src, &target) {
CrossMemRelCheck::Invalid(ValidationError::InvalidRelationshipShape {
rel_type,
from_type,
allowed_source_types,
allowed_target_types,
..
}) => {
assert_eq!(rel_type, "ADDRESSES");
assert_eq!(from_type, "decision");
assert_eq!(allowed_source_types, vec!["step".to_string()]);
assert_eq!(allowed_target_types, vec!["requirement".to_string()]);
}
other => panic!("expected Invalid(InvalidRelationshipShape), got {other:?}"),
}
}
#[test]
fn cross_mem_shape_free_rel_type_admits_any_pair() {
let src = cross_mem_source_schema();
let target = other_target_ref();
assert!(matches!(
validate_cross_mem_edge("MENTIONS", "decision", Some("page"), &src, &target),
CrossMemRelCheck::Ok
));
}
fn wildcard_source_schema() -> std::sync::Arc<Schema> {
let manifest_yaml = r#"name: source-wc
version: 0.1.0
description: wildcard cross-mem source schema
when_to_use: tests
types:
- step
- decision
relationships:
mode: strict
definitions:
- name: SOFT_REF
description: alias-emitted soft reference
default_weight: 0.5
- name: ADDRESSES
description: structural
default_weight: 1.0
- name: _default
description: fallback
default_weight: 1.0
alias_target_rel_type: SOFT_REF
cross_mem_relationships:
- to_schema: other
definitions:
- name: ADDRESSES
description: structural, per-schema
default_weight: 1.0
source_types: [step]
target_types: [requirement]
- to_schema: "*"
definitions:
- name: SOFT_REF
description: soft reference anywhere
default_weight: 0.5
source_types: [step]
community:
resolution: 1.0
seed: 42
"#;
let body_section = r#"description: t
when_to_use: tests
sections:
- key: body
heading: Body
required: true
search_weight: 10.0
catch_all: true
write_rules: []
metadata_fields: []
title_weight: 100.0
text_fields:
- body
hierarchy_relationship: _default
no_self_loop_relationships: []
updatable_fields:
- title
- body
health_required_fields:
- body
staleness_threshold_days: 90
write_rules: []
"#;
let types = vec![
("step".to_string(), format!("name: step\n{body_section}")),
(
"decision".to_string(),
format!("name: decision\n{body_section}"),
),
];
std::sync::Arc::new(
memstead_schema::load_schema_from_memory(manifest_yaml, &types)
.expect("wildcard schema loads"),
)
}
#[test]
fn cross_mem_wildcard_admits_alias_edge_to_any_schema() {
let src = wildcard_source_schema();
let user = memstead_schema::SchemaRef::new("debate", semver::Version::new(0, 1, 0));
assert!(matches!(
validate_cross_mem_edge("SOFT_REF", "step", Some("argument"), &src, &user),
CrossMemRelCheck::Ok
));
let other = memstead_schema::SchemaRef::new("other", semver::Version::new(1, 0, 0));
assert!(matches!(
validate_cross_mem_edge("SOFT_REF", "step", Some("requirement"), &src, &other),
CrossMemRelCheck::Ok
));
assert!(matches!(
validate_cross_mem_edge("ADDRESSES", "step", Some("requirement"), &src, &other),
CrossMemRelCheck::Ok
));
}
#[test]
fn cross_mem_wildcard_keeps_source_type_gate_and_structural_refusal() {
let src = wildcard_source_schema();
let user = memstead_schema::SchemaRef::new("debate", semver::Version::new(0, 1, 0));
match validate_cross_mem_edge("SOFT_REF", "decision", Some("argument"), &src, &user) {
CrossMemRelCheck::Invalid(ValidationError::InvalidRelationshipShape {
from_type,
allowed_source_types,
..
}) => {
assert_eq!(from_type, "decision");
assert_eq!(allowed_source_types, vec!["step".to_string()]);
}
other => panic!("expected shape refusal on source-type gate, got {other:?}"),
}
assert!(matches!(
validate_cross_mem_edge("ADDRESSES", "step", Some("argument"), &src, &user),
CrossMemRelCheck::EdgeNotDeclared
));
}
#[test]
fn prose_render_unknown_section_inlines_all_declared_and_suggestion() {
let err = ValidationError::UnknownSection {
key: "implimentation".to_string(),
entity_type: "spec".to_string(),
declared: (0..8).map(|i| format!("sec{i}")).collect(),
suggestion: Some("sec0".to_string()),
};
let prose = err.prose_render();
for d in (0..8).map(|i| format!("sec{i}")) {
assert!(prose.contains(&d), "missing {d} in: {prose}");
}
assert!(prose.contains("Did you mean 'sec0'?"), "got: {prose}");
assert!(!prose.contains("see details"), "got: {prose}");
}
#[test]
fn prose_render_invalid_enum_value_inlines_field_description_and_rules() {
let err = ValidationError::InvalidEnumValue {
field: "level".to_string(),
value: "M7".to_string(),
allowed: (0..7).map(|i| format!("M{i}")).collect(),
field_description: Some("maturity rung (M0=draft … M6=stable)".to_string()),
suggestion: Some("M6".to_string()),
type_write_rules: vec!["specs land at M0 unless promoted by a decision".to_string()],
entity_type: "spec".to_string(),
};
let prose = err.prose_render();
assert!(prose.contains("M0"), "got: {prose}");
assert!(prose.contains("M6"), "got: {prose}");
assert!(
prose.contains("maturity rung"),
"field_description missing: {prose}"
);
assert!(prose.contains("Did you mean 'M6'?"), "got: {prose}");
assert!(
prose.contains("specs land at M0"),
"type_write_rules missing: {prose}"
);
assert!(!prose.contains("see details"), "got: {prose}");
}
#[test]
fn prose_render_invalid_rel_shape_renders_any_when_unconstrained() {
let err = ValidationError::InvalidRelationshipShape {
rel_type: "OWNS".to_string(),
from_type: "spec".to_string(),
to_type: "spec".to_string(),
allowed_source_types: vec!["actor".to_string()],
allowed_target_types: vec![],
suggestion: None,
};
let prose = err.prose_render();
assert!(prose.contains("allowed sources: actor"), "got: {prose}");
assert!(prose.contains("allowed targets: any"), "got: {prose}");
assert!(!prose.contains("see details"), "got: {prose}");
}
fn typed_field_type() -> std::sync::Arc<TypeDefinition> {
let manifest_yaml = r#"name: tests-typed-fields
version: 0.1.0
description: typed-field test schema
when_to_use: tests
types:
- widget
relationships:
mode: strict
definitions:
- name: _default
description: fallback
default_weight: 1.0
community:
resolution: 1.0
seed: 42
"#;
let type_yaml = r#"name: widget
description: t
when_to_use: Here
sections:
- key: body
heading: Body
required: true
search_weight: 10.0
catch_all: true
write_rules: []
metadata_fields:
- key: verified_on
description: ISO YYYY-MM-DD date the widget was verified
field_type: date
optional: true
- key: order
description: numeric ordering within a plan
field_type: number
optional: true
- key: note
description: free-form note
field_type: string
optional: true
title_weight: 100.0
text_fields:
- body
hierarchy_relationship: _default
no_self_loop_relationships: []
updatable_fields:
- title
- body
health_required_fields:
- body
staleness_threshold_days: 90
write_rules: []
"#;
let schema = memstead_schema::load_schema_from_memory(
manifest_yaml,
&[("widget".to_string(), type_yaml.to_string())],
)
.expect("typed-field test schema must load");
schema.get_type("widget").expect("widget type present")
}
#[test]
fn date_field_rejects_non_date_value() {
let ty = typed_field_type();
let err = parse_metadata_value("verified_on", "not-a-real-date", &ty).unwrap_err();
assert_eq!(err.code(), "INVALID_FIELD_VALUE");
match err {
ValidationError::InvalidFieldValue {
field,
value,
expected_type,
entity_type,
..
} => {
assert_eq!(field, "verified_on");
assert_eq!(value, "not-a-real-date");
assert_eq!(expected_type, "Date");
assert_eq!(entity_type, "widget");
}
other => panic!("expected InvalidFieldValue, got {other:?}"),
}
}
#[test]
fn date_field_rejects_empty_string() {
let ty = typed_field_type();
let err = parse_metadata_value("verified_on", "", &ty).unwrap_err();
assert!(matches!(err, ValidationError::InvalidFieldValue { .. }));
}
#[test]
fn date_field_accepts_iso_date_and_datetime() {
let ty = typed_field_type();
match parse_metadata_value("verified_on", "2024-06-01", &ty).unwrap() {
MetadataValue::String(s) => assert_eq!(s, "2024-06-01"),
other => panic!("expected String, got {other:?}"),
}
assert!(parse_metadata_value("verified_on", "2024-06-01T12:30:00Z", &ty).is_ok());
}
#[test]
fn number_field_rejects_non_numeric_value() {
let ty = typed_field_type();
let err = parse_metadata_value("order", "soon", &ty).unwrap_err();
match err {
ValidationError::InvalidFieldValue {
field,
expected_type,
..
} => {
assert_eq!(field, "order");
assert_eq!(expected_type, "Number");
}
other => panic!("expected InvalidFieldValue, got {other:?}"),
}
}
#[test]
fn number_field_accepts_integer_and_float() {
let ty = typed_field_type();
assert!(matches!(
parse_metadata_value("order", "3", &ty).unwrap(),
MetadataValue::Integer(3)
));
assert!(matches!(
parse_metadata_value("order", "2.5", &ty).unwrap(),
MetadataValue::Float(_)
));
}
#[test]
fn string_field_accepts_any_value() {
let ty = typed_field_type();
assert!(parse_metadata_value("note", "not-a-real-date", &ty).is_ok());
}
#[test]
fn invalid_field_value_prose_inlines_format_and_purpose() {
let err = ValidationError::InvalidFieldValue {
field: "verified_on".to_string(),
value: "not-a-real-date".to_string(),
expected_type: "Date".to_string(),
expected_format: Some("YYYY-MM-DD or YYYY-MM-DDTHH:MM:SSZ".to_string()),
field_description: Some("date the widget was verified".to_string()),
entity_type: "widget".to_string(),
};
let prose = err.prose_render();
assert!(prose.contains("not-a-real-date"), "got: {prose}");
assert!(prose.contains("YYYY-MM-DD"), "format missing: {prose}");
assert!(
prose.contains("date the widget was verified"),
"purpose missing: {prose}"
);
assert!(!prose.contains("see details"), "got: {prose}");
}
#[test]
fn is_date_shaped_matches_strict_validator_contract() {
assert!(is_date_shaped("2024-06-01"));
assert!(is_date_shaped("2024-06-01T12:30:00Z"));
assert!(!is_date_shaped(""));
assert!(!is_date_shaped("not-a-real-date"));
assert!(!is_date_shaped("2024-6-1"));
assert!(!is_date_shaped("2024-06-01 extra"));
}
#[test]
fn section_content_refuses_nul_byte() {
let err = validate_section_content([("body", "line1\u{0}line2")].into_iter())
.expect_err("NUL in a section body must be refused");
assert_eq!(err.code(), "SECTION_CONTENT_INVALID");
match &err {
ValidationError::SectionContentControlByte {
section,
control_char,
codepoint,
byte_offset,
} => {
assert_eq!(section, "body");
assert_eq!(*control_char, '\u{0}');
assert_eq!(*codepoint, 0);
assert_eq!(*byte_offset, 5);
}
other => panic!("expected SectionContentControlByte, got {other:?}"),
}
let details = err.details();
assert_eq!(details["codepoint"], 0);
assert_eq!(details["byte_offset"], 5);
assert_eq!(details["section"], "body");
}
#[test]
fn section_content_refuses_other_c0_controls_and_cr() {
for bad in ['\u{7}', '\u{b}', '\u{c}', '\r'] {
let body = format!("ok{bad}more");
let err = validate_section_content([("s", body.as_str())].into_iter())
.expect_err("control char must be refused");
assert_eq!(err.code(), "SECTION_CONTENT_INVALID", "char {:?}", bad);
}
}
#[test]
fn section_content_allows_tab_and_newline() {
validate_section_content([("body", "line1\nline2\n\tindented\tcols\n")].into_iter())
.expect("tab and newline must stay legal in section bodies");
}
#[test]
fn section_content_keeps_backslashes_verbatim() {
validate_section_content(
[("body", r"a literal \n and \t and \0 and \\ backslash")].into_iter(),
)
.expect("backslashes are literal content, not control bytes");
}
#[test]
fn section_content_still_refuses_heading_injection() {
let err = validate_section_content([("body", "intro\n## Injected\ntail")].into_iter())
.expect_err("embedded `## ` heading must still be refused");
assert_eq!(err.code(), "SECTION_CONTENT_INVALID");
assert!(matches!(err, ValidationError::SectionContentInvalid { .. }));
}
}