Skip to main content

memstead_base/engine/
mod.rs

1//! Unified engine.
2//!
3//! **One [`Engine`] type, three storage backends**: the engine sits
4//! above [`MemBackend`] and routes reads / writes to the backend
5//! named by each mount's mem. The MCP filesystem-mem server
6//! (`memstead_mcp::filesystem_server::FilesystemMcpServer`), every CLI
7//! lean subcommand, and the macOS UniFFI consumer all reach the
8//! engine through [`Engine::from_workspace_root`] (lean: folder +
9//! archive backends) or `memstead_git_branch::engine_from_workspace_root`
10//! (full: adds git-branch).
11//!
12//! ## Routing
13//!
14//! Each mount holds one mem. Lookup is by mem name: the first
15//! mount whose `mem` field equals the requested name wins. One mount
16//! per mem is enforced — duplicates are a configuration bug, not a
17//! feature, and the constructor rejects them.
18
19use std::cell::OnceCell;
20use std::collections::HashMap;
21use std::path::{Path, PathBuf};
22use std::sync::Arc;
23
24use memstead_schema::Schema;
25
26use crate::backend::{BackendError, MemBackend};
27use crate::graph::LouvainOutput;
28use crate::mem::MemRouterSnapshot;
29use crate::ops::WarningHint;
30#[cfg(not(target_arch = "wasm32"))]
31use crate::search_index::MemIndex;
32use crate::store::Store;
33use crate::workspace::{Mount, WorkspaceSettings};
34
35pub mod apply_commit;
36pub mod archive;
37pub mod boot;
38pub mod drift;
39pub mod error;
40pub mod events;
41#[cfg(feature = "file-watcher")]
42pub mod file_watcher;
43pub mod lifecycle;
44pub mod mutation;
45pub mod outcomes;
46pub mod query;
47
48pub use archive::FromArchiveBytesError;
49pub use error::{
50    BootError, EngineError, INLINE_LIST_CAP, MissingWikiLink, ReferrerInfo, SchemaSourceDiagnostic,
51    format_inline_list_overflow,
52};
53#[cfg(feature = "tokio")]
54pub use events::DEFAULT_BROADCAST_CAPACITY;
55pub use events::{EventCallback, MemChangedEvent, SubscriptionHandle};
56#[cfg(feature = "file-watcher")]
57pub use file_watcher::{FileWatcherError, MemRepoWatcher, watch_mem_repo};
58pub use mutation::delete::DeleteReferrers;
59pub use mutation::{PATCH_OLD_NOT_FOUND_CONTENT_CAP, RELATIONSHIP_CYCLE_PATH_CAP};
60pub use outcomes::{
61    CreateEntityArgs, CreateEntityOutcome, DeleteEntityArgs, DeleteEntityOutcome, RelateAction,
62    RelateEntityArgs, RelateEntityOutcome, RenameEntityArgs, RenameEntityOutcome, SetSchemaOutcome,
63    SetSchemaResult, UpdateEntityArgs, UpdateEntityOutcome,
64};
65
66pub use boot::{SchemaResolver, resolve_builtin_schema_pin_pub};
67
68/// One mem attachment, paired with the backend that serves it.
69/// Constructed by [`Engine::from_mounts`] and held internally.
70struct MountedBackend {
71    mount: Mount,
72    backend: Box<dyn MemBackend>,
73    /// Last cursor returned by `backend.current_head()`. Seeded in
74    /// [`Engine::from_mounts`]; refreshed by
75    /// [`Engine::reload_if_stale`] after a successful reload.
76    /// `None` means the backend doesn't track a head (folder /
77    /// archive) — drift detection is a no-op for this mount.
78    last_known_head: Option<String>,
79    /// Per-mem `.memstead/config.json` payload — surfaces via
80    /// [`Engine::mem_config_for`] for handlers that need
81    /// `write_guidance` / `extra` (`memstead_health
82    /// { include_config: true }`'s per-mem detail block).
83    ///
84    /// Loaded at construction for folder backends (read from
85    /// `<path>/.memstead/config.json`). Git-branch + archive backends
86    /// carry `None` for now — the read-from-storage-backend path
87    /// lifts in a follow-up session.
88    mem_config: Option<memstead_schema::config::MemConfig>,
89    /// Per-mem authoring-provenance payload read from the archive's
90    /// `.memstead/provenance.json` at construction (via
91    /// [`crate::backend::MemBackend::read_archive_provenance`]). `None`
92    /// when the backend carries no provenance member (a pre-provenance
93    /// archive, or a backend that does not surface one) — surfaced as
94    /// provenance-absent via [`Engine::archive_provenance_for`]. A
95    /// malformed payload is downgraded to `None` rather than failing the
96    /// mount: the member is additive.
97    archive_provenance: Option<memstead_schema::ArchiveProvenance>,
98}
99
100/// Unified engine. Holds a list of mounted backends and routes
101/// mem-named operations to the right one.
102///
103/// `Send` so the engine can sit behind a `Mutex` (today's pattern
104/// in the MCP server). The trait object's `Send + Sync` bound on
105/// `MemBackend` keeps the inner backends thread-safe; the engine
106/// itself is single-threaded by design (the lazy memos are
107/// `OnceCell`, which is `!Sync`).
108///
109/// `Debug` is hand-written to avoid requiring `Debug` on the
110/// `dyn MemBackend` trait object — backend impls are free to
111/// stay non-`Debug`.
112///
113/// ## Load-on-init
114///
115/// `Engine::from_mounts` walks each backend at construction time
116/// (`list_entities` + `read_entity` + parse) and populates a single
117/// shared [`Store`] with entities and edges from every mount. Each
118/// mount's schema resolves from its own pin (the backend config's
119/// schema, or the mount-record assertion as fallback) through the
120/// `SchemaResolver`, so `schemas` holds genuinely heterogeneous
121/// schemas in a multi-schema workspace. Per-file errors don't fail
122/// construction; they collect into [`Engine::load_errors`] for the
123/// operator to inspect.
124pub struct Engine {
125    mounts: Vec<MountedBackend>,
126    store: Store,
127    schemas: HashMap<String, Arc<Schema>>,
128    /// Workspace-authored schemas loaded from
129    /// `WorkspaceSettings.schemas_dir` at construction. Distinct from
130    /// `schemas` (per-mem, only schemas pinned by a mount): this
131    /// catalogue carries every workspace-loaded schema regardless of
132    /// whether a mem pins it. Surfaced via
133    /// [`Self::workspace_schemas`] for handlers that need to enumerate
134    /// schemas referenced by `mem_create_rules.schemas[]` but not
135    /// pinned by any mem — `memstead_overview` lists them in `## Schemas`
136    /// so an agent sees what could be pinned. Empty when no
137    /// `schemas_dir` was passed.
138    workspace_schemas: Vec<Arc<Schema>>,
139    /// Embedded built-in schemas loaded once at boot from
140    /// `memstead_schema::builtins::load_builtin_schemas()`. The boot path
141    /// uses this catalogue to resolve each mount's schema pin; storing
142    /// it on the engine lets read handlers (MCP's `memstead_schema`,
143    /// `memstead_overview`'s `## Schemas` rendering) surface every built-in
144    /// without re-walking the embedded directory. Schemas declared in
145    /// `workspace_schemas` shadow built-ins on `(name, version)`
146    /// collision — handlers walking both lists must check workspace
147    /// first.
148    builtin_schemas: Vec<Arc<Schema>>,
149    load_errors: Vec<(PathBuf, String)>,
150    /// Lazily-computed Louvain community detection across the
151    /// engine-wide store. Populated on first call to
152    /// [`Self::communities`]; invalidated by
153    /// [`Self::invalidate_communities`] which every mutation method
154    /// calls after a successful write. `OnceCell` is `!Sync`; the
155    /// engine is `Send` (it is moved into a `Mutex` by every consumer)
156    /// but not `Sync`.
157    community_memo: OnceCell<LouvainOutput>,
158    /// Lazily-computed per-mem search index map. Built on first call
159    /// to [`Self::search_indexes`] via [`build_all`]; invalidated by
160    /// [`Self::invalidate_search_indexes`] alongside the community
161    /// cache so every mutation triggers a fresh build on the next
162    /// search. Absent on `wasm32` targets — search lives behind the
163    /// bridge (see `EngineError::SearchUnavailable`).
164    #[cfg(not(target_arch = "wasm32"))]
165    search_indexes_memo: OnceCell<HashMap<String, MemIndex>>,
166    /// Workspace-level operator policy — mem create/delete rules,
167    /// cross-mem link permissions. Defaults to empty; populated via
168    /// [`Self::set_settings`] when [`Self::from_workspace_root`] (or
169    /// the full counterpart) reads `.memstead/workspace.toml`. Surfaced
170    /// read-only via [`Self::settings`] for MCP handlers and other
171    /// consumers.
172    settings: WorkspaceSettings,
173    /// Lazily-compiled [`crate::mem_management::CreateRuleSet`] over
174    /// `settings.mem_create_rules`. Built on first
175    /// [`Self::cross_mem_link_allowed`] call that needs synthesis;
176    /// invalidated by [`Self::set_settings`] (so a fresh policy
177    /// re-compiles on the next call). Compilation errors are logged
178    /// and the cache stays empty — synthesis is best-effort, the
179    /// resolver falls back to explicit-policy resolution. Operators
180    /// who want hard validation pre-compile via
181    /// [`crate::mem_management::CreateRuleSet::new`] before passing
182    /// settings.
183    create_rule_set_memo: OnceCell<crate::mem_management::CreateRuleSet>,
184    /// Per-mem data-trust origin declared by the embedding deployment
185    /// (e.g. a curated hosted read tier vouching for a read-only mount as
186    /// first-party). A *composition* fact set through
187    /// [`Self::declare_mem_origin`] by the process that owns the engine —
188    /// never persisted with the mem, never derived from mem content, and
189    /// deliberately not reachable over MCP, so a publisher cannot forge
190    /// first-party. Empty by default; [`Self::mem_origin_class`] falls back
191    /// to the writability inference for undeclared mems.
192    declared_origins: HashMap<String, crate::render::OriginClass>,
193    /// Workspace root path — set when the engine boots from a
194    /// workspace store ([`Self::from_workspace_root`] or the full
195    /// counterpart). `None` for tests + ad-hoc consumers that build
196    /// the engine directly from a mount list. Surfaced via
197    /// [`Self::workspace_root`] for handlers that need filesystem
198    /// context (e.g. [`Self::health`]'s outer-repo .gitignore
199    /// check).
200    workspace_root: Option<PathBuf>,
201    /// Typed warnings surfaced during mem load — drift findings
202    /// like [`WarningHint::SuspiciousNestedPrefix`] and
203    /// [`WarningHint::DuplicateSectionHeading`] that the loader
204    /// pipeline collects per entity. Empty for the V1 unified
205    /// engine; the field is in place so handlers and the health
206    /// surface can include them when the loader pipeline grows the
207    /// warning generators.
208    load_warnings: Vec<WarningHint>,
209    /// Pipeline configs (Medium / Facet / Projection / Ingest) loaded
210    /// from the workspace store at boot. Empty for engines built via
211    /// `from_mounts*` (tests, in-memory consumers) and for any workspace
212    /// that declares no pipelines; the workspace-root boot paths
213    /// (`from_workspace_root` and the full counterpart) populate it via
214    /// [`crate::pipeline_store::load_pipeline_configs`]. Read-only
215    /// runtime surface — exposed through [`Self::pipeline_configs`]; the
216    /// engine neither runs nor schedules pipelines (the ingest skill and
217    /// future consumers do).
218    pipeline_configs: crate::pipeline_store::PipelineConfigs,
219    /// Runtime snapshot of writable / visible mems. Derived from
220    /// the mount list at construction: writable mounts
221    /// (`MountCapability::Write`) register via `add_writable` with
222    /// the storage's directory path (folder → `path`, git-branch →
223    /// None, archive shouldn't be writable); read-only mounts
224    /// register via `add_writable` (folder/git-branch) or
225    /// `add_read_only` (archive). Used by MCP handlers that need the
226    /// writable/visible roster + per-mem origin (`memstead_health
227    /// include_config: true`, `memstead_overview`'s mem list,
228    /// `memstead_mem_create`'s collision check).
229    ///
230    /// Wrapped in `Arc` so the COW-snapshot discipline — clone the
231    /// snapshot, mutate the clone, swap the `Arc` — keeps writers
232    /// and concurrent readers from contending on the live mount
233    /// list.
234    mem_router: Arc<MemRouterSnapshot>,
235    /// Backend factory — function pointer used by
236    /// [`crate::mem_management::create_mem`] (and future runtime
237    /// mount-add paths) to materialise a [`MemBackend`] from a
238    /// [`Mount`] declaration. Defaults to
239    /// [`crate::workspace_store::instantiate_lean_backend`] so lean
240    /// (folder + archive only) consumers work out of the box. Full
241    /// consumers swap in `memstead_git_branch::storage::instantiate_full_backend`
242    /// via [`Self::set_backend_factory`] after constructing the engine —
243    /// `engine_from_workspace_root` does this once at boot. Function
244    /// pointer (not `Box<dyn Fn>`) because the backend factory is
245    /// stateless, `Send + Sync + Copy`, and one less allocation on the
246    /// hot path matters for the multi-mem pattern this engine is
247    /// designed around.
248    backend_factory: BackendFactory,
249    /// Git-branch ops bundle — function pointers for the per-mount
250    /// operations whose implementations live in `memstead-git-branch`
251    /// (and therefore can't sit on the `MemBackend` trait without
252    /// inverting the crate dependency). Full boot
253    /// (`memstead_git_branch::engine_from_workspace_root`) installs the
254    /// bundle via [`Self::set_git_branch_ops`]; lean consumers leave
255    /// it `None` and `Engine::changes_since` / `Engine::export_mem`
256    /// fall through to the folder/archive-only branches.
257    git_branch_ops: Option<GitBranchOps>,
258    /// Per-mem subscriber registry for [`MemChangedEvent`]s. Held
259    /// behind `Arc<Mutex<_>>` so [`SubscriptionHandle`]s — which own
260    /// the consumer's view of the subscription lifetime — can call
261    /// back into the registry on `Drop` without a self-reference cycle
262    /// to the engine. The emit path (in `record_self_write`) snapshots
263    /// the per-mem callback list under the lock, releases the lock,
264    /// and then invokes the callbacks — so a callback that re-enters
265    /// the engine for a read does not deadlock against the registry.
266    event_subscribers: Arc<std::sync::Mutex<events::SubscriberRegistry>>,
267    /// Reload-before-operation notices accumulated by
268    /// [`Self::reload_if_stale`] when an operation triggered a mem
269    /// reload. Built at reload time — when the backend's current head
270    /// equals the head we reloaded to, *before* any mutation in the
271    /// same operation commits — so the delta describes only the
272    /// sibling's change, never the engine's own follow-on write. The
273    /// response layer drains them via
274    /// [`Self::take_mem_changed_notices`] and attaches the structured
275    /// `mem_changed` notice to the operation's response. Every entity
276    /// op that can reload drains after; an undrained accumulation would
277    /// leak into the next operation's response, so callers that reload
278    /// must take.
279    pending_mem_changed: Vec<crate::ops::MemChangedNotice>,
280}
281
282/// Backend factory function pointer. Both flavours' existing
283/// `instantiate_*_backend` functions match this signature, so the
284/// type alias is what bridges the dependency direction (memstead-base
285/// can't depend on memstead-git-branch) without an extra trait.
286/// Stateless, `Send + Sync + Copy`.
287pub type BackendFactory =
288    fn(&Mount) -> Result<Box<dyn MemBackend>, crate::workspace_store::InstantiateError>;
289
290/// `Engine::changes_since` dispatch for git-branch mounts.
291///
292/// Signature matches `memstead_git_branch::ops::changes::changes_since` after
293/// adapting the `Store` parameter away (the engine performs enrichment
294/// downstream) and the `head_ref` parameter (`refs/heads/<branch>` is
295/// constructed inside the impl from `branch`).
296pub type GitBranchChangesSinceFn = fn(
297    gitdir: &Path,
298    branch: &str,
299    mem: &str,
300    since: &str,
301    rename_similarity: f32,
302) -> Result<crate::ops::BackendChanges, BackendError>;
303
304/// `Engine::export_mem` dispatch for git-branch mounts.
305///
306/// Signature mirrors `memstead_git_branch::ops::export::export_mem_from_branch`.
307pub type GitBranchExportFn = fn(
308    gitdir: &Path,
309    branch: &str,
310    mem: &str,
311    config: &memstead_schema::MemConfig,
312    output_path: &Path,
313    workspace_root: Option<&Path>,
314    workspace_schemas_dir: Option<&Path>,
315    // Engine-sourced authoring-provenance payload bytes (from the mount's
316    // `read_provenance` log) to embed at `.memstead/provenance.json`.
317    // `None` when the mem carried no noted mutations.
318    provenance_bytes: Option<&[u8]>,
319    // Engine-sourced anchors sidecar bytes (from the mount's
320    // `read_anchors_sidecar`) to embed at `.memstead/anchors.json`. `None`
321    // when the mem carried no anchors. The engine reads the branch tip; the
322    // hook only embeds, keeping git tree-walking out of the fn-pointer.
323    anchors_bytes: Option<&[u8]>,
324) -> Result<crate::ops::MemExportResult, BackendError>;
325
326/// `Engine::export_mem_to_bytes` dispatch for git-branch mounts.
327///
328/// Signature mirrors `memstead_git_branch::ops::export::export_mem_from_branch_to_bytes`.
329/// Symmetric to `GitBranchExportFn`: same inputs minus the output path,
330/// returns archive bytes plus metadata instead of writing to disk.
331pub type GitBranchExportToBytesFn = fn(
332    gitdir: &Path,
333    branch: &str,
334    mem: &str,
335    config: &memstead_schema::MemConfig,
336    workspace_root: Option<&Path>,
337    workspace_schemas_dir: Option<&Path>,
338    // Pre-built authoring-provenance payload bytes the engine sourced from
339    // the mount's `read_provenance` log, to embed at
340    // `.memstead/provenance.json`. `None` when the mem carried no noted
341    // mutations. The engine sources it (it holds the backend); the hook
342    // only embeds, keeping git history-walking out of the fn-pointer.
343    provenance_bytes: Option<&[u8]>,
344    // Engine-sourced anchors sidecar bytes (from the mount's
345    // `read_anchors_sidecar`) to embed at `.memstead/anchors.json`. `None`
346    // when the mem carried no anchors. Symmetric with `provenance_bytes`:
347    // the engine reads the branch tip, the hook only embeds.
348    anchors_bytes: Option<&[u8]>,
349) -> Result<crate::ops::MemExportBytes, BackendError>;
350
351/// `Engine::diff` dispatch for git-branch mounts. Walks the two refs
352/// inside the workspace's mem-repo gitdir, produces a per-entity
353/// [`crate::ops::Diff`]. Refs are arbitrary `gix::rev_parse_single`
354/// inputs — branch names, commit SHAs, tag names. Resolves each
355/// independently so cross-branch (cross-mem) diffs work uniformly.
356pub type GitBranchDiffFn = fn(
357    gitdir: &Path,
358    mem: &str,
359    ref_a: &str,
360    ref_b: &str,
361    config: &crate::ops::DiffConfig,
362) -> Result<crate::ops::Diff, BackendError>;
363
364/// `Engine::fetch` dispatch for git-branch mounts.
365pub type GitBranchFetchFn = fn(
366    gitdir: &Path,
367    remote: &str,
368    refspecs: &[String],
369) -> Result<crate::ops::FetchOutcome, BackendError>;
370
371/// Read every `.md` blob at `ref_name` in `gitdir`, returning
372/// `(relative_path, utf8_content)` pairs. Skips `.memstead/` engine-internal
373/// entries and non-blob nodes. Used by the pre-merge schema-validation
374/// pass `Engine::pull` and `Engine::push` run before they advance the
375/// branch pointer / push to the remote.
376pub type GitBranchReadTreeFn =
377    fn(gitdir: &Path, ref_name: &str) -> Result<Vec<(String, String)>, BackendError>;
378
379/// `Engine::pull` dispatch for git-branch mounts.
380pub type GitBranchPullFn =
381    fn(gitdir: &Path, remote: &str, mem: &str) -> Result<crate::ops::PullOutcome, BackendError>;
382
383/// `Engine::push` dispatch for git-branch mounts.
384pub type GitBranchPushFn = fn(
385    gitdir: &Path,
386    remote: &str,
387    mem: &str,
388    force: bool,
389) -> Result<crate::ops::PushOutcome, BackendError>;
390
391/// `Engine::remote_add` dispatch — configures a named remote on the
392/// mem-repo gitdir (upsert: add, or set-url when it already exists).
393pub type GitBranchRemoteAddFn =
394    fn(gitdir: &Path, name: &str, url: &str) -> Result<crate::ops::RemoteAddOutcome, BackendError>;
395
396/// `Engine::branch_reset` dispatch for git-branch mounts. Returns the
397/// outcome on success; surfaces `BackendError::Other` carrying an
398/// in-band marker (`UNKNOWN_REF:<raw>` or
399/// `PUSHED_COMMITS_PROTECTED:<sha,sha,...>`) the engine layer
400/// un-marshals into typed `EngineError`s.
401pub type GitBranchBranchResetFn = fn(
402    gitdir: &Path,
403    branch: &str,
404    target_sha: &str,
405    expected_head: Option<&str>,
406) -> Result<crate::ops::BranchResetOutcome, BackendError>;
407
408/// Residue-prune dispatch for git-branch mounts.
409/// The `create_mem` orchestrator calls this when
410/// `RecoveryAction::ForceOverwrite` is selected against pre-existing
411/// storage residue. Drops `refs/heads/<branch_full_path>` and the
412/// `__MEMSTEAD:mems/<branch_full_path>/config.json` blob in one
413/// ref-edit transaction (the same call the
414/// `MemBackend::delete_artifacts` impl wraps for delete-files
415/// flows). Surfaces as a function pointer so `memstead-engine` can
416/// drive a prune against an unmounted gitdir without depending on
417/// `memstead-git-branch`.
418pub type GitBranchPruneResidueFn =
419    fn(gitdir: &Path, branch_full_path: &str) -> Result<(), BackendError>;
420
421/// `Engine::install_schema` dispatch for the git-branch backend: write a
422/// schema package (`(relative-path, bytes)` pairs) onto the workspace's
423/// unified `__MEMSTEAD:schemas/<name>@<version>/` ref and return the
424/// resulting commit sha. Mirrors
425/// `memstead_git_branch::storage_memstead::write_schema_to_memstead_ref`.
426pub type GitBranchWriteSchemaFn = fn(
427    gitdir: &Path,
428    name: &str,
429    version: &str,
430    files: &[(String, Vec<u8>)],
431) -> Result<String, BackendError>;
432
433/// Bundle of git-branch-specific op dispatchers. Installed on the
434/// engine at full boot. Each field is one ops-method that previously
435/// lived on the `MemBackend` trait; moving them off the trait keeps
436/// the bytes-level primitive surface clean.
437#[derive(Clone, Copy)]
438pub struct GitBranchOps {
439    pub changes_since: GitBranchChangesSinceFn,
440    pub diff: GitBranchDiffFn,
441    pub branch_reset: GitBranchBranchResetFn,
442    pub fetch: GitBranchFetchFn,
443    pub pull: GitBranchPullFn,
444    pub push: GitBranchPushFn,
445    pub remote_add: GitBranchRemoteAddFn,
446    pub read_tree: GitBranchReadTreeFn,
447    pub export: GitBranchExportFn,
448    pub export_to_bytes: GitBranchExportToBytesFn,
449    pub prune_residue: GitBranchPruneResidueFn,
450    pub write_schema: GitBranchWriteSchemaFn,
451}
452
453impl std::fmt::Debug for Engine {
454    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
455        f.debug_struct("Engine")
456            .field(
457                "mems",
458                &self
459                    .mounts
460                    .iter()
461                    .map(|m| m.mount.mem.as_str())
462                    .collect::<Vec<_>>(),
463            )
464            .finish()
465    }
466}
467
468#[cfg(test)]
469mod in_memory_mem;
470
471#[cfg(test)]
472pub(super) mod test_helpers {
473    use std::io::Write as _;
474    use std::path::{Path, PathBuf};
475
476    use memstead_schema::SchemaRef;
477
478    use crate::backend::MemBackend;
479    use crate::storage::FilesystemMemWriter;
480    use crate::vcs::{Actor, ClientId};
481    use crate::workspace::{Mount, MountCapability, MountLifecycle, MountStorage};
482
483    use super::{CreateEntityArgs, CreateEntityOutcome, Engine, RelateEntityArgs};
484
485    use indexmap::IndexMap;
486    use tempfile::TempDir;
487
488    pub(crate) fn pin(name: &str) -> SchemaRef {
489        let version = match name {
490            "default" => semver::Version::new(1, 0, 0),
491            _ => semver::Version::new(0, 1, 0),
492        };
493        SchemaRef::new(name, version)
494    }
495
496    pub(crate) fn folder_mount(mem: &str, path: PathBuf) -> Mount {
497        Mount {
498            mem: mem.to_string(),
499            schema: Some(pin("default")),
500            storage: MountStorage::Folder { path },
501            capability: MountCapability::Write,
502            lifecycle: MountLifecycle::Eager,
503            cross_linkable: true,
504            migration_target: None,
505        }
506    }
507
508    pub(crate) fn in_memory_mount(mem: &str) -> Mount {
509        Mount {
510            mem: mem.to_string(),
511            schema: Some(pin("default")),
512            storage: MountStorage::InMemory,
513            capability: MountCapability::Write,
514            lifecycle: MountLifecycle::Eager,
515            cross_linkable: true,
516            migration_target: None,
517        }
518    }
519
520    pub(crate) fn archive_mount(mem: &str, path: PathBuf) -> Mount {
521        Mount {
522            mem: mem.to_string(),
523            schema: Some(pin("default")),
524            storage: MountStorage::Archive { path },
525            capability: MountCapability::ReadOnly,
526            lifecycle: MountLifecycle::Lazy,
527            cross_linkable: false,
528            migration_target: None,
529        }
530    }
531
532    /// Build a sealed archive at `tmp/<name>.mem` from
533    /// `(relative_path, bytes)` pairs and return the path.
534    pub(crate) fn build_archive(tmp: &Path, name: &str, entries: &[(&str, &[u8])]) -> PathBuf {
535        let path = tmp.join(format!("{name}.mem"));
536        let file = std::fs::File::create(&path).unwrap();
537        let mut writer = zip::ZipWriter::new(file);
538        let opts = zip::write::SimpleFileOptions::default();
539        for (rel, bytes) in entries {
540            writer.start_file(*rel, opts).unwrap();
541            writer.write_all(bytes).unwrap();
542        }
543        writer.finish().unwrap();
544        path
545    }
546
547    /// Write a schema manifest + minimal type bodies under
548    /// `<root>/<name>/`. Each type gets a body with a single
549    /// `body` section and `_default` hierarchy/propagation — enough
550    /// to load and parse markdown that uses that type. Used by tests
551    /// that need a custom schema with shape or vocabulary constraints.
552    pub(crate) fn write_schema_files_with_default_type(
553        root: &Path,
554        name: &str,
555        manifest: &str,
556        types: &[&str],
557    ) {
558        const TYPE_BODY: &str = r#"description: t
559when_to_use: Here
560sections:
561  - key: body
562    heading: Body
563    required: true
564    search_weight: 10.0
565    catch_all: true
566    write_rules: []
567metadata_fields: []
568title_weight: 100.0
569text_fields:
570  - body
571hierarchy_relationship: _default
572propagating_relationships: []
573updatable_fields:
574  - title
575  - body
576health_required_fields:
577  - body
578staleness_threshold_days: 90
579write_rules: []
580"#;
581        let dir = root.join(name);
582        std::fs::create_dir_all(dir.join("types")).unwrap();
583        std::fs::write(dir.join("schema.yaml"), manifest).unwrap();
584        for type_name in types {
585            let body = format!("name: {type_name}\n{TYPE_BODY}");
586            std::fs::write(dir.join("types").join(format!("{type_name}.yaml")), body).unwrap();
587        }
588    }
589
590    pub(crate) fn empty_create_args(mem: &str, title: &str) -> CreateEntityArgs {
591        // The
592        // create path refuses on missing required sections. The
593        // default `spec` type requires `identity` + `purpose`. Seed
594        // both with a single space so the test fixture remains a
595        // valid creation request — every test that uses this helper
596        // as a fixture builder continues to work, and tests that
597        // specifically exercise the refusal supply an explicit
598        // empty-sections payload (see the dedicated refusal tests).
599        let mut sections = IndexMap::new();
600        sections.insert("identity".to_string(), "fixture identity body".to_string());
601        sections.insert("purpose".to_string(), "fixture purpose body".to_string());
602        CreateEntityArgs {
603            anchors: Vec::new(),
604            mem: mem.to_string(),
605            title: title.to_string(),
606            entity_type: "spec".to_string(),
607            sections,
608            metadata: IndexMap::new(),
609            relations: Vec::new(),
610            dry_run: false,
611        }
612    }
613
614    pub(crate) fn cli_actor() -> (Actor, ClientId) {
615        (
616            Actor::Cli,
617            ClientId {
618                name: "claude-code".to_string(),
619                version: "2.1.0".to_string(),
620            },
621        )
622    }
623
624    pub(crate) fn engine_with_seed(tmp: &TempDir, title: &str) -> (Engine, CreateEntityOutcome) {
625        let mem_dir = tmp.path().to_path_buf();
626        let writer = FilesystemMemWriter::new(mem_dir.clone());
627        let mut engine = Engine::from_mounts(vec![(
628            folder_mount("specs", mem_dir),
629            Box::new(writer) as Box<dyn MemBackend>,
630        )])
631        .unwrap();
632        let (actor, client) = cli_actor();
633        let outcome = engine
634            .create_entity(
635                empty_create_args("specs", title),
636                actor,
637                Some(&client),
638                None,
639            )
640            .unwrap();
641        (engine, outcome)
642    }
643    pub(crate) fn build_demo_engine(tmp: &TempDir) -> Engine {
644        let mem_dir = tmp.path().to_path_buf();
645        let writer = FilesystemMemWriter::new(mem_dir.clone());
646        let mut engine = Engine::from_mounts(vec![(
647            folder_mount("specs", mem_dir),
648            Box::new(writer) as Box<dyn MemBackend>,
649        )])
650        .unwrap();
651        let (actor, client) = cli_actor();
652        let source = engine
653            .create_entity(
654                empty_create_args("specs", "Source One"),
655                actor,
656                Some(&client),
657                None,
658            )
659            .unwrap();
660        let target = engine
661            .create_entity(
662                empty_create_args("specs", "Target Two"),
663                actor,
664                Some(&client),
665                None,
666            )
667            .unwrap();
668        engine
669            .create_entity(
670                empty_create_args("specs", "Lonely Three"),
671                actor,
672                Some(&client),
673                None,
674            )
675            .unwrap();
676        engine
677            .relate_entity(
678                RelateEntityArgs {
679                    source: source.id.clone(),
680                    expected_hash: Some(source.content_hash.clone()),
681                    rel_type: "USES".to_string(),
682                    target: target.id.clone(),
683                    remove: false,
684                    description: None,
685                },
686                actor,
687                Some(&client),
688                None,
689            )
690            .unwrap();
691        engine
692    }
693}