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 },
480 inline_links: Vec::new(),
481 parse_warnings: Vec::new(),
482 }
483 }
484
485 #[test]
486 fn sanitize_strips_cross_mem_relationships() {
487 let same = Relationship {
492 rel_type: "USES".to_string(),
493 target: EntityId::new("aws-patterns", "lambda"),
494 description: None,
495 };
496 let cross = Relationship {
497 rel_type: "DERIVES_FROM".to_string(),
498 target: EntityId::new("specs", "readme"),
499 description: None,
500 };
501 let mut results = vec![synthetic_parse_result(
502 "aws-patterns",
503 "api-gateway",
504 vec![same.clone(), cross.clone()],
505 )];
506
507 sanitize_cross_mem_relationships(&mut results, "aws-patterns");
508
509 let kept = &results[0].entity.relationships;
510 assert_eq!(kept.len(), 1, "cross-mem edge must be stripped");
511 assert_eq!(kept[0].target, same.target);
512 assert_eq!(kept[0].rel_type, same.rel_type);
513 }
514
515 #[test]
516 fn sanitize_is_noop_when_all_relationships_are_same_mem() {
517 let rel = Relationship {
518 rel_type: "USES".to_string(),
519 target: EntityId::new("aws-patterns", "lambda"),
520 description: None,
521 };
522 let mut results = vec![synthetic_parse_result(
523 "aws-patterns",
524 "api-gateway",
525 vec![rel.clone()],
526 )];
527
528 sanitize_cross_mem_relationships(&mut results, "aws-patterns");
529
530 assert_eq!(results[0].entity.relationships.len(), 1);
531 assert_eq!(results[0].entity.relationships[0].target, rel.target);
532 }
533
534 #[test]
535 fn sanitize_handles_multiple_entities_with_mixed_edges() {
536 let a_rel = Relationship {
540 rel_type: "USES".to_string(),
541 target: EntityId::new("aws-patterns", "lambda"),
542 description: None,
543 };
544 let b_cross1 = Relationship {
545 rel_type: "MENTIONS".to_string(),
546 target: EntityId::new("specs", "one"),
547 description: None,
548 };
549 let b_cross2 = Relationship {
550 rel_type: "MENTIONS".to_string(),
551 target: EntityId::new("internal-notes", "two"),
552 description: None,
553 };
554 let mut results = vec![
555 synthetic_parse_result("aws-patterns", "a", vec![a_rel.clone()]),
556 synthetic_parse_result(
557 "aws-patterns",
558 "b",
559 vec![b_cross1.clone(), b_cross2.clone()],
560 ),
561 ];
562
563 sanitize_cross_mem_relationships(&mut results, "aws-patterns");
564
565 assert_eq!(results[0].entity.relationships.len(), 1);
566 assert!(results[1].entity.relationships.is_empty());
567 }
568
569 #[test]
570 fn load_isolates_poisoned_file_and_keeps_the_rest() {
571 let dir = setup_mem(&[
577 (
578 "good.md",
579 "---\ntype: spec\n---\n# Good\n\n## Identity\n\nGood.\n",
580 ),
581 (
582 "poisoned.md",
583 "---\ntype: spec\nvalue: \"\n---\n# Poisoned\n\n## Identity\n\nStill parses.\n",
584 ),
585 ]);
586 let schema = Schema::builtin_default();
587 let result = load_mem(dir.path(), "specs", &schema).unwrap();
588 assert_eq!(
589 result.entities.len(),
590 2,
591 "lone-quote frontmatter must parse; errors: {:?}",
592 result.errors
593 );
594 }
595
596 #[test]
597 fn panic_message_extracts_str_and_string_payloads() {
598 let p = std::panic::catch_unwind(|| panic!("boom")).unwrap_err();
603 assert_eq!(panic_message(p), "boom");
604 let p = std::panic::catch_unwind(|| panic!("{}", String::from("owned boom"))).unwrap_err();
605 assert_eq!(panic_message(p), "owned boom");
606 }
607
608 #[test]
609 fn load_collects_parse_errors() {
610 let dir = setup_mem(&[
611 (
612 "good.md",
613 "---\ntype: spec\n---\n# Good\n\n## Identity\n\nGood.\n",
614 ),
615 (
617 "no-title.md",
618 "---\ntype: spec\n---\n\n## Identity\n\nNo title.\n",
619 ),
620 ]);
621 let schema = Schema::builtin_default();
622 let result = load_mem(dir.path(), "specs", &schema).unwrap();
623 assert_eq!(result.entities.len(), 2);
625 }
626}