Skip to main content

memstead_git_branch/ops/
export.rs

1//! Markdown and mem-archive export.
2//!
3//! `export_markdown` regenerates entity files from the store, only writing
4//! files that actually changed (incremental). Compares generated markdown
5//! against the current file on disk byte-for-byte.
6//!
7//! `export_mem` zips a mem directory into a portable `.mem` archive
8//! with deterministic output (sorted entries, fixed mtime).
9
10use std::fs;
11use std::io::{Cursor, Write};
12use std::path::Path;
13#[cfg(test)]
14use std::path::PathBuf;
15
16#[cfg(feature = "git-object-storage")]
17use memstead_base::ops::MemExportBytes;
18use memstead_schema::{
19    ARCHIVE_ANCHORS_PATH, ARCHIVE_CONFIG_PATH, ARCHIVE_SCHEMA_PREFIX, MemConfig, TypeDefinition,
20    collect_schema_source, published_config_from, type_by_name,
21};
22use zip::{CompressionMethod, DateTime, write::SimpleFileOptions};
23
24use super::{ExportResult, MemExportResult};
25use crate::entity::generator::generate_markdown;
26use crate::entity::writer::write_entity;
27#[cfg(feature = "git-object-storage")]
28use crate::storage::git_tree::{BranchReadError, read_branch_blobs};
29use crate::store::Store;
30use crate::validator::canonical::canonical_json;
31
32/// Regenerate all entity markdown files from the in-memory store.
33/// Only writes files that have changed (incremental export).
34pub fn export_markdown(
35    store: &Store,
36    default_schema: &TypeDefinition,
37    mem_dir: &Path,
38    schema_filter: Option<&str>,
39) -> ExportResult {
40    let mut written = 0;
41    let mut unchanged = 0;
42
43    for entity in store.all_entities() {
44        // Skip stubs
45        if entity.stub || entity.file_path.is_empty() {
46            continue;
47        }
48
49        // Type filter
50        if let Some(filter) = schema_filter
51            && entity.entity_type != filter
52        {
53            continue;
54        }
55
56        let resolved = type_by_name(&entity.entity_type);
57        let schema: &TypeDefinition = resolved.as_deref().unwrap_or(default_schema);
58        let generated = generate_markdown(entity, schema);
59
60        // Compare with file on disk
61        let full_path = mem_dir.join(&entity.file_path);
62        let needs_write = match std::fs::read_to_string(&full_path) {
63            Ok(existing) => existing != generated,
64            Err(_) => true, // File doesn't exist — write it
65        };
66
67        if needs_write {
68            let _ = write_entity(entity, mem_dir, schema);
69            written += 1;
70        } else {
71            unchanged += 1;
72        }
73    }
74
75    ExportResult {
76        written,
77        unchanged,
78        skipped_mounts: Vec::new(),
79    }
80}
81
82/// Export a single entity by ID.
83pub fn export_entity(
84    store: &Store,
85    id: &crate::entity::EntityId,
86    default_schema: &TypeDefinition,
87    mem_dir: &Path,
88) -> Result<ExportResult, String> {
89    let entity = store
90        .get(id)
91        .ok_or_else(|| format!("entity not found: {id}"))?;
92
93    if entity.stub {
94        return Err(format!("{id} is a stub — nothing to export"));
95    }
96
97    let resolved = type_by_name(&entity.entity_type);
98    let schema: &TypeDefinition = resolved.as_deref().unwrap_or(default_schema);
99    let generated = generate_markdown(entity, schema);
100
101    let full_path = mem_dir.join(&entity.file_path);
102    let needs_write = match std::fs::read_to_string(&full_path) {
103        Ok(existing) => existing != generated,
104        Err(_) => true,
105    };
106
107    if needs_write {
108        write_entity(entity, mem_dir, schema).map_err(|e| e.to_string())?;
109        Ok(ExportResult {
110            written: 1,
111            unchanged: 0,
112            skipped_mounts: Vec::new(),
113        })
114    } else {
115        Ok(ExportResult {
116            written: 0,
117            unchanged: 1,
118            skipped_mounts: Vec::new(),
119        })
120    }
121}
122
123// ---------------------------------------------------------------------------
124// Mem (.mem) archive export
125// ---------------------------------------------------------------------------
126
127// Stage 1.7-2: MemExportError and the folder-shaped `export_mem`
128// live in `memstead-base::ops::export`. Re-export here so downstream
129// callers that imported via the workspace path keep working. The
130// gix-bound `export_mem_from_branch` (below) stays here.
131pub use memstead_base::ops::export::{MemExportError, export_mem};
132
133#[cfg(feature = "git-object-storage")]
134fn branch_read_into_mem_export(e: BranchReadError) -> MemExportError {
135    MemExportError::BranchRead(e.to_string())
136}
137
138/// Export a mem as a portable `.mem` archive by walking the
139/// `mem-repo-git` branch tree directly — the git-object storage path's
140/// counterpart to [`export_mem`]. No working tree is consulted; all
141/// `.md` content comes from the per-mem branch tip, sorted by path
142/// for deterministic archive bytes.
143///
144/// Wire format matches [`export_mem`]:
145/// `.memstead/config.json` carries the whitelist projection,
146/// `.memstead/schema/` embeds the pinned schema's source files, and the rest of the
147/// archive carries mem-relative `.md` blobs.
148///
149/// `mem_repo_gitdir` is the multi-root repo (`<workspace>/mem-repo/.git/`).
150/// The function reads mem content from `refs/heads/<mem_name>` and
151/// resolves the schema from `__MEMSTEAD:schemas/<name>@<version>/`
152/// first — the tree `memstead schema install` writes on this backend —
153/// falling back to the disk/builtin chain ([`collect_schema_source`]
154/// over `workspace_root` + `workspace_schemas_dir`) for builtins and
155/// pre-ref layouts.
156#[cfg(feature = "git-object-storage")]
157#[allow(clippy::too_many_arguments)]
158pub fn export_mem_from_branch(
159    mem_repo_gitdir: &Path,
160    mem_name: &str,
161    config: &MemConfig,
162    output_path: &Path,
163    workspace_root: Option<&Path>,
164    workspace_schemas_dir: Option<&Path>,
165    provenance_bytes: Option<&[u8]>,
166    anchors_bytes: Option<&[u8]>,
167) -> Result<MemExportResult, MemExportError> {
168    let out = export_mem_from_branch_to_bytes(
169        mem_repo_gitdir,
170        mem_name,
171        config,
172        workspace_root,
173        workspace_schemas_dir,
174        provenance_bytes,
175        anchors_bytes,
176    )?;
177
178    if let Some(parent) = output_path.parent()
179        && !parent.as_os_str().is_empty()
180    {
181        fs::create_dir_all(parent)?;
182    }
183    fs::write(output_path, &out.bytes)?;
184
185    let size_bytes = fs::metadata(output_path)?.len();
186    Ok(MemExportResult {
187        archive_path: output_path.display().to_string(),
188        name: out.name,
189        version: out.version,
190        entity_count: out.entity_count,
191        size_bytes,
192        dangling_cross_mem_edges: out.dangling_cross_mem_edges,
193    })
194}
195
196/// Resolve the pinned schema's source files from the workspace's
197/// `__MEMSTEAD:schemas/<name>@<version>/` tree — the location
198/// `memstead schema install` writes on the git-branch backend. Returns
199/// `None` when the branch, the package subtree, or its `schema.yaml`
200/// is absent, so the caller falls through to the disk/builtin chain.
201#[cfg(feature = "git-object-storage")]
202fn schema_files_from_memstead_ref(
203    mem_repo_gitdir: &Path,
204    schema_ref: &memstead_schema::SchemaRef,
205) -> Option<Vec<memstead_schema::SchemaSourceFile>> {
206    let blobs = read_branch_blobs(mem_repo_gitdir, "refs/heads/__MEMSTEAD").ok()?;
207    let prefix = format!("schemas/{}@{}/", schema_ref.name, schema_ref.version);
208    // The sealed schema an archive carries is the LANGUAGE, not the
209    // install-time package: only the manifest, the sealed format
210    // marker, and the type definitions ride. Everything else the
211    // install staged — `mem-template.json`, `README.md`, the
212    // workspace-local install-provenance stamp — is setup scaffolding
213    // the strict archive whitelist rightly refuses, so collecting it
214    // here would make the export unreadable. Allowlist, not denylist:
215    // future scaffolding additions cannot re-break the export.
216    let is_language_member = |rel: &str| {
217        rel == "schema.yaml"
218            || rel == memstead_schema::loader::SCHEMA_FORMAT_MARKER_FILE
219            || (rel.starts_with("types/")
220                && rel.ends_with(".yaml")
221                && !rel["types/".len()..].contains('/'))
222    };
223    let mut files: Vec<memstead_schema::SchemaSourceFile> = blobs
224        .into_iter()
225        .filter_map(|b| {
226            b.path
227                .strip_prefix(&prefix)
228                .map(|rel| memstead_schema::SchemaSourceFile {
229                    archive_path: rel.to_string(),
230                    bytes: b.bytes,
231                })
232        })
233        .filter(|f| is_language_member(&f.archive_path))
234        .collect();
235    if !files.iter().any(|f| f.archive_path == "schema.yaml") {
236        return None;
237    }
238    files.sort_by(|a, b| a.archive_path.cmp(&b.archive_path));
239    Some(files)
240}
241
242/// Byte-shaped counterpart to [`export_mem_from_branch`]. Same wire
243/// format, same determinism contract, no on-disk artifact — the bytes
244/// are the output. The engine's `export_mem_to_bytes` dispatches to
245/// this for git-branch mounts via the [`memstead_base::GitBranchOps`]
246/// bundle so byte-snapshot consumers (the bridge, future WASM
247/// replicas) treat folder and git-branch backends symmetrically.
248#[cfg(feature = "git-object-storage")]
249#[allow(clippy::too_many_arguments)]
250pub fn export_mem_from_branch_to_bytes(
251    mem_repo_gitdir: &Path,
252    mem_name: &str,
253    config: &MemConfig,
254    workspace_root: Option<&Path>,
255    workspace_schemas_dir: Option<&Path>,
256    provenance_bytes: Option<&[u8]>,
257    anchors_bytes: Option<&[u8]>,
258) -> Result<MemExportBytes, MemExportError> {
259    let published = published_config_from(config, mem_name)?;
260    let config_bytes = canonical_json(&published)
261        .map_err(|e| MemExportError::Canonical(e.to_string()))?
262        .into_bytes();
263
264    // Engine-canonical location first: `memstead schema install` on the
265    // git-branch backend writes the package onto the `__MEMSTEAD` branch,
266    // not the workspace disk. Builtins and pre-ref disk layouts fall
267    // through to the shared chain.
268    let schema_files = match schema_files_from_memstead_ref(mem_repo_gitdir, &published.schema) {
269        Some(files) => files,
270        None => collect_schema_source(workspace_root, workspace_schemas_dir, &published.schema)?,
271    };
272
273    let ref_name = match workspace_root {
274        Some(root) => crate::mem_repo_config::branch_ref_for_mem(root, mem_name),
275        None => format!("refs/heads/{mem_name}"),
276    };
277    let blobs = match read_branch_blobs(mem_repo_gitdir, &ref_name) {
278        Ok(b) => b,
279        Err(BranchReadError::BranchMissing { .. }) => Vec::new(),
280        Err(e) => return Err(branch_read_into_mem_export(e)),
281    };
282    let md_entries: Vec<(String, Vec<u8>)> = blobs
283        .into_iter()
284        .filter(|b| b.path.ends_with(".md"))
285        .map(|b| (b.path, b.bytes))
286        .collect();
287    let entity_count = md_entries.len();
288
289    let mut all_entries: Vec<(String, Vec<u8>)> =
290        Vec::with_capacity(2 + schema_files.len() + md_entries.len());
291    all_entries.push((ARCHIVE_CONFIG_PATH.to_string(), config_bytes));
292    // Embed the engine-sourced authoring-provenance payload (commit-trailer
293    // rationale, keyed by mem-relative path). The engine walks the log;
294    // this assembler only places the member.
295    if let Some(prov) = provenance_bytes {
296        all_entries.push((
297            memstead_schema::ARCHIVE_PROVENANCE_PATH.to_string(),
298            prov.to_vec(),
299        ));
300    }
301    // Embed the engine-owned anchors sidecar verbatim when present — the
302    // engine reads it from the branch tip and hands it here; this assembler
303    // only places the recognised member so a git-branch `.mem` carries
304    // anchors identically to the folder / in-memory exports.
305    if let Some(anchors) = anchors_bytes {
306        all_entries.push((ARCHIVE_ANCHORS_PATH.to_string(), anchors.to_vec()));
307    }
308    for sf in &schema_files {
309        all_entries.push((
310            format!("{ARCHIVE_SCHEMA_PREFIX}{}", sf.archive_path),
311            sf.bytes.clone(),
312        ));
313    }
314    all_entries.extend(md_entries);
315    all_entries.sort_by(|a, b| a.0.cmp(&b.0));
316
317    let mut buf: Vec<u8> = Vec::new();
318    {
319        let cursor = Cursor::new(&mut buf);
320        let mut zip = zip::ZipWriter::new(cursor);
321        let options = SimpleFileOptions::default()
322            .compression_method(CompressionMethod::Deflated)
323            .last_modified_time(fixed_mtime())
324            .unix_permissions(0o644);
325
326        for (archive_path, bytes) in &all_entries {
327            zip.start_file(archive_path, options)?;
328            zip.write_all(bytes)?;
329        }
330        zip.finish()?;
331    }
332
333    // Surface every cross-mem
334    // edge whose target won't travel inside this single-mem archive —
335    // exactly what `install` will refuse on. Detected via the shared
336    // predicate so export and install agree. Cross-mem-only (tolerant
337    // parse): the git-branch export keeps its lenient-on-drift posture;
338    // this adds the missing cross-mem signal so the failure no longer
339    // surfaces silently at install time on the share boundary.
340    let dangling_cross_mem_edges =
341        memstead_base::validator::collect_dangling_cross_mem_edges_from_bytes(&buf)
342            .map_err(|e| MemExportError::ArchiveValidationFailed(e.to_string()))?;
343
344    Ok(MemExportBytes {
345        bytes: buf,
346        name: published.name.clone(),
347        version: published.version.to_string(),
348        entity_count,
349        dangling_cross_mem_edges,
350    })
351}
352
353/// Zip's minimum representable timestamp — 1980-01-01 00:00:00. Used as a
354/// fixed mtime so archives are byte-stable across exports.
355fn fixed_mtime() -> DateTime {
356    DateTime::default()
357}
358
359#[cfg(test)]
360mod tests {
361    use super::*;
362    use crate::entity::{Entity, EntityId, MetadataValue};
363    use indexmap::IndexMap;
364    use memstead_schema::type_by_name;
365    use tempfile::TempDir;
366
367    fn make_entity(name: &str) -> Entity {
368        let mut metadata = IndexMap::new();
369        metadata.insert("level".into(), MetadataValue::String("M0".into()));
370        metadata.insert(
371            "created_date".into(),
372            MetadataValue::String("2026-01-15".into()),
373        );
374        metadata.insert(
375            "last_modified".into(),
376            MetadataValue::String("2026-04-12".into()),
377        );
378        metadata.insert("type".into(), MetadataValue::String("spec".into()));
379
380        let mut sections = IndexMap::new();
381        sections.insert("identity".into(), "Test.".into());
382        sections.insert("purpose".into(), "Test.".into());
383
384        Entity {
385            id: EntityId::new("specs", name),
386            title: name.into(),
387            entity_type: "spec".into(),
388            mem: "specs".into(),
389            file_path: format!("{name}.md"),
390            metadata,
391            sections,
392            relationships: Vec::new(),
393            content_hash: String::new(),
394            stub: false,
395            stub_kind: None,
396            heading_spans: std::collections::HashMap::new(),
397            raw_section_headings: Vec::new(),
398        }
399    }
400
401    fn make_memo_entity(name: &str) -> Entity {
402        let mut metadata = IndexMap::new();
403        metadata.insert("status".into(), MetadataValue::String("active".into()));
404        metadata.insert(
405            "created_date".into(),
406            MetadataValue::String("2026-01-15".into()),
407        );
408        metadata.insert(
409            "last_modified".into(),
410            MetadataValue::String("2026-04-12".into()),
411        );
412        metadata.insert("tags".into(), MetadataValue::String("decision".into()));
413        metadata.insert("type".into(), MetadataValue::String("memo".into()));
414
415        let mut sections = IndexMap::new();
416        sections.insert("claim".into(), "Sled is the choice.".into());
417        sections.insert("context".into(), "Evaluated three stores.".into());
418
419        Entity {
420            id: EntityId::new("memos", name),
421            title: name.into(),
422            entity_type: "memo".into(),
423            mem: "memos".into(),
424            file_path: format!("{name}.md"),
425            metadata,
426            sections,
427            relationships: Vec::new(),
428            content_hash: String::new(),
429            stub: false,
430            stub_kind: None,
431            heading_spans: std::collections::HashMap::new(),
432            raw_section_headings: Vec::new(),
433        }
434    }
435
436    fn make_assertion_entity(name: &str) -> Entity {
437        let mut metadata = IndexMap::new();
438        metadata.insert("confidence".into(), MetadataValue::String("medium".into()));
439        metadata.insert(
440            "verification_status".into(),
441            MetadataValue::String("unverified".into()),
442        );
443        metadata.insert(
444            "created_date".into(),
445            MetadataValue::String("2026-01-15".into()),
446        );
447        metadata.insert(
448            "last_modified".into(),
449            MetadataValue::String("2026-04-12".into()),
450        );
451        metadata.insert("type".into(), MetadataValue::String("assertion".into()));
452
453        let mut sections = IndexMap::new();
454        sections.insert("claim".into(), "Sled outperforms rocksdb.".into());
455        sections.insert("evidence".into(), "Bench results attached.".into());
456
457        Entity {
458            id: EntityId::new("assertions", name),
459            title: name.into(),
460            entity_type: "assertion".into(),
461            mem: "assertions".into(),
462            file_path: format!("{name}.md"),
463            metadata,
464            sections,
465            relationships: Vec::new(),
466            content_hash: String::new(),
467            stub: false,
468            stub_kind: None,
469            heading_spans: std::collections::HashMap::new(),
470            raw_section_headings: Vec::new(),
471        }
472    }
473
474    #[test]
475    fn export_mixed_schemas_uses_per_schema_headings() {
476        let dir = TempDir::new().unwrap();
477        let mut store = Store::new();
478        let memo = make_memo_entity("memo-entity");
479        let assertion = make_assertion_entity("assertion-entity");
480        store.upsert(memo.id.clone(), memo);
481        store.upsert(assertion.id.clone(), assertion);
482
483        // default_schema is only the fallback for unknown schema names; each
484        // entity's own schema should still win.
485        let default_schema = &type_by_name("spec").unwrap();
486        let result = export_markdown(&store, default_schema, dir.path(), None);
487        assert_eq!(result.written, 2);
488
489        let memo_md = std::fs::read_to_string(dir.path().join("memo-entity.md")).unwrap();
490        assert!(memo_md.contains("## Claim"));
491        assert!(memo_md.contains("## Context"));
492        assert!(memo_md.contains("type: memo"));
493        assert!(!memo_md.contains("## Identity"));
494        assert!(!memo_md.contains("## Purpose"));
495
496        let assertion_md = std::fs::read_to_string(dir.path().join("assertion-entity.md")).unwrap();
497        assert!(assertion_md.contains("## Claim"));
498        assert!(assertion_md.contains("## Evidence"));
499        assert!(assertion_md.contains("type: assertion"));
500        assert!(!assertion_md.contains("## Identity"));
501        assert!(!assertion_md.contains("## Purpose"));
502    }
503
504    #[test]
505    fn export_writes_new_files() {
506        let dir = TempDir::new().unwrap();
507        let mut store = Store::new();
508        let e = make_entity("export-test");
509        store.upsert(e.id.clone(), e);
510
511        let schema = &type_by_name("spec").unwrap();
512        let result = export_markdown(&store, schema, dir.path(), None);
513        assert_eq!(result.written, 1);
514        assert_eq!(result.unchanged, 0);
515        assert!(dir.path().join("export-test.md").exists());
516    }
517
518    #[test]
519    fn export_incremental_skips_unchanged() {
520        let dir = TempDir::new().unwrap();
521        let mut store = Store::new();
522        let e = make_entity("incremental");
523        store.upsert(e.id.clone(), e);
524
525        let schema = &type_by_name("spec").unwrap();
526
527        // First export: writes
528        let r1 = export_markdown(&store, schema, dir.path(), None);
529        assert_eq!(r1.written, 1);
530
531        // Second export: unchanged
532        let r2 = export_markdown(&store, schema, dir.path(), None);
533        assert_eq!(r2.written, 0);
534        assert_eq!(r2.unchanged, 1);
535    }
536
537    // ---- mem archive export ---------------------------------------------
538
539    fn write_mem_fixture(dir: &Path) {
540        std::fs::create_dir_all(dir.join(".memstead")).unwrap();
541        // Author-side config with every flavor of author-only field the
542        // whitelist projection has to strip. If the export pipeline ever
543        // leaks one of these into the archive, the round-trip assertion
544        // below catches it.
545        std::fs::write(
546            dir.join(".memstead/config.json"),
547            r#"{"version":"1.2.0","description":"AWS patterns","schema":"default@1.0.0","writeGuidance":{"context":"secret"},"mediums":{},"projections":{},"readMems":{}}"#,
548        ).unwrap();
549        std::fs::write(
550            dir.join("api-gateway.md"),
551            "---\ntype: spec\ncreated_date: 2026-01-15\nlast_modified: 2026-04-12\nlevel: M0\n---\n# API Gateway\n\n## Identity\n\nGateway.\n\n## Purpose\n\nServe API traffic.\n",
552        ).unwrap();
553        std::fs::create_dir_all(dir.join("well-architected")).unwrap();
554        std::fs::write(
555            dir.join("well-architected/reliability.md"),
556            "---\ntype: spec\ncreated_date: 2026-01-15\nlast_modified: 2026-04-12\nlevel: M0\n---\n# Reliability\n\n## Identity\n\nReliability.\n\n## Purpose\n\nKeep the system available.\n",
557        ).unwrap();
558        // A cache file that MUST NOT end up in the archive.
559        std::fs::write(dir.join(".memstead/communities.json"), "{}").unwrap();
560    }
561
562    #[test]
563    fn export_mem_writes_whitelisted_config_and_markdown() {
564        let tmp = TempDir::new().unwrap();
565        let mem = tmp.path().join("aws-patterns");
566        write_mem_fixture(&mem);
567
568        let config = memstead_schema::load_and_validate(&mem).unwrap();
569        let out = tmp.path().join("aws-patterns.mem");
570
571        // `mem` acts as the source root for schema resolution. It has no
572        // `.memstead/schemas/` dir of its own, so the default pin falls through
573        // to the embedded builtin.
574        let result = export_mem(&mem, &config, &out, None, None).unwrap();
575        assert_eq!(result.name, "aws-patterns");
576        assert_eq!(result.version, "1.2.0");
577        assert_eq!(result.entity_count, 2);
578
579        let file = std::fs::File::open(&out).unwrap();
580        let mut archive = zip::ZipArchive::new(file).unwrap();
581
582        let mut names: Vec<String> = (0..archive.len())
583            .map(|i| archive.by_index(i).unwrap().name().to_string())
584            .collect();
585        names.sort();
586
587        // Config + all markdown entries must be present; `schema/` tree
588        // varies with builtin type count, so we assert inclusion of the
589        // non-schema paths and a non-empty schema subtree separately.
590        for required in [
591            ".memstead/config.json",
592            "api-gateway.md",
593            "well-architected/reliability.md",
594            ".memstead/schema/schema.yaml",
595        ] {
596            assert!(
597                names.iter().any(|n| n == required),
598                "archive missing expected entry {required:?}; got {names:?}"
599            );
600        }
601        let type_entries: Vec<&String> = names
602            .iter()
603            .filter(|n| n.starts_with(".memstead/schema/types/"))
604            .collect();
605        assert!(
606            !type_entries.is_empty(),
607            "archive must embed at least one type yaml under .memstead/schema/types/"
608        );
609
610        use std::io::Read as _;
611        let mut config_bytes = Vec::new();
612        archive
613            .by_name(".memstead/config.json")
614            .unwrap()
615            .read_to_end(&mut config_bytes)
616            .unwrap();
617        let written: serde_json::Value = serde_json::from_slice(&config_bytes).unwrap();
618        assert_eq!(written["format"], memstead_schema::PUBLISHED_MEM_FORMAT);
619        assert_eq!(written["name"], "aws-patterns");
620        assert_eq!(written["version"], "1.2.0");
621        assert_eq!(written["description"], "AWS patterns");
622        assert_eq!(written["schema"], "default@1.0.0");
623
624        // Every author-only field the fixture declares must have been
625        // stripped. A single survivor reintroduces the leak the
626        // whitelist was designed to prevent.
627        for forbidden in [
628            "writeGuidance",
629            "mediums",
630            "projections",
631            "rules",
632            "publish",
633            "readMems",
634            "vcs",
635            "language",
636            "community",
637            "defaultSchema",
638        ] {
639            assert!(
640                written.get(forbidden).is_none(),
641                "author-only field {forbidden:?} leaked into archive config"
642            );
643        }
644    }
645
646    #[test]
647    fn export_mem_is_deterministic() {
648        let tmp = TempDir::new().unwrap();
649        let mem = tmp.path().join("aws-patterns");
650        write_mem_fixture(&mem);
651
652        let config = memstead_schema::load_and_validate(&mem).unwrap();
653        let out1 = tmp.path().join("a.mem");
654        let out2 = tmp.path().join("b.mem");
655
656        export_mem(&mem, &config, &out1, None, None).unwrap();
657        std::thread::sleep(std::time::Duration::from_millis(10));
658        export_mem(&mem, &config, &out2, None, None).unwrap();
659
660        let a = std::fs::read(&out1).unwrap();
661        let b = std::fs::read(&out2).unwrap();
662        assert_eq!(a, b, "mem archive exports must be byte-stable");
663    }
664
665    #[test]
666    fn export_mem_errors_when_version_missing() {
667        let tmp = TempDir::new().unwrap();
668        let mem = tmp.path().join("no-version");
669        std::fs::create_dir_all(mem.join(".memstead")).unwrap();
670        std::fs::write(
671            mem.join(".memstead/config.json"),
672            r#"{"schema":"default@1.0.0","mediums":{},"projections":{}}"#,
673        )
674        .unwrap();
675        std::fs::write(
676            mem.join("a.md"),
677            "---\ntype: spec\ncreated_date: 2026-01-15\nlast_modified: 2026-04-12\nlevel: M0\n---\n# A\n\n## Identity\n\nA.\n",
678        ).unwrap();
679
680        let config = memstead_schema::load_and_validate(&mem).unwrap();
681        let out = tmp.path().join("out.mem");
682        let err = export_mem(&mem, &config, &out, None, None).unwrap_err();
683        assert!(matches!(
684            err,
685            MemExportError::Convert(memstead_schema::PublishConversionError::MissingVersion)
686        ));
687        // The whitelist projection fails before any archive bytes are
688        // written, so the output path must stay untouched.
689        assert!(!out.exists(), "no archive should be written on error");
690    }
691
692    #[test]
693    fn export_with_schema_filter() {
694        let dir = TempDir::new().unwrap();
695        let mut store = Store::new();
696        let e1 = make_entity("spec-entity");
697        let mut e2 = make_entity("memo-entity");
698        e2.entity_type = "memo".into();
699        store.upsert(e1.id.clone(), e1);
700        store.upsert(e2.id.clone(), e2);
701
702        let schema = &type_by_name("spec").unwrap();
703        let result = export_markdown(&store, schema, dir.path(), Some("spec"));
704        assert_eq!(result.written, 1); // Only spec entity
705    }
706
707    // ---- mem archive export from git-object branch ----------------------
708
709    #[cfg(feature = "git-object-storage")]
710    mod git_object_export {
711        use super::*;
712        use crate::storage::MemWriter;
713        use crate::storage::git_tree::GitTreeMemWriter;
714        use crate::vcs::CommitContext;
715
716        /// Build a fresh `mem-repo-git`-style bare repo and a side-by-side
717        /// mem config dir so [`export_mem_from_branch`] has both inputs
718        /// it needs: the gitdir + ref to walk, plus a disk-resident
719        /// `<mem>/.memstead/config.json` for the metadata projection.
720        fn seed_mem_branch(
721            workspace: &Path,
722            mem_name: &str,
723            entries: &[(&str, &str)],
724        ) -> (PathBuf, PathBuf) {
725            let gitdir = workspace.join("mem-repo").join(".git");
726            std::fs::create_dir_all(&gitdir).unwrap();
727            gix::init_bare(&gitdir).unwrap();
728
729            // Per-mem on-disk config dir mirrors the cutover layout —
730            // schemas resolve through workspace paths regardless of the
731            // adapter, and the engine still loads MemConfig from disk.
732            let mem_dir = workspace.join(mem_name);
733            std::fs::create_dir_all(mem_dir.join(".memstead")).unwrap();
734            std::fs::write(
735                mem_dir.join(".memstead/config.json"),
736                r#"{"version":"1.0.0","description":"fixture","schema":"default@1.0.0"}"#,
737            )
738            .unwrap();
739
740            // Commit each entry to `refs/heads/<mem_name>` via the
741            // production write path so the test exercises the same tree
742            // shape `memstead-cli`'s mutations would produce.
743            let writer = GitTreeMemWriter::new(gitdir.clone(), format!("refs/heads/{mem_name}"));
744            for (rel, content) in entries {
745                writer
746                    .write_entity(Path::new(rel), content.as_bytes())
747                    .unwrap();
748            }
749            writer.commit("seed", &CommitContext::internal()).unwrap();
750            (gitdir, mem_dir)
751        }
752
753        #[test]
754        fn publish_from_branch_produces_correct_tarball() {
755            let tmp = TempDir::new().unwrap();
756            let (gitdir, mem_dir) = seed_mem_branch(
757                tmp.path(),
758                "fixture",
759                &[
760                    (
761                        "alpha.md",
762                        "---\ntype: spec\ncreated_date: 2026-01-01\nlast_modified: 2026-01-01\nlevel: M0\n---\n# Alpha\n\n## Identity\n\nA.\n",
763                    ),
764                    (
765                        "nested/beta.md",
766                        "---\ntype: spec\ncreated_date: 2026-01-01\nlast_modified: 2026-01-01\nlevel: M0\n---\n# Beta\n\n## Identity\n\nB.\n",
767                    ),
768                ],
769            );
770
771            let config = memstead_schema::load_and_validate(&mem_dir).unwrap();
772            let out = tmp.path().join("fixture.mem");
773            let result =
774                export_mem_from_branch(&gitdir, "fixture", &config, &out, None, None, None, None)
775                    .unwrap();
776
777            assert_eq!(result.name, "fixture");
778            assert_eq!(result.version, "1.0.0");
779            assert_eq!(result.entity_count, 2, "two `.md` blobs were committed");
780
781            let file = std::fs::File::open(&out).unwrap();
782            let mut archive = zip::ZipArchive::new(file).unwrap();
783            let mut names: Vec<String> = (0..archive.len())
784                .map(|i| archive.by_index(i).unwrap().name().to_string())
785                .collect();
786            names.sort();
787            for required in [".memstead/config.json", "alpha.md", "nested/beta.md"] {
788                assert!(
789                    names.iter().any(|n| n == required),
790                    "archive missing entry {required:?}; got {names:?}"
791                );
792            }
793
794            // Branch-tree contents must round-trip byte-for-byte through
795            // the archive — no normalisation happens between blob and zip.
796            use std::io::Read as _;
797            let mut alpha = Vec::new();
798            archive
799                .by_name("alpha.md")
800                .unwrap()
801                .read_to_end(&mut alpha)
802                .unwrap();
803            assert!(String::from_utf8_lossy(&alpha).contains("# Alpha"));
804        }
805
806        #[test]
807        fn publish_includes_schema_under_underscore_schema_prefix() {
808            // The session plan's `_schema/` reference contradicts its own
809            // "tarball wire format unchanged" rule — the validator and
810            // cache reader both require `.memstead/schema/`. Test name keeps
811            // the plan's identifier; assertion targets the wire-format
812            // rule.
813            let tmp = TempDir::new().unwrap();
814            let (gitdir, mem_dir) = seed_mem_branch(
815                tmp.path(),
816                "fixture",
817                &[(
818                    "a.md",
819                    "---\ntype: spec\ncreated_date: 2026-01-01\nlast_modified: 2026-01-01\nlevel: M0\n---\n# A\n\n## Identity\n\nA.\n",
820                )],
821            );
822
823            let config = memstead_schema::load_and_validate(&mem_dir).unwrap();
824            let out = tmp.path().join("fixture.mem");
825            export_mem_from_branch(&gitdir, "fixture", &config, &out, None, None, None, None)
826                .unwrap();
827
828            let file = std::fs::File::open(&out).unwrap();
829            let mut archive = zip::ZipArchive::new(file).unwrap();
830            let names: Vec<String> = (0..archive.len())
831                .map(|i| archive.by_index(i).unwrap().name().to_string())
832                .collect();
833            assert!(
834                names.iter().any(|n| n == ".memstead/schema/schema.yaml"),
835                "schema manifest must embed under `.memstead/schema/`; got {names:?}"
836            );
837            assert!(
838                names
839                    .iter()
840                    .any(|n: &String| n.starts_with(".memstead/schema/types/")
841                        && n.ends_with(".yaml")),
842                "at least one type yaml must embed under `.memstead/schema/types/`; got {names:?}"
843            );
844        }
845
846        #[test]
847        fn byte_export_matches_path_export_byte_for_byte() {
848            // Same input through the path-based and byte-based exports
849            // must produce identical bytes — the bytes variant is the
850            // primitive and the path variant is now a thin write
851            // wrapper. Guards against future drift between the two.
852            let tmp = TempDir::new().unwrap();
853            let (gitdir, mem_dir) = seed_mem_branch(
854                tmp.path(),
855                "fixture",
856                &[(
857                    "a.md",
858                    "---\ntype: spec\ncreated_date: 2026-01-01\nlast_modified: 2026-01-01\nlevel: M0\n---\n# A\n\n## Identity\n\nA.\n",
859                )],
860            );
861            let config = memstead_schema::load_and_validate(&mem_dir).unwrap();
862            let out = tmp.path().join("fixture.mem");
863            export_mem_from_branch(&gitdir, "fixture", &config, &out, None, None, None, None)
864                .unwrap();
865            let path_bytes = std::fs::read(&out).unwrap();
866            let byte_bytes = export_mem_from_branch_to_bytes(
867                &gitdir, "fixture", &config, None, None, None, None,
868            )
869            .unwrap()
870            .bytes;
871            assert_eq!(path_bytes, byte_bytes);
872        }
873
874        #[test]
875        fn byte_export_validates_and_hydrates_via_engine() {
876            // The bridge consumer's contract: bytes out of
877            // `export_mem_to_bytes` validate against `extract_entries`
878            // standalone and hydrate into a new engine with the same
879            // read surface for the exported mem.
880            let tmp = TempDir::new().unwrap();
881            let (gitdir, mem_dir) = seed_mem_branch(
882                tmp.path(),
883                "fixture",
884                &[(
885                    "alpha.md",
886                    "---\ntype: spec\ncreated_date: 2026-01-01\nlast_modified: 2026-01-01\nlevel: M0\n---\n# Alpha\n\n## Identity\n\nA round-trip seed.\n",
887                )],
888            );
889            let config = memstead_schema::load_and_validate(&mem_dir).unwrap();
890            let bytes = export_mem_from_branch_to_bytes(
891                &gitdir, "fixture", &config, None, None, None, None,
892            )
893            .unwrap()
894            .bytes;
895
896            // Validator accepts the bytes standalone.
897            let entries = memstead_base::validator::archive::extract_entries(
898                &bytes,
899                &memstead_base::validator::ValidatorLimits::DEFAULT,
900            )
901            .unwrap();
902            assert_eq!(entries.markdown_files.len(), 1);
903
904            // Engine hydrate produces a working read surface.
905            let hydrated = memstead_base::Engine::from_archive_bytes(bytes).unwrap();
906            let entity = hydrated
907                .get_entity(&memstead_base::EntityId::new("fixture", "alpha"))
908                .expect("alpha must round-trip");
909            assert_eq!(entity.title, "Alpha");
910        }
911
912        #[test]
913        fn export_from_branch_embeds_supplied_anchors_member() {
914            // Export leg (criterion 5, git-branch producer): the engine sources
915            // the anchors sidecar from the branch tip and hands it here; the
916            // assembler places the recognised `.memstead/anchors.json` member.
917            let tmp = TempDir::new().unwrap();
918            let (gitdir, mem_dir) = seed_mem_branch(
919                tmp.path(),
920                "fixture",
921                &[(
922                    "a.md",
923                    "---\ntype: spec\ncreated_date: 2026-01-01\nlast_modified: 2026-01-01\nlevel: M0\n---\n# A\n\n## Identity\n\nA.\n",
924                )],
925            );
926            let config = memstead_schema::load_and_validate(&mem_dir).unwrap();
927            let sidecar = br#"{"version":1,"entities":{"fixture--a":[{"artifact":"a.rs","grain":"file","class":"anchored","hash_stability":"stable","hash":"h1"}]}}"#;
928            let bytes = export_mem_from_branch_to_bytes(
929                &gitdir,
930                "fixture",
931                &config,
932                None,
933                None,
934                None,
935                Some(sidecar),
936            )
937            .unwrap()
938            .bytes;
939            let entries = memstead_base::validator::archive::extract_entries(
940                &bytes,
941                &memstead_base::validator::ValidatorLimits::DEFAULT,
942            )
943            .unwrap();
944            assert_eq!(
945                entries.anchors_bytes.as_deref(),
946                Some(&sidecar[..]),
947                "git-branch export must embed the supplied anchors member"
948            );
949            // A None sidecar embeds no member (byte-identical to pre-anchor).
950            let without = export_mem_from_branch_to_bytes(
951                &gitdir, "fixture", &config, None, None, None, None,
952            )
953            .unwrap()
954            .bytes;
955            let entries_without = memstead_base::validator::archive::extract_entries(
956                &without,
957                &memstead_base::validator::ValidatorLimits::DEFAULT,
958            )
959            .unwrap();
960            assert!(entries_without.anchors_bytes.is_none());
961        }
962
963        #[test]
964        fn publish_re_runs_yield_byte_identical_tarballs() {
965            let tmp = TempDir::new().unwrap();
966            let (gitdir, mem_dir) = seed_mem_branch(
967                tmp.path(),
968                "fixture",
969                &[(
970                    "a.md",
971                    "---\ntype: spec\ncreated_date: 2026-01-01\nlast_modified: 2026-01-01\nlevel: M0\n---\n# A\n\n## Identity\n\nA.\n",
972                )],
973            );
974
975            let config = memstead_schema::load_and_validate(&mem_dir).unwrap();
976            let out1 = tmp.path().join("a.mem");
977            let out2 = tmp.path().join("b.mem");
978            export_mem_from_branch(&gitdir, "fixture", &config, &out1, None, None, None, None)
979                .unwrap();
980            // Sleep a few ms to defeat any wallclock-based determinism leak.
981            std::thread::sleep(std::time::Duration::from_millis(10));
982            export_mem_from_branch(&gitdir, "fixture", &config, &out2, None, None, None, None)
983                .unwrap();
984            let a = std::fs::read(&out1).unwrap();
985            let b = std::fs::read(&out2).unwrap();
986            assert_eq!(
987                a, b,
988                "branch-walk archive exports must be byte-stable across re-runs"
989            );
990        }
991    }
992}