1use std::path::PathBuf;
11use std::sync::Arc;
12
13use memstead_schema::{Schema, TypeDefinition, type_by_name};
14
15use super::ParseResult;
16use super::parser;
17use super::source::EntitySource;
18
19fn resolve_type_for_entry(mem_schema: &Schema, content: &str) -> Arc<TypeDefinition> {
36 if let Some(name) = parser::peek_type_from_frontmatter(content) {
37 if let Some(t) = mem_schema.get_type(&name) {
38 return t;
39 }
40 if let Some(t) = type_by_name(&name) {
41 return t;
42 }
43 }
44 mem_schema
45 .get_type("spec")
46 .or_else(|| type_by_name("spec"))
47 .expect("default-schema spec must always exist")
48}
49
50pub struct LoadResult {
52 pub entities: Vec<ParseResult>,
54 pub errors: Vec<(PathBuf, String)>,
56}
57
58pub fn load_mem(
64 mem_dir: &std::path::Path,
65 mem: &str,
66 mem_schema: &Schema,
67) -> Result<LoadResult, LoadError> {
68 load_from_source(
69 EntitySource::Directory {
70 root: mem_dir.to_path_buf(),
71 },
72 mem,
73 mem_schema,
74 )
75}
76
77pub fn load_mem_archive(
93 archive_path: &std::path::Path,
94 mem: &str,
95 mem_schema: &Schema,
96) -> Result<LoadResult, LoadError> {
97 let mut result = load_from_source(
100 EntitySource::ZipArchive(archive_path.to_path_buf()),
101 mem,
102 mem_schema,
103 )?;
104 sanitize_cross_mem_relationships(&mut result.entities, mem);
105 Ok(result)
106}
107
108fn sanitize_cross_mem_relationships(parse_results: &mut [ParseResult], mem: &str) {
117 let mut stripped_total: usize = 0;
118 for parse_result in parse_results.iter_mut() {
119 let entity_id = parse_result.entity.id.clone();
120 let before = parse_result.entity.relationships.len();
121 parse_result.entity.relationships.retain(|rel| {
122 let same_mem = rel.target.mem() == mem;
123 if !same_mem {
124 tracing::warn!(
125 mem = mem,
126 from = %entity_id,
127 to = %rel.target,
128 rel_type = rel.rel_type.as_str(),
129 "stripping cross-mem relationship from read mem \
130 (published archives are self-contained; cross-mem \
131 authorization is workspace-local and does not travel)"
132 );
133 }
134 same_mem
135 });
136 stripped_total += before - parse_result.entity.relationships.len();
137 }
138 if stripped_total > 0 {
139 tracing::warn!(
140 mem = mem,
141 stripped = stripped_total,
142 "read mem contained {} cross-mem relationship(s); stripped on load",
143 stripped_total
144 );
145 }
146}
147
148fn load_from_source(
159 source: EntitySource,
160 mem: &str,
161 mem_schema: &Schema,
162) -> Result<LoadResult, LoadError> {
163 let (source_entries, read_errors) = source.read_all()?;
164 Ok(parse_entries(source_entries, read_errors, mem, mem_schema))
165}
166
167pub fn parse_entries(
172 source_entries: Vec<super::source::SourceEntry>,
173 read_errors: Vec<super::source::SourceReadError>,
174 mem: &str,
175 mem_schema: &Schema,
176) -> LoadResult {
177 let mut entities = Vec::new();
178 let mut errors: Vec<(PathBuf, String)> = read_errors
179 .into_iter()
180 .map(|e| (e.source_path, e.error.to_string()))
181 .collect();
182
183 for entry in source_entries {
184 if entry.content.trim().is_empty() {
186 continue;
187 }
188
189 let outcome = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
193 let resolved_type = resolve_type_for_entry(mem_schema, &entry.content);
194 parser::parse_markdown(
195 &entry.content,
196 &entry.relative_path,
197 resolved_type.as_ref(),
198 mem,
199 )
200 }));
201
202 match outcome {
203 Ok(Ok(mut result)) => {
204 result.entity.file_path = entry.relative_path;
205 entities.push(result);
206 }
207 Ok(Err(e)) => {
208 errors.push((entry.source_path, e.to_string()));
209 }
210 Err(panic) => {
211 errors.push((
212 entry.source_path,
213 format!("parser panicked: {}", panic_message(panic)),
214 ));
215 }
216 }
217 }
218
219 LoadResult { entities, errors }
220}
221
222fn panic_message(panic: Box<dyn std::any::Any + Send>) -> String {
224 panic
225 .downcast_ref::<&str>()
226 .map(|s| (*s).to_string())
227 .or_else(|| panic.downcast_ref::<String>().cloned())
228 .unwrap_or_else(|| "unknown panic payload".to_string())
229}
230
231#[derive(Debug, thiserror::Error)]
232pub enum LoadError {
233 #[error("mem directory not found: {0}")]
234 DirNotFound(String),
235 #[error("parse error in {file}: {source}")]
236 Parse {
237 file: String,
238 source: parser::ParseError,
239 },
240 #[error("io error: {0}")]
241 Io(#[from] std::io::Error),
242 #[error("archive not found: {0}")]
243 ArchiveNotFound(String),
244 #[error("invalid archive: {0}")]
249 InvalidArchive(String),
250 #[error("zip error: {0}")]
251 Zip(#[from] zip::result::ZipError),
252 #[error("git ref not found: {0}")]
257 RefNotFound(String),
258 #[error("git tree read error: {0}")]
264 GitTree(String),
265}
266
267#[cfg(test)]
268mod tests {
269 use super::*;
270 use crate::entity::{Entity, EntityId, Relationship};
271 use indexmap::IndexMap;
272 use memstead_schema::Schema;
273 use std::fs;
274 use tempfile::TempDir;
275
276 fn setup_mem(entities: &[(&str, &str)]) -> TempDir {
277 let dir = TempDir::new().unwrap();
278 for (name, content) in entities {
279 let path = dir.path().join(name);
280 if let Some(parent) = path.parent() {
281 fs::create_dir_all(parent).unwrap();
282 }
283 fs::write(&path, content).unwrap();
284 }
285 dir
286 }
287
288 #[test]
293 fn load_single_entity() {
294 let dir = setup_mem(&[(
295 "test-entity.md",
296 "---\ntype: spec\n---\n# Test Entity\n\n## Identity\n\nTest.\n",
297 )]);
298 let schema = Schema::builtin_default();
299 let result = load_mem(dir.path(), "specs", &schema).unwrap();
300 assert_eq!(result.entities.len(), 1);
301 assert!(result.errors.is_empty());
302 assert_eq!(result.entities[0].entity.title, "Test Entity");
303 }
304
305 #[test]
306 fn load_nested_entities() {
307 let dir = setup_mem(&[
308 (
309 "parent.md",
310 "---\ntype: spec\n---\n# Parent\n\n## Identity\n\nParent entity.\n",
311 ),
312 (
313 "parent/child.md",
314 "---\ntype: spec\n---\n# Child\n\n## Identity\n\nChild entity.\n",
315 ),
316 ]);
317 let schema = Schema::builtin_default();
318 let result = load_mem(dir.path(), "specs", &schema).unwrap();
319 assert_eq!(result.entities.len(), 2);
320 }
321
322 #[test]
323 fn load_skips_engine_internal_dirs() {
324 let dir = setup_mem(&[
328 (
329 "visible.md",
330 "---\ntype: spec\n---\n# Visible\n\n## Identity\n\nTest.\n",
331 ),
332 (
333 ".git/secret.md",
334 "---\ntype: spec\n---\n# GitSecret\n\n## Identity\n\nSecret.\n",
335 ),
336 (
337 ".memstead/note.md",
338 "---\ntype: spec\n---\n# MemsteadNote\n\n## Identity\n\nNote.\n",
339 ),
340 ]);
341 let schema = Schema::builtin_default();
342 let result = load_mem(dir.path(), "specs", &schema).unwrap();
343 assert_eq!(result.entities.len(), 1);
344 assert_eq!(result.entities[0].entity.title, "Visible");
345 }
346
347 #[test]
348 fn load_skips_empty_files() {
349 let dir = setup_mem(&[
350 (
351 "real.md",
352 "---\ntype: spec\n---\n# Real\n\n## Identity\n\nContent.\n",
353 ),
354 ("empty.md", ""),
355 ("whitespace.md", " \n \n "),
356 ]);
357 let schema = Schema::builtin_default();
358 let result = load_mem(dir.path(), "specs", &schema).unwrap();
359 assert_eq!(result.entities.len(), 1);
360 }
361
362 #[test]
363 fn load_nonexistent_dir() {
364 let schema = Schema::builtin_default();
365 let result = load_mem(std::path::Path::new("/nonexistent/path"), "specs", &schema);
366 assert!(result.is_err());
367 }
368
369 #[test]
370 fn load_mixed_schema_mem_uses_per_file_schema() {
371 let principle_body = "---\ntype: principle\n---\n\
375# My Principle\n\n\
376## Statement\n\nPrinciple statement body.\n\n\
377## Scope\n\nScope body.\n\n\
378## Justification\n\nJustification body.\n\n\
379## Exceptions\n\n- one\n- two\n\n\
380## Consequences\n\nConsequences body.\n";
381 let concept_body = "---\ntype: concept\n---\n\
382# My Concept\n\n\
383## Definition\n\nConcept definition.\n\n\
384## Explanation\n\nExplanation body.\n\n\
385## Boundaries\n\nBoundaries body.\n\n\
386## Significance\n\nSignificance body.\n";
387 let dir = setup_mem(&[("p.md", principle_body), ("c.md", concept_body)]);
388
389 let schema = Schema::builtin_default();
394 let result = load_mem(dir.path(), "knowledge", &schema).unwrap();
395 assert_eq!(result.entities.len(), 2);
396 assert!(result.errors.is_empty());
397
398 let by_title: std::collections::HashMap<_, _> = result
399 .entities
400 .iter()
401 .map(|r| (r.entity.title.as_str(), &r.entity))
402 .collect();
403
404 let principle = by_title.get("My Principle").expect("principle entity");
405 assert_eq!(principle.entity_type, "principle");
406 assert!(principle.sections.contains_key("statement"));
407 assert!(principle.sections.contains_key("scope"));
408 assert!(principle.sections.contains_key("justification"));
409 assert!(!principle.sections.contains_key("definition"));
411 assert!(!principle.sections.contains_key("explanation"));
412 assert!(
413 !principle.sections["statement"].is_empty(),
414 "principle's Statement must retain content"
415 );
416
417 let concept = by_title.get("My Concept").expect("concept entity");
418 assert_eq!(concept.entity_type, "concept");
419 assert!(concept.sections.contains_key("definition"));
420 assert!(!concept.sections.contains_key("statement"));
421 }
422
423 #[test]
424 fn load_mem_falls_back_when_frontmatter_missing_schema() {
425 let body = "---\nlevel: M0\n---\n\
426# Fallback Case\n\n\
427## Identity\n\nBody.\n";
428 let dir = setup_mem(&[("x.md", body)]);
429 let schema = Schema::builtin_default();
430 let result = load_mem(dir.path(), "specs", &schema).unwrap();
431 assert_eq!(result.entities.len(), 1);
432 let entity = &result.entities[0].entity;
433 assert_eq!(entity.entity_type, "spec");
434 assert!(entity.sections.contains_key("identity"));
435 }
436
437 #[test]
438 fn load_mem_falls_back_on_unknown_type_name() {
439 let body = "---\ntype: nonexistent-type\n---\n\
440# Unknown Case\n\n\
441## Identity\n\nBody.\n";
442 let dir = setup_mem(&[("x.md", body)]);
443 let schema = Schema::builtin_default();
444 let result = load_mem(dir.path(), "specs", &schema).unwrap();
445 assert_eq!(result.entities.len(), 1);
446 let entity = &result.entities[0].entity;
447 assert_eq!(entity.entity_type, "nonexistent-type");
450 assert!(entity.sections.contains_key("identity"));
451 }
452
453 fn synthetic_parse_result(
460 entity_mem: &str,
461 entity_slug: &str,
462 rels: Vec<Relationship>,
463 ) -> ParseResult {
464 let id = EntityId::new(entity_mem, entity_slug);
465 ParseResult {
466 entity: Entity {
467 id: id.clone(),
468 title: entity_slug.to_string(),
469 entity_type: "spec".to_string(),
470 mem: entity_mem.to_string(),
471 file_path: format!("{entity_slug}.md"),
472 metadata: IndexMap::new(),
473 sections: IndexMap::new(),
474 relationships: rels,
475 content_hash: String::new(),
476 stub: false,
477 stub_kind: None,
478 heading_spans: std::collections::HashMap::new(),
479 raw_section_headings: Vec::new(),
480 },
481 inline_links: Vec::new(),
482 parse_warnings: Vec::new(),
483 }
484 }
485
486 #[test]
487 fn sanitize_strips_cross_mem_relationships() {
488 let same = Relationship {
493 rel_type: "USES".to_string(),
494 target: EntityId::new("aws-patterns", "lambda"),
495 description: None,
496 };
497 let cross = Relationship {
498 rel_type: "DERIVES_FROM".to_string(),
499 target: EntityId::new("specs", "readme"),
500 description: None,
501 };
502 let mut results = vec![synthetic_parse_result(
503 "aws-patterns",
504 "api-gateway",
505 vec![same.clone(), cross.clone()],
506 )];
507
508 sanitize_cross_mem_relationships(&mut results, "aws-patterns");
509
510 let kept = &results[0].entity.relationships;
511 assert_eq!(kept.len(), 1, "cross-mem edge must be stripped");
512 assert_eq!(kept[0].target, same.target);
513 assert_eq!(kept[0].rel_type, same.rel_type);
514 }
515
516 #[test]
517 fn sanitize_is_noop_when_all_relationships_are_same_mem() {
518 let rel = Relationship {
519 rel_type: "USES".to_string(),
520 target: EntityId::new("aws-patterns", "lambda"),
521 description: None,
522 };
523 let mut results = vec![synthetic_parse_result(
524 "aws-patterns",
525 "api-gateway",
526 vec![rel.clone()],
527 )];
528
529 sanitize_cross_mem_relationships(&mut results, "aws-patterns");
530
531 assert_eq!(results[0].entity.relationships.len(), 1);
532 assert_eq!(results[0].entity.relationships[0].target, rel.target);
533 }
534
535 #[test]
536 fn sanitize_handles_multiple_entities_with_mixed_edges() {
537 let a_rel = Relationship {
541 rel_type: "USES".to_string(),
542 target: EntityId::new("aws-patterns", "lambda"),
543 description: None,
544 };
545 let b_cross1 = Relationship {
546 rel_type: "MENTIONS".to_string(),
547 target: EntityId::new("specs", "one"),
548 description: None,
549 };
550 let b_cross2 = Relationship {
551 rel_type: "MENTIONS".to_string(),
552 target: EntityId::new("internal-notes", "two"),
553 description: None,
554 };
555 let mut results = vec![
556 synthetic_parse_result("aws-patterns", "a", vec![a_rel.clone()]),
557 synthetic_parse_result(
558 "aws-patterns",
559 "b",
560 vec![b_cross1.clone(), b_cross2.clone()],
561 ),
562 ];
563
564 sanitize_cross_mem_relationships(&mut results, "aws-patterns");
565
566 assert_eq!(results[0].entity.relationships.len(), 1);
567 assert!(results[1].entity.relationships.is_empty());
568 }
569
570 #[test]
571 fn load_isolates_poisoned_file_and_keeps_the_rest() {
572 let dir = setup_mem(&[
578 (
579 "good.md",
580 "---\ntype: spec\n---\n# Good\n\n## Identity\n\nGood.\n",
581 ),
582 (
583 "poisoned.md",
584 "---\ntype: spec\nvalue: \"\n---\n# Poisoned\n\n## Identity\n\nStill parses.\n",
585 ),
586 ]);
587 let schema = Schema::builtin_default();
588 let result = load_mem(dir.path(), "specs", &schema).unwrap();
589 assert_eq!(
590 result.entities.len(),
591 2,
592 "lone-quote frontmatter must parse; errors: {:?}",
593 result.errors
594 );
595 }
596
597 #[test]
598 fn panic_message_extracts_str_and_string_payloads() {
599 let p = std::panic::catch_unwind(|| panic!("boom")).unwrap_err();
604 assert_eq!(panic_message(p), "boom");
605 let p = std::panic::catch_unwind(|| panic!("{}", String::from("owned boom"))).unwrap_err();
606 assert_eq!(panic_message(p), "owned boom");
607 }
608
609 #[test]
610 fn load_collects_parse_errors() {
611 let dir = setup_mem(&[
612 (
613 "good.md",
614 "---\ntype: spec\n---\n# Good\n\n## Identity\n\nGood.\n",
615 ),
616 (
618 "no-title.md",
619 "---\ntype: spec\n---\n\n## Identity\n\nNo title.\n",
620 ),
621 ]);
622 let schema = Schema::builtin_default();
623 let result = load_mem(dir.path(), "specs", &schema).unwrap();
624 assert_eq!(result.entities.len(), 2);
626 }
627}