Skip to main content

memstead_base/entity/
loader.rs

1//! Filesystem walker — loads all .md files from mem directories into entities.
2//!
3//! Thin wrapper over `entity::source::EntitySource`: the source hands back
4//! `(relative_path, content)` pairs, and this module layers on the
5//! entity-level concerns (empty-file skipping, per-file schema resolution,
6//! parse). The source abstraction is deliberately narrow so new backing
7//! stores (directory, zip archive, …) can be added without touching this
8//! file.
9
10use 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
19/// Resolve the per-entity `TypeDefinition` for a markdown entry against
20/// the mem's pinned schema. Resolution order:
21///
22/// 1. If the file's frontmatter declares `type: foo`, look `foo` up in
23///    the mem schema. Hit → use that type.
24/// 2. Same name, default-schema fallback (`type_by_name(name)`). Hit →
25///    use that type. This preserves the pre-cutover behavior for files
26///    declaring a type the mem schema does not declare (typo, in-flight
27///    schema migration, archived dummy data).
28/// 3. No frontmatter type: fall back to the mem schema's `spec` type;
29///    if the mem schema has no `spec`, fall back to the default
30///    schema's `spec` (always available — used as the engine-wide
31///    sentinel via `engine_fallback_type`).
32///
33/// The function never panics — the final default-schema `spec` lookup is
34/// guaranteed to exist by the schema crate's invariants.
35fn 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
50/// Result of loading a mem directory.
51pub struct LoadResult {
52    /// Successfully parsed entities with their inline links.
53    pub entities: Vec<ParseResult>,
54    /// Parse errors encountered (file path + error message). Non-fatal.
55    pub errors: Vec<(PathBuf, String)>,
56}
57
58/// Load all entities from a mem directory.
59///
60/// Walks the directory recursively, finds `.md` files, parses each.
61/// Collects parse errors without stopping — returns all entities + all errors.
62/// Sequential reads for deterministic ordering.
63pub 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
77/// Load all entities from a sealed `.mem` mem archive.
78///
79/// Shape-identical to `load_mem` — opens the zip, yields one
80/// `ParseResult` per `.md` entry, collects per-file errors. The
81/// archive's `.memstead/config.json` is not consulted here; use
82/// `mem_cache::read_published_config` up front if you need identity
83/// or format-version checks before loading entities.
84///
85/// Strips any explicit relationship whose target is outside this mem's
86/// own mem and logs it. v1 keeps every mem an island, and
87/// `memstead_relate` already rejects cross-mem edges on the write side —
88/// this is the defensive pass for hand-edited archives that may still
89/// carry them. Inline wiki-links are already same-mem by construction
90/// (`wiki_link_to_id` resolves every `[[…]]` to `current_mem`), so no
91/// sanitization is needed for the inline-links list.
92pub fn load_mem_archive(
93    archive_path: &std::path::Path,
94    mem: &str,
95    mem_schema: &Schema,
96) -> Result<LoadResult, LoadError> {
97    // Archives are self-contained and schema-published — their internal
98    // layout is frozen at publish time. No skip list applies.
99    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
108/// Strip relationships whose target lives outside the given mem.
109///
110/// Mutates `parse_results.entity.relationships` in place. Logs each
111/// stripped relationship at `warn` level so surprises surface in the
112/// user's logs, with a summary line per mem when any were removed.
113/// Intentionally does not fail the load — the load policy is
114/// best-effort-with-warnings, matching the engine's log+skip handling
115/// for missing or corrupt archives.
116fn 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
148/// Parse every `.md` entry from the given source. Shared between
149/// directory-backed (writable) and archive-backed (read-only) loads.
150///
151/// Per-entity type resolution goes through `resolve_type_for_entry` —
152/// the mem's pinned schema is the authority, with the default schema
153/// as a fallback for files declaring a type the mem schema does not
154/// declare. This matches the engine's mutation-time schema lookup
155/// (`schema_for_mem`) so parse-time consumers (duplicate-section
156/// warnings, missing-required-section warnings, write_rules retrieval)
157/// see the schema the workspace pinned, not the engine default.
158fn 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
167/// Parse a pre-collected set of source entries against the mem's
168/// schema. Public so the workspace-side git-tree adapter can reuse the
169/// same parse loop without re-implementing empty-file skipping or the
170/// per-entity schema lookup.
171pub 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        // Skip empty files
185        if entry.content.trim().is_empty() {
186            continue;
187        }
188
189        // Panic boundary: one poisoned file must never abort the whole
190        // mem's load. A parser panic degrades to a per-file error like
191        // any other parse failure; the remaining entities still load.
192        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
222/// Extract a human-readable message from a caught panic payload.
223fn 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    /// A zip-level failure (corrupt header, invalid entry, etc.) or a
245    /// policy rejection (zip-slip, symlink, absolute entry path). Kept
246    /// as a single variant because both mean "this archive is unsafe to
247    /// load" — the message is the action item.
248    #[error("invalid archive: {0}")]
249    InvalidArchive(String),
250    #[error("zip error: {0}")]
251    Zip(#[from] zip::result::ZipError),
252    /// A git ref named by the workspace-side adapter could not be
253    /// resolved in the open repository. The ref-name string is echoed
254    /// back so an operator log line is self-explanatory. Constructed
255    /// only by `memstead-git-branch::entity::git_tree_source`.
256    #[error("git ref not found: {0}")]
257    RefNotFound(String),
258    /// A `gix`-level failure while reading the tree (object missing,
259    /// corrupt repository, IO underneath the object database). The
260    /// wrapped message names the underlying gix error so the
261    /// loader-level message stays one-line and grep-friendly.
262    /// Constructed only by `memstead-git-branch::entity::git_tree_source`.
263    #[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    // The git-tree round-trip test lives in
289    // `memstead-git-branch::entity::git_tree_source` alongside the
290    // GitTreeSource impl that constructs the gix-backed source.
291
292    #[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        // `.git/` and `.memstead/` are engine-internal and must never
325        // yield entities. Other dot-prefixed directories (e.g.
326        // `.obsidian/`) DO load by default.
327        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        // Principle file and concept file in the same mem, loaded with the
372        // concept schema as the (fallback) default. Each entity must parse
373        // against its own frontmatter-declared schema.
374        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        // Both files declare their type explicitly, so the loader's
390        // schema-driven type lookup picks the right TypeDefinition per
391        // entity from the default schema regardless of which "fallback"
392        // would apply.
393        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        // Must NOT carry concept-schema keys
410        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        // Parser preserves the frontmatter type name verbatim in entity.entity_type.
448        // The fallback only dictates which type's sections are used to parse.
449        assert_eq!(entity.entity_type, "nonexistent-type");
450        assert!(entity.sections.contains_key("identity"));
451    }
452
453    // --- cross-mem relationship sanitization ---
454
455    /// Build a ParseResult directly. The markdown parser can't naturally
456    /// emit a cross-mem relationship (`wiki_link_to_id` forces every
457    /// target into the current mem), so the defensive strip is
458    /// exercised by synthesizing the poisoned state directly.
459    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        // Poisoned fixture: one in-mem edge (kept) and one out-of-mem
489        // edge (stripped). Guards against hand-edited archives that carry
490        // pre-v1 cross-mem references — the read-side mirror of the
491        // write-side guard in `engine::mutation::relate`.
492        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        // Two entities: first has only same-mem edges, second has only
538        // cross-mem ones. After sanitization the second ends up empty
539        // and the first is untouched.
540        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        // The regression shape from the audit: a frontmatter value of a
573        // single quote character used to panic strip_quotes and abort
574        // the whole mem's load. It must now parse (the fix) — and even
575        // a genuine parser panic must surface as a per-file error, not
576        // take down the load (the catch_unwind boundary).
577        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        // No content shape is currently known that makes the parser
600        // panic (that's the point of the strip_quotes fix), so the
601        // boundary's message plumbing is exercised with real panics
602        // directly.
603        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            // This file has content but no title — should still parse (title defaults to id)
617            (
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        // Both should parse — no-title falls back to filename-derived title
625        assert_eq!(result.entities.len(), 2);
626    }
627}