use std::collections::HashMap;
use std::sync::Arc;
use memstead_schema::{Schema, TypeDefinition, type_by_name};
use super::{
DanglingLink, FoldedTag, HealthIssue, HealthReport, HealthSummary, StaleEntity,
TagDistribution, TagVariant, UntaggedStats,
};
use crate::entity::MetadataValue;
use crate::graph::query;
use crate::store::Store;
pub const HEALTH_INCLUDE_KEYS: &[&str] = &[
"orphans",
"stubs",
"most_connected",
"missing_fields",
"stale",
"dangling_links",
"tags",
"missing_required_outgoing",
"conformance",
"integrity",
];
pub fn compute_health(
store: &Store,
default_schema: &TypeDefinition,
mem_schemas: &HashMap<String, Arc<Schema>>,
) -> HealthSummary {
let mut missing_fields = Vec::new();
let mut stale_entities = Vec::new();
let today_days = days_since_epoch();
for entity in store.all_entities() {
if entity.stub {
continue;
}
let resolved = mem_schemas
.get(entity.mem.as_str())
.and_then(|s| s.types.get(entity.entity_type.as_str()).cloned())
.or_else(|| type_by_name(&entity.entity_type));
let schema: &TypeDefinition = resolved.as_deref().unwrap_or(default_schema);
let mut issues = Vec::new();
for field in &schema.health_required_fields {
if schema.section(field).is_some() {
let content = entity.sections.get(field.as_str());
if content.is_none_or(|c| c.trim().is_empty()) {
issues.push(HealthIssue {
field: field.clone(),
message: format!("required section '{field}' is empty"),
});
}
} else {
let value = entity.metadata.get(field.as_str());
let is_empty = match value {
None => true,
Some(v) => v.to_frontmatter_string().trim().is_empty(),
};
if is_empty {
issues.push(HealthIssue {
field: field.clone(),
message: format!("required field '{field}' is missing"),
});
}
}
}
if let Some(mem_schema) = mem_schemas.get(entity.mem.as_str()) {
let mut seen_unknown = std::collections::HashSet::new();
for rel in &entity.relationships {
if !mem_schema.relationship_known(&rel.rel_type) {
if seen_unknown.insert(rel.rel_type.clone()) {
let suggestion = mem_schema
.suggest_relationship(&rel.rel_type)
.map(|s| format!(" Did you mean '{s}'?"))
.unwrap_or_default();
let (schema_name, schema_version) = mem_schema.id();
issues.push(HealthIssue {
field: "relationships".to_string(),
message: format!(
"relationship '{}' is not declared in schema \
'{schema_name}@{schema_version}'.{suggestion}",
rel.rel_type
),
});
}
continue;
}
let target_type = store
.get(&rel.target)
.map(|t| t.entity_type.clone())
.filter(|t| !t.is_empty());
if let Err(crate::runtime_validator::ValidationError::InvalidRelationshipShape {
rel_type,
from_type,
to_type,
allowed_source_types,
allowed_target_types,
..
}) = crate::runtime_validator::validate_rel_shape(
&rel.rel_type,
entity.entity_type.as_str(),
target_type.as_deref(),
mem_schema.as_ref(),
) {
let allowed_src = if allowed_source_types.is_empty() {
"<any>".to_string()
} else {
allowed_source_types.join(", ")
};
let allowed_tgt = if allowed_target_types.is_empty() {
"<any>".to_string()
} else {
allowed_target_types.join(", ")
};
issues.push(HealthIssue {
field: "relationships".to_string(),
message: format!(
"INVALID_REL_SHAPE: edge '{rel_type}' from \
'{from_type}' to '{to_type}' (target {target}) \
violates declared shape — allowed_source_types: \
[{allowed_src}], allowed_target_types: \
[{allowed_tgt}]. Remove via \
`memstead_relate from={from_id} to={target} \
type={rel_type} remove=true`.",
target = rel.target,
from_id = entity.id,
),
});
}
}
}
let auto_ts_field = schema.metadata_fields.iter().find(|f| f.auto_timestamp);
if let Some(ts_field) = auto_ts_field
&& let Some(val) = entity.metadata.get(ts_field.key.as_str())
{
let date_str = val.to_frontmatter_string();
if let Some(modified_days) = parse_iso_to_days(&date_str) {
let days_since = today_days.saturating_sub(modified_days);
if days_since > schema.staleness_threshold_days as u64 {
stale_entities.push(StaleEntity {
id: entity.id.clone(),
title: entity.title.clone(),
days_since_modified: days_since,
});
}
}
}
if !issues.is_empty() {
let total = schema.health_required_fields.len();
let score = if total > 0 {
(total.saturating_sub(issues.len()) as f32) / (total as f32)
} else {
1.0
};
missing_fields.push(HealthReport {
id: entity.id.clone(),
title: entity.title.clone(),
score,
issues,
});
}
}
stale_entities.sort_by_key(|e| std::cmp::Reverse(e.days_since_modified));
let orphan_count = query::find_orphans(store).len();
let stub_count = query::find_stubs(store).len();
HealthSummary {
stale_entities,
missing_fields,
orphan_count,
stub_count,
warnings: Vec::new(),
dangling_links: None,
findings: None,
tag_distribution: None,
tag_distribution_folded: None,
untagged_entities: None,
}
}
pub fn collect_tag_distribution(
store: &Store,
mem_filter: Option<&str>,
limit: usize,
) -> (Vec<TagDistribution>, Vec<FoldedTag>, UntaggedStats) {
let mut counts: HashMap<String, (usize, HashMap<String, usize>)> = HashMap::new();
let mut untagged = UntaggedStats {
total: 0,
by_entity_type: HashMap::new(),
};
for entity in store.all_entities() {
if entity.stub {
continue;
}
if let Some(v) = mem_filter
&& entity.mem != v
{
continue;
}
let tags_raw = entity
.metadata
.get("tags")
.and_then(|v| match v {
MetadataValue::String(s) => Some(s.as_str()),
_ => None,
})
.unwrap_or("");
let mut any_tag = false;
for tag in tags_raw.split(',').map(str::trim).filter(|s| !s.is_empty()) {
any_tag = true;
let entry = counts
.entry(tag.to_string())
.or_insert_with(|| (0, HashMap::new()));
entry.0 += 1;
*entry.1.entry(entity.entity_type.clone()).or_insert(0) += 1;
}
if !any_tag {
untagged.total += 1;
*untagged
.by_entity_type
.entry(entity.entity_type.clone())
.or_insert(0) += 1;
}
}
let mut entries: Vec<TagDistribution> = counts
.iter()
.map(|(tag, (count, by_type))| TagDistribution {
tag: tag.clone(),
count: *count,
by_entity_type: by_type.clone(),
})
.collect();
entries.sort_by(|a, b| b.count.cmp(&a.count).then_with(|| a.tag.cmp(&b.tag)));
entries.truncate(limit);
let mut by_canonical: HashMap<String, Vec<(String, usize)>> = HashMap::new();
for (tag, (count, _)) in counts.iter() {
by_canonical
.entry(tag.to_lowercase())
.or_default()
.push((tag.clone(), *count));
}
let mut folded: Vec<FoldedTag> = by_canonical
.into_iter()
.filter(|(_, v)| v.len() > 1)
.map(|(canonical, mut variants)| {
variants.sort_by(|a, b| b.1.cmp(&a.1).then_with(|| a.0.cmp(&b.0)));
let total = variants.iter().map(|(_, c)| *c).sum();
FoldedTag {
canonical,
total,
variants: variants
.into_iter()
.map(|(tag, count)| TagVariant { tag, count })
.collect(),
}
})
.collect();
folded.sort_by(|a, b| {
b.total
.cmp(&a.total)
.then_with(|| a.canonical.cmp(&b.canonical))
});
(entries, folded, untagged)
}
pub fn collect_dangling_links(store: &Store, mem_filter: Option<&str>) -> Vec<DanglingLink> {
use crate::entity::parser::extract_inline_links_lenient;
use std::collections::HashSet;
let mut out = Vec::new();
for entity in store.all_entities() {
if entity.stub {
continue;
}
if let Some(v) = mem_filter
&& entity.mem != v
{
continue;
}
let explicit_targets: HashSet<_> = entity
.relationships
.iter()
.map(|r| r.target.clone())
.collect();
for (section_key, section_body) in &entity.sections {
for target_id in extract_inline_links_lenient(section_body, &entity.mem) {
let target_missing = store.get(&target_id).map(|e| e.stub).unwrap_or(true);
let alias_orphan = !target_missing && !explicit_targets.contains(&target_id);
if target_missing || alias_orphan {
out.push(DanglingLink {
from: entity.id.clone(),
target_id: target_id.clone(),
target_path: target_id.path().to_string(),
section: Some(section_key.clone()),
});
}
}
}
for rel in &entity.relationships {
if store.get(&rel.target).is_some() {
continue;
}
let already_reported = out
.iter()
.any(|d| d.from == entity.id && d.target_id == rel.target);
if already_reported {
continue;
}
out.push(DanglingLink {
from: entity.id.clone(),
target_id: rel.target.clone(),
target_path: rel.target.path().to_string(),
section: None,
});
}
}
out
}
pub fn collect_missing_required_outgoing(
store: &Store,
mem_filter: Option<&str>,
mem_schemas: &HashMap<String, Arc<memstead_schema::Schema>>,
) -> Vec<MissingRequiredOutgoingReport> {
let mut out = Vec::new();
for entity in store.all_entities() {
if entity.stub {
continue;
}
if let Some(v) = mem_filter
&& entity.mem != v
{
continue;
}
let Some(mem_schema) = mem_schemas.get(entity.mem.as_str()) else {
continue;
};
let Some(td) = mem_schema.types.get(entity.entity_type.as_str()) else {
continue;
};
if td.required_outgoing.is_empty() {
continue;
}
let unsatisfied: Vec<MissingOutgoingBlock> = td
.required_outgoing
.iter()
.filter(|block| {
let count = entity
.relationships
.iter()
.filter(|rel| block.relationships.iter().any(|name| name == &rel.rel_type))
.count();
!block.admits(count)
})
.map(|block| MissingOutgoingBlock {
relationships: block.relationships.clone(),
cardinality: block.cardinality.to_string(),
})
.collect();
if unsatisfied.is_empty() {
continue;
}
out.push(MissingRequiredOutgoingReport {
id: entity.id.clone(),
title: entity.title.clone(),
entity_type: entity.entity_type.clone(),
mem: entity.mem.clone(),
missing: unsatisfied,
});
}
out.sort_by(|a, b| a.mem.cmp(&b.mem).then_with(|| a.id.0.cmp(&b.id.0)));
out
}
#[derive(Debug, Clone, serde::Serialize)]
pub struct MissingRequiredOutgoingReport {
pub id: crate::entity::EntityId,
pub title: String,
pub entity_type: String,
pub mem: String,
pub missing: Vec<MissingOutgoingBlock>,
}
#[derive(Debug, Clone, serde::Serialize)]
pub struct MissingOutgoingBlock {
pub relationships: Vec<String>,
pub cardinality: String,
}
pub fn entity_health(entity: &crate::entity::Entity, schema: &TypeDefinition) -> HealthReport {
let mut issues = Vec::new();
for field in &schema.health_required_fields {
if schema.section(field).is_some() {
let content = entity.sections.get(field.as_str());
if content.is_none_or(|c| c.trim().is_empty()) {
issues.push(HealthIssue {
field: field.clone(),
message: format!("required section '{field}' is empty"),
});
}
} else {
let value = entity.metadata.get(field.as_str());
if value.is_none() {
issues.push(HealthIssue {
field: field.clone(),
message: format!("required field '{field}' is missing"),
});
}
}
}
let total = schema.health_required_fields.len();
let score = if total > 0 {
((total - issues.len()) as f32) / (total as f32)
} else {
1.0
};
HealthReport {
id: entity.id.clone(),
title: entity.title.clone(),
score,
issues,
}
}
fn days_since_epoch() -> u64 {
#[cfg(target_arch = "wasm32")]
{
(js_sys::Date::now() / 1000.0) as u64 / 86400
}
#[cfg(not(target_arch = "wasm32"))]
{
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap_or_default()
.as_secs()
/ 86400
}
}
fn parse_iso_to_days(date: &str) -> Option<u64> {
let date_part = date.split('T').next()?;
let parts: Vec<&str> = date_part.split('-').collect();
if parts.len() != 3 {
return None;
}
let year: u64 = parts[0].parse().ok()?;
let month: u64 = parts[1].parse().ok()?;
let day: u64 = parts[2].parse().ok()?;
Some(ymd_to_days(year, month, day))
}
fn ymd_to_days(year: u64, month: u64, day: u64) -> u64 {
let y = if month <= 2 { year - 1 } else { year };
let m = if month <= 2 { month + 9 } else { month - 3 };
let era = y / 400;
let yoe = y - era * 400;
let doy = (153 * m + 2) / 5 + day - 1;
let doe = yoe * 365 + yoe / 4 - yoe / 100 + doy;
let days = era * 146097 + doe;
days - 719468
}
#[cfg(test)]
mod tests {
use super::*;
use crate::entity::{Entity, EntityId, MetadataValue};
use crate::store::Store;
use indexmap::IndexMap;
use memstead_schema::type_by_name;
fn make_entity(name: &str, has_required: bool) -> Entity {
let mut metadata = IndexMap::new();
metadata.insert("level".into(), MetadataValue::String("M0".into()));
metadata.insert("type".into(), MetadataValue::String("spec".into()));
metadata.insert(
"created_date".into(),
MetadataValue::String("2026-01-15".into()),
);
metadata.insert(
"last_modified".into(),
MetadataValue::String("2026-04-12".into()),
);
let mut sections = IndexMap::new();
if has_required {
sections.insert("identity".into(), "Has identity.".into());
sections.insert("purpose".into(), "Has purpose.".into());
}
Entity {
id: EntityId::new("specs", name),
title: name.into(),
entity_type: "spec".into(),
mem: "specs".into(),
file_path: format!("{name}.md"),
metadata,
sections,
relationships: Vec::new(),
content_hash: String::new(),
stub: false,
stub_kind: None,
heading_spans: std::collections::HashMap::new(),
}
}
fn make_concept_entity(name: &str, with_definition: bool) -> Entity {
let mut metadata = IndexMap::new();
metadata.insert("type".into(), MetadataValue::String("concept".into()));
metadata.insert("maturity".into(), MetadataValue::String("emerging".into()));
metadata.insert(
"abstraction_level".into(),
MetadataValue::String("concrete".into()),
);
metadata.insert(
"created_date".into(),
MetadataValue::String("2026-01-15".into()),
);
metadata.insert(
"last_modified".into(),
MetadataValue::String("2026-04-12".into()),
);
let mut sections = IndexMap::new();
if with_definition {
sections.insert("definition".into(), "Precise definition.".into());
}
sections.insert("explanation".into(), "How it works.".into());
Entity {
id: EntityId::new("concepts", name),
title: name.into(),
entity_type: "concept".into(),
mem: "concepts".into(),
file_path: format!("{name}.md"),
metadata,
sections,
relationships: Vec::new(),
content_hash: String::new(),
stub: false,
stub_kind: None,
heading_spans: std::collections::HashMap::new(),
}
}
#[test]
fn health_concept_missing_definition_reports_definition_field() {
let schema = &type_by_name("concept").unwrap();
let entity = make_concept_entity("clarity", false);
let report = entity_health(&entity, schema);
assert!(report.issues.iter().any(|i| i.field == "definition"));
assert!(!report.issues.iter().any(|i| i.field == "identity"));
assert!(!report.issues.iter().any(|i| i.field == "purpose"));
assert!(report.score < 1.0);
let healthy = make_concept_entity("clarity-ok", true);
let healthy_report = entity_health(&healthy, schema);
assert!(
!healthy_report
.issues
.iter()
.any(|i| i.field == "definition")
);
}
#[test]
fn health_detects_missing_sections() {
let schema = &type_by_name("spec").unwrap();
let entity = make_entity("incomplete", false);
let report = entity_health(&entity, schema);
assert!(!report.issues.is_empty());
assert!(report.score < 1.0);
}
#[test]
fn health_clean_entity() {
let schema = &type_by_name("spec").unwrap();
let entity = make_entity("complete", true);
let report = entity_health(&entity, schema);
let section_issues: Vec<_> = report
.issues
.iter()
.filter(|i| i.field == "identity" || i.field == "purpose")
.collect();
assert!(section_issues.is_empty());
}
#[test]
fn health_summary_counts() {
let mut store = Store::new();
let e1 = make_entity("healthy", true);
let e2 = make_entity("unhealthy", false);
store.upsert(e1.id.clone(), e1);
store.upsert(e2.id.clone(), e2);
let schema = &type_by_name("spec").unwrap();
let summary = compute_health(&store, schema, &HashMap::new());
assert_eq!(summary.orphan_count, 2); assert_eq!(summary.stub_count, 0);
}
#[test]
fn health_surfaces_invalid_rel_shape_on_existing_edges() {
use crate::entity::Relationship;
use memstead_schema::SchemaRegistry;
let registry = SchemaRegistry::builtin();
let software = registry
.resolve_by_name("software")
.unwrap()
.expect("software schema ships as a builtin");
let mut store = Store::new();
let mut bad = make_entity("bad-owns-source", true);
bad.entity_type = "spec".into();
bad.metadata
.insert("level".into(), MetadataValue::String("M0".into()));
bad.metadata
.insert("stability".into(), MetadataValue::String("evolving".into()));
bad.relationships.push(Relationship {
rel_type: "OWNS".into(),
target: EntityId::new("specs", "victim"),
description: None,
});
let mut victim = make_entity("victim", true);
victim.entity_type = "spec".into();
store.upsert(bad.id.clone(), bad);
store.upsert(victim.id.clone(), victim);
let mut mem_schemas = HashMap::new();
mem_schemas.insert("specs".to_string(), software);
let schema = &type_by_name("spec").unwrap();
let summary = compute_health(&store, schema, &mem_schemas);
let report = summary
.missing_fields
.iter()
.find(|r| r.id.as_ref() == "specs--bad-owns-source")
.expect("shape-violating entity must surface");
let issue = report
.issues
.iter()
.find(|i| i.field == "relationships" && i.message.contains("INVALID_REL_SHAPE"))
.expect("shape violation must produce an INVALID_REL_SHAPE issue");
assert!(
issue.message.contains("OWNS"),
"issue must name the offending rel_type: {}",
issue.message
);
assert!(
issue.message.contains("spec"),
"issue must name the actual source type: {}",
issue.message
);
assert!(
issue.message.contains("actor"),
"issue must name the allowed source type: {}",
issue.message
);
assert!(
issue.message.contains("remove=true"),
"issue must surface the recovery path: {}",
issue.message
);
}
#[test]
fn health_does_not_flag_shape_compliant_edges() {
use crate::entity::Relationship;
use memstead_schema::SchemaRegistry;
let registry = SchemaRegistry::builtin();
let software = registry
.resolve_by_name("software")
.unwrap()
.expect("software schema ships as a builtin");
let mut store = Store::new();
let mut owner = make_entity("owner", true);
owner.entity_type = "actor".into();
owner
.metadata
.insert("kind".into(), MetadataValue::String("team".into()));
owner
.metadata
.insert("active".into(), MetadataValue::Bool(true));
owner
.metadata
.insert("handle".into(), MetadataValue::String("owner".into()));
owner.relationships.push(Relationship {
rel_type: "OWNS".into(),
target: EntityId::new("specs", "owned"),
description: None,
});
let mut owned = make_entity("owned", true);
owned.entity_type = "spec".into();
store.upsert(owner.id.clone(), owner);
store.upsert(owned.id.clone(), owned);
let mut mem_schemas = HashMap::new();
mem_schemas.insert("specs".to_string(), software);
let schema = &type_by_name("spec").unwrap();
let summary = compute_health(&store, schema, &mem_schemas);
let shape_issue = summary
.missing_fields
.iter()
.flat_map(|r| r.issues.iter())
.find(|i| i.message.contains("INVALID_REL_SHAPE"));
assert!(
shape_issue.is_none(),
"shape-compliant edge must not surface a shape issue, got: {shape_issue:?}"
);
}
#[test]
fn health_warns_on_undeclared_relationship_in_existing_entity() {
use crate::entity::Relationship;
use memstead_schema::Schema;
let mut store = Store::new();
let mut entity = make_entity("with-bad-rel", true);
entity.relationships.push(Relationship {
rel_type: "CONJURES".into(),
target: EntityId::new("specs", "unknown"),
description: None,
});
store.upsert(entity.id.clone(), entity);
let mut mem_schemas = HashMap::new();
mem_schemas.insert("specs".to_string(), Schema::builtin_default());
let schema = &type_by_name("spec").unwrap();
let summary = compute_health(&store, schema, &mem_schemas);
let report = summary
.missing_fields
.iter()
.find(|r| r.id.as_ref() == "specs--with-bad-rel")
.expect("entity must surface in missing_fields");
let rel_issue = report
.issues
.iter()
.find(|i| i.field == "relationships")
.expect("undeclared relationship must produce an issue");
assert!(
rel_issue.message.contains("CONJURES"),
"issue message must name the offending relationship: {}",
rel_issue.message
);
assert!(
rel_issue.message.contains("default@1.0.0"),
"issue must name the schema pin: {}",
rel_issue.message
);
}
fn make_entity_with_body(name: &str, section_key: &str, body: &str) -> Entity {
let mut entity = make_entity(name, true);
entity.sections.insert(section_key.into(), body.to_string());
entity
}
#[test]
fn dangling_link_detected_after_delete() {
use crate::entity::store_builder::make_stub;
let mut store = Store::new();
let a = make_entity_with_body("a", "purpose", "Refers to [[b]] in prose.");
store.upsert(a.id.clone(), a.clone());
let b_id = EntityId::new("specs", "b");
store.upsert(b_id.clone(), make_stub(b_id.clone()));
let dangling = super::collect_dangling_links(&store, None);
assert_eq!(dangling.len(), 1, "exactly one dangling link expected");
let d = &dangling[0];
assert_eq!(d.from, a.id);
assert_eq!(d.target_id, b_id);
assert_eq!(d.target_path, "b");
assert_eq!(d.section.as_deref(), Some("purpose"));
}
#[test]
fn dangling_link_does_not_flag_stub_target_of_explicit_relationship() {
use crate::entity::Relationship;
use crate::entity::store_builder::make_stub;
let mut store = Store::new();
let mut a = make_entity("a", true);
let b_id = EntityId::new("specs", "b");
a.relationships.push(Relationship {
rel_type: "REFERENCES".into(),
target: b_id.clone(),
description: None,
});
store.upsert(a.id.clone(), a);
store.upsert(b_id.clone(), make_stub(b_id));
let dangling = super::collect_dangling_links(&store, None);
assert!(
dangling.is_empty(),
"explicit relationships to stubs are valid by design \
(stubs are first-class placeholders); only inline-body \
wiki-links to stubs must surface"
);
}
#[test]
fn dangling_link_does_not_flag_real_reference() {
use crate::entity::Relationship;
let mut store = Store::new();
let mut a = make_entity_with_body("a", "purpose", "Refers to [[b]] in prose.");
a.relationships.push(Relationship {
rel_type: "REFERENCES".into(),
target: EntityId::new("specs", "b"),
description: None,
});
let b = make_entity("b", true);
store.upsert(a.id.clone(), a);
store.upsert(b.id.clone(), b);
let dangling = super::collect_dangling_links(&store, None);
assert!(
dangling.is_empty(),
"real reference backed by relation — not dangling, not alias-orphan"
);
}
#[test]
fn dangling_link_relationship_section_target_absent() {
use crate::entity::Relationship;
let mut store = Store::new();
let mut a = make_entity("a", true);
a.relationships.push(Relationship {
rel_type: "DEPENDS_ON".into(),
target: EntityId::new("specs", "gone"),
description: None,
});
store.upsert(a.id.clone(), a.clone());
let dangling = super::collect_dangling_links(&store, None);
assert_eq!(
dangling.len(),
1,
"exactly one relationship-section dangler"
);
let d = &dangling[0];
assert_eq!(d.from, a.id);
assert_eq!(d.target_id, EntityId::new("specs", "gone"));
assert!(
d.section.is_none(),
"relationship-section danglers ship `section: None`, got {:?}",
d.section
);
}
#[test]
fn dangling_link_relationship_section_stub_target_not_flagged() {
use crate::entity::Relationship;
use crate::entity::store_builder::make_stub;
let mut store = Store::new();
let mut a = make_entity("a", true);
let b_id = EntityId::new("specs", "b");
a.relationships.push(Relationship {
rel_type: "DEPENDS_ON".into(),
target: b_id.clone(),
description: None,
});
store.upsert(a.id.clone(), a);
store.upsert(b_id.clone(), make_stub(b_id));
let dangling = super::collect_dangling_links(&store, None);
assert!(
dangling.is_empty(),
"relationship targets that resolve to stubs are forward-references, not corruption"
);
}
#[test]
fn dangling_link_dedups_across_body_and_relations() {
use crate::entity::Relationship;
use crate::entity::store_builder::make_stub;
let mut store = Store::new();
let mut a = make_entity_with_body("a", "purpose", "Refers to [[b]] in prose.");
let b_id = EntityId::new("specs", "b");
a.relationships.push(Relationship {
rel_type: "REFERENCES".into(),
target: b_id.clone(),
description: None,
});
store.upsert(a.id.clone(), a.clone());
store.upsert(b_id.clone(), make_stub(b_id.clone()));
let dangling = super::collect_dangling_links(&store, None);
assert_eq!(
dangling.len(),
1,
"body + relations both pointing at the same stub should dedup"
);
assert!(dangling[0].section.is_some(), "body axis wins the dedup");
}
#[test]
fn dangling_links_scope_to_mem_filter() {
use crate::entity::store_builder::make_stub;
let mut store = Store::new();
let a = make_entity_with_body("a", "purpose", "Refers to [[gone]] in prose.");
store.upsert(a.id.clone(), a);
let gone_specs = EntityId::new("specs", "gone");
store.upsert(gone_specs.clone(), make_stub(gone_specs));
let mut x = make_entity("x", true);
x.id = EntityId::new("web", "x");
x.mem = "web".into();
x.file_path = "x.md".into();
x.sections
.insert("purpose".into(), "Refers to [[gone]] in prose.".into());
store.upsert(x.id.clone(), x);
let gone_web = EntityId::new("web", "gone");
store.upsert(gone_web.clone(), make_stub(gone_web));
let all = super::collect_dangling_links(&store, None);
assert_eq!(all.len(), 2);
let specs_only = super::collect_dangling_links(&store, Some("specs"));
assert_eq!(specs_only.len(), 1);
assert_eq!(specs_only[0].from.mem(), "specs");
let web_only = super::collect_dangling_links(&store, Some("web"));
assert_eq!(web_only.len(), 1);
assert_eq!(web_only[0].from.mem(), "web");
}
#[test]
fn parse_iso_date() {
let days = parse_iso_to_days("2026-04-12").unwrap();
assert!(days > 0);
let days_with_time = parse_iso_to_days("2026-04-12T10:00:00Z").unwrap();
assert_eq!(days, days_with_time);
}
#[test]
fn ymd_roundtrip() {
let days = ymd_to_days(2026, 1, 1);
assert!(days > 20000); }
fn make_entity_with_tags(name: &str, mem: &str, entity_type: &str, tags: &str) -> Entity {
let mut e = make_entity(name, true);
e.id = EntityId::new(mem, name);
e.mem = mem.into();
e.entity_type = entity_type.into();
e.metadata
.insert("tags".into(), MetadataValue::String(tags.into()));
e
}
fn make_entity_no_tags(name: &str) -> Entity {
make_entity(name, true)
}
#[test]
fn tag_distribution_aggregates_across_entities() {
let mut store = Store::new();
let a = make_entity_with_tags("a", "specs", "spec", "decision, plan");
let b = make_entity_with_tags("b", "specs", "spec", "decision, plan");
let c = make_entity_with_tags("c", "specs", "spec", "plan");
store.upsert(a.id.clone(), a);
store.upsert(b.id.clone(), b);
store.upsert(c.id.clone(), c);
let (dist, _folded, untagged) = collect_tag_distribution(&store, None, 10);
assert_eq!(dist.len(), 2);
assert_eq!(dist[0].tag, "plan");
assert_eq!(dist[0].count, 3);
assert_eq!(dist[0].by_entity_type.get("spec"), Some(&3));
assert_eq!(dist[1].tag, "decision");
assert_eq!(dist[1].count, 2);
assert_eq!(untagged.total, 0);
}
#[test]
fn tag_distribution_case_sensitive() {
let mut store = Store::new();
let a = make_entity_with_tags("a", "specs", "spec", "Decision");
let b = make_entity_with_tags("b", "specs", "spec", "decision");
store.upsert(a.id.clone(), a);
store.upsert(b.id.clone(), b);
let (dist, folded, _untagged) = collect_tag_distribution(&store, None, 10);
assert_eq!(dist.len(), 2, "`decision` and `Decision` stay distinct");
let tags: std::collections::HashSet<&str> = dist.iter().map(|t| t.tag.as_str()).collect();
assert!(tags.contains("decision"));
assert!(tags.contains("Decision"));
assert_eq!(folded.len(), 1);
assert_eq!(folded[0].canonical, "decision");
assert_eq!(folded[0].total, 2);
assert_eq!(folded[0].variants.len(), 2);
}
#[test]
fn untagged_entities_counts_missing_and_empty() {
let mut store = Store::new();
let a = make_entity_no_tags("a"); let b = make_entity_with_tags("b", "specs", "spec", "");
let c = make_entity_with_tags("c", "specs", "spec", " , , ");
store.upsert(a.id.clone(), a);
store.upsert(b.id.clone(), b);
store.upsert(c.id.clone(), c);
let (dist, _folded, untagged) = collect_tag_distribution(&store, None, 10);
assert!(dist.is_empty(), "no effective tags → empty distribution");
assert_eq!(untagged.total, 3);
assert_eq!(untagged.by_entity_type.get("spec"), Some(&3));
}
#[test]
fn tag_distribution_respects_mem_filter() {
let mut store = Store::new();
let a = make_entity_with_tags("a", "specs", "spec", "decision");
let b = make_entity_with_tags("b", "memos", "memo", "observation");
let c = make_entity_no_tags("c");
store.upsert(a.id.clone(), a);
store.upsert(b.id.clone(), b);
store.upsert(c.id.clone(), c);
let (dist, _folded, untagged) = collect_tag_distribution(&store, Some("memos"), 10);
assert_eq!(dist.len(), 1);
assert_eq!(dist[0].tag, "observation");
assert_eq!(untagged.total, 0, "untagged scoped to filter mem");
}
#[test]
fn tag_distribution_respects_limit() {
let mut store = Store::new();
for (name, tag) in [
("a", "t-alpha"),
("b", "t-beta"),
("c", "t-gamma"),
("d", "t-delta"),
("e", "t-epsilon"),
] {
let e = make_entity_with_tags(name, "specs", "spec", tag);
store.upsert(e.id.clone(), e);
}
let (dist, _folded, _untagged) = collect_tag_distribution(&store, None, 3);
assert_eq!(dist.len(), 3);
assert_eq!(dist[0].tag, "t-alpha");
assert_eq!(dist[1].tag, "t-beta");
assert_eq!(dist[2].tag, "t-delta");
}
fn required_outgoing_fixture_schema() -> std::sync::Arc<memstead_schema::Schema> {
let manifest = r#"name: tests-ro-health
version: 0.1.0
description: required_outgoing health test schema
when_to_use: tests
types:
- decision
- note
relationships:
mode: strict
definitions:
- name: PART_OF
description: Hier
default_weight: 3.0
acyclic: true
- name: CHOSEN
description: ch
default_weight: 3.0
- name: REJECTED
description: rj
default_weight: 2.0
- name: REFERENCES
description: ref
default_weight: 0.5
- name: _default
description: Fallback
default_weight: 1.0
community:
resolution: 1.0
seed: 42
"#;
let body_section = "sections:\n - key: body\n heading: Body\n required: true\n search_weight: 10.0\n catch_all: true\n write_rules: []\nmetadata_fields: []\ntitle_weight: 100.0\ntext_fields:\n - body\nhierarchy_relationship: PART_OF\npropagating_relationships: []\nupdatable_fields:\n - title\n - body\nhealth_required_fields:\n - body\nstaleness_threshold_days: 90\nwrite_rules: []\n";
let decision_yaml = format!(
"name: decision\ndescription: t\nwhen_to_use: Here\n{body_section}required_outgoing:\n - relationships: [CHOSEN]\n cardinality: at_least_one\n - relationships: [REJECTED]\n cardinality: at_least_one\n",
);
let note_yaml = format!("name: note\ndescription: t\nwhen_to_use: Here\n{body_section}",);
std::sync::Arc::new(
memstead_schema::load_schema_from_memory(
manifest,
&[
("decision".to_string(), decision_yaml),
("note".to_string(), note_yaml),
],
)
.expect("ro fixture schema must parse"),
)
}
fn make_typed_entity(mem: &str, slug: &str, entity_type: &str) -> crate::entity::Entity {
use crate::entity::MetadataValue;
let mut metadata = IndexMap::new();
metadata.insert("type".into(), MetadataValue::String(entity_type.into()));
let mut sections = IndexMap::new();
sections.insert("body".into(), "Body.".into());
crate::entity::Entity {
id: EntityId::new(mem, slug),
title: slug.to_string(),
entity_type: entity_type.into(),
mem: mem.into(),
file_path: format!("{slug}.md"),
metadata,
sections,
relationships: Vec::new(),
content_hash: String::new(),
stub: false,
stub_kind: None,
heading_spans: std::collections::HashMap::new(),
}
}
#[test]
fn missing_required_outgoing_collects_violators_only() {
let schema = required_outgoing_fixture_schema();
let mut store = Store::new();
let mut violator = make_typed_entity("plan", "stalled", "decision");
let mut satisfied = make_typed_entity("plan", "wired", "decision");
let opt_a = make_typed_entity("plan", "a", "note");
let opt_b = make_typed_entity("plan", "b", "note");
let happy_note = make_typed_entity("plan", "side", "note");
satisfied.relationships.push(crate::entity::Relationship {
rel_type: "CHOSEN".into(),
target: opt_a.id.clone(),
description: None,
});
satisfied.relationships.push(crate::entity::Relationship {
rel_type: "REJECTED".into(),
target: opt_b.id.clone(),
description: None,
});
for e in [violator.clone(), satisfied, opt_a, opt_b, happy_note] {
store.upsert(e.id.clone(), e);
}
let mut mem_schemas = HashMap::new();
mem_schemas.insert("plan".to_string(), schema);
let reports = collect_missing_required_outgoing(&store, None, &mem_schemas);
assert_eq!(
reports.len(),
1,
"exactly one violator (the empty decision); got {reports:?}"
);
let r = &reports[0];
assert_eq!(r.id, violator.id);
assert_eq!(r.entity_type, "decision");
assert_eq!(r.mem, "plan");
assert_eq!(r.missing.len(), 2);
let names: Vec<&str> = r
.missing
.iter()
.flat_map(|b| b.relationships.iter().map(String::as_str))
.collect();
assert!(names.contains(&"CHOSEN"));
assert!(names.contains(&"REJECTED"));
violator.relationships.push(crate::entity::Relationship {
rel_type: "CHOSEN".into(),
target: EntityId::new("plan", "x"),
description: None,
});
}
#[test]
fn missing_required_outgoing_respects_mem_filter() {
let schema = required_outgoing_fixture_schema();
let mut store = Store::new();
let v_a = make_typed_entity("alpha", "stalled", "decision");
let v_b = make_typed_entity("beta", "stalled", "decision");
store.upsert(v_a.id.clone(), v_a);
store.upsert(v_b.id.clone(), v_b.clone());
let mut mem_schemas = HashMap::new();
mem_schemas.insert("alpha".to_string(), schema.clone());
mem_schemas.insert("beta".to_string(), schema);
let alpha_only = collect_missing_required_outgoing(&store, Some("alpha"), &mem_schemas);
assert_eq!(alpha_only.len(), 1);
assert_eq!(alpha_only[0].mem, "alpha");
let both = collect_missing_required_outgoing(&store, None, &mem_schemas);
assert_eq!(both.len(), 2);
}
#[test]
fn missing_required_outgoing_skips_stubs_and_unschemaed_mems() {
let schema = required_outgoing_fixture_schema();
let mut store = Store::new();
let mut stub = make_typed_entity("plan", "ghost", "");
stub.stub = true;
stub.entity_type = String::new();
let other = make_typed_entity("uncharted", "lonely", "decision");
store.upsert(stub.id.clone(), stub);
store.upsert(other.id.clone(), other);
let mut mem_schemas = HashMap::new();
mem_schemas.insert("plan".to_string(), schema);
let reports = collect_missing_required_outgoing(&store, None, &mem_schemas);
assert!(
reports.is_empty(),
"stub (no schema lookup) and unschemaed mem must be skipped; got {reports:?}",
);
}
}