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