memstead_base/filesystem/
publish.rs1use std::io::{Cursor, Write as _};
49use std::path::Path;
50
51use memstead_schema::{
52 ARCHIVE_CONFIG_PATH, ARCHIVE_SCHEMA_PREFIX, PublishConversionError, SchemaRef,
53 SchemaSourceError, collect_schema_source,
54};
55use zip::CompressionMethod;
56use zip::result::ZipError;
57use zip::write::SimpleFileOptions;
58
59use super::config::{WorkspaceConfigError, read_workspace_config};
60use crate::entity::source::EntitySource;
61
62#[derive(Debug, thiserror::Error)]
64pub enum AssembleError {
65 #[error("workspace config: {0}")]
68 WorkspaceConfig(#[from] WorkspaceConfigError),
69 #[error("config projection: {0}")]
73 Config(#[from] PublishConversionError),
74 #[error("schema source: {0}")]
79 Schema(#[from] SchemaSourceError),
80 #[error("workspace io: {0}")]
82 Io(String),
83 #[error("zip writer: {0}")]
87 Zip(#[from] ZipError),
88 #[error("config serialisation: {0}")]
90 Serialise(#[from] serde_json::Error),
91}
92
93pub fn assemble_archive(workspace_root: &Path) -> Result<Vec<u8>, AssembleError> {
101 let config = read_workspace_config(workspace_root)?;
104 let published = config.to_published()?;
105 let schema_ref: SchemaRef = published.schema.clone();
108
109 let schemas_dir = workspace_root.join(".memstead").join("schemas");
115 let schema_files =
116 collect_schema_source(Some(workspace_root), Some(&schemas_dir), &schema_ref)?;
117
118 let source = EntitySource::Directory {
120 root: workspace_root.to_path_buf(),
121 };
122 let (source_entries, read_errors) = source
123 .read_all()
124 .map_err(|e| AssembleError::Io(e.to_string()))?;
125 if let Some(first) = read_errors.first() {
126 return Err(AssembleError::Io(format!(
127 "{}: {}",
128 first.source_path.display(),
129 first.error
130 )));
131 }
132
133 let mut buf: Vec<u8> = Vec::new();
138 {
139 let cursor = Cursor::new(&mut buf);
140 let mut zip = zip::ZipWriter::new(cursor);
141 let opts = SimpleFileOptions::default()
142 .compression_method(CompressionMethod::Stored)
143 .last_modified_time(zip::DateTime::default());
144
145 let config_bytes = serde_json::to_vec_pretty(&published)?;
148 zip.start_file(ARCHIVE_CONFIG_PATH, opts)?;
149 zip.write_all(&config_bytes)
150 .map_err(|e| AssembleError::Io(format!("write config: {e}")))?;
151
152 for sf in &schema_files {
157 let archive_path = format!("{ARCHIVE_SCHEMA_PREFIX}{}", sf.archive_path);
158 zip.start_file(&archive_path, opts)?;
159 zip.write_all(&sf.bytes)
160 .map_err(|e| AssembleError::Io(format!("write schema: {e}")))?;
161 }
162
163 let mut entries = source_entries;
167 entries.sort_by(|a, b| a.relative_path.cmp(&b.relative_path));
168 for entry in &entries {
169 let archive_path = entry.relative_path.replace('\\', "/");
170 zip.start_file(&archive_path, opts)?;
171 zip.write_all(entry.content.as_bytes())
172 .map_err(|e| AssembleError::Io(format!("write entity {archive_path}: {e}")))?;
173 }
174
175 zip.finish()?;
176 }
177 Ok(buf)
178}
179
180#[cfg(test)]
181mod tests {
182 use super::*;
183 use crate::filesystem::config::{WorkspaceConfig, write_workspace_config};
184 use crate::validator::ValidatorLimits;
185 use crate::validator::archive::extract_entries;
186 use memstead_schema::SchemaRef;
187 use std::path::PathBuf;
188 use tempfile::TempDir;
189
190 fn versioned(name: &str, version: &str) -> SchemaRef {
191 SchemaRef::new(name, semver::Version::parse(version).unwrap())
192 }
193
194 fn write_workspace(tmp: &TempDir, name: &str, with_version: bool) -> PathBuf {
197 let root = tmp.path().join(name);
198 std::fs::create_dir_all(&root).unwrap();
199 let mut cfg = WorkspaceConfig::new(name, versioned("default", "1.0.0"));
203 if with_version {
204 cfg.description = Some("test mem".into());
205 cfg.add_dep("anthropic/core".parse().unwrap());
206 } else {
207 cfg.version = None;
208 }
209 write_workspace_config(&root, &cfg).unwrap();
210 root
211 }
212
213 fn write_spec(root: &Path, slug: &str, title: &str) {
217 std::fs::write(
218 root.join(format!("{slug}.md")),
219 format!("---\ntype: spec\n---\n# {title}\n"),
220 )
221 .unwrap();
222 }
223
224 #[test]
225 fn assemble_archive_round_trips_through_validator() {
226 let tmp = TempDir::new().unwrap();
227 let root = write_workspace(&tmp, "demo", true);
228
229 write_spec(&root, "first", "First");
231 write_spec(&root, "second", "Second");
232
233 let bytes = assemble_archive(&root).expect("archive must build");
234 assert!(!bytes.is_empty());
235
236 let limits = ValidatorLimits::default();
238 let entries = extract_entries(&bytes, &limits).expect("validator must accept");
239
240 let cfg_text = String::from_utf8_lossy(&entries.config_bytes);
242 assert!(cfg_text.contains("\"name\": \"demo\""));
243 assert!(cfg_text.contains("\"version\": \"0.1.0\""));
244 assert!(!cfg_text.contains("\"deps\""), "deps must drop on publish");
245
246 let schema_paths: Vec<_> = entries
248 .schema_files
249 .iter()
250 .map(|s| s.archive_path.as_str())
251 .collect();
252 assert!(schema_paths.contains(&".memstead/schema/schema.yaml"));
253
254 let md_paths: Vec<_> = entries
256 .markdown_files
257 .iter()
258 .map(|m| m.path.as_str())
259 .collect();
260 assert!(md_paths.contains(&"first.md"));
261 assert!(md_paths.contains(&"second.md"));
262 }
263
264 #[test]
265 fn assemble_archive_resolves_installed_workspace_schema() {
266 let tmp = TempDir::new().unwrap();
273 let root = tmp.path().join("demo");
274 std::fs::create_dir_all(&root).unwrap();
275 let mut cfg = WorkspaceConfig::new("demo", versioned("cookbook", "0.1.0"));
276 cfg.description = Some("custom-schema mem".into());
277 write_workspace_config(&root, &cfg).unwrap();
278
279 let schema_dir = root
281 .join(".memstead")
282 .join("schemas")
283 .join("cookbook@0.1.0");
284 std::fs::create_dir_all(schema_dir.join("types")).unwrap();
285 std::fs::write(
286 schema_dir.join("schema.yaml"),
287 "name: cookbook\nversion: 0.1.0\ndescription: installed-cookbook-manifest\ntypes:\n - note\n",
288 )
289 .unwrap();
290 std::fs::write(
291 schema_dir.join("types").join("note.yaml"),
292 "name: note\ndescription: test\n",
293 )
294 .unwrap();
295
296 write_spec(&root, "only", "Only");
297
298 let bytes = assemble_archive(&root).expect("installed schema must resolve");
299 let limits = ValidatorLimits::default();
300 let entries = extract_entries(&bytes, &limits).expect("validator must accept");
301
302 let manifest = entries
304 .schema_files
305 .iter()
306 .find(|s| s.archive_path == ".memstead/schema/schema.yaml")
307 .expect("manifest must embed");
308 assert!(
309 manifest.content.contains("installed-cookbook-manifest"),
310 "embedded manifest must come from .memstead/schemas/cookbook@0.1.0"
311 );
312 assert!(
313 entries
314 .schema_files
315 .iter()
316 .any(|s| s.archive_path == ".memstead/schema/types/note.yaml"),
317 "installed type definitions must embed too"
318 );
319 }
320
321 #[test]
322 fn assemble_archive_rejects_workspace_without_version() {
323 let tmp = TempDir::new().unwrap();
324 let root = write_workspace(&tmp, "demo", false);
327
328 let err = assemble_archive(&root).expect_err("missing version must fail");
329 assert!(matches!(
330 err,
331 AssembleError::Config(PublishConversionError::MissingVersion)
332 ));
333 }
334
335 #[test]
336 fn assemble_archive_excludes_engine_internal_dirs() {
337 let tmp = TempDir::new().unwrap();
342 let root = write_workspace(&tmp, "demo", true);
343 std::fs::write(
344 root.join(".memstead").join("rogue.md"),
345 "---\ntype: spec\n---\n# Rogue\n\n## Identity\n\nNo.\n",
346 )
347 .unwrap();
348
349 write_spec(&root, "visible", "Visible");
350
351 let bytes = assemble_archive(&root).unwrap();
352 let limits = ValidatorLimits::default();
353 let entries = extract_entries(&bytes, &limits).unwrap();
354 let md_paths: Vec<_> = entries
355 .markdown_files
356 .iter()
357 .map(|m| m.path.as_str())
358 .collect();
359 assert!(md_paths.contains(&"visible.md"));
360 assert!(!md_paths.iter().any(|p| p.contains("rogue")));
361 }
362
363 #[test]
364 fn assemble_archive_is_deterministic_across_calls() {
365 let tmp = TempDir::new().unwrap();
366 let root = write_workspace(&tmp, "demo", true);
367 for (slug, title) in [("a", "A"), ("b", "B"), ("c", "C")] {
368 write_spec(&root, slug, title);
369 }
370
371 let bytes1 = assemble_archive(&root).unwrap();
372 let bytes2 = assemble_archive(&root).unwrap();
373 assert_eq!(
374 bytes1, bytes2,
375 "two assemble calls on the same workspace must yield byte-identical archives"
376 );
377 }
378}