use std::io::{Cursor, Write as _};
use std::path::Path;
use memstead_schema::{
ARCHIVE_CONFIG_PATH, ARCHIVE_SCHEMA_PREFIX, PublishConversionError, SchemaRef,
SchemaSourceError, collect_schema_source,
};
use zip::CompressionMethod;
use zip::result::ZipError;
use zip::write::SimpleFileOptions;
use super::config::{WorkspaceConfigError, read_workspace_config};
use crate::entity::source::EntitySource;
#[derive(Debug, thiserror::Error)]
pub enum AssembleError {
#[error("workspace config: {0}")]
WorkspaceConfig(#[from] WorkspaceConfigError),
#[error("config projection: {0}")]
Config(#[from] PublishConversionError),
#[error("schema source: {0}")]
Schema(#[from] SchemaSourceError),
#[error("workspace io: {0}")]
Io(String),
#[error("zip writer: {0}")]
Zip(#[from] ZipError),
#[error("config serialisation: {0}")]
Serialise(#[from] serde_json::Error),
}
pub fn assemble_archive(workspace_root: &Path) -> Result<Vec<u8>, AssembleError> {
let config = read_workspace_config(workspace_root)?;
let published = config.to_published()?;
let schema_ref: SchemaRef = published.schema.clone();
let schemas_dir = workspace_root.join(".memstead").join("schemas");
let schema_files =
collect_schema_source(Some(workspace_root), Some(&schemas_dir), &schema_ref)?;
let source = EntitySource::Directory {
root: workspace_root.to_path_buf(),
};
let (source_entries, read_errors) = source
.read_all()
.map_err(|e| AssembleError::Io(e.to_string()))?;
if let Some(first) = read_errors.first() {
return Err(AssembleError::Io(format!(
"{}: {}",
first.source_path.display(),
first.error
)));
}
let mut buf: Vec<u8> = Vec::new();
{
let cursor = Cursor::new(&mut buf);
let mut zip = zip::ZipWriter::new(cursor);
let opts = SimpleFileOptions::default()
.compression_method(CompressionMethod::Stored)
.last_modified_time(zip::DateTime::default());
let config_bytes = serde_json::to_vec_pretty(&published)?;
zip.start_file(ARCHIVE_CONFIG_PATH, opts)?;
zip.write_all(&config_bytes)
.map_err(|e| AssembleError::Io(format!("write config: {e}")))?;
for sf in &schema_files {
let archive_path = format!("{ARCHIVE_SCHEMA_PREFIX}{}", sf.archive_path);
zip.start_file(&archive_path, opts)?;
zip.write_all(&sf.bytes)
.map_err(|e| AssembleError::Io(format!("write schema: {e}")))?;
}
let mut entries = source_entries;
entries.sort_by(|a, b| a.relative_path.cmp(&b.relative_path));
for entry in &entries {
let archive_path = entry.relative_path.replace('\\', "/");
zip.start_file(&archive_path, opts)?;
zip.write_all(entry.content.as_bytes())
.map_err(|e| AssembleError::Io(format!("write entity {archive_path}: {e}")))?;
}
zip.finish()?;
}
Ok(buf)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::filesystem::config::{WorkspaceConfig, write_workspace_config};
use crate::validator::ValidatorLimits;
use crate::validator::archive::extract_entries;
use memstead_schema::SchemaRef;
use std::path::PathBuf;
use tempfile::TempDir;
fn versioned(name: &str, version: &str) -> SchemaRef {
SchemaRef::new(name, semver::Version::parse(version).unwrap())
}
fn write_workspace(tmp: &TempDir, name: &str, with_version: bool) -> PathBuf {
let root = tmp.path().join(name);
std::fs::create_dir_all(&root).unwrap();
let mut cfg = WorkspaceConfig::new(name, versioned("default", "1.0.0"));
if with_version {
cfg.description = Some("test mem".into());
cfg.add_dep("anthropic/core".parse().unwrap());
} else {
cfg.version = None;
}
write_workspace_config(&root, &cfg).unwrap();
root
}
fn write_spec(root: &Path, slug: &str, title: &str) {
std::fs::write(
root.join(format!("{slug}.md")),
format!("---\ntype: spec\n---\n# {title}\n"),
)
.unwrap();
}
#[test]
fn assemble_archive_round_trips_through_validator() {
let tmp = TempDir::new().unwrap();
let root = write_workspace(&tmp, "demo", true);
write_spec(&root, "first", "First");
write_spec(&root, "second", "Second");
let bytes = assemble_archive(&root).expect("archive must build");
assert!(!bytes.is_empty());
let limits = ValidatorLimits::default();
let entries = extract_entries(&bytes, &limits).expect("validator must accept");
let cfg_text = String::from_utf8_lossy(&entries.config_bytes);
assert!(cfg_text.contains("\"name\": \"demo\""));
assert!(cfg_text.contains("\"version\": \"0.1.0\""));
assert!(!cfg_text.contains("\"deps\""), "deps must drop on publish");
let schema_paths: Vec<_> = entries
.schema_files
.iter()
.map(|s| s.archive_path.as_str())
.collect();
assert!(schema_paths.contains(&".memstead/schema/schema.yaml"));
let md_paths: Vec<_> = entries
.markdown_files
.iter()
.map(|m| m.path.as_str())
.collect();
assert!(md_paths.contains(&"first.md"));
assert!(md_paths.contains(&"second.md"));
}
#[test]
fn assemble_archive_resolves_installed_workspace_schema() {
let tmp = TempDir::new().unwrap();
let root = tmp.path().join("demo");
std::fs::create_dir_all(&root).unwrap();
let mut cfg = WorkspaceConfig::new("demo", versioned("cookbook", "0.1.0"));
cfg.description = Some("custom-schema mem".into());
write_workspace_config(&root, &cfg).unwrap();
let schema_dir = root
.join(".memstead")
.join("schemas")
.join("cookbook@0.1.0");
std::fs::create_dir_all(schema_dir.join("types")).unwrap();
std::fs::write(
schema_dir.join("schema.yaml"),
"name: cookbook\nversion: 0.1.0\ndescription: installed-cookbook-manifest\ntypes:\n - note\n",
)
.unwrap();
std::fs::write(
schema_dir.join("types").join("note.yaml"),
"name: note\ndescription: test\n",
)
.unwrap();
write_spec(&root, "only", "Only");
let bytes = assemble_archive(&root).expect("installed schema must resolve");
let limits = ValidatorLimits::default();
let entries = extract_entries(&bytes, &limits).expect("validator must accept");
let manifest = entries
.schema_files
.iter()
.find(|s| s.archive_path == ".memstead/schema/schema.yaml")
.expect("manifest must embed");
assert!(
manifest.content.contains("installed-cookbook-manifest"),
"embedded manifest must come from .memstead/schemas/cookbook@0.1.0"
);
assert!(
entries
.schema_files
.iter()
.any(|s| s.archive_path == ".memstead/schema/types/note.yaml"),
"installed type definitions must embed too"
);
}
#[test]
fn assemble_archive_rejects_workspace_without_version() {
let tmp = TempDir::new().unwrap();
let root = write_workspace(&tmp, "demo", false);
let err = assemble_archive(&root).expect_err("missing version must fail");
assert!(matches!(
err,
AssembleError::Config(PublishConversionError::MissingVersion)
));
}
#[test]
fn assemble_archive_excludes_engine_internal_dirs() {
let tmp = TempDir::new().unwrap();
let root = write_workspace(&tmp, "demo", true);
std::fs::write(
root.join(".memstead").join("rogue.md"),
"---\ntype: spec\n---\n# Rogue\n\n## Identity\n\nNo.\n",
)
.unwrap();
write_spec(&root, "visible", "Visible");
let bytes = assemble_archive(&root).unwrap();
let limits = ValidatorLimits::default();
let entries = extract_entries(&bytes, &limits).unwrap();
let md_paths: Vec<_> = entries
.markdown_files
.iter()
.map(|m| m.path.as_str())
.collect();
assert!(md_paths.contains(&"visible.md"));
assert!(!md_paths.iter().any(|p| p.contains("rogue")));
}
#[test]
fn assemble_archive_is_deterministic_across_calls() {
let tmp = TempDir::new().unwrap();
let root = write_workspace(&tmp, "demo", true);
for (slug, title) in [("a", "A"), ("b", "B"), ("c", "C")] {
write_spec(&root, slug, title);
}
let bytes1 = assemble_archive(&root).unwrap();
let bytes2 = assemble_archive(&root).unwrap();
assert_eq!(
bytes1, bytes2,
"two assemble calls on the same workspace must yield byte-identical archives"
);
}
}