memstead_base/ops/export.rs
1//! Mem-archive export.
2//!
3//! The folder-shaped export (the gix-free path) lives here so the
4//! unified `Engine::export_mem` can call it without crossing into
5//! `memstead-git-branch`. The git-branch-shaped variant
6//! (`export_mem_from_branch`) stays in `memstead-git-branch::ops::export`
7//! because it walks a gitdir.
8//!
9//! Output wire shape is deterministic and matches the git-branch
10//! variant byte-for-byte for equivalent input: `.memstead/config.json`
11//! carries the whitelist-projection of the author's `MemConfig`,
12//! `.memstead/schema/` embeds the pinned schema's source files, and the
13//! mem's `.md` blobs land at their mem-relative paths. Entries are
14//! sorted by path and zip-stamped with a fixed mtime so identical
15//! input produces byte-identical archives.
16
17use std::fs;
18use std::io::{Cursor, Write};
19use std::path::{Path, PathBuf};
20
21use memstead_schema::{
22 ARCHIVE_CONFIG_PATH, ARCHIVE_PROVENANCE_PATH, ARCHIVE_SCHEMA_PREFIX, ArchiveProvenance,
23 EntityProvenance, MemConfig, PublishConversionError, SchemaSourceError, collect_schema_source,
24 published_config_from,
25};
26use zip::{CompressionMethod, DateTime, write::SimpleFileOptions};
27
28use crate::entity::EntityId;
29use crate::ops::MemExportResult;
30use crate::provenance::Provenance;
31use crate::validator::canonical::canonical_json;
32
33/// Build the per-entity authoring-provenance payload from a backend's
34/// mutation log (`read_provenance`). Keys by each entity's mem-relative
35/// path ([`EntityId::path`]) so the payload survives a remount under a
36/// different mem name. For each entity, keeps the most recent record
37/// that carries a non-empty note — the entity's *current* rationale.
38///
39/// No-fabrication: records with no entity (batch) or no note are skipped,
40/// so an entity authored without rationale is simply absent from the
41/// payload (the read path reports it absent). Returns `None` when no
42/// entity carried a note — the export then ships no provenance member,
43/// distinct from an empty payload.
44pub fn build_archive_provenance(records: &[Provenance]) -> Option<ArchiveProvenance> {
45 use std::collections::BTreeMap;
46 use std::time::SystemTime;
47
48 let mut by_path: BTreeMap<String, (SystemTime, EntityProvenance)> = BTreeMap::new();
49 for r in records {
50 let Some(entity) = r.entity.as_deref() else {
51 continue;
52 };
53 let Some(note) = r.note.as_deref().map(str::trim).filter(|n| !n.is_empty()) else {
54 continue;
55 };
56 let path = EntityId(entity.to_string()).path().to_string();
57 if path.is_empty() {
58 continue;
59 }
60 let candidate = EntityProvenance {
61 rationale: Some(note.to_string()),
62 kind: Some(r.kind.as_str().to_string()),
63 timestamp: Some(crate::filesystem::changelog::format_rfc3339_utc(
64 r.timestamp,
65 )),
66 actor: Some(r.actor.as_trailer().to_string()),
67 };
68 match by_path.get(&path) {
69 // Keep the existing entry when it is at least as recent.
70 Some((ts, _)) if *ts >= r.timestamp => {}
71 _ => {
72 by_path.insert(path, (r.timestamp, candidate));
73 }
74 }
75 }
76 if by_path.is_empty() {
77 return None;
78 }
79 Some(ArchiveProvenance::summarised(
80 by_path.into_iter().map(|(k, (_, v))| (k, v)).collect(),
81 ))
82}
83
84/// Byte-shaped output of [`export_mem_to_bytes`]. Bundles the
85/// produced archive bytes with the same metadata
86/// [`MemExportResult`] reports for path-based exports.
87#[derive(Debug, Clone)]
88pub struct MemExportBytes {
89 /// The `.mem` archive bytes — self-contained, ready to validate
90 /// via `extract_entries` and hydrate via `Engine::from_archive_bytes`.
91 pub bytes: Vec<u8>,
92 /// Mem name (mirrors `MemExportResult.name`).
93 pub name: String,
94 /// Mem version (mirrors `MemExportResult.version`).
95 pub version: String,
96 /// `.md` entity count in the produced archive.
97 pub entity_count: usize,
98 /// Cross-mem edges whose target won't travel inside this archive —
99 /// `install` will reject each. Mirrors
100 /// `MemExportResult.dangling_cross_mem_edges`; empty for a
101 /// self-contained export.
102 pub dangling_cross_mem_edges: Vec<crate::validator::DanglingCrossMemEdge>,
103}
104
105#[derive(Debug, thiserror::Error)]
106pub enum MemExportError {
107 #[error("mem directory not found: {0}")]
108 DirNotFound(String),
109 #[error(transparent)]
110 Convert(#[from] PublishConversionError),
111 #[error("io error: {0}")]
112 Io(#[from] std::io::Error),
113 #[error("zip error: {0}")]
114 Zip(#[from] zip::result::ZipError),
115 #[error("config serialization error: {0}")]
116 Canonical(String),
117 #[error(transparent)]
118 SchemaSource(#[from] SchemaSourceError),
119 #[error("branch read error: {0}")]
120 BranchRead(String),
121 /// The
122 /// produced archive failed strict validation. Reaches this state
123 /// when the mem carries on-disk drift from a pre-fix engine —
124 /// entities created when `MISSING_REQUIRED_SECTION` was a
125 /// warning, hand-edited markdown, or archive-imports from
126 /// non-canonical sources. Export and install share one strict
127 /// validator pass; the trust boundary now fires at export rather
128 /// than letting an invalid archive land on disk and refuse only
129 /// at the next install attempt.
130 #[error("export archive failed strict validation: {0}")]
131 ArchiveValidationFailed(String),
132}
133
134/// Export a mem directory as a portable `.mem` archive.
135///
136/// The archive contains `.memstead/config.json` (a **whitelist projection**
137/// of the author's `MemConfig` — author-only fields like
138/// `writeGuidance`, `mediums`, `projections`, `readMems` never enter
139/// the archive), every `.md` file under the mem root, and the pinned
140/// schema's source YAML under `schema/`.
141///
142/// Output is deterministic — entries are sorted by path and written
143/// with a fixed modification time — so identical input produces
144/// byte-identical archives, and archives produced here round-trip
145/// through `validate_and_normalize_archive` without rewriting.
146pub fn export_mem(
147 mem_dir: &Path,
148 config: &MemConfig,
149 output_path: &Path,
150 workspace_root: Option<&Path>,
151 workspace_schemas_dir: Option<&Path>,
152) -> Result<MemExportResult, MemExportError> {
153 let basename = mem_dir.file_name().and_then(|n| n.to_str()).unwrap_or("");
154 let explicit_name = config.name.as_deref().unwrap_or(basename);
155 let out = export_mem_to_bytes(
156 mem_dir,
157 config,
158 workspace_root,
159 workspace_schemas_dir,
160 explicit_name,
161 )?;
162
163 if let Some(parent) = output_path.parent()
164 && !parent.as_os_str().is_empty()
165 {
166 fs::create_dir_all(parent)?;
167 }
168 fs::write(output_path, &out.bytes)?;
169
170 let size_bytes = fs::metadata(output_path)?.len();
171
172 Ok(MemExportResult {
173 archive_path: output_path.display().to_string(),
174 name: out.name,
175 version: out.version,
176 entity_count: out.entity_count,
177 size_bytes,
178 dangling_cross_mem_edges: out.dangling_cross_mem_edges,
179 })
180}
181
182/// Produce a portable `.mem` archive **as bytes** for a folder-backed
183/// mem. Same wire format as [`export_mem`] — same whitelist
184/// projection, same embedded schema source, same deterministic sort
185/// order and fixed mtime — but the output stays in memory so the
186/// bridge / WASM consumers can return it directly over HTTP.
187///
188/// `explicit_name` is the mem name the publish whitelist receives.
189/// Callers reaching this through [`crate::Engine::export_mem_to_bytes`]
190/// pass the mount's mem name; callers reaching it directly choose
191/// the disk basename or a config-supplied alias.
192pub fn export_mem_to_bytes(
193 mem_dir: &Path,
194 config: &MemConfig,
195 workspace_root: Option<&Path>,
196 workspace_schemas_dir: Option<&Path>,
197 explicit_name: &str,
198) -> Result<MemExportBytes, MemExportError> {
199 if !mem_dir.is_dir() {
200 return Err(MemExportError::DirNotFound(mem_dir.display().to_string()));
201 }
202
203 let mut md_files = Vec::new();
204 collect_markdown(mem_dir, &mut md_files)?;
205
206 let mut md_entries: Vec<(PathBuf, Vec<u8>)> = Vec::with_capacity(md_files.len());
207 for abs in &md_files {
208 let rel = abs
209 .strip_prefix(mem_dir)
210 .expect("markdown file must live under mem_dir");
211 md_entries.push((rel.to_path_buf(), fs::read(abs)?));
212 }
213
214 // Source the per-entity authoring provenance from the folder mem's
215 // own mutation log (`.memstead/changes.jsonl`), read through the folder
216 // backend so the JSONL parsing has one home. A mem with no changelog
217 // yields no records → no provenance member (absent, not empty).
218 use crate::backend::MemBackend;
219 let provenance = crate::storage::FilesystemMemWriter::new(mem_dir.to_path_buf())
220 .read_provenance(None)
221 .ok()
222 .and_then(|records| build_archive_provenance(&records));
223
224 export_entries_to_bytes(
225 config,
226 workspace_root,
227 workspace_schemas_dir,
228 explicit_name,
229 md_entries,
230 provenance.as_ref(),
231 )
232}
233
234/// Seal already-collected entity bytes into a portable `.mem` archive —
235/// the storage-agnostic core shared by the folder exporter
236/// ([`export_mem_to_bytes`], which walks a directory) and the
237/// in-memory exporter (which lists entities from a
238/// [`crate::backend::MemBackend`] holding them in RAM). Same wire
239/// format either way: whitelist config projection, embedded schema
240/// source, deterministic path-sorted entries, fixed mtime, and the same
241/// pre-write lenient validation pass.
242///
243/// `md_entries` are `(mem-relative path, bytes)` pairs; paths are
244/// posix-normalised for the archive. Entries need not be pre-sorted — the
245/// archive sort makes the output deterministic regardless of input order.
246pub fn export_entries_to_bytes(
247 config: &MemConfig,
248 workspace_root: Option<&Path>,
249 workspace_schemas_dir: Option<&Path>,
250 explicit_name: &str,
251 md_entries: Vec<(PathBuf, Vec<u8>)>,
252 provenance: Option<&ArchiveProvenance>,
253) -> Result<MemExportBytes, MemExportError> {
254 let published = published_config_from(config, explicit_name)?;
255 let config_bytes = canonical_json(&published)
256 .map_err(|e| MemExportError::Canonical(e.to_string()))?
257 .into_bytes();
258
259 let schema_files =
260 collect_schema_source(workspace_root, workspace_schemas_dir, &published.schema)?;
261
262 let entity_count = md_entries.len();
263 let mut all_entries: Vec<(String, Vec<u8>)> =
264 Vec::with_capacity(2 + schema_files.len() + md_entries.len());
265 all_entries.push((ARCHIVE_CONFIG_PATH.to_string(), config_bytes));
266 // Embed the authoring-provenance payload when present. Serialised
267 // canonically; the validator tolerates it as a recognised meta member
268 // and the consumer reads it back via `read_archive_provenance`.
269 if let Some(prov) = provenance
270 && let Ok(bytes) = prov.to_archive_bytes()
271 {
272 all_entries.push((ARCHIVE_PROVENANCE_PATH.to_string(), bytes));
273 }
274 for sf in &schema_files {
275 all_entries.push((
276 format!("{ARCHIVE_SCHEMA_PREFIX}{}", sf.archive_path),
277 sf.bytes.clone(),
278 ));
279 }
280 for (rel, bytes) in md_entries {
281 all_entries.push((posix_path(&rel), bytes));
282 }
283 all_entries.sort_by(|a, b| a.0.cmp(&b.0));
284
285 let mut buf: Vec<u8> = Vec::new();
286 {
287 let cursor = Cursor::new(&mut buf);
288 let mut zip = zip::ZipWriter::new(cursor);
289 let options = SimpleFileOptions::default()
290 .compression_method(CompressionMethod::Deflated)
291 .last_modified_time(fixed_mtime())
292 .unix_permissions(0o644);
293
294 for (archive_path, bytes) in &all_entries {
295 zip.start_file(archive_path, options)?;
296 zip.write_all(bytes)?;
297 }
298 zip.finish()?;
299 }
300
301 // Strict
302 // archive validation runs against the in-memory bytes before they
303 // leave this function. Export and install share one validator
304 // pass — the trust boundary fires at export rather than letting an
305 // invalid archive land on disk and refuse only at the next install
306 // attempt. If the produced archive doesn't validate (legacy
307 // on-disk drift, hand-edited markdown, archive-imports from
308 // non-canonical sources), surface the typed refusal; the
309 // disk-shaped wrapper (`export_mem`) never writes a broken
310 // archive because validation happens here, pre-write.
311 //
312 // The *lenient* variant collects cross-mem edges (whose target
313 // won't travel inside this single-mem archive) instead of
314 // refusing on them — export warns and still produces, where install
315 // refuses. Every other strict check still refuses, so a
316 // genuinely-broken archive never lands.
317 let validated = crate::validator::validate_and_normalize_archive_lenient(&buf)
318 .map_err(|e| MemExportError::ArchiveValidationFailed(e.to_string()))?;
319
320 Ok(MemExportBytes {
321 bytes: buf,
322 name: published.name.clone(),
323 version: published.version.to_string(),
324 entity_count,
325 dangling_cross_mem_edges: validated.dangling_cross_mem_edges,
326 })
327}
328
329/// Zip's minimum representable timestamp — 1980-01-01 00:00:00. Used
330/// as a fixed mtime so archives are byte-stable across exports.
331fn fixed_mtime() -> DateTime {
332 DateTime::default()
333}
334
335fn posix_path(path: &Path) -> String {
336 path.components()
337 .filter_map(|c| c.as_os_str().to_str())
338 .collect::<Vec<_>>()
339 .join("/")
340}
341
342/// Recursively collect `.md` files. Skips hidden directories (same
343/// policy as the entity loader).
344fn collect_markdown(dir: &Path, out: &mut Vec<PathBuf>) -> Result<(), std::io::Error> {
345 let mut children: Vec<_> = fs::read_dir(dir)?.collect::<Result<_, _>>()?;
346 children.sort_by_key(|e| e.file_name());
347
348 for entry in children {
349 let path = entry.path();
350 let name = entry.file_name();
351 let name = name.to_string_lossy();
352
353 if path.is_dir() {
354 if name.starts_with('.') {
355 continue;
356 }
357 collect_markdown(&path, out)?;
358 } else if name.ends_with(".md") {
359 out.push(path);
360 }
361 }
362 Ok(())
363}