1use 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
32pub 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 if entity.stub || entity.file_path.is_empty() {
46 continue;
47 }
48
49 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 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, };
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
82pub 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
123pub 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#[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#[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#[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 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 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 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
325fn 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 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 let r1 = export_markdown(&store, schema, dir.path(), None);
498 assert_eq!(r1.written, 1);
499
500 let r2 = export_markdown(&store, schema, dir.path(), None);
502 assert_eq!(r2.written, 0);
503 assert_eq!(r2.unchanged, 1);
504 }
505
506 fn write_mem_fixture(dir: &Path) {
509 std::fs::create_dir_all(dir.join(".memstead")).unwrap();
510 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 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 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 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 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 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); }
675
676 #[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 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 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 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 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 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 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 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 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 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 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}