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_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
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")]
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#[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 is_language_member = |rel: &str| {
217 rel == "schema.yaml"
218 || rel == memstead_schema::loader::SCHEMA_FORMAT_MARKER_FILE
219 || (rel.starts_with("types/")
220 && rel.ends_with(".yaml")
221 && !rel["types/".len()..].contains('/'))
222 };
223 let mut files: Vec<memstead_schema::SchemaSourceFile> = blobs
224 .into_iter()
225 .filter_map(|b| {
226 b.path
227 .strip_prefix(&prefix)
228 .map(|rel| memstead_schema::SchemaSourceFile {
229 archive_path: rel.to_string(),
230 bytes: b.bytes,
231 })
232 })
233 .filter(|f| is_language_member(&f.archive_path))
234 .collect();
235 if !files.iter().any(|f| f.archive_path == "schema.yaml") {
236 return None;
237 }
238 files.sort_by(|a, b| a.archive_path.cmp(&b.archive_path));
239 Some(files)
240}
241
242#[cfg(feature = "git-object-storage")]
249#[allow(clippy::too_many_arguments)]
250pub fn export_mem_from_branch_to_bytes(
251 mem_repo_gitdir: &Path,
252 mem_name: &str,
253 config: &MemConfig,
254 workspace_root: Option<&Path>,
255 workspace_schemas_dir: Option<&Path>,
256 provenance_bytes: Option<&[u8]>,
257 anchors_bytes: Option<&[u8]>,
258) -> Result<MemExportBytes, MemExportError> {
259 let published = published_config_from(config, mem_name)?;
260 let config_bytes = canonical_json(&published)
261 .map_err(|e| MemExportError::Canonical(e.to_string()))?
262 .into_bytes();
263
264 let schema_files = match schema_files_from_memstead_ref(mem_repo_gitdir, &published.schema) {
269 Some(files) => files,
270 None => collect_schema_source(workspace_root, workspace_schemas_dir, &published.schema)?,
271 };
272
273 let ref_name = match workspace_root {
274 Some(root) => crate::mem_repo_config::branch_ref_for_mem(root, mem_name),
275 None => format!("refs/heads/{mem_name}"),
276 };
277 let blobs = match read_branch_blobs(mem_repo_gitdir, &ref_name) {
278 Ok(b) => b,
279 Err(BranchReadError::BranchMissing { .. }) => Vec::new(),
280 Err(e) => return Err(branch_read_into_mem_export(e)),
281 };
282 let md_entries: Vec<(String, Vec<u8>)> = blobs
283 .into_iter()
284 .filter(|b| b.path.ends_with(".md"))
285 .map(|b| (b.path, b.bytes))
286 .collect();
287 let entity_count = md_entries.len();
288
289 let mut all_entries: Vec<(String, Vec<u8>)> =
290 Vec::with_capacity(2 + schema_files.len() + md_entries.len());
291 all_entries.push((ARCHIVE_CONFIG_PATH.to_string(), config_bytes));
292 if let Some(prov) = provenance_bytes {
296 all_entries.push((
297 memstead_schema::ARCHIVE_PROVENANCE_PATH.to_string(),
298 prov.to_vec(),
299 ));
300 }
301 if let Some(anchors) = anchors_bytes {
306 all_entries.push((ARCHIVE_ANCHORS_PATH.to_string(), anchors.to_vec()));
307 }
308 for sf in &schema_files {
309 all_entries.push((
310 format!("{ARCHIVE_SCHEMA_PREFIX}{}", sf.archive_path),
311 sf.bytes.clone(),
312 ));
313 }
314 all_entries.extend(md_entries);
315 all_entries.sort_by(|a, b| a.0.cmp(&b.0));
316
317 let mut buf: Vec<u8> = Vec::new();
318 {
319 let cursor = Cursor::new(&mut buf);
320 let mut zip = zip::ZipWriter::new(cursor);
321 let options = SimpleFileOptions::default()
322 .compression_method(CompressionMethod::Deflated)
323 .last_modified_time(fixed_mtime())
324 .unix_permissions(0o644);
325
326 for (archive_path, bytes) in &all_entries {
327 zip.start_file(archive_path, options)?;
328 zip.write_all(bytes)?;
329 }
330 zip.finish()?;
331 }
332
333 let dangling_cross_mem_edges =
341 memstead_base::validator::collect_dangling_cross_mem_edges_from_bytes(&buf)
342 .map_err(|e| MemExportError::ArchiveValidationFailed(e.to_string()))?;
343
344 Ok(MemExportBytes {
345 bytes: buf,
346 name: published.name.clone(),
347 version: published.version.to_string(),
348 entity_count,
349 dangling_cross_mem_edges,
350 })
351}
352
353fn fixed_mtime() -> DateTime {
356 DateTime::default()
357}
358
359#[cfg(test)]
360mod tests {
361 use super::*;
362 use crate::entity::{Entity, EntityId, MetadataValue};
363 use indexmap::IndexMap;
364 use memstead_schema::type_by_name;
365 use tempfile::TempDir;
366
367 fn make_entity(name: &str) -> Entity {
368 let mut metadata = IndexMap::new();
369 metadata.insert("level".into(), MetadataValue::String("M0".into()));
370 metadata.insert(
371 "created_date".into(),
372 MetadataValue::String("2026-01-15".into()),
373 );
374 metadata.insert(
375 "last_modified".into(),
376 MetadataValue::String("2026-04-12".into()),
377 );
378 metadata.insert("type".into(), MetadataValue::String("spec".into()));
379
380 let mut sections = IndexMap::new();
381 sections.insert("identity".into(), "Test.".into());
382 sections.insert("purpose".into(), "Test.".into());
383
384 Entity {
385 id: EntityId::new("specs", name),
386 title: name.into(),
387 entity_type: "spec".into(),
388 mem: "specs".into(),
389 file_path: format!("{name}.md"),
390 metadata,
391 sections,
392 relationships: Vec::new(),
393 content_hash: String::new(),
394 stub: false,
395 stub_kind: None,
396 heading_spans: std::collections::HashMap::new(),
397 raw_section_headings: Vec::new(),
398 }
399 }
400
401 fn make_memo_entity(name: &str) -> Entity {
402 let mut metadata = IndexMap::new();
403 metadata.insert("status".into(), MetadataValue::String("active".into()));
404 metadata.insert(
405 "created_date".into(),
406 MetadataValue::String("2026-01-15".into()),
407 );
408 metadata.insert(
409 "last_modified".into(),
410 MetadataValue::String("2026-04-12".into()),
411 );
412 metadata.insert("tags".into(), MetadataValue::String("decision".into()));
413 metadata.insert("type".into(), MetadataValue::String("memo".into()));
414
415 let mut sections = IndexMap::new();
416 sections.insert("claim".into(), "Sled is the choice.".into());
417 sections.insert("context".into(), "Evaluated three stores.".into());
418
419 Entity {
420 id: EntityId::new("memos", name),
421 title: name.into(),
422 entity_type: "memo".into(),
423 mem: "memos".into(),
424 file_path: format!("{name}.md"),
425 metadata,
426 sections,
427 relationships: Vec::new(),
428 content_hash: String::new(),
429 stub: false,
430 stub_kind: None,
431 heading_spans: std::collections::HashMap::new(),
432 raw_section_headings: Vec::new(),
433 }
434 }
435
436 fn make_assertion_entity(name: &str) -> Entity {
437 let mut metadata = IndexMap::new();
438 metadata.insert("confidence".into(), MetadataValue::String("medium".into()));
439 metadata.insert(
440 "verification_status".into(),
441 MetadataValue::String("unverified".into()),
442 );
443 metadata.insert(
444 "created_date".into(),
445 MetadataValue::String("2026-01-15".into()),
446 );
447 metadata.insert(
448 "last_modified".into(),
449 MetadataValue::String("2026-04-12".into()),
450 );
451 metadata.insert("type".into(), MetadataValue::String("assertion".into()));
452
453 let mut sections = IndexMap::new();
454 sections.insert("claim".into(), "Sled outperforms rocksdb.".into());
455 sections.insert("evidence".into(), "Bench results attached.".into());
456
457 Entity {
458 id: EntityId::new("assertions", name),
459 title: name.into(),
460 entity_type: "assertion".into(),
461 mem: "assertions".into(),
462 file_path: format!("{name}.md"),
463 metadata,
464 sections,
465 relationships: Vec::new(),
466 content_hash: String::new(),
467 stub: false,
468 stub_kind: None,
469 heading_spans: std::collections::HashMap::new(),
470 raw_section_headings: Vec::new(),
471 }
472 }
473
474 #[test]
475 fn export_mixed_schemas_uses_per_schema_headings() {
476 let dir = TempDir::new().unwrap();
477 let mut store = Store::new();
478 let memo = make_memo_entity("memo-entity");
479 let assertion = make_assertion_entity("assertion-entity");
480 store.upsert(memo.id.clone(), memo);
481 store.upsert(assertion.id.clone(), assertion);
482
483 let default_schema = &type_by_name("spec").unwrap();
486 let result = export_markdown(&store, default_schema, dir.path(), None);
487 assert_eq!(result.written, 2);
488
489 let memo_md = std::fs::read_to_string(dir.path().join("memo-entity.md")).unwrap();
490 assert!(memo_md.contains("## Claim"));
491 assert!(memo_md.contains("## Context"));
492 assert!(memo_md.contains("type: memo"));
493 assert!(!memo_md.contains("## Identity"));
494 assert!(!memo_md.contains("## Purpose"));
495
496 let assertion_md = std::fs::read_to_string(dir.path().join("assertion-entity.md")).unwrap();
497 assert!(assertion_md.contains("## Claim"));
498 assert!(assertion_md.contains("## Evidence"));
499 assert!(assertion_md.contains("type: assertion"));
500 assert!(!assertion_md.contains("## Identity"));
501 assert!(!assertion_md.contains("## Purpose"));
502 }
503
504 #[test]
505 fn export_writes_new_files() {
506 let dir = TempDir::new().unwrap();
507 let mut store = Store::new();
508 let e = make_entity("export-test");
509 store.upsert(e.id.clone(), e);
510
511 let schema = &type_by_name("spec").unwrap();
512 let result = export_markdown(&store, schema, dir.path(), None);
513 assert_eq!(result.written, 1);
514 assert_eq!(result.unchanged, 0);
515 assert!(dir.path().join("export-test.md").exists());
516 }
517
518 #[test]
519 fn export_incremental_skips_unchanged() {
520 let dir = TempDir::new().unwrap();
521 let mut store = Store::new();
522 let e = make_entity("incremental");
523 store.upsert(e.id.clone(), e);
524
525 let schema = &type_by_name("spec").unwrap();
526
527 let r1 = export_markdown(&store, schema, dir.path(), None);
529 assert_eq!(r1.written, 1);
530
531 let r2 = export_markdown(&store, schema, dir.path(), None);
533 assert_eq!(r2.written, 0);
534 assert_eq!(r2.unchanged, 1);
535 }
536
537 fn write_mem_fixture(dir: &Path) {
540 std::fs::create_dir_all(dir.join(".memstead")).unwrap();
541 std::fs::write(
546 dir.join(".memstead/config.json"),
547 r#"{"version":"1.2.0","description":"AWS patterns","schema":"default@1.0.0","writeGuidance":{"context":"secret"},"mediums":{},"projections":{},"readMems":{}}"#,
548 ).unwrap();
549 std::fs::write(
550 dir.join("api-gateway.md"),
551 "---\ntype: spec\ncreated_date: 2026-01-15\nlast_modified: 2026-04-12\nlevel: M0\n---\n# API Gateway\n\n## Identity\n\nGateway.\n\n## Purpose\n\nServe API traffic.\n",
552 ).unwrap();
553 std::fs::create_dir_all(dir.join("well-architected")).unwrap();
554 std::fs::write(
555 dir.join("well-architected/reliability.md"),
556 "---\ntype: spec\ncreated_date: 2026-01-15\nlast_modified: 2026-04-12\nlevel: M0\n---\n# Reliability\n\n## Identity\n\nReliability.\n\n## Purpose\n\nKeep the system available.\n",
557 ).unwrap();
558 std::fs::write(dir.join(".memstead/communities.json"), "{}").unwrap();
560 }
561
562 #[test]
563 fn export_mem_writes_whitelisted_config_and_markdown() {
564 let tmp = TempDir::new().unwrap();
565 let mem = tmp.path().join("aws-patterns");
566 write_mem_fixture(&mem);
567
568 let config = memstead_schema::load_and_validate(&mem).unwrap();
569 let out = tmp.path().join("aws-patterns.mem");
570
571 let result = export_mem(&mem, &config, &out, None, None).unwrap();
575 assert_eq!(result.name, "aws-patterns");
576 assert_eq!(result.version, "1.2.0");
577 assert_eq!(result.entity_count, 2);
578
579 let file = std::fs::File::open(&out).unwrap();
580 let mut archive = zip::ZipArchive::new(file).unwrap();
581
582 let mut names: Vec<String> = (0..archive.len())
583 .map(|i| archive.by_index(i).unwrap().name().to_string())
584 .collect();
585 names.sort();
586
587 for required in [
591 ".memstead/config.json",
592 "api-gateway.md",
593 "well-architected/reliability.md",
594 ".memstead/schema/schema.yaml",
595 ] {
596 assert!(
597 names.iter().any(|n| n == required),
598 "archive missing expected entry {required:?}; got {names:?}"
599 );
600 }
601 let type_entries: Vec<&String> = names
602 .iter()
603 .filter(|n| n.starts_with(".memstead/schema/types/"))
604 .collect();
605 assert!(
606 !type_entries.is_empty(),
607 "archive must embed at least one type yaml under .memstead/schema/types/"
608 );
609
610 use std::io::Read as _;
611 let mut config_bytes = Vec::new();
612 archive
613 .by_name(".memstead/config.json")
614 .unwrap()
615 .read_to_end(&mut config_bytes)
616 .unwrap();
617 let written: serde_json::Value = serde_json::from_slice(&config_bytes).unwrap();
618 assert_eq!(written["format"], memstead_schema::PUBLISHED_MEM_FORMAT);
619 assert_eq!(written["name"], "aws-patterns");
620 assert_eq!(written["version"], "1.2.0");
621 assert_eq!(written["description"], "AWS patterns");
622 assert_eq!(written["schema"], "default@1.0.0");
623
624 for forbidden in [
628 "writeGuidance",
629 "mediums",
630 "projections",
631 "rules",
632 "publish",
633 "readMems",
634 "vcs",
635 "language",
636 "community",
637 "defaultSchema",
638 ] {
639 assert!(
640 written.get(forbidden).is_none(),
641 "author-only field {forbidden:?} leaked into archive config"
642 );
643 }
644 }
645
646 #[test]
647 fn export_mem_is_deterministic() {
648 let tmp = TempDir::new().unwrap();
649 let mem = tmp.path().join("aws-patterns");
650 write_mem_fixture(&mem);
651
652 let config = memstead_schema::load_and_validate(&mem).unwrap();
653 let out1 = tmp.path().join("a.mem");
654 let out2 = tmp.path().join("b.mem");
655
656 export_mem(&mem, &config, &out1, None, None).unwrap();
657 std::thread::sleep(std::time::Duration::from_millis(10));
658 export_mem(&mem, &config, &out2, None, None).unwrap();
659
660 let a = std::fs::read(&out1).unwrap();
661 let b = std::fs::read(&out2).unwrap();
662 assert_eq!(a, b, "mem archive exports must be byte-stable");
663 }
664
665 #[test]
666 fn export_mem_errors_when_version_missing() {
667 let tmp = TempDir::new().unwrap();
668 let mem = tmp.path().join("no-version");
669 std::fs::create_dir_all(mem.join(".memstead")).unwrap();
670 std::fs::write(
671 mem.join(".memstead/config.json"),
672 r#"{"schema":"default@1.0.0","mediums":{},"projections":{}}"#,
673 )
674 .unwrap();
675 std::fs::write(
676 mem.join("a.md"),
677 "---\ntype: spec\ncreated_date: 2026-01-15\nlast_modified: 2026-04-12\nlevel: M0\n---\n# A\n\n## Identity\n\nA.\n",
678 ).unwrap();
679
680 let config = memstead_schema::load_and_validate(&mem).unwrap();
681 let out = tmp.path().join("out.mem");
682 let err = export_mem(&mem, &config, &out, None, None).unwrap_err();
683 assert!(matches!(
684 err,
685 MemExportError::Convert(memstead_schema::PublishConversionError::MissingVersion)
686 ));
687 assert!(!out.exists(), "no archive should be written on error");
690 }
691
692 #[test]
693 fn export_with_schema_filter() {
694 let dir = TempDir::new().unwrap();
695 let mut store = Store::new();
696 let e1 = make_entity("spec-entity");
697 let mut e2 = make_entity("memo-entity");
698 e2.entity_type = "memo".into();
699 store.upsert(e1.id.clone(), e1);
700 store.upsert(e2.id.clone(), e2);
701
702 let schema = &type_by_name("spec").unwrap();
703 let result = export_markdown(&store, schema, dir.path(), Some("spec"));
704 assert_eq!(result.written, 1); }
706
707 #[cfg(feature = "git-object-storage")]
710 mod git_object_export {
711 use super::*;
712 use crate::storage::MemWriter;
713 use crate::storage::git_tree::GitTreeMemWriter;
714 use crate::vcs::CommitContext;
715
716 fn seed_mem_branch(
721 workspace: &Path,
722 mem_name: &str,
723 entries: &[(&str, &str)],
724 ) -> (PathBuf, PathBuf) {
725 let gitdir = workspace.join("mem-repo").join(".git");
726 std::fs::create_dir_all(&gitdir).unwrap();
727 gix::init_bare(&gitdir).unwrap();
728
729 let mem_dir = workspace.join(mem_name);
733 std::fs::create_dir_all(mem_dir.join(".memstead")).unwrap();
734 std::fs::write(
735 mem_dir.join(".memstead/config.json"),
736 r#"{"version":"1.0.0","description":"fixture","schema":"default@1.0.0"}"#,
737 )
738 .unwrap();
739
740 let writer = GitTreeMemWriter::new(gitdir.clone(), format!("refs/heads/{mem_name}"));
744 for (rel, content) in entries {
745 writer
746 .write_entity(Path::new(rel), content.as_bytes())
747 .unwrap();
748 }
749 writer.commit("seed", &CommitContext::internal()).unwrap();
750 (gitdir, mem_dir)
751 }
752
753 #[test]
754 fn publish_from_branch_produces_correct_tarball() {
755 let tmp = TempDir::new().unwrap();
756 let (gitdir, mem_dir) = seed_mem_branch(
757 tmp.path(),
758 "fixture",
759 &[
760 (
761 "alpha.md",
762 "---\ntype: spec\ncreated_date: 2026-01-01\nlast_modified: 2026-01-01\nlevel: M0\n---\n# Alpha\n\n## Identity\n\nA.\n",
763 ),
764 (
765 "nested/beta.md",
766 "---\ntype: spec\ncreated_date: 2026-01-01\nlast_modified: 2026-01-01\nlevel: M0\n---\n# Beta\n\n## Identity\n\nB.\n",
767 ),
768 ],
769 );
770
771 let config = memstead_schema::load_and_validate(&mem_dir).unwrap();
772 let out = tmp.path().join("fixture.mem");
773 let result =
774 export_mem_from_branch(&gitdir, "fixture", &config, &out, None, None, None, None)
775 .unwrap();
776
777 assert_eq!(result.name, "fixture");
778 assert_eq!(result.version, "1.0.0");
779 assert_eq!(result.entity_count, 2, "two `.md` blobs were committed");
780
781 let file = std::fs::File::open(&out).unwrap();
782 let mut archive = zip::ZipArchive::new(file).unwrap();
783 let mut names: Vec<String> = (0..archive.len())
784 .map(|i| archive.by_index(i).unwrap().name().to_string())
785 .collect();
786 names.sort();
787 for required in [".memstead/config.json", "alpha.md", "nested/beta.md"] {
788 assert!(
789 names.iter().any(|n| n == required),
790 "archive missing entry {required:?}; got {names:?}"
791 );
792 }
793
794 use std::io::Read as _;
797 let mut alpha = Vec::new();
798 archive
799 .by_name("alpha.md")
800 .unwrap()
801 .read_to_end(&mut alpha)
802 .unwrap();
803 assert!(String::from_utf8_lossy(&alpha).contains("# Alpha"));
804 }
805
806 #[test]
807 fn publish_includes_schema_under_underscore_schema_prefix() {
808 let tmp = TempDir::new().unwrap();
814 let (gitdir, mem_dir) = seed_mem_branch(
815 tmp.path(),
816 "fixture",
817 &[(
818 "a.md",
819 "---\ntype: spec\ncreated_date: 2026-01-01\nlast_modified: 2026-01-01\nlevel: M0\n---\n# A\n\n## Identity\n\nA.\n",
820 )],
821 );
822
823 let config = memstead_schema::load_and_validate(&mem_dir).unwrap();
824 let out = tmp.path().join("fixture.mem");
825 export_mem_from_branch(&gitdir, "fixture", &config, &out, None, None, None, None)
826 .unwrap();
827
828 let file = std::fs::File::open(&out).unwrap();
829 let mut archive = zip::ZipArchive::new(file).unwrap();
830 let names: Vec<String> = (0..archive.len())
831 .map(|i| archive.by_index(i).unwrap().name().to_string())
832 .collect();
833 assert!(
834 names.iter().any(|n| n == ".memstead/schema/schema.yaml"),
835 "schema manifest must embed under `.memstead/schema/`; got {names:?}"
836 );
837 assert!(
838 names
839 .iter()
840 .any(|n: &String| n.starts_with(".memstead/schema/types/")
841 && n.ends_with(".yaml")),
842 "at least one type yaml must embed under `.memstead/schema/types/`; got {names:?}"
843 );
844 }
845
846 #[test]
847 fn byte_export_matches_path_export_byte_for_byte() {
848 let tmp = TempDir::new().unwrap();
853 let (gitdir, mem_dir) = seed_mem_branch(
854 tmp.path(),
855 "fixture",
856 &[(
857 "a.md",
858 "---\ntype: spec\ncreated_date: 2026-01-01\nlast_modified: 2026-01-01\nlevel: M0\n---\n# A\n\n## Identity\n\nA.\n",
859 )],
860 );
861 let config = memstead_schema::load_and_validate(&mem_dir).unwrap();
862 let out = tmp.path().join("fixture.mem");
863 export_mem_from_branch(&gitdir, "fixture", &config, &out, None, None, None, None)
864 .unwrap();
865 let path_bytes = std::fs::read(&out).unwrap();
866 let byte_bytes = export_mem_from_branch_to_bytes(
867 &gitdir, "fixture", &config, None, None, None, None,
868 )
869 .unwrap()
870 .bytes;
871 assert_eq!(path_bytes, byte_bytes);
872 }
873
874 #[test]
875 fn byte_export_validates_and_hydrates_via_engine() {
876 let tmp = TempDir::new().unwrap();
881 let (gitdir, mem_dir) = seed_mem_branch(
882 tmp.path(),
883 "fixture",
884 &[(
885 "alpha.md",
886 "---\ntype: spec\ncreated_date: 2026-01-01\nlast_modified: 2026-01-01\nlevel: M0\n---\n# Alpha\n\n## Identity\n\nA round-trip seed.\n",
887 )],
888 );
889 let config = memstead_schema::load_and_validate(&mem_dir).unwrap();
890 let bytes = export_mem_from_branch_to_bytes(
891 &gitdir, "fixture", &config, None, None, None, None,
892 )
893 .unwrap()
894 .bytes;
895
896 let entries = memstead_base::validator::archive::extract_entries(
898 &bytes,
899 &memstead_base::validator::ValidatorLimits::DEFAULT,
900 )
901 .unwrap();
902 assert_eq!(entries.markdown_files.len(), 1);
903
904 let hydrated = memstead_base::Engine::from_archive_bytes(bytes).unwrap();
906 let entity = hydrated
907 .get_entity(&memstead_base::EntityId::new("fixture", "alpha"))
908 .expect("alpha must round-trip");
909 assert_eq!(entity.title, "Alpha");
910 }
911
912 #[test]
913 fn export_from_branch_embeds_supplied_anchors_member() {
914 let tmp = TempDir::new().unwrap();
918 let (gitdir, mem_dir) = seed_mem_branch(
919 tmp.path(),
920 "fixture",
921 &[(
922 "a.md",
923 "---\ntype: spec\ncreated_date: 2026-01-01\nlast_modified: 2026-01-01\nlevel: M0\n---\n# A\n\n## Identity\n\nA.\n",
924 )],
925 );
926 let config = memstead_schema::load_and_validate(&mem_dir).unwrap();
927 let sidecar = br#"{"version":1,"entities":{"fixture--a":[{"artifact":"a.rs","grain":"file","class":"anchored","hash_stability":"stable","hash":"h1"}]}}"#;
928 let bytes = export_mem_from_branch_to_bytes(
929 &gitdir,
930 "fixture",
931 &config,
932 None,
933 None,
934 None,
935 Some(sidecar),
936 )
937 .unwrap()
938 .bytes;
939 let entries = memstead_base::validator::archive::extract_entries(
940 &bytes,
941 &memstead_base::validator::ValidatorLimits::DEFAULT,
942 )
943 .unwrap();
944 assert_eq!(
945 entries.anchors_bytes.as_deref(),
946 Some(&sidecar[..]),
947 "git-branch export must embed the supplied anchors member"
948 );
949 let without = export_mem_from_branch_to_bytes(
951 &gitdir, "fixture", &config, None, None, None, None,
952 )
953 .unwrap()
954 .bytes;
955 let entries_without = memstead_base::validator::archive::extract_entries(
956 &without,
957 &memstead_base::validator::ValidatorLimits::DEFAULT,
958 )
959 .unwrap();
960 assert!(entries_without.anchors_bytes.is_none());
961 }
962
963 #[test]
964 fn publish_re_runs_yield_byte_identical_tarballs() {
965 let tmp = TempDir::new().unwrap();
966 let (gitdir, mem_dir) = seed_mem_branch(
967 tmp.path(),
968 "fixture",
969 &[(
970 "a.md",
971 "---\ntype: spec\ncreated_date: 2026-01-01\nlast_modified: 2026-01-01\nlevel: M0\n---\n# A\n\n## Identity\n\nA.\n",
972 )],
973 );
974
975 let config = memstead_schema::load_and_validate(&mem_dir).unwrap();
976 let out1 = tmp.path().join("a.mem");
977 let out2 = tmp.path().join("b.mem");
978 export_mem_from_branch(&gitdir, "fixture", &config, &out1, None, None, None, None)
979 .unwrap();
980 std::thread::sleep(std::time::Duration::from_millis(10));
982 export_mem_from_branch(&gitdir, "fixture", &config, &out2, None, None, None, None)
983 .unwrap();
984 let a = std::fs::read(&out1).unwrap();
985 let b = std::fs::read(&out2).unwrap();
986 assert_eq!(
987 a, b,
988 "branch-walk archive exports must be byte-stable across re-runs"
989 );
990 }
991 }
992}