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