use std::path::PathBuf;
use std::sync::Arc;
use memstead_schema::{Schema, TypeDefinition, type_by_name};
use super::ParseResult;
use super::parser;
use super::source::EntitySource;
fn resolve_type_for_entry(mem_schema: &Schema, content: &str) -> Arc<TypeDefinition> {
if let Some(name) = parser::peek_type_from_frontmatter(content) {
if let Some(t) = mem_schema.get_type(&name) {
return t;
}
if let Some(t) = type_by_name(&name) {
return t;
}
}
mem_schema
.get_type("spec")
.or_else(|| type_by_name("spec"))
.expect("default-schema spec must always exist")
}
pub struct LoadResult {
pub entities: Vec<ParseResult>,
pub errors: Vec<(PathBuf, String)>,
}
pub fn load_mem(
mem_dir: &std::path::Path,
mem: &str,
mem_schema: &Schema,
) -> Result<LoadResult, LoadError> {
load_from_source(
EntitySource::Directory {
root: mem_dir.to_path_buf(),
},
mem,
mem_schema,
)
}
pub fn load_mem_archive(
archive_path: &std::path::Path,
mem: &str,
mem_schema: &Schema,
) -> Result<LoadResult, LoadError> {
let mut result = load_from_source(
EntitySource::ZipArchive(archive_path.to_path_buf()),
mem,
mem_schema,
)?;
sanitize_cross_mem_relationships(&mut result.entities, mem);
Ok(result)
}
fn sanitize_cross_mem_relationships(parse_results: &mut [ParseResult], mem: &str) {
let mut stripped_total: usize = 0;
for parse_result in parse_results.iter_mut() {
let entity_id = parse_result.entity.id.clone();
let before = parse_result.entity.relationships.len();
parse_result.entity.relationships.retain(|rel| {
let same_mem = rel.target.mem() == mem;
if !same_mem {
tracing::warn!(
mem = mem,
from = %entity_id,
to = %rel.target,
rel_type = rel.rel_type.as_str(),
"stripping cross-mem relationship from read mem \
(published archives are self-contained; cross-mem \
authorization is workspace-local and does not travel)"
);
}
same_mem
});
stripped_total += before - parse_result.entity.relationships.len();
}
if stripped_total > 0 {
tracing::warn!(
mem = mem,
stripped = stripped_total,
"read mem contained {} cross-mem relationship(s); stripped on load",
stripped_total
);
}
}
fn load_from_source(
source: EntitySource,
mem: &str,
mem_schema: &Schema,
) -> Result<LoadResult, LoadError> {
let (source_entries, read_errors) = source.read_all()?;
Ok(parse_entries(source_entries, read_errors, mem, mem_schema))
}
pub fn parse_entries(
source_entries: Vec<super::source::SourceEntry>,
read_errors: Vec<super::source::SourceReadError>,
mem: &str,
mem_schema: &Schema,
) -> LoadResult {
let mut entities = Vec::new();
let mut errors: Vec<(PathBuf, String)> = read_errors
.into_iter()
.map(|e| (e.source_path, e.error.to_string()))
.collect();
for entry in source_entries {
if entry.content.trim().is_empty() {
continue;
}
let outcome = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
let resolved_type = resolve_type_for_entry(mem_schema, &entry.content);
parser::parse_markdown(
&entry.content,
&entry.relative_path,
resolved_type.as_ref(),
mem,
)
}));
match outcome {
Ok(Ok(mut result)) => {
result.entity.file_path = entry.relative_path;
entities.push(result);
}
Ok(Err(e)) => {
errors.push((entry.source_path, e.to_string()));
}
Err(panic) => {
errors.push((
entry.source_path,
format!("parser panicked: {}", panic_message(panic)),
));
}
}
}
LoadResult { entities, errors }
}
fn panic_message(panic: Box<dyn std::any::Any + Send>) -> String {
panic
.downcast_ref::<&str>()
.map(|s| (*s).to_string())
.or_else(|| panic.downcast_ref::<String>().cloned())
.unwrap_or_else(|| "unknown panic payload".to_string())
}
#[derive(Debug, thiserror::Error)]
pub enum LoadError {
#[error("mem directory not found: {0}")]
DirNotFound(String),
#[error("parse error in {file}: {source}")]
Parse {
file: String,
source: parser::ParseError,
},
#[error("io error: {0}")]
Io(#[from] std::io::Error),
#[error("archive not found: {0}")]
ArchiveNotFound(String),
#[error("invalid archive: {0}")]
InvalidArchive(String),
#[error("zip error: {0}")]
Zip(#[from] zip::result::ZipError),
#[error("git ref not found: {0}")]
RefNotFound(String),
#[error("git tree read error: {0}")]
GitTree(String),
}
#[cfg(test)]
mod tests {
use super::*;
use crate::entity::{Entity, EntityId, Relationship};
use indexmap::IndexMap;
use memstead_schema::Schema;
use std::fs;
use tempfile::TempDir;
fn setup_mem(entities: &[(&str, &str)]) -> TempDir {
let dir = TempDir::new().unwrap();
for (name, content) in entities {
let path = dir.path().join(name);
if let Some(parent) = path.parent() {
fs::create_dir_all(parent).unwrap();
}
fs::write(&path, content).unwrap();
}
dir
}
#[test]
fn load_single_entity() {
let dir = setup_mem(&[(
"test-entity.md",
"---\ntype: spec\n---\n# Test Entity\n\n## Identity\n\nTest.\n",
)]);
let schema = Schema::builtin_default();
let result = load_mem(dir.path(), "specs", &schema).unwrap();
assert_eq!(result.entities.len(), 1);
assert!(result.errors.is_empty());
assert_eq!(result.entities[0].entity.title, "Test Entity");
}
#[test]
fn load_nested_entities() {
let dir = setup_mem(&[
(
"parent.md",
"---\ntype: spec\n---\n# Parent\n\n## Identity\n\nParent entity.\n",
),
(
"parent/child.md",
"---\ntype: spec\n---\n# Child\n\n## Identity\n\nChild entity.\n",
),
]);
let schema = Schema::builtin_default();
let result = load_mem(dir.path(), "specs", &schema).unwrap();
assert_eq!(result.entities.len(), 2);
}
#[test]
fn load_skips_engine_internal_dirs() {
let dir = setup_mem(&[
(
"visible.md",
"---\ntype: spec\n---\n# Visible\n\n## Identity\n\nTest.\n",
),
(
".git/secret.md",
"---\ntype: spec\n---\n# GitSecret\n\n## Identity\n\nSecret.\n",
),
(
".memstead/note.md",
"---\ntype: spec\n---\n# MemsteadNote\n\n## Identity\n\nNote.\n",
),
]);
let schema = Schema::builtin_default();
let result = load_mem(dir.path(), "specs", &schema).unwrap();
assert_eq!(result.entities.len(), 1);
assert_eq!(result.entities[0].entity.title, "Visible");
}
#[test]
fn load_skips_empty_files() {
let dir = setup_mem(&[
(
"real.md",
"---\ntype: spec\n---\n# Real\n\n## Identity\n\nContent.\n",
),
("empty.md", ""),
("whitespace.md", " \n \n "),
]);
let schema = Schema::builtin_default();
let result = load_mem(dir.path(), "specs", &schema).unwrap();
assert_eq!(result.entities.len(), 1);
}
#[test]
fn load_nonexistent_dir() {
let schema = Schema::builtin_default();
let result = load_mem(std::path::Path::new("/nonexistent/path"), "specs", &schema);
assert!(result.is_err());
}
#[test]
fn load_mixed_schema_mem_uses_per_file_schema() {
let principle_body = "---\ntype: principle\n---\n\
# My Principle\n\n\
## Statement\n\nPrinciple statement body.\n\n\
## Scope\n\nScope body.\n\n\
## Justification\n\nJustification body.\n\n\
## Exceptions\n\n- one\n- two\n\n\
## Consequences\n\nConsequences body.\n";
let concept_body = "---\ntype: concept\n---\n\
# My Concept\n\n\
## Definition\n\nConcept definition.\n\n\
## Explanation\n\nExplanation body.\n\n\
## Boundaries\n\nBoundaries body.\n\n\
## Significance\n\nSignificance body.\n";
let dir = setup_mem(&[("p.md", principle_body), ("c.md", concept_body)]);
let schema = Schema::builtin_default();
let result = load_mem(dir.path(), "knowledge", &schema).unwrap();
assert_eq!(result.entities.len(), 2);
assert!(result.errors.is_empty());
let by_title: std::collections::HashMap<_, _> = result
.entities
.iter()
.map(|r| (r.entity.title.as_str(), &r.entity))
.collect();
let principle = by_title.get("My Principle").expect("principle entity");
assert_eq!(principle.entity_type, "principle");
assert!(principle.sections.contains_key("statement"));
assert!(principle.sections.contains_key("scope"));
assert!(principle.sections.contains_key("justification"));
assert!(!principle.sections.contains_key("definition"));
assert!(!principle.sections.contains_key("explanation"));
assert!(
!principle.sections["statement"].is_empty(),
"principle's Statement must retain content"
);
let concept = by_title.get("My Concept").expect("concept entity");
assert_eq!(concept.entity_type, "concept");
assert!(concept.sections.contains_key("definition"));
assert!(!concept.sections.contains_key("statement"));
}
#[test]
fn load_mem_falls_back_when_frontmatter_missing_schema() {
let body = "---\nlevel: M0\n---\n\
# Fallback Case\n\n\
## Identity\n\nBody.\n";
let dir = setup_mem(&[("x.md", body)]);
let schema = Schema::builtin_default();
let result = load_mem(dir.path(), "specs", &schema).unwrap();
assert_eq!(result.entities.len(), 1);
let entity = &result.entities[0].entity;
assert_eq!(entity.entity_type, "spec");
assert!(entity.sections.contains_key("identity"));
}
#[test]
fn load_mem_falls_back_on_unknown_type_name() {
let body = "---\ntype: nonexistent-type\n---\n\
# Unknown Case\n\n\
## Identity\n\nBody.\n";
let dir = setup_mem(&[("x.md", body)]);
let schema = Schema::builtin_default();
let result = load_mem(dir.path(), "specs", &schema).unwrap();
assert_eq!(result.entities.len(), 1);
let entity = &result.entities[0].entity;
assert_eq!(entity.entity_type, "nonexistent-type");
assert!(entity.sections.contains_key("identity"));
}
fn synthetic_parse_result(
entity_mem: &str,
entity_slug: &str,
rels: Vec<Relationship>,
) -> ParseResult {
let id = EntityId::new(entity_mem, entity_slug);
ParseResult {
entity: Entity {
id: id.clone(),
title: entity_slug.to_string(),
entity_type: "spec".to_string(),
mem: entity_mem.to_string(),
file_path: format!("{entity_slug}.md"),
metadata: IndexMap::new(),
sections: IndexMap::new(),
relationships: rels,
content_hash: String::new(),
stub: false,
stub_kind: None,
heading_spans: std::collections::HashMap::new(),
},
inline_links: Vec::new(),
parse_warnings: Vec::new(),
}
}
#[test]
fn sanitize_strips_cross_mem_relationships() {
let same = Relationship {
rel_type: "USES".to_string(),
target: EntityId::new("aws-patterns", "lambda"),
description: None,
};
let cross = Relationship {
rel_type: "DERIVES_FROM".to_string(),
target: EntityId::new("specs", "readme"),
description: None,
};
let mut results = vec![synthetic_parse_result(
"aws-patterns",
"api-gateway",
vec![same.clone(), cross.clone()],
)];
sanitize_cross_mem_relationships(&mut results, "aws-patterns");
let kept = &results[0].entity.relationships;
assert_eq!(kept.len(), 1, "cross-mem edge must be stripped");
assert_eq!(kept[0].target, same.target);
assert_eq!(kept[0].rel_type, same.rel_type);
}
#[test]
fn sanitize_is_noop_when_all_relationships_are_same_mem() {
let rel = Relationship {
rel_type: "USES".to_string(),
target: EntityId::new("aws-patterns", "lambda"),
description: None,
};
let mut results = vec![synthetic_parse_result(
"aws-patterns",
"api-gateway",
vec![rel.clone()],
)];
sanitize_cross_mem_relationships(&mut results, "aws-patterns");
assert_eq!(results[0].entity.relationships.len(), 1);
assert_eq!(results[0].entity.relationships[0].target, rel.target);
}
#[test]
fn sanitize_handles_multiple_entities_with_mixed_edges() {
let a_rel = Relationship {
rel_type: "USES".to_string(),
target: EntityId::new("aws-patterns", "lambda"),
description: None,
};
let b_cross1 = Relationship {
rel_type: "MENTIONS".to_string(),
target: EntityId::new("specs", "one"),
description: None,
};
let b_cross2 = Relationship {
rel_type: "MENTIONS".to_string(),
target: EntityId::new("internal-notes", "two"),
description: None,
};
let mut results = vec![
synthetic_parse_result("aws-patterns", "a", vec![a_rel.clone()]),
synthetic_parse_result(
"aws-patterns",
"b",
vec![b_cross1.clone(), b_cross2.clone()],
),
];
sanitize_cross_mem_relationships(&mut results, "aws-patterns");
assert_eq!(results[0].entity.relationships.len(), 1);
assert!(results[1].entity.relationships.is_empty());
}
#[test]
fn load_isolates_poisoned_file_and_keeps_the_rest() {
let dir = setup_mem(&[
(
"good.md",
"---\ntype: spec\n---\n# Good\n\n## Identity\n\nGood.\n",
),
(
"poisoned.md",
"---\ntype: spec\nvalue: \"\n---\n# Poisoned\n\n## Identity\n\nStill parses.\n",
),
]);
let schema = Schema::builtin_default();
let result = load_mem(dir.path(), "specs", &schema).unwrap();
assert_eq!(
result.entities.len(),
2,
"lone-quote frontmatter must parse; errors: {:?}",
result.errors
);
}
#[test]
fn panic_message_extracts_str_and_string_payloads() {
let p = std::panic::catch_unwind(|| panic!("boom")).unwrap_err();
assert_eq!(panic_message(p), "boom");
let p = std::panic::catch_unwind(|| panic!("{}", String::from("owned boom"))).unwrap_err();
assert_eq!(panic_message(p), "owned boom");
}
#[test]
fn load_collects_parse_errors() {
let dir = setup_mem(&[
(
"good.md",
"---\ntype: spec\n---\n# Good\n\n## Identity\n\nGood.\n",
),
(
"no-title.md",
"---\ntype: spec\n---\n\n## Identity\n\nNo title.\n",
),
]);
let schema = Schema::builtin_default();
let result = load_mem(dir.path(), "specs", &schema).unwrap();
assert_eq!(result.entities.len(), 2);
}
}