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