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
19pub(crate) fn 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 if parser::has_merge_conflict_markers(&entry.content) {
198 errors.push((
199 entry.source_path,
200 "git merge-conflict markers detected — resolve through the engine: \
201 `memstead conflicts list` shows the conflicted entities, `memstead \
202 conflicts resolve <entity-id> --side ours|theirs` picks a side \
203 (validated and provenance-tracked). Never repair via git verbs or \
204 raw file edits — the engine owns mem content."
205 .to_string(),
206 ));
207 continue;
208 }
209
210 let outcome = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
214 let resolved_type = resolve_type_for_entry(mem_schema, &entry.content);
215 parser::parse_markdown(
216 &entry.content,
217 &entry.relative_path,
218 resolved_type.as_ref(),
219 mem,
220 )
221 }));
222
223 match outcome {
224 Ok(Ok(mut result)) => {
225 result.entity.file_path = entry.relative_path;
226 entities.push(result);
227 }
228 Ok(Err(e)) => {
229 errors.push((entry.source_path, e.to_string()));
230 }
231 Err(panic) => {
232 errors.push((
233 entry.source_path,
234 format!("parser panicked: {}", panic_message(panic)),
235 ));
236 }
237 }
238 }
239
240 LoadResult { entities, errors }
241}
242
243fn panic_message(panic: Box<dyn std::any::Any + Send>) -> String {
245 panic
246 .downcast_ref::<&str>()
247 .map(|s| (*s).to_string())
248 .or_else(|| panic.downcast_ref::<String>().cloned())
249 .unwrap_or_else(|| "unknown panic payload".to_string())
250}
251
252#[derive(Debug, thiserror::Error)]
253pub enum LoadError {
254 #[error("mem directory not found: {0}")]
255 DirNotFound(String),
256 #[error("parse error in {file}: {source}")]
257 Parse {
258 file: String,
259 source: parser::ParseError,
260 },
261 #[error("io error: {0}")]
262 Io(#[from] std::io::Error),
263 #[error("archive not found: {0}")]
264 ArchiveNotFound(String),
265 #[error("invalid archive: {0}")]
270 InvalidArchive(String),
271 #[error("zip error: {0}")]
272 Zip(#[from] zip::result::ZipError),
273 #[error("git ref not found: {0}")]
278 RefNotFound(String),
279 #[error("git tree read error: {0}")]
285 GitTree(String),
286}
287
288#[cfg(test)]
289mod tests {
290 use super::*;
291 use crate::entity::{Entity, EntityId, Relationship};
292 use indexmap::IndexMap;
293 use memstead_schema::Schema;
294 use std::fs;
295 use tempfile::TempDir;
296
297 fn setup_mem(entities: &[(&str, &str)]) -> TempDir {
298 let dir = TempDir::new().unwrap();
299 for (name, content) in entities {
300 let path = dir.path().join(name);
301 if let Some(parent) = path.parent() {
302 fs::create_dir_all(parent).unwrap();
303 }
304 fs::write(&path, content).unwrap();
305 }
306 dir
307 }
308
309 #[test]
314 fn load_single_entity() {
315 let dir = setup_mem(&[(
316 "test-entity.md",
317 "---\ntype: spec\n---\n# Test Entity\n\n## Identity\n\nTest.\n",
318 )]);
319 let schema = Schema::builtin_default();
320 let result = load_mem(dir.path(), "specs", &schema).unwrap();
321 assert_eq!(result.entities.len(), 1);
322 assert!(result.errors.is_empty());
323 assert_eq!(result.entities[0].entity.title, "Test Entity");
324 }
325
326 #[test]
327 fn load_nested_entities() {
328 let dir = setup_mem(&[
329 (
330 "parent.md",
331 "---\ntype: spec\n---\n# Parent\n\n## Identity\n\nParent entity.\n",
332 ),
333 (
334 "parent/child.md",
335 "---\ntype: spec\n---\n# Child\n\n## Identity\n\nChild entity.\n",
336 ),
337 ]);
338 let schema = Schema::builtin_default();
339 let result = load_mem(dir.path(), "specs", &schema).unwrap();
340 assert_eq!(result.entities.len(), 2);
341 }
342
343 #[test]
344 fn load_skips_engine_internal_dirs() {
345 let dir = setup_mem(&[
349 (
350 "visible.md",
351 "---\ntype: spec\n---\n# Visible\n\n## Identity\n\nTest.\n",
352 ),
353 (
354 ".git/secret.md",
355 "---\ntype: spec\n---\n# GitSecret\n\n## Identity\n\nSecret.\n",
356 ),
357 (
358 ".memstead/note.md",
359 "---\ntype: spec\n---\n# MemsteadNote\n\n## Identity\n\nNote.\n",
360 ),
361 ]);
362 let schema = Schema::builtin_default();
363 let result = load_mem(dir.path(), "specs", &schema).unwrap();
364 assert_eq!(result.entities.len(), 1);
365 assert_eq!(result.entities[0].entity.title, "Visible");
366 }
367
368 #[test]
369 fn load_skips_empty_files() {
370 let dir = setup_mem(&[
371 (
372 "real.md",
373 "---\ntype: spec\n---\n# Real\n\n## Identity\n\nContent.\n",
374 ),
375 ("empty.md", ""),
376 ("whitespace.md", " \n \n "),
377 ]);
378 let schema = Schema::builtin_default();
379 let result = load_mem(dir.path(), "specs", &schema).unwrap();
380 assert_eq!(result.entities.len(), 1);
381 }
382
383 #[test]
384 fn load_nonexistent_dir() {
385 let schema = Schema::builtin_default();
386 let result = load_mem(std::path::Path::new("/nonexistent/path"), "specs", &schema);
387 assert!(result.is_err());
388 }
389
390 #[test]
391 fn load_mixed_schema_mem_uses_per_file_schema() {
392 let principle_body = "---\ntype: principle\n---\n\
396# My Principle\n\n\
397## Statement\n\nPrinciple statement body.\n\n\
398## Scope\n\nScope body.\n\n\
399## Justification\n\nJustification body.\n\n\
400## Exceptions\n\n- one\n- two\n\n\
401## Consequences\n\nConsequences body.\n";
402 let concept_body = "---\ntype: concept\n---\n\
403# My Concept\n\n\
404## Definition\n\nConcept definition.\n\n\
405## Explanation\n\nExplanation body.\n\n\
406## Boundaries\n\nBoundaries body.\n\n\
407## Significance\n\nSignificance body.\n";
408 let dir = setup_mem(&[("p.md", principle_body), ("c.md", concept_body)]);
409
410 let schema = Schema::builtin_default();
415 let result = load_mem(dir.path(), "knowledge", &schema).unwrap();
416 assert_eq!(result.entities.len(), 2);
417 assert!(result.errors.is_empty());
418
419 let by_title: std::collections::HashMap<_, _> = result
420 .entities
421 .iter()
422 .map(|r| (r.entity.title.as_str(), &r.entity))
423 .collect();
424
425 let principle = by_title.get("My Principle").expect("principle entity");
426 assert_eq!(principle.entity_type, "principle");
427 assert!(principle.sections.contains_key("statement"));
428 assert!(principle.sections.contains_key("scope"));
429 assert!(principle.sections.contains_key("justification"));
430 assert!(!principle.sections.contains_key("definition"));
432 assert!(!principle.sections.contains_key("explanation"));
433 assert!(
434 !principle.sections["statement"].is_empty(),
435 "principle's Statement must retain content"
436 );
437
438 let concept = by_title.get("My Concept").expect("concept entity");
439 assert_eq!(concept.entity_type, "concept");
440 assert!(concept.sections.contains_key("definition"));
441 assert!(!concept.sections.contains_key("statement"));
442 }
443
444 #[test]
445 fn load_mem_falls_back_when_frontmatter_missing_schema() {
446 let body = "---\nlevel: M0\n---\n\
447# Fallback Case\n\n\
448## Identity\n\nBody.\n";
449 let dir = setup_mem(&[("x.md", body)]);
450 let schema = Schema::builtin_default();
451 let result = load_mem(dir.path(), "specs", &schema).unwrap();
452 assert_eq!(result.entities.len(), 1);
453 let entity = &result.entities[0].entity;
454 assert_eq!(entity.entity_type, "spec");
455 assert!(entity.sections.contains_key("identity"));
456 }
457
458 #[test]
459 fn load_mem_falls_back_on_unknown_type_name() {
460 let body = "---\ntype: nonexistent-type\n---\n\
461# Unknown Case\n\n\
462## Identity\n\nBody.\n";
463 let dir = setup_mem(&[("x.md", body)]);
464 let schema = Schema::builtin_default();
465 let result = load_mem(dir.path(), "specs", &schema).unwrap();
466 assert_eq!(result.entities.len(), 1);
467 let entity = &result.entities[0].entity;
468 assert_eq!(entity.entity_type, "nonexistent-type");
471 assert!(entity.sections.contains_key("identity"));
472 }
473
474 fn synthetic_parse_result(
481 entity_mem: &str,
482 entity_slug: &str,
483 rels: Vec<Relationship>,
484 ) -> ParseResult {
485 let id = EntityId::new(entity_mem, entity_slug);
486 ParseResult {
487 entity: Entity {
488 id: id.clone(),
489 title: entity_slug.to_string(),
490 entity_type: "spec".to_string(),
491 mem: entity_mem.to_string(),
492 file_path: format!("{entity_slug}.md"),
493 metadata: IndexMap::new(),
494 sections: IndexMap::new(),
495 relationships: rels,
496 content_hash: String::new(),
497 stub: false,
498 stub_kind: None,
499 heading_spans: std::collections::HashMap::new(),
500 raw_section_headings: Vec::new(),
501 },
502 inline_links: Vec::new(),
503 parse_warnings: Vec::new(),
504 }
505 }
506
507 #[test]
508 fn sanitize_strips_cross_mem_relationships() {
509 let same = Relationship {
514 rel_type: "USES".to_string(),
515 target: EntityId::new("aws-patterns", "lambda"),
516 description: None,
517 };
518 let cross = Relationship {
519 rel_type: "DERIVES_FROM".to_string(),
520 target: EntityId::new("specs", "readme"),
521 description: None,
522 };
523 let mut results = vec![synthetic_parse_result(
524 "aws-patterns",
525 "api-gateway",
526 vec![same.clone(), cross.clone()],
527 )];
528
529 sanitize_cross_mem_relationships(&mut results, "aws-patterns");
530
531 let kept = &results[0].entity.relationships;
532 assert_eq!(kept.len(), 1, "cross-mem edge must be stripped");
533 assert_eq!(kept[0].target, same.target);
534 assert_eq!(kept[0].rel_type, same.rel_type);
535 }
536
537 #[test]
538 fn sanitize_is_noop_when_all_relationships_are_same_mem() {
539 let rel = Relationship {
540 rel_type: "USES".to_string(),
541 target: EntityId::new("aws-patterns", "lambda"),
542 description: None,
543 };
544 let mut results = vec![synthetic_parse_result(
545 "aws-patterns",
546 "api-gateway",
547 vec![rel.clone()],
548 )];
549
550 sanitize_cross_mem_relationships(&mut results, "aws-patterns");
551
552 assert_eq!(results[0].entity.relationships.len(), 1);
553 assert_eq!(results[0].entity.relationships[0].target, rel.target);
554 }
555
556 #[test]
557 fn sanitize_handles_multiple_entities_with_mixed_edges() {
558 let a_rel = Relationship {
562 rel_type: "USES".to_string(),
563 target: EntityId::new("aws-patterns", "lambda"),
564 description: None,
565 };
566 let b_cross1 = Relationship {
567 rel_type: "MENTIONS".to_string(),
568 target: EntityId::new("specs", "one"),
569 description: None,
570 };
571 let b_cross2 = Relationship {
572 rel_type: "MENTIONS".to_string(),
573 target: EntityId::new("internal-notes", "two"),
574 description: None,
575 };
576 let mut results = vec![
577 synthetic_parse_result("aws-patterns", "a", vec![a_rel.clone()]),
578 synthetic_parse_result(
579 "aws-patterns",
580 "b",
581 vec![b_cross1.clone(), b_cross2.clone()],
582 ),
583 ];
584
585 sanitize_cross_mem_relationships(&mut results, "aws-patterns");
586
587 assert_eq!(results[0].entity.relationships.len(), 1);
588 assert!(results[1].entity.relationships.is_empty());
589 }
590
591 #[test]
592 fn load_isolates_poisoned_file_and_keeps_the_rest() {
593 let dir = setup_mem(&[
599 (
600 "good.md",
601 "---\ntype: spec\n---\n# Good\n\n## Identity\n\nGood.\n",
602 ),
603 (
604 "poisoned.md",
605 "---\ntype: spec\nvalue: \"\n---\n# Poisoned\n\n## Identity\n\nStill parses.\n",
606 ),
607 ]);
608 let schema = Schema::builtin_default();
609 let result = load_mem(dir.path(), "specs", &schema).unwrap();
610 assert_eq!(
611 result.entities.len(),
612 2,
613 "lone-quote frontmatter must parse; errors: {:?}",
614 result.errors
615 );
616 }
617
618 #[test]
619 fn panic_message_extracts_str_and_string_payloads() {
620 let p = std::panic::catch_unwind(|| panic!("boom")).unwrap_err();
625 assert_eq!(panic_message(p), "boom");
626 let p = std::panic::catch_unwind(|| panic!("{}", String::from("owned boom"))).unwrap_err();
627 assert_eq!(panic_message(p), "owned boom");
628 }
629
630 #[test]
631 fn load_collects_parse_errors() {
632 let dir = setup_mem(&[
633 (
634 "good.md",
635 "---\ntype: spec\n---\n# Good\n\n## Identity\n\nGood.\n",
636 ),
637 (
639 "no-title.md",
640 "---\ntype: spec\n---\n\n## Identity\n\nNo title.\n",
641 ),
642 ]);
643 let schema = Schema::builtin_default();
644 let result = load_mem(dir.path(), "specs", &schema).unwrap();
645 assert_eq!(result.entities.len(), 2);
647 }
648}