Skip to main content

memstead_base/engine/
boot.rs

1//! Engine construction — `from_mounts*` and `from_workspace_root`.
2//!
3//! `from_mounts` is the in-process constructor every test, the macOS
4//! UniFFI consumer, and the MCP filesystem server reach through.
5//! `from_workspace_root` is the lean boot helper that produces the
6//! same engine from a workspace root; the full counterpart lives in
7//! `memstead_git_branch::engine_from_workspace_root` and follows the same
8//! shape with the git-branch backend added to the factory.
9//!
10//! Free helpers in this module materialise the workspace schemas
11//! catalogue, walk each mount's backend at load-time, and synthesise
12//! the [`MemRouterSnapshot`] from the resolved mount list — pieces
13//! the two entry points share.
14
15use std::cell::OnceCell;
16use std::collections::HashMap;
17use std::path::{Path, PathBuf};
18use std::sync::Arc;
19
20use memstead_schema::Schema;
21
22use crate::backend::MemBackend;
23use crate::engine_fallback_type;
24use crate::entity::loader::parse_entries;
25use crate::entity::source::{SourceEntry, SourceReadError};
26use crate::entity::store_builder::push_entities_into_store;
27use crate::mem::{MemOrigin, MemRouterSnapshot};
28use crate::ops::WarningHint;
29use crate::store::Store;
30use crate::workspace::{Mount, MountCapability, MountStorage, WorkspaceSettings};
31
32use super::{BootError, Engine, EngineError, MountedBackend};
33
34impl Engine {
35    /// Build an engine from `(mount, backend)` pairs. The backend
36    /// is the implementor that will serve reads / writes for that
37    /// mount's mem.
38    ///
39    /// Returns [`EngineError::DuplicateMem`] when two mounts name
40    /// the same mem; that's a configuration error the caller must
41    /// fix before the engine can route deterministically. An empty
42    /// mount list is allowed (returns an engine that errors
43    /// `UnknownMem` on every read) — useful for tests; production
44    /// callers will reject empty inputs at the persistence-adapter
45    /// layer.
46    pub fn from_mounts(mounts: Vec<(Mount, Box<dyn MemBackend>)>) -> Result<Self, EngineError> {
47        Self::from_mounts_inner(mounts, Vec::new())
48    }
49
50    /// Construct an engine from mounts plus an optional workspace
51    /// schemas directory. Loads every subdirectory of `schemas_dir`
52    /// as a workspace-authored schema and combines with the builtin
53    /// catalogue for per-mem schema-pin resolution. Workspace
54    /// schemas take precedence on (name, version) collision —
55    /// matches full's behaviour.
56    ///
57    /// `schemas_dir = None` is equivalent to [`Self::from_mounts`].
58    /// Used by `engine_from_workspace_root` to thread the
59    /// `[schemas_dir]` workspace-toml entry into schema resolution.
60    pub fn from_mounts_with_schemas_dir(
61        mounts: Vec<(Mount, Box<dyn MemBackend>)>,
62        schemas_dir: Option<&Path>,
63    ) -> Result<Self, EngineError> {
64        let extra_schemas = load_workspace_schemas(schemas_dir)?;
65        Self::from_mounts_inner(mounts, extra_schemas)
66    }
67
68    /// Like [`Self::from_mounts_with_schemas_dir`] but layers additional,
69    /// pre-loaded local-storage schemas (e.g. those a git-branch backend
70    /// reads from its `__MEMSTEAD:schemas/` ref via `SchemaSource`) on
71    /// top of the folder `schemas_dir` set. Both are local-storage
72    /// schemas — they override built-ins on `(name, version)` collision.
73    /// The git-branch boot path uses this to make ref-installed schemas
74    /// resolvable, which `from_mounts_with_schemas_dir` (folder only)
75    /// does not.
76    pub fn from_mounts_with_schemas_dir_and_extra(
77        mounts: Vec<(Mount, Box<dyn MemBackend>)>,
78        schemas_dir: Option<&Path>,
79        mut extra: Vec<Arc<memstead_schema::Schema>>,
80    ) -> Result<Self, EngineError> {
81        let mut local = load_workspace_schemas(schemas_dir)?;
82        local.append(&mut extra);
83        Self::from_mounts_inner(mounts, local)
84    }
85
86    pub(crate) fn from_mounts_inner(
87        mounts: Vec<(Mount, Box<dyn MemBackend>)>,
88        extra_schemas: Vec<Arc<memstead_schema::Schema>>,
89    ) -> Result<Self, EngineError> {
90        let mut seen: std::collections::HashSet<String> =
91            std::collections::HashSet::with_capacity(mounts.len());
92        let mut mounted: Vec<MountedBackend> = Vec::with_capacity(mounts.len());
93        for (mount, backend) in mounts {
94            if !seen.insert(mount.mem.clone()) {
95                return Err(EngineError::DuplicateMem(mount.mem));
96            }
97            // Seed the per-mount drift baseline. A backend that
98            // doesn't track HEAD (folder, archive) returns Ok(None)
99            // — drift detection is then a no-op for the mount. A
100            // probe failure during init falls back to None so a
101            // later successful probe can establish the baseline.
102            let last_known_head = backend.current_head().ok().flatten();
103            // Load the per-mem `.memstead/config.json` via the
104            // backend trait. Each backend resolves its own
105            // canonical location (folder: `<root>/.memstead/config.json`;
106            // archive: inside the zip; git-branch:
107            // `__MEMSTEAD:mems/<leaf>/config.json`). Read failures
108            // or missing files surface as
109            // `None` — `memstead_health` accommodates the missing-config
110            // case (handler emits empty `writeGuidance` + `extra`).
111            let mem_config = backend.read_mem_config().ok().flatten().and_then(|bytes| {
112                let value: serde_json::Value = serde_json::from_slice(&bytes).ok()?;
113                memstead_schema::config::parse_mem_config(&value).ok()
114            });
115            // Read the optional authoring-provenance payload the archive
116            // carries (`.memstead/provenance.json`). A malformed payload is
117            // downgraded to `None` (the member is additive — a parse
118            // failure means "provenance absent", not "mount failed").
119            let archive_provenance =
120                backend
121                    .read_archive_provenance()
122                    .ok()
123                    .flatten()
124                    .and_then(|bytes| {
125                        memstead_schema::ArchiveProvenance::from_archive_bytes(&bytes).ok()
126                    });
127            mounted.push(MountedBackend {
128                mount,
129                backend,
130                last_known_head,
131                mem_config,
132                archive_provenance,
133            });
134        }
135
136        // Walk each backend, parse entries, populate one shared Store.
137        // Resolve each mount's schema pin against the built-in schema
138        // catalogue. The schema-registry resolver (which would also
139        // honor workspace-authored schemas living inside the storage
140        // backend) lands as a separate plan; this resolution closes
141        // the gap for the built-in catalogue so a workspace pinning
142        // a non-default built-in (e.g. `software`, `memory`) surfaces
143        // the right schema rather than silently downgrading to
144        // `default`.
145        let builtin_schemas_only = memstead_schema::builtins::load_builtin_schemas()
146            .map_err(|e| EngineError::SchemaResolverInit(e.to_string()))?;
147        // Workspace-authored schemas resolve first (override builtins
148        // on (name, version) collision); builtins fill the rest.
149        let workspace_schemas = extra_schemas.clone();
150        let mut catalogue: Vec<Arc<memstead_schema::Schema>> =
151            Vec::with_capacity(extra_schemas.len() + builtin_schemas_only.len());
152        catalogue.extend(extra_schemas);
153        catalogue.extend(builtin_schemas_only.clone());
154        let builtin_schemas = catalogue;
155        let mut store = Store::new();
156        let mut load_errors: Vec<(PathBuf, String)> = Vec::new();
157        let mut schemas: HashMap<String, Arc<Schema>> = HashMap::with_capacity(mounted.len());
158        let fallback = engine_fallback_type();
159
160        // Derive the mem roster + last-segment suffixes ONCE so the
161        // per-mount load loop hands the same view to every
162        // `LoadCollector`. `known_suffixes` is the input the
163        // nested-prefix detector compares against; the full
164        // `mem_names` list feeds the two-pass cross-mem resolver
165        // in `push_entities_into_store`.
166        let mem_names: Vec<String> = mounted.iter().map(|m| m.mount.mem.clone()).collect();
167        let known_suffixes: Vec<String> = mem_names
168            .iter()
169            .map(|n| crate::entity::store_builder::last_segment_suffix(n).to_string())
170            .collect();
171        let mut load_warnings: Vec<WarningHint> = Vec::new();
172
173        for m in &mounted {
174            // Schema-pin authority: the mem's own per-mem config is
175            // the authoritative settled pin, so a copied or cloned mem
176            // resolves its schema from its own backend without consulting
177            // this workspace's `mounts.json`. `Mount.schema` (the mount
178            // record's pin) is the fallback when the config carries no
179            // schema, and an expectation assertion when it does — a
180            // disagreement surfaces a `SchemaPinMismatch` warning rather
181            // than silently preferring either.
182            let config_pin = m.mem_config.as_ref().and_then(|c| c.schema.as_ref());
183            let mount_pin = m.mount.schema.as_ref();
184            // `Mount.schema` is an optional expectation assertion: warn
185            // only when it is set *and* disagrees with the authoritative
186            // config pin.
187            if let (Some(cfg), Some(mp)) = (config_pin, mount_pin)
188                && cfg != mp
189            {
190                load_warnings.push(WarningHint::SchemaPinMismatch {
191                    mem: m.mount.mem.clone(),
192                    config_pin: cfg.as_display(),
193                    mount_pin: mp.as_display(),
194                });
195            }
196            // Authoritative pin first (the backend config), then the
197            // mount assertion as fallback when the config carries none.
198            let settled_pin = config_pin.or(mount_pin);
199            // Dual-pin: a mem mid-migration validates against the
200            // migration target, not the settled pin.
201            let effective_pin = m
202                .mount
203                .migration_target
204                .as_ref()
205                .or(settled_pin)
206                .ok_or_else(|| EngineError::MemConfigIncomplete {
207                    mem: m.mount.mem.clone(),
208                    missing_fields: vec!["schema".to_string()],
209                })?;
210            let schema = SchemaResolver::new(&builtin_schemas)
211                .resolve(effective_pin)
212                .map_err(|sources| EngineError::SchemaNotFound {
213                    mem: m.mount.mem.clone(),
214                    pin: effective_pin.as_display(),
215                    sources,
216                })?;
217            schemas.insert(m.mount.mem.clone(), schema.clone());
218
219            let (entries, read_errors) = collect_source_entries(m.backend.as_ref())?;
220            let load_result = parse_entries(entries, read_errors, &m.mount.mem, schema.as_ref());
221            // Wire the LoadCollector so the parser/store-builder
222            // pipeline forwards typed drift warnings
223            // (`SuspiciousNestedPrefix`, `DuplicateSectionHeading`,
224            // `InlineWikiLinkAutoStubbed`) into `load_warnings`.
225            // Mutation paths still pass `None` to stay silent.
226            push_entities_into_store(
227                &mut store,
228                load_result.entities,
229                fallback.as_ref(),
230                Some(crate::entity::store_builder::LoadCollector {
231                    warnings: &mut load_warnings,
232                    known_suffixes: &known_suffixes,
233                    mem_names: &mem_names,
234                }),
235            );
236            load_errors.extend(load_result.errors);
237        }
238
239        // Parse-time relation validation runs after every mount's
240        // entities are loaded so cross-mem target types are
241        // resolvable. Hand-edits, external tooling, and the macOS
242        // app's editor surface can inject relations that bypass
243        // `memstead_relate`; this is the only place those get caught.
244        // Mutation paths pre-validate before writing, so they
245        // never trip the warning post-load.
246        let mount_caps: std::collections::HashMap<String, crate::workspace::MountCapability> =
247            mounted
248                .iter()
249                .map(|m| (m.mount.mem.clone(), m.mount.capability))
250                .collect();
251        crate::entity::store_builder::validate_loaded_relations(
252            &mut store,
253            &schemas,
254            &mount_caps,
255            &mut load_warnings,
256        );
257
258        // Stamp `EdgeSource::BodyLink` on edges whose rel-type matches
259        // the source mem's `alias_target_rel_type` pointer. Runs
260        // after `validate_loaded_relations` so the surviving relation
261        // set is schema-clean before the labeling pass.
262        crate::entity::store_builder::remap_alias_target_edge_sources(&mut store, &schemas);
263
264        // Derive the runtime mem router from the mount list.
265        // Mirrors full's `Engine::from_init` step that registers every
266        // mount with `MemRouterSnapshot` so handlers reach a
267        // consistent writable/visible roster regardless of which
268        // backend serves the mem.
269        let mem_router = build_mem_router_from_mounts(&mounted);
270
271        Ok(Self {
272            mounts: mounted,
273            store,
274            schemas,
275            workspace_schemas,
276            builtin_schemas: builtin_schemas_only,
277            load_errors,
278            community_memo: OnceCell::new(),
279            #[cfg(not(target_arch = "wasm32"))]
280            search_indexes_memo: OnceCell::new(),
281            settings: WorkspaceSettings::default(),
282            create_rule_set_memo: OnceCell::new(),
283            workspace_root: None,
284            load_warnings,
285            pipeline_configs: crate::pipeline_store::PipelineConfigs::default(),
286            mem_router: Arc::new(mem_router),
287            backend_factory: crate::workspace_store::instantiate_lean_backend,
288            git_branch_ops: None,
289            event_subscribers: Arc::new(std::sync::Mutex::new(
290                crate::engine::events::SubscriberRegistry::new(),
291            )),
292            pending_mem_changed: Vec::new(),
293        })
294    }
295
296    /// Boot an engine from a workspace root using only lean-flavour
297    /// backends (folder + archive). The MCP filesystem server, the
298    /// CLI's lean dispatcher, and the macOS UniFFI consumer all reach
299    /// the new engine through this entry point — replacing per-flavour
300    /// init code with one call.
301    ///
302    /// Loads the workspace through [`crate::FileWorkspaceStore`],
303    /// instantiates each mount's backend via
304    /// [`crate::instantiate_lean_backend`], and constructs the
305    /// engine via [`Engine::from_mounts`].
306    ///
307    /// Errors:
308    /// - [`Layout::Empty`](crate::Layout) → [`BootError::NotInitialised`]
309    /// - any mount declaring [`crate::workspace::MountStorage::GitBranch`]
310    ///   → [`BootError::Instantiate`] wrapping
311    ///   [`crate::InstantiateError::GitBranchRequiresMemRepoFeature`]
312    /// - underlying store / engine failures lift through the
313    ///   `#[from]` conversions
314    pub fn from_workspace_root(workspace_root: &Path) -> Result<Self, BootError> {
315        use crate::workspace_store::{
316            FileWorkspaceStore, Layout, WorkspaceStoreAdapter, detect_layout,
317            instantiate_lean_backend,
318        };
319
320        let workspace = match detect_layout(workspace_root) {
321            // Standalone collapse: a bare folder mem (`.memstead/config.json`,
322            // no `workspace.toml`) roots as a one-mount workspace rather than
323            // refusing — the lone-mem boot path is the unified one.
324            Layout::Empty => match crate::workspace_store::standalone_workspace(workspace_root) {
325                Some(ws) => ws,
326                None => {
327                    return Err(BootError::NotInitialised(workspace_root.to_path_buf()));
328                }
329            },
330            Layout::New => FileWorkspaceStore::new().load(workspace_root)?,
331        };
332
333        let settings = workspace.settings.clone();
334        let mut mounts: Vec<(Mount, Box<dyn MemBackend>)> =
335            Vec::with_capacity(workspace.mounts.len());
336        for mount in workspace.mounts {
337            let backend = instantiate_lean_backend(&mount)?;
338            mounts.push((mount, backend));
339        }
340        // Folder-backend authoring path: authored schema packages live
341        // at the fixed `<workspace>/.memstead/schemas/<name>@<version>/`
342        // location — the folder analogue of the git-branch backend's
343        // `__MEMSTEAD:schemas/` ref. Read them through the folder
344        // `SchemaSource` (which no-ops when the directory is absent, so a
345        // workspace that authored no schemas resolves exactly as before —
346        // built-ins only). This is the lean flavour's schema-authoring
347        // path, which it lacked.
348        use crate::schema_source::SchemaSource as _;
349        let local = crate::schema_source::FolderSchemaSource::for_workspace(workspace_root)
350            .read_schemas()
351            .map_err(|e| EngineError::SchemaResolverInit(e.to_string()))?;
352        let mut engine = Engine::from_mounts_inner(mounts, local)?;
353        engine.set_settings(settings);
354        engine.workspace_root = Some(workspace_root.to_path_buf());
355        // Load the workspace store's pipeline configs (Medium / Facet /
356        // Projection / Ingest) and expose them read-only. A malformed
357        // config surfaces a typed `StoreError::Parse` naming the file —
358        // early validation of operator-edited configs (the loader's stated
359        // value). Absent primitive directories resolve to empty.
360        // Authored pipeline configs (Medium / Facet / Projection / Ingest)
361        // from the workspace store. The legacy `scopes|projections|ingests/`
362        // JSON folders are no longer read at boot — the migration-window
363        // compatibility shim retired once the bundled pipelines migrated
364        // (2026-06-14). `memstead pipeline migrate` is the only path from
365        // old-shape configs into the store. A malformed config surfaces a
366        // typed `StoreError::Parse` naming the file.
367        engine.set_pipeline_configs(crate::pipeline_store::load_pipeline_configs(
368            workspace_root,
369        )?);
370        // Publish the authoring meta-schemas into `.memstead/meta-schemas/`
371        // so an editor validates authored schema YAML against them
372        // (resolved by each package's `# yaml-language-server:` directive).
373        // Best-effort — a read-only workspace still boots.
374        let _ = memstead_schema::meta_schema::publish_meta_schemas(workspace_root);
375        Ok(engine)
376    }
377}
378
379/// Derive a [`MemRouterSnapshot`] from the engine's resolved mount
380/// list. Mirrors full's `Engine::from_init` mount-register loop so the
381/// runtime router carries the same writable/visible roster regardless
382/// of which backend serves each mem.
383///
384/// One pass over the mounts:
385/// - Writable mounts ([`MountCapability::Write`]) register via
386///   `add_writable` with the storage's worktree path. Folder mounts
387///   surface `MountStorage::Folder.path`; git-branch mounts surface
388///   `None` (the mem content lives only inside the gitdir).
389///   Archive mounts should never be writable; if one slips through,
390///   it registers with `dir: None`.
391/// - Read-only folder / git-branch mounts also register via
392///   `add_writable` with `dir: None`, then are *visible-only* —
393///   `is_writable` returns `false` because we follow up with a
394///   `remove_writable` (no-op for archives because archives are
395///   registered as `add_read_only`).
396///
397/// Actually we keep it simple: writable mounts go through
398/// `add_writable`; read-only mounts go through `add_read_only` with
399/// a synthesized archive-style path. For folder/git-branch read-only
400/// mounts we use the path the storage offers as the archive_path
401/// argument — semantically wrong but the router treats
402/// `add_read_only` data as opaque for visibility tracking. The two
403/// callers that care (`archive_path_for_mem`, `dir_for_mem`)
404/// branch on backend type at the handler level rather than reading
405/// these synthesized paths.
406///
407/// Origin is `MemOrigin::ExplicitToml` for every mount built from
408/// `Workspace.mounts` — the file-adapter case. `RuntimeCreated`
409/// origins land when `memstead_mem_create` migrates onto the unified
410/// engine and produces fresh runtime registrations.
411fn build_mem_router_from_mounts(mounts: &[MountedBackend]) -> MemRouterSnapshot {
412    let mut router = MemRouterSnapshot::new();
413    for m in mounts {
414        match m.mount.capability {
415            MountCapability::Write => {
416                let dir: Option<PathBuf> = match &m.mount.storage {
417                    MountStorage::Folder { path } => Some(path.clone()),
418                    MountStorage::GitBranch { .. } => None,
419                    MountStorage::Archive { .. } => None,
420                    // In-memory mounts have no on-disk working dir —
421                    // they register writable with `dir: None`, the same
422                    // shape mem-repo-backed mounts use.
423                    MountStorage::InMemory => None,
424                };
425                router.add_writable(m.mount.mem.clone(), dir, MemOrigin::ExplicitToml);
426            }
427            MountCapability::ReadOnly => match &m.mount.storage {
428                MountStorage::Archive { path } => {
429                    router.add_read_only(m.mount.mem.clone(), path.clone());
430                }
431                MountStorage::Folder { path } => {
432                    router.add_read_only(m.mount.mem.clone(), path.clone());
433                }
434                MountStorage::GitBranch { gitdir, .. } => {
435                    router.add_read_only(m.mount.mem.clone(), gitdir.clone());
436                }
437                // A read-only in-memory mount has no on-disk read
438                // source to register. The engine never produces this
439                // configuration (in-memory mounts are created writable
440                // for ephemeral sessions); handled here only to keep
441                // the match total.
442                MountStorage::InMemory => {}
443            },
444        }
445    }
446    router
447}
448
449/// Public re-export of [`resolve_builtin_schema_pin`] for lifecycle
450/// orchestrators in `memstead-engine`. Mirrors full's
451/// `resolve_mem_schema` against the built-in catalogue;
452/// workspace-schema-registry resolution lifts later.
453pub fn resolve_builtin_schema_pin_pub(
454    pin: &memstead_schema::SchemaRef,
455    catalogue: &[Arc<memstead_schema::Schema>],
456) -> Option<Arc<memstead_schema::Schema>> {
457    resolve_builtin_schema_pin(pin, catalogue)
458}
459
460/// The engine's schema-pin resolver — the single named entry point a
461/// load path resolves a `name@version` pin through. Consults schema
462/// sources in a fixed order: **local storage** (the mem's own storage
463/// backend — folder `.memstead/schemas/` or the git-branch
464/// `__MEMSTEAD:schemas/` ref, layered first into the catalogue so it
465/// wins on `(name, version)` collision), **built-in** (compiled into the
466/// binary), **remote** (memstead.io, reserved, not implemented). The
467/// order is fixed in code — local-over-built-in by the catalogue's
468/// insertion precedence, remote always last. On a miss it yields the
469/// per-source [`SchemaSourceDiagnostic`] trail the `SCHEMA_NOT_FOUND`
470/// envelope carries.
471///
472/// Holds a borrowed view of the merged catalogue (`local ⧺ built-in`)
473/// the boot / register paths assemble, so resolution allocates nothing.
474pub struct SchemaResolver<'a> {
475    catalogue: &'a [Arc<memstead_schema::Schema>],
476}
477
478impl<'a> SchemaResolver<'a> {
479    /// Wrap the merged resolution catalogue (workspace-authored schemas
480    /// layered over the built-in set, local winning on collision).
481    pub fn new(catalogue: &'a [Arc<memstead_schema::Schema>]) -> Self {
482        Self { catalogue }
483    }
484
485    /// Resolve a pin to its schema, or the fixed-order source
486    /// diagnostics on a miss (fed straight into
487    /// `EngineError::SchemaNotFound`'s `sources`).
488    pub fn resolve(
489        &self,
490        pin: &memstead_schema::SchemaRef,
491    ) -> Result<Arc<memstead_schema::Schema>, Vec<crate::engine::error::SchemaSourceDiagnostic>>
492    {
493        resolve_builtin_schema_pin(pin, self.catalogue).ok_or_else(|| {
494            crate::engine::error::SchemaSourceDiagnostic::for_failed_pin(
495                &pin.name,
496                &pin.version,
497                self.catalogue,
498            )
499        })
500    }
501}
502
503/// Walk `schemas_dir` and load every immediate subdirectory as a
504/// workspace-authored schema. Each subdirectory must contain a
505/// `schema.yaml` manifest (and optional `types/*.yaml`) — silently
506/// skips entries that don't carry the manifest. `pub(crate)` so the
507/// folder `SchemaSource` reads through the same walker the boot path uses.
508pub(crate) fn load_workspace_schemas(
509    schemas_dir: Option<&Path>,
510) -> Result<Vec<Arc<memstead_schema::Schema>>, EngineError> {
511    let Some(dir) = schemas_dir else {
512        return Ok(Vec::new());
513    };
514    if !dir.is_dir() {
515        return Ok(Vec::new());
516    }
517    let entries = match std::fs::read_dir(dir) {
518        Ok(e) => e,
519        Err(_) => return Ok(Vec::new()),
520    };
521    let mut schemas: Vec<Arc<memstead_schema::Schema>> = Vec::new();
522    for entry in entries.flatten() {
523        let path = entry.path();
524        if !path.is_dir() {
525            continue;
526        }
527        if !path.join("schema.yaml").is_file() {
528            continue;
529        }
530        let schema = memstead_schema::load_schema_from_dir(&path)
531            .map_err(|e| EngineError::SchemaResolverInit(e.to_string()))?;
532        schemas.push(Arc::new(schema));
533    }
534    Ok(schemas)
535}
536
537pub(super) fn resolve_builtin_schema_pin(
538    pin: &memstead_schema::SchemaRef,
539    catalogue: &[Arc<memstead_schema::Schema>],
540) -> Option<Arc<memstead_schema::Schema>> {
541    catalogue
542        .iter()
543        .find(|s| {
544            let id = s.id();
545            id.0 == pin.name && id.1 == pin.version
546        })
547        .cloned()
548}
549
550pub(super) fn collect_source_entries(
551    backend: &dyn MemBackend,
552) -> Result<(Vec<SourceEntry>, Vec<SourceReadError>), EngineError> {
553    let paths = backend.list_entities()?;
554    let mut entries: Vec<SourceEntry> = Vec::with_capacity(paths.len());
555    let mut errors: Vec<SourceReadError> = Vec::new();
556    for path in paths {
557        match backend.read_entity(&path) {
558            Ok(Some(bytes)) => match String::from_utf8(bytes) {
559                Ok(content) => entries.push(SourceEntry {
560                    relative_path: path.to_string_lossy().into_owned(),
561                    source_path: path.clone(),
562                    content,
563                }),
564                Err(e) => errors.push(SourceReadError {
565                    source_path: path,
566                    error: std::io::Error::new(std::io::ErrorKind::InvalidData, e.to_string()),
567                }),
568            },
569            Ok(None) => {
570                // Listed-but-absent: list/read race. Skip silently.
571            }
572            Err(e) => errors.push(SourceReadError {
573                source_path: path,
574                error: std::io::Error::other(e.to_string()),
575            }),
576        }
577    }
578    Ok((entries, errors))
579}
580
581#[cfg(test)]
582mod tests {
583
584    use std::path::Path;
585
586    use memstead_schema::SchemaRef;
587    use tempfile::TempDir;
588
589    use crate::backend::MemBackend;
590    use crate::engine::test_helpers::*;
591    use crate::engine::{Engine, EngineError};
592    use crate::ops::WarningHint;
593    use crate::storage::{ArchiveBackend, FilesystemMemWriter, MemWriter};
594    use crate::vcs::CommitContext;
595    use crate::workspace::{Mount, MountCapability, MountLifecycle, MountStorage};
596
597    /// The `SchemaResolver` resolves a pin against the catalogue and, on
598    /// a miss, yields the fixed-order (`local_storage` → `builtin` →
599    /// `remote`) source diagnostics the `SCHEMA_NOT_FOUND` envelope carries.
600    #[test]
601    fn schema_resolver_resolves_builtin_and_yields_ordered_diagnostics_on_miss() {
602        let catalogue = memstead_schema::builtins::load_builtin_schemas().unwrap();
603        let resolver = super::SchemaResolver::new(&catalogue);
604
605        let ok: SchemaRef = "default@1.0.0".parse().unwrap();
606        assert!(resolver.resolve(&ok).is_ok(), "shipped built-in resolves");
607
608        let miss: SchemaRef = "nope@9.9.9".parse().unwrap();
609        let sources = resolver.resolve(&miss).unwrap_err();
610        let labels: Vec<&str> = sources.iter().map(|s| s.source).collect();
611        assert_eq!(labels, ["local_storage", "builtin", "remote"]);
612        assert!(sources.iter().all(|s| !s.pinned_version_match));
613    }
614
615    #[test]
616    fn empty_mount_list_constructs_and_errors_unknown_mem_on_read() {
617        let engine = Engine::from_mounts(Vec::new()).unwrap();
618        assert!(engine.mem_names().is_empty());
619        match engine.list_entities("missing") {
620            Err(EngineError::UnknownMem(v)) => assert_eq!(v, "missing"),
621            other => panic!("expected UnknownMem, got {other:?}"),
622        }
623    }
624
625    #[test]
626    fn duplicate_mem_names_rejected_at_construction() {
627        let tmp = TempDir::new().unwrap();
628        let writer1: Box<dyn MemBackend> =
629            Box::new(FilesystemMemWriter::new(tmp.path().to_path_buf()));
630        let writer2: Box<dyn MemBackend> =
631            Box::new(FilesystemMemWriter::new(tmp.path().to_path_buf()));
632        let err = Engine::from_mounts(vec![
633            (folder_mount("specs", tmp.path().to_path_buf()), writer1),
634            (folder_mount("specs", tmp.path().to_path_buf()), writer2),
635        ])
636        .unwrap_err();
637        assert!(matches!(err, EngineError::DuplicateMem(v) if v == "specs"));
638    }
639
640    #[test]
641    fn from_mounts_populates_load_warnings_from_duplicate_section_heading() {
642        // A markdown file with the same `## Identity` heading twice
643        // should cause the parser to emit a typed
644        // `DuplicateSectionHeading` warning. With the
645        // LoadCollector wiring, that warning lands on
646        // `engine.load_warnings()`.
647        let tmp = TempDir::new().unwrap();
648        let mem_dir = tmp.path().to_path_buf();
649        let body =
650            "---\ntype: spec\n---\n# Dup\n\n## Identity\n\nfirst.\n\n## Identity\n\nsecond.\n";
651        std::fs::write(mem_dir.join("dup.md"), body).unwrap();
652
653        let writer = FilesystemMemWriter::new(mem_dir.clone());
654        let engine = Engine::from_mounts(vec![(
655            folder_mount("specs", mem_dir),
656            Box::new(writer) as Box<dyn MemBackend>,
657        )])
658        .unwrap();
659
660        let warnings = engine.load_warnings();
661        assert!(
662            warnings
663                .iter()
664                .any(|w| matches!(w, WarningHint::DuplicateSectionHeading { .. })),
665            "load_warnings must surface DuplicateSectionHeading: {warnings:?}",
666        );
667    }
668
669    /// Parse-time relation validation drops relations whose `rel_type`
670    /// is not declared in the source mem's strict-mode schema and
671    /// emits `PARSED_RELATION_INVALID { reason: "unknown_rel_type" }`.
672    /// The entity itself loads normally; only the bad relation goes
673    /// missing from the in-memory store.
674    #[test]
675    fn from_mounts_drops_unknown_rel_type_from_hand_edit_with_warning() {
676        let tmp = TempDir::new().unwrap();
677        let mem_dir = tmp.path().to_path_buf();
678        // Hand-authored markdown with a `## Relationships` entry whose
679        // type isn't declared in the default schema (strict mode).
680        let target = "---\ntype: spec\n---\n# Target\n\n## Identity\n\nThe target.\n";
681        let source = "---\ntype: spec\n---\n# Source\n\n## Identity\n\nThe source.\n\n## Relationships\n\n- **MADE_UP_TYPE**: [[specs--target]]\n";
682        std::fs::write(mem_dir.join("target.md"), target).unwrap();
683        std::fs::write(mem_dir.join("source.md"), source).unwrap();
684
685        let writer = FilesystemMemWriter::new(mem_dir.clone());
686        let engine = Engine::from_mounts(vec![(
687            folder_mount("specs", mem_dir),
688            Box::new(writer) as Box<dyn MemBackend>,
689        )])
690        .unwrap();
691
692        let source_id = crate::entity::EntityId::new("specs", "source");
693        let target_id = crate::entity::EntityId::new("specs", "target");
694        let source_entity = engine.get_entity(&source_id).expect("source loaded");
695        // The offending relation does not survive into the entity's
696        // in-memory relationships list.
697        assert!(
698            source_entity.relationships.is_empty(),
699            "MADE_UP_TYPE relation must be dropped from entity.relationships, got: {:?}",
700            source_entity.relationships,
701        );
702        // Nor into the store's edge index.
703        let outgoing: Vec<_> = engine
704            .store()
705            .outgoing(&source_id)
706            .iter()
707            .filter(|e| e.rel_type == "MADE_UP_TYPE")
708            .collect();
709        assert!(
710            outgoing.is_empty(),
711            "MADE_UP_TYPE edge must be dropped from the store"
712        );
713        // The warning surfaces with the correct payload.
714        let parsed_invalid: Vec<_> = engine
715            .load_warnings()
716            .iter()
717            .filter_map(|w| match w {
718                WarningHint::ParsedRelationInvalid {
719                    entity_id,
720                    rel_type,
721                    target,
722                    reason,
723                    origin,
724                    recovery,
725                } => Some((
726                    entity_id.clone(),
727                    rel_type.clone(),
728                    target.clone(),
729                    reason.clone(),
730                    origin.clone(),
731                    recovery.clone(),
732                )),
733                _ => None,
734            })
735            .collect();
736        assert_eq!(
737            parsed_invalid.len(),
738            1,
739            "expected one warning, got {parsed_invalid:?}"
740        );
741        assert_eq!(parsed_invalid[0].0, source_id);
742        assert_eq!(parsed_invalid[0].1, "MADE_UP_TYPE");
743        assert_eq!(parsed_invalid[0].2, target_id);
744        assert_eq!(parsed_invalid[0].3, "unknown_rel_type");
745        assert_eq!(parsed_invalid[0].4, "writable");
746        // Writable-origin warnings carry the abstract recovery action.
747        let recovery = parsed_invalid[0]
748            .5
749            .as_ref()
750            .expect("writable-origin warning must carry recovery");
751        assert_eq!(
752            recovery.kind,
753            crate::ops::ParsedRelationRecovery::KIND_REMOVE_EXPLICIT_RELATION
754        );
755        assert_eq!(recovery.source_id, parsed_invalid[0].0);
756        assert_eq!(recovery.target_id, parsed_invalid[0].2);
757        assert_eq!(recovery.rel_type, parsed_invalid[0].1);
758    }
759
760    /// Hand-edited markdown can inject a cycle in an `acyclic: true`
761    /// rel-type's subgraph — the mutation surface's `would_cycle`
762    /// guard never fires for that path. The boot validator's
763    /// second pass finds the back-edge and drops it with
764    /// `reason: "cycle"`. The entity itself loads normally; one of
765    /// the two cycle-closing edges goes missing from the in-memory
766    /// store; the other survives.
767    #[test]
768    fn from_mounts_drops_cycle_closing_edge_in_acyclic_subgraph() {
769        let tmp = TempDir::new().unwrap();
770        let mem_dir = tmp.path().to_path_buf();
771        // Mutual PART_OF — acyclic in the default schema. The
772        // wiki-link grammar admits both as well-formed cross-
773        // references, so only the cycle pass can catch this.
774        let alpha = "---\ntype: spec\n---\n# Alpha\n\n## Identity\n\nfirst.\n\n## Relationships\n\n- **PART_OF**: [[specs--beta]]\n";
775        let beta = "---\ntype: spec\n---\n# Beta\n\n## Identity\n\nsecond.\n\n## Relationships\n\n- **PART_OF**: [[specs--alpha]]\n";
776        std::fs::write(mem_dir.join("alpha.md"), alpha).unwrap();
777        std::fs::write(mem_dir.join("beta.md"), beta).unwrap();
778
779        let writer = FilesystemMemWriter::new(mem_dir.clone());
780        let engine = Engine::from_mounts(vec![(
781            folder_mount("specs", mem_dir),
782            Box::new(writer) as Box<dyn MemBackend>,
783        )])
784        .unwrap();
785
786        let alpha_id = crate::entity::EntityId::new("specs", "alpha");
787        let beta_id = crate::entity::EntityId::new("specs", "beta");
788
789        // Both entities are real — only the relation in the cycle
790        // gets dropped.
791        assert!(engine.get_entity(&alpha_id).is_some_and(|e| !e.stub));
792        assert!(engine.get_entity(&beta_id).is_some_and(|e| !e.stub));
793
794        // Exactly one of the two PART_OF edges survives — the cycle
795        // is broken by dropping a single back-edge.
796        let surviving: Vec<_> = engine
797            .store()
798            .all_entities()
799            .flat_map(|e| {
800                engine
801                    .store()
802                    .outgoing(&e.id)
803                    .iter()
804                    .filter(|edge| edge.rel_type == "PART_OF")
805                    .map(|edge| (e.id.clone(), edge.target.clone()))
806                    .collect::<Vec<_>>()
807            })
808            .collect();
809        assert_eq!(
810            surviving.len(),
811            1,
812            "exactly one PART_OF edge must survive the cycle break, got {surviving:?}",
813        );
814
815        // The warning surfaces with `reason: "cycle"` and names the
816        // dropped pair.
817        let cycle_drops: Vec<_> = engine
818            .load_warnings()
819            .iter()
820            .filter_map(|w| match w {
821                WarningHint::ParsedRelationInvalid {
822                    entity_id,
823                    rel_type,
824                    target,
825                    reason,
826                    ..
827                } if reason == "cycle" => {
828                    Some((entity_id.clone(), rel_type.clone(), target.clone()))
829                }
830                _ => None,
831            })
832            .collect();
833        assert_eq!(
834            cycle_drops.len(),
835            1,
836            "exactly one cycle warning must fire, got {cycle_drops:?}",
837        );
838        // The dropped edge is one of the two PART_OF entries.
839        let (dropped_from, dropped_rel_type, dropped_to) = &cycle_drops[0];
840        assert_eq!(dropped_rel_type, "PART_OF");
841        let is_alpha_to_beta = dropped_from == &alpha_id && dropped_to == &beta_id;
842        let is_beta_to_alpha = dropped_from == &beta_id && dropped_to == &alpha_id;
843        assert!(
844            is_alpha_to_beta || is_beta_to_alpha,
845            "dropped edge must be one of the mutual PART_OF pair, got ({dropped_from} -> {dropped_to})",
846        );
847        // And the surviving edge isn't the same as the dropped one.
848        assert_ne!(
849            (&surviving[0].0, &surviving[0].1),
850            (dropped_from, dropped_to),
851            "surviving edge must differ from the dropped one",
852        );
853    }
854
855    /// Shape-invalid relations on a writable-origin mount get dropped
856    /// with `reason: "shape"`; the warning carries a
857    /// `remove_explicit_relation` recovery hint whose ids and rel-type
858    /// mirror the warning's top-level fields. Same envelope shape as
859    /// the `unknown_rel_type` reason — `reason` discriminates the
860    /// cause; `recovery.kind` discriminates the action. Uses a
861    /// synthetic schema with `source_types` / `target_types`
862    /// constraints because the default schema's rel-types are
863    /// unconstrained.
864    #[test]
865    fn from_mounts_emits_recovery_hint_for_writable_shape_drop() {
866        use crate::engine::test_helpers::write_schema_files_with_default_type;
867
868        let tmp = TempDir::new().unwrap();
869        let schemas_dir = tmp.path().join("schemas");
870        std::fs::create_dir_all(&schemas_dir).unwrap();
871        // A schema declaring a single rel-type whose shape only
872        // admits `actor -> doc`. The source markdown below uses
873        // `doc -> doc`, which trips the shape validator.
874        let manifest = r#"name: shape-test
875version: 0.1.0
876description: shape-constraint schema
877when_to_use: tests
878types:
879  - doc
880  - actor
881relationships:
882  mode: strict
883  definitions:
884    - name: OWNS
885      description: actor owns doc
886      default_weight: 1.0
887      source_types: [actor]
888      target_types: [doc]
889    - name: _default
890      description: fallback
891      default_weight: 1.0
892community:
893  resolution: 1.0
894  seed: 42
895"#;
896        write_schema_files_with_default_type(
897            &schemas_dir,
898            "shape-test",
899            manifest,
900            &["doc", "actor"],
901        );
902
903        let mem_dir = tmp.path().join("mem");
904        std::fs::create_dir_all(&mem_dir).unwrap();
905        // Source is type `doc`; target is also type `doc`. The
906        // declared `OWNS` rel-type expects `actor -> doc`, so the
907        // shape check rejects this pair at load.
908        let target = "---\ntype: doc\n---\n# Target\n\n## Body\n\nthe target\n";
909        let source = "---\ntype: doc\n---\n# Source\n\n## Body\n\nthe source\n\n## Relationships\n\n- **OWNS**: [[specs--target]]\n";
910        std::fs::write(mem_dir.join("target.md"), target).unwrap();
911        std::fs::write(mem_dir.join("source.md"), source).unwrap();
912
913        let writer = FilesystemMemWriter::new(mem_dir.clone());
914        let pin = SchemaRef::new("shape-test", semver::Version::new(0, 1, 0));
915        let mount = Mount {
916            mem: "specs".to_string(),
917            schema: Some(pin),
918            storage: MountStorage::Folder { path: mem_dir },
919            capability: MountCapability::Write,
920            lifecycle: MountLifecycle::Eager,
921            cross_linkable: true,
922            migration_target: None,
923        };
924        let engine = Engine::from_mounts_with_schemas_dir(
925            vec![(mount, Box::new(writer) as Box<dyn MemBackend>)],
926            Some(&schemas_dir),
927        )
928        .unwrap();
929
930        let source_id = crate::entity::EntityId::new("specs", "source");
931        let target_id = crate::entity::EntityId::new("specs", "target");
932
933        let shape_drops: Vec<_> = engine
934            .load_warnings()
935            .iter()
936            .filter_map(|w| match w {
937                WarningHint::ParsedRelationInvalid {
938                    entity_id,
939                    rel_type,
940                    target,
941                    reason,
942                    origin,
943                    recovery,
944                } if reason == "shape" => Some((
945                    entity_id.clone(),
946                    rel_type.clone(),
947                    target.clone(),
948                    origin.clone(),
949                    recovery.clone(),
950                )),
951                _ => None,
952            })
953            .collect();
954        assert_eq!(
955            shape_drops.len(),
956            1,
957            "expected one shape-reason warning, got {shape_drops:?}; all warnings = {:?}",
958            engine.load_warnings(),
959        );
960        let (drop_from, drop_type, drop_to, drop_origin, drop_recovery) =
961            shape_drops.into_iter().next().unwrap();
962        assert_eq!(drop_from, source_id);
963        assert_eq!(drop_type, "OWNS");
964        assert_eq!(drop_to, target_id);
965        assert_eq!(drop_origin, "writable");
966        // Recovery mirrors the warning's top-level fields and names
967        // the abstract `remove_explicit_relation` action.
968        let recovery = drop_recovery.expect("writable origin must carry recovery");
969        assert_eq!(
970            recovery.kind,
971            crate::ops::ParsedRelationRecovery::KIND_REMOVE_EXPLICIT_RELATION
972        );
973        assert_eq!(recovery.source_id, source_id);
974        assert_eq!(recovery.target_id, target_id);
975        assert_eq!(recovery.rel_type, "OWNS");
976    }
977
978    /// Read-only-origin warnings omit the recovery hint — the engine
979    /// cannot rewrite a read-only mount's markdown, so no abstract
980    /// action is available. The message field still names the
981    /// operator-level path (uninstall the archive or accept the
982    /// drift); structured consumers branch on `recovery.is_none()`.
983    #[test]
984    fn from_mounts_emits_no_recovery_hint_for_readonly_origin() {
985        let tmp = TempDir::new().unwrap();
986        // Archive content with a `MADE_UP_TYPE` row that the schema
987        // does not declare — parses to a `PARSED_RELATION_INVALID`
988        // with `reason: "unknown_rel_type"` on a read-only mount.
989        let target = "---\ntype: spec\n---\n# Target\n\n## Identity\n\nThe target.\n";
990        let source = "---\ntype: spec\n---\n# Source\n\n## Identity\n\nThe source.\n\n## Relationships\n\n- **MADE_UP_TYPE**: [[external--target]]\n";
991        let archive_path = build_archive(
992            tmp.path(),
993            "ext",
994            &[
995                ("target.md", target.as_bytes()),
996                ("source.md", source.as_bytes()),
997            ],
998        );
999
1000        let engine = Engine::from_mounts(vec![(
1001            archive_mount("external", archive_path.clone()),
1002            Box::new(ArchiveBackend::new(archive_path)),
1003        )])
1004        .unwrap();
1005
1006        let invalid: Vec<_> = engine
1007            .load_warnings()
1008            .iter()
1009            .filter_map(|w| match w {
1010                WarningHint::ParsedRelationInvalid {
1011                    rel_type,
1012                    reason,
1013                    origin,
1014                    recovery,
1015                    ..
1016                } => Some((
1017                    rel_type.clone(),
1018                    reason.clone(),
1019                    origin.clone(),
1020                    recovery.clone(),
1021                )),
1022                _ => None,
1023            })
1024            .collect();
1025        assert_eq!(
1026            invalid.len(),
1027            1,
1028            "expected one parse-time drop on the readonly mount, got {invalid:?}",
1029        );
1030        assert_eq!(invalid[0].0, "MADE_UP_TYPE");
1031        assert_eq!(invalid[0].1, "unknown_rel_type");
1032        assert_eq!(invalid[0].2, "readonly");
1033        assert!(
1034            invalid[0].3.is_none(),
1035            "readonly-origin warning must omit the recovery hint, got {:?}",
1036            invalid[0].3,
1037        );
1038    }
1039
1040    #[test]
1041    fn load_on_init_populates_store_from_folder_mount() {
1042        // Real markdown content: minimal but parses cleanly against
1043        // the builtin default schema.
1044        let body = "---\ntype: spec\n---\n# Hello\n\n## Identity\n\nA test entity.\n";
1045
1046        let tmp = TempDir::new().unwrap();
1047        let mem_dir = tmp.path().to_path_buf();
1048        let writer = FilesystemMemWriter::new(mem_dir.clone());
1049        <FilesystemMemWriter as MemWriter>::write_entity(
1050            &writer,
1051            Path::new("hello.md"),
1052            body.as_bytes(),
1053        )
1054        .unwrap();
1055        <FilesystemMemWriter as MemWriter>::commit(&writer, "seed", &CommitContext::internal())
1056            .unwrap();
1057
1058        let engine = Engine::from_mounts(vec![(
1059            folder_mount("specs", mem_dir),
1060            Box::new(writer) as Box<dyn MemBackend>,
1061        )])
1062        .unwrap();
1063
1064        // Store is populated.
1065        assert_eq!(engine.store().len(), 1, "expected one entity in the store");
1066        let id = crate::EntityId::new("specs", "hello");
1067        let entity = engine.get_entity(&id).expect("entity must be present");
1068        assert_eq!(entity.title, "Hello");
1069        assert_eq!(entity.entity_type, "spec");
1070        assert!(engine.load_errors().is_empty());
1071        // Schema map carries one entry per mount.
1072        assert_eq!(engine.schemas().len(), 1);
1073        assert!(engine.schemas().contains_key("specs"));
1074    }
1075
1076    #[test]
1077    fn load_on_init_populates_store_from_archive_mount() {
1078        let body =
1079            "---\ntype: spec\n---\n# From Archive\n\n## Identity\n\nLives in a .memstead zip.\n";
1080
1081        let tmp = TempDir::new().unwrap();
1082        let archive_path =
1083            build_archive(tmp.path(), "ext", &[("from-archive.md", body.as_bytes())]);
1084
1085        let engine = Engine::from_mounts(vec![(
1086            archive_mount("external", archive_path.clone()),
1087            Box::new(ArchiveBackend::new(archive_path)),
1088        )])
1089        .unwrap();
1090
1091        let id = crate::EntityId::new("external", "from-archive");
1092        let entity = engine.get_entity(&id).expect("entity must be present");
1093        assert_eq!(entity.title, "From Archive");
1094        assert!(engine.load_errors().is_empty());
1095    }
1096
1097    #[test]
1098    fn load_on_init_populates_store_from_heterogeneous_mounts() {
1099        let folder_body = "---\ntype: spec\n---\n# Local\n\n## Identity\n\nLocal entity.\n";
1100        let archive_body = "---\ntype: spec\n---\n# External\n\n## Identity\n\nArchive entity.\n";
1101
1102        let tmp = TempDir::new().unwrap();
1103
1104        let folder_dir = tmp.path().join("folder-mem");
1105        std::fs::create_dir_all(&folder_dir).unwrap();
1106        let folder_writer = FilesystemMemWriter::new(folder_dir.clone());
1107        <FilesystemMemWriter as MemWriter>::write_entity(
1108            &folder_writer,
1109            Path::new("local.md"),
1110            folder_body.as_bytes(),
1111        )
1112        .unwrap();
1113        <FilesystemMemWriter as MemWriter>::commit(
1114            &folder_writer,
1115            "seed",
1116            &CommitContext::internal(),
1117        )
1118        .unwrap();
1119
1120        let archive_path = build_archive(
1121            tmp.path(),
1122            "external",
1123            &[("external.md", archive_body.as_bytes())],
1124        );
1125
1126        let engine = Engine::from_mounts(vec![
1127            (
1128                folder_mount("local", folder_dir),
1129                Box::new(folder_writer) as Box<dyn MemBackend>,
1130            ),
1131            (
1132                archive_mount("external", archive_path.clone()),
1133                Box::new(ArchiveBackend::new(archive_path)),
1134            ),
1135        ])
1136        .unwrap();
1137
1138        // Both mems' entities live in one shared store.
1139        assert_eq!(engine.store().len(), 2);
1140        assert!(
1141            engine
1142                .get_entity(&crate::EntityId::new("local", "local"))
1143                .is_some()
1144        );
1145        assert!(
1146            engine
1147                .get_entity(&crate::EntityId::new("external", "external"))
1148                .is_some()
1149        );
1150    }
1151
1152    #[test]
1153    fn load_on_init_collects_per_file_parse_errors_without_failing() {
1154        // One good file + one with malformed frontmatter — the parser
1155        // produces an error for the malformed file but the good one
1156        // still loads.
1157        let good = "---\ntype: spec\n---\n# Good\n\n## Identity\n\nFine.\n";
1158        let bad = "---\nthis is not valid yaml: : :\n---\n# Bad\n";
1159
1160        let tmp = TempDir::new().unwrap();
1161        let mem_dir = tmp.path().to_path_buf();
1162        let writer = FilesystemMemWriter::new(mem_dir.clone());
1163        <FilesystemMemWriter as MemWriter>::write_entity(
1164            &writer,
1165            Path::new("good.md"),
1166            good.as_bytes(),
1167        )
1168        .unwrap();
1169        <FilesystemMemWriter as MemWriter>::write_entity(
1170            &writer,
1171            Path::new("bad.md"),
1172            bad.as_bytes(),
1173        )
1174        .unwrap();
1175        <FilesystemMemWriter as MemWriter>::commit(&writer, "seed", &CommitContext::internal())
1176            .unwrap();
1177
1178        let engine = Engine::from_mounts(vec![(
1179            folder_mount("specs", mem_dir),
1180            Box::new(writer) as Box<dyn MemBackend>,
1181        )])
1182        .unwrap();
1183
1184        // The good entity is in the store; construction did not fail.
1185        assert!(
1186            engine
1187                .get_entity(&crate::EntityId::new("specs", "good"))
1188                .is_some(),
1189            "good.md must parse and reach the store"
1190        );
1191        // Either bad.md surfaces as a load error, or it parses
1192        // permissively — both are acceptable outcomes here. The
1193        // contract under test is "construction does not fail on a
1194        // single bad file".
1195        let bad_known_to_engine = engine
1196            .get_entity(&crate::EntityId::new("specs", "bad"))
1197            .is_some()
1198            || !engine.load_errors().is_empty();
1199        assert!(
1200            bad_known_to_engine,
1201            "bad.md must either parse or surface in load_errors"
1202        );
1203    }
1204
1205    #[test]
1206    fn empty_mount_list_yields_empty_store() {
1207        let engine = Engine::from_mounts(Vec::new()).unwrap();
1208        assert!(engine.store().is_empty());
1209        assert!(engine.schemas().is_empty());
1210        assert!(engine.load_errors().is_empty());
1211    }
1212
1213    // ---- Engine::create_entity --------------------------------------
1214
1215    #[test]
1216    fn from_workspace_root_errors_for_empty_layout() {
1217        let tmp = TempDir::new().unwrap();
1218        let err = Engine::from_workspace_root(tmp.path()).unwrap_err();
1219        match err {
1220            crate::BootError::NotInitialised(p) => {
1221                assert_eq!(p, tmp.path());
1222            }
1223            other => panic!("expected NotInitialised, got {other:?}"),
1224        }
1225    }
1226
1227    #[test]
1228    fn from_workspace_root_loads_new_two_layer_layout() {
1229        let tmp = TempDir::new().unwrap();
1230        let mem_dir = tmp.path().join("mem");
1231        std::fs::create_dir_all(&mem_dir).unwrap();
1232        std::fs::write(
1233            mem_dir.join("hello.md"),
1234            "---\ntype: spec\n---\n# Hello\n\n## Identity\n\nA.\n",
1235        )
1236        .unwrap();
1237
1238        let memstead = tmp.path().join(".memstead");
1239        std::fs::create_dir_all(&memstead).unwrap();
1240        std::fs::write(
1241            memstead.join("workspace.toml"),
1242            "format = \"memstead-git-branch-2\"\n\n[persistence_adapter]\nname = \"file-two-layer\"\n",
1243        )
1244        .unwrap();
1245        // Save the mount via the file adapter so the JSON shape matches
1246        // the wire format the loader expects.
1247        use crate::workspace_store::WorkspaceStoreAdapter;
1248        let store = crate::FileWorkspaceStore::new();
1249        store
1250            .save_state(
1251                tmp.path(),
1252                &crate::workspace::Workspace {
1253                    mounts: vec![folder_mount("specs", mem_dir)],
1254                    settings: crate::workspace::WorkspaceSettings::default(),
1255                },
1256            )
1257            .unwrap();
1258
1259        let engine = Engine::from_workspace_root(tmp.path()).unwrap();
1260        assert_eq!(engine.mem_names(), vec!["specs"]);
1261        let entity = engine
1262            .get_entity(&crate::EntityId::new("specs", "hello"))
1263            .expect("seeded entity must load through from_workspace_root");
1264        assert_eq!(entity.title, "Hello");
1265    }
1266
1267    /// AC ("engine-side pipeline loader is activated"): with a workspace
1268    /// store carrying one Medium, one Facet, one Projection, one Ingest,
1269    /// the engine on boot enumerates all four through its read-only
1270    /// queryable surface in the post-refactor shape.
1271    #[test]
1272    fn from_workspace_root_loads_pipeline_configs_into_queryable_surface() {
1273        use crate::pipeline::{
1274            Facet, Ingest, IngestMode, IngestTrigger, Medium, MediumType, Projection,
1275        };
1276        let tmp = TempDir::new().unwrap();
1277        let mem_dir = tmp.path().join("mem");
1278        std::fs::create_dir_all(&mem_dir).unwrap();
1279
1280        let memstead = tmp.path().join(".memstead");
1281        std::fs::create_dir_all(&memstead).unwrap();
1282        std::fs::write(
1283            memstead.join("workspace.toml"),
1284            "format = \"memstead-git-branch-2\"\n\n[persistence_adapter]\nname = \"file-two-layer\"\n",
1285        )
1286        .unwrap();
1287        use crate::workspace_store::WorkspaceStoreAdapter;
1288        crate::FileWorkspaceStore::new()
1289            .save_state(
1290                tmp.path(),
1291                &crate::workspace::Workspace {
1292                    mounts: vec![folder_mount("specs", mem_dir)],
1293                    settings: crate::workspace::WorkspaceSettings::default(),
1294                },
1295            )
1296            .unwrap();
1297
1298        // One of each primitive in the store.
1299        crate::pipeline_store::write_medium(
1300            tmp.path(),
1301            "specs",
1302            "src",
1303            &Medium {
1304                name: "src".to_string(),
1305                medium_type: MediumType::Codebase,
1306                pointer: "..".to_string(),
1307            },
1308        )
1309        .unwrap();
1310        crate::pipeline_store::write_facet(
1311            tmp.path(),
1312            "specs",
1313            "view",
1314            &Facet {
1315                name: "view".to_string(),
1316                medium: "src".to_string(),
1317                scope: Vec::new(),
1318                engagement: None,
1319                preparation: None,
1320            },
1321        )
1322        .unwrap();
1323        crate::pipeline_store::write_projection(
1324            tmp.path(),
1325            "specs",
1326            "graph",
1327            &Projection {
1328                intent: None,
1329                source_facets: vec!["view".to_string()],
1330                reference_mems: Vec::new(),
1331                destination_mem: "specs".to_string(),
1332            },
1333        )
1334        .unwrap();
1335        crate::pipeline_store::write_ingest(
1336            tmp.path(),
1337            "specs-graph",
1338            &Ingest {
1339                projection: "specs/graph".to_string(),
1340                mode: IngestMode::Discovery,
1341                trigger: IngestTrigger::Loop,
1342                batch_size: 10,
1343                deny_paths: Vec::new(),
1344            },
1345        )
1346        .unwrap();
1347
1348        let engine = Engine::from_workspace_root(tmp.path()).unwrap();
1349        let pc = engine.pipeline_configs();
1350        assert_eq!(pc.mediums.len(), 1, "one medium enumerated");
1351        assert_eq!(pc.mediums[0].config.medium_type, MediumType::Codebase);
1352        assert_eq!(pc.facets.len(), 1, "one facet enumerated");
1353        assert_eq!(pc.facets[0].config.medium, "src");
1354        assert_eq!(pc.projections.len(), 1, "one projection enumerated");
1355        assert_eq!(pc.projections[0].config.destination_mem, "specs");
1356        assert_eq!(pc.ingests.len(), 1, "one ingest enumerated");
1357        assert_eq!(pc.ingests[0].name, "specs-graph");
1358        assert_eq!(pc.ingests[0].config.mode, IngestMode::Discovery);
1359    }
1360
1361    /// The engine edit surface: a wrapper edit (`add_medium`) routes through
1362    /// the pipeline-edit layer, writes the store, and refreshes the in-memory
1363    /// snapshot in place (no `reload()`), and referential integrity (a facet
1364    /// pinning the medium) is enforced through the engine method.
1365    #[test]
1366    fn engine_pipeline_edit_methods_mutate_and_refresh_the_snapshot() {
1367        use crate::pipeline::{Facet, Medium, MediumType};
1368        use crate::workspace_store::WorkspaceStoreAdapter;
1369
1370        let tmp = TempDir::new().unwrap();
1371        let mem_dir = tmp.path().join("mem");
1372        std::fs::create_dir_all(&mem_dir).unwrap();
1373        let memstead = tmp.path().join(".memstead");
1374        std::fs::create_dir_all(&memstead).unwrap();
1375        std::fs::write(
1376            memstead.join("workspace.toml"),
1377            "format = \"memstead-git-branch-2\"\n\n[persistence_adapter]\nname = \"file-two-layer\"\n",
1378        )
1379        .unwrap();
1380        crate::FileWorkspaceStore::new()
1381            .save_state(
1382                tmp.path(),
1383                &crate::workspace::Workspace {
1384                    mounts: vec![folder_mount("specs", mem_dir)],
1385                    settings: crate::workspace::WorkspaceSettings::default(),
1386                },
1387            )
1388            .unwrap();
1389
1390        let mut engine = Engine::from_workspace_root(tmp.path()).unwrap();
1391        assert!(engine.pipeline_configs().mediums.is_empty());
1392
1393        engine
1394            .add_medium(
1395                "specs",
1396                "src",
1397                &Medium {
1398                    name: "src".to_string(),
1399                    medium_type: MediumType::Codebase,
1400                    pointer: "..".to_string(),
1401                },
1402            )
1403            .unwrap();
1404        // Snapshot refreshed in place.
1405        assert_eq!(engine.pipeline_configs().mediums.len(), 1);
1406        assert_eq!(engine.pipeline_configs().mediums[0].name, "src");
1407
1408        engine
1409            .add_facet(
1410                "specs",
1411                "view",
1412                &Facet {
1413                    name: "view".to_string(),
1414                    medium: "src".to_string(),
1415                    scope: Vec::new(),
1416                    engagement: None,
1417                    preparation: None,
1418                },
1419            )
1420            .unwrap();
1421        // Deleting a referenced medium is refused through the engine surface.
1422        let err = engine.delete_medium("specs", "src").unwrap_err();
1423        assert!(
1424            matches!(
1425                err,
1426                crate::pipeline_edit::PipelineEditError::Referenced { .. }
1427            ),
1428            "got {err:?}"
1429        );
1430        assert_eq!(
1431            engine.pipeline_configs().mediums.len(),
1432            1,
1433            "refused delete left the medium in place"
1434        );
1435
1436        // The JSON entry point (the FFI-facing shape) deserializes and lands.
1437        engine
1438            .add_medium_json(
1439                "specs",
1440                "docs",
1441                r#"{"name":"docs","type":"filesystem","pointer":"./docs"}"#,
1442            )
1443            .unwrap();
1444        assert!(
1445            engine
1446                .pipeline_configs()
1447                .mediums
1448                .iter()
1449                .any(|m| m.name == "docs" && m.config.medium_type == MediumType::Filesystem),
1450            "add_medium_json should land a filesystem medium"
1451        );
1452        // A malformed payload is refused without touching the store.
1453        let err = engine
1454            .add_medium_json("specs", "bad", "{ not json")
1455            .unwrap_err();
1456        assert!(
1457            matches!(
1458                err,
1459                crate::pipeline_edit::PipelineEditError::InvalidJson { .. }
1460            ),
1461            "got {err:?}"
1462        );
1463
1464        // The JSON read counterpart reflects the live store.
1465        let json = engine.pipeline_configs_json();
1466        let parsed: serde_json::Value = serde_json::from_str(&json).unwrap();
1467        let mediums = parsed["mediums"].as_array().unwrap();
1468        assert_eq!(mediums.len(), 2, "src + docs");
1469        assert!(
1470            mediums
1471                .iter()
1472                .any(|m| m["name"] == "docs" && m["config"]["type"] == "filesystem"),
1473            "pipeline_configs_json should carry the docs medium: {json}"
1474        );
1475    }
1476
1477    /// The lean folder authoring path: a schema package authored at the
1478    /// fixed `<workspace>/.memstead/schemas/<name>@<version>/` location
1479    /// is resolved at boot, so a folder mem can pin a non-built-in
1480    /// schema. Before this wiring `from_workspace_root` loaded only
1481    /// built-ins, so the pin would refuse with `SCHEMA_NOT_FOUND`.
1482    #[test]
1483    fn from_workspace_root_resolves_authored_schema_from_dot_memstead_schemas() {
1484        use crate::engine::test_helpers::write_schema_files_with_default_type;
1485
1486        let tmp = TempDir::new().unwrap();
1487        let mem_dir = tmp.path().join("mem");
1488        std::fs::create_dir_all(&mem_dir).unwrap();
1489
1490        // Author a schema package at the fixed folder location.
1491        let authored_dir = tmp.path().join(".memstead").join("schemas");
1492        let manifest = r#"name: authored
1493version: 0.1.0
1494description: an authored-in-workspace test schema
1495when_to_use: tests
1496types:
1497  - doc
1498relationships:
1499  mode: strict
1500  definitions:
1501    - name: _default
1502      description: fallback
1503      default_weight: 1.0
1504community:
1505  resolution: 1.0
1506  seed: 42
1507"#;
1508        write_schema_files_with_default_type(&authored_dir, "authored@0.1.0", manifest, &["doc"]);
1509
1510        // A folder mem pinning the authored (non-built-in) schema.
1511        let memstead = tmp.path().join(".memstead");
1512        std::fs::create_dir_all(&memstead).unwrap();
1513        std::fs::write(
1514            memstead.join("workspace.toml"),
1515            "format = \"memstead-git-branch-2\"\n\n[persistence_adapter]\nname = \"file-two-layer\"\n",
1516        )
1517        .unwrap();
1518        let mount = Mount {
1519            mem: "specs".to_string(),
1520            schema: Some(SchemaRef::new("authored", semver::Version::new(0, 1, 0))),
1521            storage: MountStorage::Folder { path: mem_dir },
1522            capability: MountCapability::Write,
1523            lifecycle: MountLifecycle::Eager,
1524            cross_linkable: true,
1525            migration_target: None,
1526        };
1527        use crate::workspace_store::WorkspaceStoreAdapter;
1528        crate::FileWorkspaceStore::new()
1529            .save_state(
1530                tmp.path(),
1531                &crate::workspace::Workspace {
1532                    mounts: vec![mount],
1533                    settings: crate::workspace::WorkspaceSettings::default(),
1534                },
1535            )
1536            .unwrap();
1537
1538        // Boots cleanly — the authored pin resolved against the fixed
1539        // location rather than refusing as an unknown built-in.
1540        let engine = Engine::from_workspace_root(tmp.path())
1541            .expect("authored schema at .memstead/schemas/ must resolve at boot");
1542        assert_eq!(engine.mem_names(), vec!["specs"]);
1543    }
1544
1545    #[test]
1546    fn from_workspace_root_propagates_mem_management_settings() {
1547        // workspace.toml carries [mem_management] rules; the file
1548        // adapter parses them into Workspace.settings; from_workspace_root
1549        // calls Engine::set_settings so the engine surface reflects them.
1550        // End-to-end check that the carriers, parser, and plumbing connect.
1551        let tmp = TempDir::new().unwrap();
1552        let mem_dir = tmp.path().join("mem");
1553        std::fs::create_dir_all(&mem_dir).unwrap();
1554
1555        let memstead = tmp.path().join(".memstead");
1556        std::fs::create_dir_all(&memstead).unwrap();
1557        std::fs::write(
1558            memstead.join("workspace.toml"),
1559            r#"format = "memstead-git-branch-2"
1560
1561[persistence_adapter]
1562name = "file-two-layer"
1563
1564[[mem_management.create]]
1565pattern = "exec-*"
1566schemas = ["default@1.0.0"]
1567
1568[[mem_management.delete]]
1569pattern = "exec-*"
1570"#,
1571        )
1572        .unwrap();
1573        use crate::workspace_store::WorkspaceStoreAdapter;
1574        let store = crate::FileWorkspaceStore::new();
1575        store
1576            .save_state(
1577                tmp.path(),
1578                &crate::workspace::Workspace {
1579                    mounts: vec![folder_mount("specs", mem_dir)],
1580                    settings: crate::workspace::WorkspaceSettings::default(),
1581                },
1582            )
1583            .unwrap();
1584
1585        let engine = Engine::from_workspace_root(tmp.path()).unwrap();
1586        let s = engine.settings();
1587        assert_eq!(s.mem_create_rules.len(), 1);
1588        assert_eq!(s.mem_create_rules[0].pattern, "exec-*");
1589        assert_eq!(
1590            s.mem_create_rules[0].schemas,
1591            vec!["default@1.0.0".to_string()]
1592        );
1593        assert_eq!(s.mem_delete_rules.len(), 1);
1594        assert_eq!(s.mem_delete_rules[0].pattern, "exec-*");
1595    }
1596
1597    #[test]
1598    fn from_mounts_rejects_unknown_schema_pin_with_typed_error() {
1599        let tmp = TempDir::new().unwrap();
1600        let writer = FilesystemMemWriter::new(tmp.path().to_path_buf());
1601        let mount = Mount {
1602            mem: "specs".to_string(),
1603            schema: Some(SchemaRef::new(
1604                "totally-not-a-schema",
1605                semver::Version::new(1, 0, 0),
1606            )),
1607            storage: MountStorage::Folder {
1608                path: tmp.path().to_path_buf(),
1609            },
1610            capability: MountCapability::Write,
1611            lifecycle: MountLifecycle::Eager,
1612            cross_linkable: true,
1613            migration_target: None,
1614        };
1615        let err = Engine::from_mounts(vec![(mount, Box::new(writer) as Box<dyn MemBackend>)])
1616            .unwrap_err();
1617        match err {
1618            EngineError::SchemaNotFound { mem, pin, sources } => {
1619                assert_eq!(mem, "specs");
1620                assert_eq!(pin, "totally-not-a-schema@1.0.0");
1621                // The diagnostics name all three sources in fixed order;
1622                // none carries the unknown pin, and remote is reserved.
1623                let labels: Vec<&str> = sources.iter().map(|s| s.source).collect();
1624                assert_eq!(labels, ["local_storage", "builtin", "remote"]);
1625                assert!(sources.iter().all(|s| !s.pinned_version_match));
1626                assert_eq!(
1627                    sources
1628                        .iter()
1629                        .find(|s| s.source == "remote")
1630                        .unwrap()
1631                        .status,
1632                    Some("not_configured"),
1633                );
1634            }
1635            other => panic!("expected SchemaNotFound, got {other:?}"),
1636        }
1637    }
1638
1639    /// Schema-pin authority: the mem's own per-mem config is the
1640    /// authoritative settled pin. Here the config pins a resolvable
1641    /// schema (`software@0.1.0`) while the workspace mount expects an
1642    /// unresolvable one — boot succeeds (proving the config pin won,
1643    /// not the mount's) and surfaces a `SchemaPinMismatch` warning
1644    /// naming both pins.
1645    #[test]
1646    fn mem_config_schema_is_authoritative_over_mount_pin() {
1647        let tmp = TempDir::new().unwrap();
1648        let mem_dir = tmp.path().to_path_buf();
1649        std::fs::create_dir_all(mem_dir.join(".memstead")).unwrap();
1650        std::fs::write(
1651            mem_dir.join(".memstead").join("config.json"),
1652            r#"{"schema":"software@0.1.0"}"#,
1653        )
1654        .unwrap();
1655        let writer = FilesystemMemWriter::new(mem_dir.clone());
1656        let mount = Mount {
1657            mem: "specs".to_string(),
1658            schema: Some(SchemaRef::new(
1659                "totally-not-a-schema",
1660                semver::Version::new(9, 9, 9),
1661            )),
1662            storage: MountStorage::Folder { path: mem_dir },
1663            capability: MountCapability::Write,
1664            lifecycle: MountLifecycle::Eager,
1665            cross_linkable: true,
1666            migration_target: None,
1667        };
1668        let engine = Engine::from_mounts(vec![(
1669            mount,
1670            Box::new(writer) as Box<dyn MemBackend>,
1671        )])
1672        .expect("config pin software@0.1.0 is authoritative — boot must resolve it despite the unresolvable mount pin");
1673
1674        let mismatch = engine
1675            .load_warnings()
1676            .iter()
1677            .find_map(|w| match w {
1678                WarningHint::SchemaPinMismatch {
1679                    mem,
1680                    config_pin,
1681                    mount_pin,
1682                } => Some((mem.clone(), config_pin.clone(), mount_pin.clone())),
1683                _ => None,
1684            })
1685            .expect("SchemaPinMismatch warning must surface naming both pins");
1686        assert_eq!(mismatch.0, "specs");
1687        assert_eq!(mismatch.1, "software@0.1.0");
1688        assert_eq!(mismatch.2, "totally-not-a-schema@9.9.9");
1689    }
1690
1691    #[test]
1692    fn from_workspace_root_rejects_git_branch_mount_with_typed_error() {
1693        let tmp = TempDir::new().unwrap();
1694        let memstead = tmp.path().join(".memstead");
1695        std::fs::create_dir_all(&memstead).unwrap();
1696        std::fs::write(
1697            memstead.join("workspace.toml"),
1698            "format = \"memstead-git-branch-2\"\n\n[persistence_adapter]\nname = \"file-two-layer\"\n",
1699        )
1700        .unwrap();
1701        // Hand-craft a state/mounts.json carrying a git-branch mount —
1702        // the lean boot path can't instantiate that backend.
1703        let state_dir = memstead.join("state");
1704        std::fs::create_dir_all(&state_dir).unwrap();
1705        std::fs::write(
1706            state_dir.join("mounts.json"),
1707            r#"{
1708                "format": "memstead-mounts-3",
1709                "mounts": [
1710                    {
1711                        "mem": "specs",
1712                        "schema": "default@1.0.0",
1713                        "storage": { "type": "git-branch", "gitdir": "/tmp/x.git", "branch": "specs" },
1714                        "capability": "write",
1715                        "lifecycle": "eager",
1716                        "cross_linkable": true
1717                    }
1718                ]
1719            }"#,
1720        )
1721        .unwrap();
1722        let err = Engine::from_workspace_root(tmp.path()).unwrap_err();
1723        match err {
1724            crate::BootError::Instantiate(
1725                crate::workspace_store::InstantiateError::GitBranchRequiresMemRepoFeature { mem },
1726            ) => {
1727                assert_eq!(mem, "specs");
1728            }
1729            other => panic!("expected Instantiate(GitBranchRequiresMemRepoFeature), got {other:?}"),
1730        }
1731    }
1732
1733    #[test]
1734    fn from_workspace_root_roots_standalone_folder_mem() {
1735        // Standalone collapse: a bare folder mem — `.memstead/config.json`
1736        // pinning a schema, no `workspace.toml` — boots as a one-mount
1737        // workspace instead of refusing with NotInitialised.
1738        let tmp = TempDir::new().unwrap();
1739        let root = tmp.path();
1740        std::fs::create_dir_all(root.join(".memstead")).unwrap();
1741        std::fs::write(
1742            root.join(".memstead").join("config.json"),
1743            r#"{"schema":"default@1.0.0"}"#,
1744        )
1745        .unwrap();
1746        // A collapsed single-mem folder keeps its `.md` files at the root.
1747        std::fs::write(
1748            root.join("hello.md"),
1749            "---\ntype: spec\n---\n# Hello\n\n## Identity\n\nStandalone body.\n",
1750        )
1751        .unwrap();
1752
1753        let engine = Engine::from_workspace_root(root)
1754            .expect("a bare folder mem must root as a one-mount workspace");
1755        assert_eq!(engine.stats().mem_count, 1, "exactly one mount");
1756        assert!(
1757            engine.stats().entity_count >= 1,
1758            "the standalone mem's entity must load"
1759        );
1760    }
1761
1762    #[test]
1763    fn from_workspace_root_still_rejects_truly_empty_dir() {
1764        // Refusal complement: a directory with neither `workspace.toml` nor a
1765        // `.memstead/config.json` is not a mem — it still refuses, so the
1766        // standalone path never masks a genuinely uninitialised directory.
1767        let tmp = TempDir::new().unwrap();
1768        let err = Engine::from_workspace_root(tmp.path()).unwrap_err();
1769        assert!(
1770            matches!(err, crate::BootError::NotInitialised(_)),
1771            "got {err:?}"
1772        );
1773    }
1774
1775    // ---- Engine::reload_one_mem -----------------------------------
1776}