use std::fs;
use std::io::{Cursor, Write};
use std::path::{Path, PathBuf};
use memstead_schema::{
ARCHIVE_ANCHORS_PATH, ARCHIVE_CONFIG_PATH, ARCHIVE_PROVENANCE_PATH, ARCHIVE_SCHEMA_PREFIX,
ArchiveProvenance, EntityProvenance, MemConfig, PublishConversionError, SchemaSourceError,
collect_schema_source, published_config_from,
};
use zip::{CompressionMethod, DateTime, write::SimpleFileOptions};
use crate::entity::EntityId;
use crate::ops::MemExportResult;
use crate::provenance::Provenance;
use crate::validator::canonical::canonical_json;
pub fn build_archive_provenance(records: &[Provenance]) -> Option<ArchiveProvenance> {
use std::collections::BTreeMap;
use std::time::SystemTime;
let mut by_path: BTreeMap<String, (SystemTime, EntityProvenance)> = BTreeMap::new();
for r in records {
let Some(entity) = r.entity.as_deref() else {
continue;
};
let Some(note) = r.note.as_deref().map(str::trim).filter(|n| !n.is_empty()) else {
continue;
};
let path = EntityId(entity.to_string()).path().to_string();
if path.is_empty() {
continue;
}
let candidate = EntityProvenance {
rationale: Some(note.to_string()),
kind: Some(r.kind.as_str().to_string()),
timestamp: Some(crate::filesystem::changelog::format_rfc3339_utc(
r.timestamp,
)),
actor: Some(r.actor.as_trailer().to_string()),
};
match by_path.get(&path) {
Some((ts, _)) if *ts >= r.timestamp => {}
_ => {
by_path.insert(path, (r.timestamp, candidate));
}
}
}
if by_path.is_empty() {
return None;
}
Some(ArchiveProvenance::summarised(
by_path.into_iter().map(|(k, (_, v))| (k, v)).collect(),
))
}
#[derive(Debug, Clone)]
pub struct MemExportBytes {
pub bytes: Vec<u8>,
pub name: String,
pub version: String,
pub entity_count: usize,
pub dangling_cross_mem_edges: Vec<crate::validator::DanglingCrossMemEdge>,
}
#[derive(Debug, thiserror::Error)]
pub enum MemExportError {
#[error("mem directory not found: {0}")]
DirNotFound(String),
#[error(transparent)]
Convert(#[from] PublishConversionError),
#[error("io error: {0}")]
Io(#[from] std::io::Error),
#[error("zip error: {0}")]
Zip(#[from] zip::result::ZipError),
#[error("config serialization error: {0}")]
Canonical(String),
#[error(transparent)]
SchemaSource(#[from] SchemaSourceError),
#[error("branch read error: {0}")]
BranchRead(String),
#[error("export archive failed strict validation: {0}")]
ArchiveValidationFailed(String),
}
pub fn export_mem(
mem_dir: &Path,
config: &MemConfig,
output_path: &Path,
workspace_root: Option<&Path>,
workspace_schemas_dir: Option<&Path>,
) -> Result<MemExportResult, MemExportError> {
let basename = mem_dir.file_name().and_then(|n| n.to_str()).unwrap_or("");
let explicit_name = config.name.as_deref().unwrap_or(basename);
let out = export_mem_to_bytes(
mem_dir,
config,
workspace_root,
workspace_schemas_dir,
explicit_name,
)?;
if let Some(parent) = output_path.parent()
&& !parent.as_os_str().is_empty()
{
fs::create_dir_all(parent)?;
}
fs::write(output_path, &out.bytes)?;
let size_bytes = fs::metadata(output_path)?.len();
Ok(MemExportResult {
archive_path: output_path.display().to_string(),
name: out.name,
version: out.version,
entity_count: out.entity_count,
size_bytes,
dangling_cross_mem_edges: out.dangling_cross_mem_edges,
})
}
pub fn export_mem_to_bytes(
mem_dir: &Path,
config: &MemConfig,
workspace_root: Option<&Path>,
workspace_schemas_dir: Option<&Path>,
explicit_name: &str,
) -> Result<MemExportBytes, MemExportError> {
if !mem_dir.is_dir() {
return Err(MemExportError::DirNotFound(mem_dir.display().to_string()));
}
let mut md_files = Vec::new();
collect_markdown(mem_dir, &mut md_files)?;
let mut md_entries: Vec<(PathBuf, Vec<u8>)> = Vec::with_capacity(md_files.len());
for abs in &md_files {
let rel = abs
.strip_prefix(mem_dir)
.expect("markdown file must live under mem_dir");
md_entries.push((rel.to_path_buf(), fs::read(abs)?));
}
use crate::backend::MemBackend;
let backend = crate::storage::FilesystemMemWriter::new(mem_dir.to_path_buf());
let provenance = backend
.read_provenance(None)
.ok()
.and_then(|records| build_archive_provenance(&records));
let anchors_bytes = backend.read_anchors_sidecar().ok().flatten();
export_entries_to_bytes(
config,
workspace_root,
workspace_schemas_dir,
explicit_name,
md_entries,
provenance.as_ref(),
anchors_bytes.as_deref(),
)
}
pub fn export_entries_to_bytes(
config: &MemConfig,
workspace_root: Option<&Path>,
workspace_schemas_dir: Option<&Path>,
explicit_name: &str,
md_entries: Vec<(PathBuf, Vec<u8>)>,
provenance: Option<&ArchiveProvenance>,
anchors_bytes: Option<&[u8]>,
) -> Result<MemExportBytes, MemExportError> {
let published = published_config_from(config, explicit_name)?;
let config_bytes = canonical_json(&published)
.map_err(|e| MemExportError::Canonical(e.to_string()))?
.into_bytes();
let schema_files =
collect_schema_source(workspace_root, workspace_schemas_dir, &published.schema)?;
let entity_count = md_entries.len();
let mut all_entries: Vec<(String, Vec<u8>)> =
Vec::with_capacity(2 + schema_files.len() + md_entries.len());
all_entries.push((ARCHIVE_CONFIG_PATH.to_string(), config_bytes));
if let Some(prov) = provenance
&& let Ok(bytes) = prov.to_archive_bytes()
{
all_entries.push((ARCHIVE_PROVENANCE_PATH.to_string(), bytes));
}
if let Some(anchors) = anchors_bytes {
all_entries.push((ARCHIVE_ANCHORS_PATH.to_string(), anchors.to_vec()));
}
for sf in &schema_files {
all_entries.push((
format!("{ARCHIVE_SCHEMA_PREFIX}{}", sf.archive_path),
sf.bytes.clone(),
));
}
for (rel, bytes) in md_entries {
all_entries.push((posix_path(&rel), bytes));
}
all_entries.sort_by(|a, b| a.0.cmp(&b.0));
let mut buf: Vec<u8> = Vec::new();
{
let cursor = Cursor::new(&mut buf);
let mut zip = zip::ZipWriter::new(cursor);
let options = SimpleFileOptions::default()
.compression_method(CompressionMethod::Deflated)
.last_modified_time(fixed_mtime())
.unix_permissions(0o644);
for (archive_path, bytes) in &all_entries {
zip.start_file(archive_path, options)?;
zip.write_all(bytes)?;
}
zip.finish()?;
}
let validated = crate::validator::validate_and_normalize_archive_lenient(&buf)
.map_err(|e| MemExportError::ArchiveValidationFailed(e.to_string()))?;
Ok(MemExportBytes {
bytes: buf,
name: published.name.clone(),
version: published.version.to_string(),
entity_count,
dangling_cross_mem_edges: validated.dangling_cross_mem_edges,
})
}
fn fixed_mtime() -> DateTime {
DateTime::default()
}
fn posix_path(path: &Path) -> String {
path.components()
.filter_map(|c| c.as_os_str().to_str())
.collect::<Vec<_>>()
.join("/")
}
fn collect_markdown(dir: &Path, out: &mut Vec<PathBuf>) -> Result<(), std::io::Error> {
let mut children: Vec<_> = fs::read_dir(dir)?.collect::<Result<_, _>>()?;
children.sort_by_key(|e| e.file_name());
for entry in children {
let path = entry.path();
let name = entry.file_name();
let name = name.to_string_lossy();
if path.is_dir() {
if name.starts_with('.') {
continue;
}
collect_markdown(&path, out)?;
} else if name.ends_with(".md") && name.as_ref() != "README.md" {
out.push(path);
}
}
Ok(())
}