use std::sync::Arc;
use memstead_schema::{TypeDefinition, type_by_name};
use super::ValidationError;
use crate::entity::ParseResult;
use crate::entity::store_builder::push_entities_into_store;
use crate::graph::{LouvainOutput, community::detect_communities};
use crate::store::Store;
pub const VALIDATOR_LOUVAIN_SEED: u32 = 1;
pub const VALIDATOR_RESOLUTION: f64 = 1.0;
#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize)]
pub struct DanglingCrossMemEdge {
pub entity_path: String,
pub target_id: String,
pub target_mem: String,
}
#[derive(Debug)]
pub struct GraphCheckResult {
pub store: Store,
pub communities: LouvainOutput,
pub dangling_cross_mem_edges: Vec<DanglingCrossMemEdge>,
}
pub fn build_and_check(
parse_results: Vec<ParseResult>,
fallback_schema: &TypeDefinition,
mem_name: &str,
cross_mem_as_error: bool,
) -> Result<GraphCheckResult, ValidationError> {
if cross_mem_as_error
&& let Some(pr) = parse_results.iter().find(|pr| {
pr.entity
.relationships
.iter()
.any(|r| r.target.mem() != mem_name)
})
{
let rel = pr
.entity
.relationships
.iter()
.find(|r| r.target.mem() != mem_name)
.expect("find guaranteed a match");
return Err(ValidationError::CrossMemRelationship {
path: pr.entity.file_path.clone(),
target: rel.target.as_ref().to_string(),
});
}
let dangling_cross_mem_edges = dangling_cross_mem_edges_in(&parse_results, mem_name);
let mut store = Store::new();
push_entities_into_store(&mut store, parse_results, fallback_schema, None);
let communities = detect_communities(
&store,
VALIDATOR_RESOLUTION,
VALIDATOR_LOUVAIN_SEED,
|rel_type| {
fallback_schema.edge_weight(rel_type) as f64
},
);
Ok(GraphCheckResult {
store,
communities,
dangling_cross_mem_edges,
})
}
pub fn dangling_cross_mem_edges_in(
parse_results: &[ParseResult],
mem_name: &str,
) -> Vec<DanglingCrossMemEdge> {
let mut edges = Vec::new();
for pr in parse_results {
for rel in &pr.entity.relationships {
if rel.target.mem() != mem_name {
edges.push(DanglingCrossMemEdge {
entity_path: pr.entity.file_path.clone(),
target_id: rel.target.as_ref().to_string(),
target_mem: rel.target.mem().to_string(),
});
}
}
}
edges
}
pub fn resolve_fallback_type(config_types: Option<&[String]>) -> Arc<TypeDefinition> {
if let Some(name) = config_types.and_then(|v| v.first())
&& let Some(s) = type_by_name(name)
{
return s;
}
crate::engine_fallback_type()
}
pub fn tally(store: &Store) -> (usize, usize) {
let entity_count = store.len();
let edge_count: usize = store.all_ids().map(|id| store.outgoing(id).len()).sum();
(entity_count, edge_count)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::entity::id::file_path_to_id;
use crate::entity::parser::parse_markdown;
use memstead_schema::type_by_name;
const MINIMAL_SPEC: &str = "\
---
type: spec
created_date: 2026-01-15
last_modified: 2026-01-15
level: M0
---
# Alpha
## Identity
A
## Purpose
B
## Specifies
C
## Constraints
D
## Rationale
E
## Relationships
- **USES**: [[beta]]
";
fn spec_type() -> Arc<TypeDefinition> {
type_by_name("spec").unwrap()
}
fn parse(path: &str, mem: &str, content: &str) -> ParseResult {
parse_markdown(content, path, &spec_type(), mem).unwrap()
}
#[test]
fn accepts_single_entity_archive() {
let result = build_and_check(
vec![parse("alpha.md", "v", MINIMAL_SPEC)],
&spec_type(),
"v",
true,
)
.unwrap();
assert!(result.store.contains(&file_path_to_id("alpha.md", "v")));
}
#[test]
fn materializes_stub_for_unresolved_wiki_link() {
let result = build_and_check(
vec![parse("alpha.md", "v", MINIMAL_SPEC)],
&spec_type(),
"v",
true,
)
.unwrap();
let stub_id = file_path_to_id("beta", "v");
let stub = result.store.get(&stub_id).expect("stub materialized");
assert!(stub.stub);
}
#[test]
fn empty_archive_yields_zero_communities() {
let result = build_and_check(vec![], &spec_type(), "v", true).unwrap();
assert_eq!(result.communities.count, 0);
}
#[test]
fn rejects_cross_mem_relationship() {
let cross_mem_pr = || {
let mut pr = parse("alpha.md", "v", MINIMAL_SPEC);
pr.entity.relationships.push(crate::entity::Relationship {
rel_type: "DEPENDS_ON".to_string(),
target: crate::entity::EntityId("other-mem--thing".to_string()),
description: None,
});
pr
};
let err = build_and_check(vec![cross_mem_pr()], &spec_type(), "v", true).unwrap_err();
assert!(matches!(err, ValidationError::CrossMemRelationship { .. }));
let result = build_and_check(vec![cross_mem_pr()], &spec_type(), "v", false).unwrap();
assert_eq!(result.dangling_cross_mem_edges.len(), 1);
let edge = &result.dangling_cross_mem_edges[0];
assert_eq!(edge.target_id, "other-mem--thing");
assert_eq!(edge.target_mem, "other-mem");
}
#[test]
fn resolve_fallback_type_picks_first_entry() {
let s = resolve_fallback_type(Some(&["concept".to_string(), "spec".to_string()]));
assert_eq!(s.name.as_str(), "concept");
}
#[test]
fn resolve_fallback_type_falls_back_on_unknown() {
let s = resolve_fallback_type(Some(&["bogus".to_string()]));
assert_eq!(s.name.as_str(), "spec");
}
#[test]
fn resolve_fallback_type_falls_back_on_empty() {
let s = resolve_fallback_type(None);
assert_eq!(s.name.as_str(), "spec");
}
}