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.
35pub(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
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        // Git merge-conflict markers: refuse the file with the remedy
190        // named, before the parser can either fail confusingly or —
191        // worse — "succeed" and load BOTH sides' content as one body.
192        // The failure mode must name the door (backlog-sweep plan 07):
193        // the guards correctly block git verbs and raw edits against
194        // mem content, so the engine-side resolve operation is the one
195        // sanctioned repair, and this message is where an agent finds
196        // it at the exact moment it is needed.
197        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        // Panic boundary: one poisoned file must never abort the whole
211        // mem's load. A parser panic degrades to a per-file error like
212        // any other parse failure; the remaining entities still load.
213        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
243/// Extract a human-readable message from a caught panic payload.
244fn 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    /// A zip-level failure (corrupt header, invalid entry, etc.) or a
266    /// policy rejection (zip-slip, symlink, absolute entry path). Kept
267    /// as a single variant because both mean "this archive is unsafe to
268    /// load" — the message is the action item.
269    #[error("invalid archive: {0}")]
270    InvalidArchive(String),
271    #[error("zip error: {0}")]
272    Zip(#[from] zip::result::ZipError),
273    /// A git ref named by the workspace-side adapter could not be
274    /// resolved in the open repository. The ref-name string is echoed
275    /// back so an operator log line is self-explanatory. Constructed
276    /// only by `memstead-git-branch::entity::git_tree_source`.
277    #[error("git ref not found: {0}")]
278    RefNotFound(String),
279    /// A `gix`-level failure while reading the tree (object missing,
280    /// corrupt repository, IO underneath the object database). The
281    /// wrapped message names the underlying gix error so the
282    /// loader-level message stays one-line and grep-friendly.
283    /// Constructed only by `memstead-git-branch::entity::git_tree_source`.
284    #[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    // The git-tree round-trip test lives in
310    // `memstead-git-branch::entity::git_tree_source` alongside the
311    // GitTreeSource impl that constructs the gix-backed source.
312
313    #[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        // `.git/` and `.memstead/` are engine-internal and must never
346        // yield entities. Other dot-prefixed directories (e.g.
347        // `.obsidian/`) DO load by default.
348        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        // Principle file and concept file in the same mem, loaded with the
393        // concept schema as the (fallback) default. Each entity must parse
394        // against its own frontmatter-declared schema.
395        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        // Both files declare their type explicitly, so the loader's
411        // schema-driven type lookup picks the right TypeDefinition per
412        // entity from the default schema regardless of which "fallback"
413        // would apply.
414        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        // Must NOT carry concept-schema keys
431        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        // Parser preserves the frontmatter type name verbatim in entity.entity_type.
469        // The fallback only dictates which type's sections are used to parse.
470        assert_eq!(entity.entity_type, "nonexistent-type");
471        assert!(entity.sections.contains_key("identity"));
472    }
473
474    // --- cross-mem relationship sanitization ---
475
476    /// Build a ParseResult directly. The markdown parser can't naturally
477    /// emit a cross-mem relationship (`wiki_link_to_id` forces every
478    /// target into the current mem), so the defensive strip is
479    /// exercised by synthesizing the poisoned state directly.
480    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        // Poisoned fixture: one in-mem edge (kept) and one out-of-mem
510        // edge (stripped). Guards against hand-edited archives that carry
511        // pre-v1 cross-mem references — the read-side mirror of the
512        // write-side guard in `engine::mutation::relate`.
513        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        // Two entities: first has only same-mem edges, second has only
559        // cross-mem ones. After sanitization the second ends up empty
560        // and the first is untouched.
561        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        // The regression shape from the audit: a frontmatter value of a
594        // single quote character used to panic strip_quotes and abort
595        // the whole mem's load. It must now parse (the fix) — and even
596        // a genuine parser panic must surface as a per-file error, not
597        // take down the load (the catch_unwind boundary).
598        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        // No content shape is currently known that makes the parser
621        // panic (that's the point of the strip_quotes fix), so the
622        // boundary's message plumbing is exercised with real panics
623        // directly.
624        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            // This file has content but no title — should still parse (title defaults to id)
638            (
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        // Both should parse — no-title falls back to filename-derived title
646        assert_eq!(result.entities.len(), 2);
647    }
648}