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 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 .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#[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 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 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 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 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
340fn 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 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 let r1 = export_markdown(&store, schema, dir.path(), None);
516 assert_eq!(r1.written, 1);
517
518 let r2 = export_markdown(&store, schema, dir.path(), None);
520 assert_eq!(r2.written, 0);
521 assert_eq!(r2.unchanged, 1);
522 }
523
524 fn write_mem_fixture(dir: &Path) {
527 std::fs::create_dir_all(dir.join(".memstead")).unwrap();
528 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 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 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 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 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 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); }
693
694 #[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 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 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 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 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 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 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 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 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 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 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 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 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}