use indexmap::IndexMap;
use memstead_schema::TypeDefinition;
use super::parser::extract_inline_links_lenient;
use super::{Entity, EntityId, ParseResult};
use crate::ops::WarningHint;
use crate::store::{Edge, EdgeSource, Store};
pub struct LoadCollector<'a> {
pub warnings: &'a mut Vec<WarningHint>,
pub known_suffixes: &'a [String],
pub mem_names: &'a [String],
}
pub fn push_entities_into_store(
store: &mut Store,
parse_results: Vec<ParseResult>,
_fallback_schema: &TypeDefinition,
mut load_ctx: Option<LoadCollector<'_>>,
) {
let mut drift_scan_inputs: Vec<(EntityId, String, IndexMap<String, String>)> = Vec::new();
for parse_result in parse_results {
let entity_id = parse_result.entity.id.clone();
let entity_mem = parse_result.entity.mem.clone();
if let Some(ctx) = load_ctx.as_mut()
&& !parse_result.parse_warnings.is_empty()
{
ctx.warnings
.extend(parse_result.parse_warnings.iter().cloned());
}
if load_ctx.is_some() {
drift_scan_inputs.push((
entity_id.clone(),
entity_mem,
parse_result.entity.sections.clone(),
));
}
store.remove_edges_from(&entity_id);
store.upsert(entity_id.clone(), parse_result.entity);
let relationships: Vec<_> = store
.get(&entity_id)
.map(|e| e.relationships.clone())
.unwrap_or_default();
for rel in &relationships {
if !store.contains(&rel.target) {
store.upsert(rel.target.clone(), make_stub(rel.target.clone()));
}
store.add_edge(
entity_id.clone(),
Edge {
rel_type: rel.rel_type.clone(),
target: rel.target.clone(),
source: EdgeSource::Explicit,
},
);
}
}
if let Some(ctx) = load_ctx.as_mut() {
for (id, mem, sections) in &drift_scan_inputs {
scan_nested_prefix_drift(id, mem, sections, ctx, store);
}
}
}
pub fn reconstruct_incoming_cross_mem_edges(store: &mut Store, reloaded_mem: &str) {
let mut to_add: Vec<(EntityId, Edge)> = Vec::new();
for entity in store.all_entities() {
if entity.mem == reloaded_mem {
continue;
}
for rel in &entity.relationships {
if rel.target.mem() == reloaded_mem {
to_add.push((
entity.id.clone(),
Edge {
rel_type: rel.rel_type.clone(),
target: rel.target.clone(),
source: EdgeSource::Explicit,
},
));
}
}
}
for (from, edge) in to_add {
if !store.contains(&edge.target) {
store.upsert(edge.target.clone(), make_stub(edge.target.clone()));
}
store.add_edge(from, edge);
}
}
pub fn last_segment_suffix(mem_name: &str) -> &str {
mem_name.rsplit('-').next().unwrap_or(mem_name)
}
fn scan_nested_prefix_drift(
from: &EntityId,
current_mem: &str,
sections: &IndexMap<String, String>,
ctx: &mut LoadCollector<'_>,
store: &Store,
) {
for (section, body) in sections {
for target_id in extract_inline_links_lenient(body, current_mem) {
let target_mem = target_id.mem();
if target_mem == current_mem {
continue;
}
if ctx.mem_names.iter().any(|v| v.as_str() == target_mem)
&& store.get(&target_id).is_some_and(|e| !e.stub)
{
continue;
}
for suffix in ctx.known_suffixes.iter() {
if target_mem == suffix {
let candidate_target =
resolve_two_pass(target_id.path(), current_mem, ctx.mem_names, store);
ctx.warnings.push(WarningHint::SuspiciousNestedPrefix {
from: from.clone(),
resolved_id: target_id.clone(),
candidate_target,
section: section.clone(),
});
break;
}
}
}
}
}
fn resolve_two_pass(
rest: &str,
current_mem: &str,
mem_names: &[String],
store: &Store,
) -> Option<EntityId> {
let mut hits: Vec<EntityId> = Vec::new();
for mem in mem_names {
if mem == current_mem {
continue;
}
let candidate = EntityId::new(mem, rest);
if let Some(e) = store.get(&candidate)
&& !e.stub
{
hits.push(candidate);
}
}
match hits.len() {
1 => hits.pop(),
0 => {
let candidate = EntityId::new(current_mem, rest);
if let Some(e) = store.get(&candidate)
&& !e.stub
{
Some(candidate)
} else {
None
}
}
_ => None, }
}
pub fn validate_loaded_relations(
store: &mut Store,
schemas: &std::collections::HashMap<String, std::sync::Arc<memstead_schema::Schema>>,
mount_caps: &std::collections::HashMap<String, crate::workspace::MountCapability>,
warnings: &mut Vec<WarningHint>,
) {
use crate::entity::Relationship;
use crate::entity::id::validate_id_path_grammar;
use crate::runtime_validator::{
CrossMemRelCheck, validate_cross_mem_edge, validate_rel_shape, validate_rel_type,
};
use crate::workspace::MountCapability;
use memstead_schema::SchemaRef;
let origin_for = |mem: &str| -> &'static str {
match mount_caps.get(mem) {
Some(MountCapability::ReadOnly) => "readonly",
_ => "writable",
}
};
let mut to_drop: Vec<(EntityId, Relationship, &'static str)> = Vec::new();
for entity in store.all_entities() {
if entity.stub {
continue;
}
let Some(schema) = schemas.get(entity.mem.as_str()) else {
continue;
};
for rel in &entity.relationships {
if validate_id_path_grammar(rel.target.path()).is_err() {
to_drop.push((entity.id.clone(), rel.clone(), "grammar"));
continue;
}
let target_mem = rel.target.mem();
let target_schema = if entity.mem.as_str() == target_mem {
None
} else {
schemas.get(target_mem).cloned()
};
let target_schema_ref: Option<SchemaRef> = target_schema.as_ref().map(|s| {
let (name, version) = s.id();
SchemaRef::new(name, version)
});
let cross_mem_different = match (&target_schema_ref, schema.id()) {
(Some(target), (src_name, _)) => target.name != src_name,
(None, _) => false,
};
let target_type = store
.get(&rel.target)
.map(|e| e.entity_type.clone())
.filter(|t| !t.is_empty());
if cross_mem_different {
let target_ref = target_schema_ref.as_ref().expect("present when different");
match validate_cross_mem_edge(
&rel.rel_type,
entity.entity_type.as_str(),
target_type.as_deref(),
schema.as_ref(),
target_ref,
) {
CrossMemRelCheck::Ok => {}
CrossMemRelCheck::EdgeNotDeclared => {
to_drop.push((entity.id.clone(), rel.clone(), "cross_mem_not_declared"));
continue;
}
CrossMemRelCheck::Invalid(_) => {
to_drop.push((entity.id.clone(), rel.clone(), "cross_mem_shape"));
continue;
}
}
} else {
if validate_rel_type(&rel.rel_type, schema.as_ref()).is_err() {
to_drop.push((entity.id.clone(), rel.clone(), "unknown_rel_type"));
continue;
}
if validate_rel_shape(
&rel.rel_type,
entity.entity_type.as_str(),
target_type.as_deref(),
schema.as_ref(),
)
.is_err()
{
to_drop.push((entity.id.clone(), rel.clone(), "shape"));
continue;
}
}
}
}
for (from_id, rel, reason) in to_drop {
let origin = origin_for(from_id.mem()).to_string();
store.remove_edge(&from_id, &rel.target, &rel.rel_type);
if let Some(entity) = store.get_mut(&from_id) {
entity
.relationships
.retain(|r| !(r.rel_type == rel.rel_type && r.target == rel.target));
}
let recovery = if origin == "writable" {
Some(
crate::ops::ParsedRelationRecovery::remove_explicit_relation(
from_id.clone(),
rel.target.clone(),
rel.rel_type.clone(),
),
)
} else {
None
};
warnings.push(WarningHint::ParsedRelationInvalid {
entity_id: from_id,
rel_type: rel.rel_type,
target: rel.target,
reason: reason.to_string(),
origin,
recovery,
});
}
{
use memstead_schema::PerEdgeDescription;
let mut posture_warnings: Vec<WarningHint> = Vec::new();
let mut to_strip_description: Vec<(EntityId, String, EntityId)> = Vec::new();
for entity in store.all_entities() {
if entity.stub {
continue;
}
let Some(schema) = schemas.get(entity.mem.as_str()) else {
continue;
};
for rel in &entity.relationships {
let target_mem = rel.target.mem();
let target_schema = if entity.mem.as_str() == target_mem {
None
} else {
schemas.get(target_mem).cloned()
};
let target_schema_ref: Option<SchemaRef> = target_schema.as_ref().map(|s| {
let (name, version) = s.id();
SchemaRef::new(name, version)
});
let cross_mem_different = match (&target_schema_ref, schema.id()) {
(Some(target), (src_name, _)) => target.name != src_name,
(None, _) => false,
};
let posture = if cross_mem_different {
let target_ref = target_schema_ref
.as_ref()
.expect("target_schema_ref is Some when cross_mem_different");
schema
.cross_mem_entry(&target_ref.name)
.and_then(|entry| entry.definitions.iter().find(|d| d.name == rel.rel_type))
.map(|d| d.per_edge_description)
} else {
schema
.relationship_def(&rel.rel_type)
.map(|d| d.per_edge_description)
};
match posture {
Some(PerEdgeDescription::Required) if rel.description.is_none() => {
posture_warnings.push(WarningHint::ParseMissingRequiredDescription {
from: entity.id.clone(),
rel_type: rel.rel_type.clone(),
target: rel.target.clone(),
});
}
Some(PerEdgeDescription::Forbidden) if rel.description.is_some() => {
posture_warnings.push(WarningHint::ParseDescriptionNotPermitted {
from: entity.id.clone(),
rel_type: rel.rel_type.clone(),
target: rel.target.clone(),
});
to_strip_description.push((
entity.id.clone(),
rel.rel_type.clone(),
rel.target.clone(),
));
}
_ => {}
}
}
}
for (from_id, rel_type, target) in to_strip_description {
if let Some(entity) = store.get_mut(&from_id) {
for rel in entity.relationships.iter_mut() {
if rel.rel_type == rel_type && rel.target == target {
rel.description = None;
}
}
}
}
warnings.extend(posture_warnings);
}
let mut acyclic_rel_types: Vec<String> = Vec::new();
for schema in schemas.values() {
for def in &schema.manifest.relationships.definitions {
if def.acyclic && !acyclic_rel_types.contains(&def.name) {
acyclic_rel_types.push(def.name.clone());
}
}
}
let mut cycle_drops: Vec<(EntityId, EntityId, String)> = Vec::new();
for rel_type in &acyclic_rel_types {
let mut adj: std::collections::HashMap<EntityId, Vec<EntityId>> =
std::collections::HashMap::new();
for entity in store.all_entities() {
let Some(schema) = schemas.get(entity.mem.as_str()) else {
continue;
};
if !schema.relationship_acyclic(rel_type) {
continue;
}
for edge in store.outgoing(&entity.id) {
if &edge.rel_type == rel_type {
adj.entry(entity.id.clone())
.or_default()
.push(edge.target.clone());
}
}
}
#[derive(Clone, Copy, PartialEq, Eq)]
enum Color {
White,
Gray,
Black,
}
let mut color: std::collections::HashMap<EntityId, Color> =
adj.keys().map(|k| (k.clone(), Color::White)).collect();
let mut seeds: Vec<EntityId> = adj.keys().cloned().collect();
seeds.sort_by(|a, b| a.as_ref().cmp(b.as_ref()));
for seed in seeds {
if color.get(&seed).copied() != Some(Color::White) {
continue;
}
let mut stack: Vec<(EntityId, usize, Vec<EntityId>)> = Vec::new();
let mut start_targets: Vec<EntityId> = adj.get(&seed).cloned().unwrap_or_default();
start_targets.sort_by(|a, b| a.as_ref().cmp(b.as_ref()));
color.insert(seed.clone(), Color::Gray);
stack.push((seed.clone(), 0, start_targets));
while let Some((node, idx, targets)) = stack.last_mut() {
if *idx >= targets.len() {
let done = node.clone();
color.insert(done, Color::Black);
stack.pop();
continue;
}
let target = targets[*idx].clone();
*idx += 1;
let node_id = node.clone();
match color.get(&target).copied() {
Some(Color::White) => {
let mut next_targets: Vec<EntityId> =
adj.get(&target).cloned().unwrap_or_default();
next_targets.sort_by(|a, b| a.as_ref().cmp(b.as_ref()));
color.insert(target.clone(), Color::Gray);
stack.push((target, 0, next_targets));
}
Some(Color::Gray) => {
cycle_drops.push((node_id, target, rel_type.clone()));
}
Some(Color::Black) | None => {
}
}
}
}
}
for (from_id, target, rel_type) in cycle_drops {
let origin = origin_for(from_id.mem()).to_string();
store.remove_edge(&from_id, &target, &rel_type);
if let Some(entity) = store.get_mut(&from_id) {
entity
.relationships
.retain(|r| !(r.rel_type == rel_type && r.target == target));
}
let recovery = if origin == "writable" {
Some(
crate::ops::ParsedRelationRecovery::remove_explicit_relation(
from_id.clone(),
target.clone(),
rel_type.clone(),
),
)
} else {
None
};
warnings.push(WarningHint::ParsedRelationInvalid {
entity_id: from_id,
rel_type,
target,
reason: "cycle".to_string(),
origin,
recovery,
});
}
}
pub fn remap_alias_target_edge_sources(
store: &mut Store,
schemas: &std::collections::HashMap<String, std::sync::Arc<memstead_schema::Schema>>,
) {
let mut remaps: Vec<(EntityId, EntityId, String)> = Vec::new();
for entity in store.all_entities() {
let Some(schema) = schemas.get(entity.mem.as_str()) else {
continue;
};
let Some(pointer) = schema.alias_target_rel_type() else {
continue;
};
for edge in store.outgoing(&entity.id) {
if edge.rel_type == pointer && edge.source != EdgeSource::BodyLink {
remaps.push((
entity.id.clone(),
edge.target.clone(),
edge.rel_type.clone(),
));
}
}
}
for (from, to, rel_type) in remaps {
store.add_edge(
from,
Edge {
rel_type,
target: to,
source: EdgeSource::BodyLink,
},
);
}
}
pub fn make_stub(id: EntityId) -> Entity {
Entity {
title: id.name().to_string(),
entity_type: String::new(),
mem: id.mem().to_string(),
file_path: String::new(),
metadata: IndexMap::new(),
sections: IndexMap::new(),
relationships: Vec::new(),
content_hash: String::new(),
stub: true,
stub_kind: Some(crate::entity::StubKind::LoadTime),
id,
heading_spans: std::collections::HashMap::new(),
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::entity::Entity;
use memstead_schema::type_by_name;
fn default_fallback() -> std::sync::Arc<TypeDefinition> {
type_by_name("spec").expect("spec type must exist")
}
fn real_entity(id_str: &str, sections: &[(&str, &str)]) -> ParseResult {
let id = EntityId(id_str.to_string());
let mem = id.mem().to_string();
let mut sec = IndexMap::new();
for (k, v) in sections {
sec.insert(k.to_string(), v.to_string());
}
ParseResult {
entity: Entity {
title: id.name().to_string(),
entity_type: "spec".to_string(),
mem,
file_path: format!("{}.md", id.name()),
metadata: IndexMap::new(),
sections: sec,
relationships: Vec::new(),
content_hash: "deadbeef00000000".to_string(),
stub: false,
stub_kind: None,
id,
heading_spans: std::collections::HashMap::new(),
},
inline_links: Vec::new(),
parse_warnings: Vec::new(),
}
}
#[test]
fn nested_prefix_emits_warning_with_candidate() {
let fallback = default_fallback();
let mut store = Store::new();
let target = real_entity("test-mem-plugin--foo", &[]);
push_entities_into_store(&mut store, vec![target], &fallback, None);
let author = real_entity(
"test-mem-plugin--author",
&[("constraints", "See [[plugin--foo]] for details.")],
);
let mut warnings = Vec::new();
let mem_names = vec!["test-mem-plugin".to_string()];
let known_suffixes = vec!["plugin".to_string()];
push_entities_into_store(
&mut store,
vec![author],
&fallback,
Some(LoadCollector {
warnings: &mut warnings,
known_suffixes: &known_suffixes,
mem_names: &mem_names,
}),
);
assert_eq!(warnings.len(), 1, "one nested-prefix warning expected");
match &warnings[0] {
WarningHint::SuspiciousNestedPrefix {
from,
resolved_id,
candidate_target,
section,
} => {
assert_eq!(from.as_ref(), "test-mem-plugin--author");
assert_eq!(resolved_id.as_ref(), "plugin--foo");
assert_eq!(
candidate_target.as_ref().map(|c| c.as_ref()),
Some("test-mem-plugin--foo")
);
assert_eq!(section, "constraints");
}
other => panic!("unexpected variant: {other:?}"),
}
}
#[test]
fn nested_prefix_skips_when_target_is_a_real_mem() {
let fallback = default_fallback();
let mut store = Store::new();
let target = real_entity("engine--foo", &[]);
push_entities_into_store(&mut store, vec![target], &fallback, None);
let author = real_entity(
"macos--author",
&[("constraints", "See [[engine--foo]] for details.")],
);
let mut warnings = Vec::new();
let mem_names = vec!["macos".to_string(), "engine".to_string()];
let known_suffixes = vec!["macos".to_string(), "engine".to_string()];
push_entities_into_store(
&mut store,
vec![author],
&fallback,
Some(LoadCollector {
warnings: &mut warnings,
known_suffixes: &known_suffixes,
mem_names: &mem_names,
}),
);
assert!(
warnings.is_empty(),
"a cross-mem link to a real mem must not warn: {warnings:?}"
);
}
#[test]
fn nested_prefix_emits_warning_without_candidate() {
let fallback = default_fallback();
let mut store = Store::new();
let author = real_entity(
"test-mem-plugin--author",
&[("constraints", "[[plugin--ghost]]")],
);
let mut warnings = Vec::new();
let mem_names = vec!["test-mem-plugin".to_string()];
let known_suffixes = vec!["plugin".to_string()];
push_entities_into_store(
&mut store,
vec![author],
&fallback,
Some(LoadCollector {
warnings: &mut warnings,
known_suffixes: &known_suffixes,
mem_names: &mem_names,
}),
);
assert_eq!(warnings.len(), 1);
match &warnings[0] {
WarningHint::SuspiciousNestedPrefix {
candidate_target, ..
} => assert!(candidate_target.is_none()),
other => panic!("unexpected variant: {other:?}"),
}
}
#[test]
fn non_nested_link_no_warning() {
let fallback = default_fallback();
let mut store = Store::new();
let author = real_entity("test-mem-plugin--author", &[("constraints", "[[foo]]")]);
let mut warnings = Vec::new();
let mem_names = vec!["test-mem-plugin".to_string()];
let known_suffixes = vec!["plugin".to_string()];
push_entities_into_store(
&mut store,
vec![author],
&fallback,
Some(LoadCollector {
warnings: &mut warnings,
known_suffixes: &known_suffixes,
mem_names: &mem_names,
}),
);
assert!(warnings.is_empty());
}
#[test]
fn cross_mem_qualified_fires_with_cross_mem_candidate() {
let fallback = default_fallback();
let mut store = Store::new();
let target = real_entity("test-mem-engine--health", &[]);
push_entities_into_store(&mut store, vec![target], &fallback, None);
let author = real_entity(
"test-mem-plugin--author",
&[("purpose", "See [[engine--health]].")],
);
let mut warnings = Vec::new();
let mem_names = vec!["test-mem-engine".to_string(), "test-mem-plugin".to_string()];
let known_suffixes = vec!["engine".to_string(), "plugin".to_string()];
push_entities_into_store(
&mut store,
vec![author],
&fallback,
Some(LoadCollector {
warnings: &mut warnings,
known_suffixes: &known_suffixes,
mem_names: &mem_names,
}),
);
assert_eq!(warnings.len(), 1);
match &warnings[0] {
WarningHint::SuspiciousNestedPrefix {
candidate_target, ..
} => {
assert_eq!(
candidate_target.as_ref().map(|c| c.as_ref()),
Some("test-mem-engine--health"),
"cross-mem pass-1 must find the engine mem candidate"
);
}
other => panic!("unexpected variant: {other:?}"),
}
}
#[test]
fn suffix_collision_resolves_first_match() {
let fallback = default_fallback();
let mut store = Store::new();
let target = real_entity("beta-alpha--target", &[]);
push_entities_into_store(&mut store, vec![target], &fallback, None);
let author = real_entity("beta-alpha--author", &[("purpose", "[[alpha--target]]")]);
let mut warnings = Vec::new();
let mem_names = vec!["alpha".to_string(), "beta-alpha".to_string()];
let known_suffixes = vec!["alpha".to_string(), "alpha".to_string()]; push_entities_into_store(
&mut store,
vec![author],
&fallback,
Some(LoadCollector {
warnings: &mut warnings,
known_suffixes: &known_suffixes,
mem_names: &mem_names,
}),
);
assert_eq!(
warnings.len(),
1,
"collision must not duplicate the warning"
);
match &warnings[0] {
WarningHint::SuspiciousNestedPrefix {
candidate_target, ..
} => {
assert_eq!(
candidate_target.as_ref().map(|c| c.as_ref()),
Some("beta-alpha--target")
);
}
other => panic!("unexpected variant: {other:?}"),
}
}
}