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