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 #[error("anchors sidecar: {0}")]
96 Anchors(String),
97}
98
99pub fn assemble_archive(workspace_root: &Path) -> Result<Vec<u8>, AssembleError> {
107 let config = read_workspace_config(workspace_root)?;
110 let published = config.to_published()?;
111 let schema_ref: SchemaRef = published.schema.clone();
114
115 let schemas_dir = workspace_root.join(".memstead").join("schemas");
121 let schema_files =
122 collect_schema_source(Some(workspace_root), Some(&schemas_dir), &schema_ref)?;
123
124 let source = EntitySource::Directory {
126 root: workspace_root.to_path_buf(),
127 };
128 let (source_entries, read_errors) = source
129 .read_all()
130 .map_err(|e| AssembleError::Io(e.to_string()))?;
131 if let Some(first) = read_errors.first() {
132 return Err(AssembleError::Io(format!(
133 "{}: {}",
134 first.source_path.display(),
135 first.error
136 )));
137 }
138
139 let mut buf: Vec<u8> = Vec::new();
144 {
145 let cursor = Cursor::new(&mut buf);
146 let mut zip = zip::ZipWriter::new(cursor);
147 let opts = SimpleFileOptions::default()
148 .compression_method(CompressionMethod::Stored)
149 .last_modified_time(zip::DateTime::default());
150
151 let config_bytes = serde_json::to_vec_pretty(&published)?;
154 zip.start_file(ARCHIVE_CONFIG_PATH, opts)?;
155 zip.write_all(&config_bytes)
156 .map_err(|e| AssembleError::Io(format!("write config: {e}")))?;
157
158 let anchors_path = workspace_root.join(crate::anchor::ANCHOR_SIDECAR_PATH);
167 if anchors_path.exists() {
168 let bytes = std::fs::read(&anchors_path)
169 .map_err(|e| AssembleError::Anchors(format!("read: {e}")))?;
170 let sidecar = crate::anchor::AnchorSidecar::from_bytes(&bytes)
171 .map_err(|e| AssembleError::Anchors(e.to_string()))?;
172 if !sidecar.entities.is_empty() {
175 zip.start_file(crate::anchor::ANCHOR_SIDECAR_PATH, opts)?;
176 zip.write_all(&bytes)
177 .map_err(|e| AssembleError::Io(format!("write anchors: {e}")))?;
178 }
179 }
180
181 for sf in &schema_files {
186 let archive_path = format!("{ARCHIVE_SCHEMA_PREFIX}{}", sf.archive_path);
187 zip.start_file(&archive_path, opts)?;
188 zip.write_all(&sf.bytes)
189 .map_err(|e| AssembleError::Io(format!("write schema: {e}")))?;
190 }
191
192 let mut entries = source_entries;
196 entries.sort_by(|a, b| a.relative_path.cmp(&b.relative_path));
197 for entry in &entries {
198 let archive_path = entry.relative_path.replace('\\', "/");
199 zip.start_file(&archive_path, opts)?;
200 zip.write_all(entry.content.as_bytes())
201 .map_err(|e| AssembleError::Io(format!("write entity {archive_path}: {e}")))?;
202 }
203
204 zip.finish()?;
205 }
206 Ok(buf)
207}
208
209pub fn redact_archive_anchors(archive: &[u8]) -> Result<Vec<u8>, AssembleError> {
223 use crate::anchor::{ANCHOR_SIDECAR_PATH, AnchorSidecar};
224 use std::io::Read as _;
225
226 let mut zip = zip::ZipArchive::new(Cursor::new(archive))
227 .map_err(|e| AssembleError::Anchors(format!("read archive: {e}")))?;
228 let names: Vec<String> = zip.file_names().map(str::to_string).collect();
229 if !names.iter().any(|n| n == ANCHOR_SIDECAR_PATH) {
230 return Ok(archive.to_vec());
231 }
232
233 let mut buf: Vec<u8> = Vec::new();
234 {
235 let cursor = Cursor::new(&mut buf);
236 let mut out = zip::ZipWriter::new(cursor);
237 let opts = SimpleFileOptions::default()
238 .compression_method(CompressionMethod::Stored)
239 .last_modified_time(zip::DateTime::default());
240 for index in 0..zip.len() {
241 let mut member = zip
242 .by_index(index)
243 .map_err(|e| AssembleError::Anchors(format!("read member: {e}")))?;
244 let name = member.name().to_string();
245 let mut bytes = Vec::new();
246 member
247 .read_to_end(&mut bytes)
248 .map_err(|e| AssembleError::Io(format!("read member {name}: {e}")))?;
249 if name == ANCHOR_SIDECAR_PATH {
250 let mut sidecar = AnchorSidecar::from_bytes(&bytes)
251 .map_err(|e| AssembleError::Anchors(e.to_string()))?;
252 sidecar.redact_artifact_references();
253 bytes = sidecar.to_bytes();
254 }
255 out.start_file(&name, opts)?;
256 out.write_all(&bytes)
257 .map_err(|e| AssembleError::Io(format!("write member {name}: {e}")))?;
258 }
259 out.finish()?;
260 }
261 Ok(buf)
262}
263
264#[cfg(test)]
265mod tests {
266 use super::*;
267 use crate::filesystem::config::{WorkspaceConfig, write_workspace_config};
268 use crate::validator::ValidatorLimits;
269 use crate::validator::archive::extract_entries;
270 use memstead_schema::SchemaRef;
271 use std::path::PathBuf;
272 use tempfile::TempDir;
273
274 fn versioned(name: &str, version: &str) -> SchemaRef {
275 SchemaRef::new(name, semver::Version::parse(version).unwrap())
276 }
277
278 fn write_workspace(tmp: &TempDir, name: &str, with_version: bool) -> PathBuf {
281 let root = tmp.path().join(name);
282 std::fs::create_dir_all(&root).unwrap();
283 let mut cfg = WorkspaceConfig::new(name, versioned("default", "1.0.0"));
287 if with_version {
288 cfg.description = Some("test mem".into());
289 cfg.add_dep("anthropic/core".parse().unwrap());
290 } else {
291 cfg.version = None;
292 }
293 write_workspace_config(&root, &cfg).unwrap();
294 root
295 }
296
297 fn write_spec(root: &Path, slug: &str, title: &str) {
301 std::fs::write(
302 root.join(format!("{slug}.md")),
303 format!("---\ntype: spec\n---\n# {title}\n"),
304 )
305 .unwrap();
306 }
307
308 #[test]
309 fn assemble_archive_round_trips_through_validator() {
310 let tmp = TempDir::new().unwrap();
311 let root = write_workspace(&tmp, "demo", true);
312
313 write_spec(&root, "first", "First");
315 write_spec(&root, "second", "Second");
316
317 let bytes = assemble_archive(&root).expect("archive must build");
318 assert!(!bytes.is_empty());
319
320 let limits = ValidatorLimits::default();
322 let entries = extract_entries(&bytes, &limits).expect("validator must accept");
323
324 let cfg_text = String::from_utf8_lossy(&entries.config_bytes);
326 assert!(cfg_text.contains("\"name\": \"demo\""));
327 assert!(cfg_text.contains("\"version\": \"0.1.0\""));
328 assert!(!cfg_text.contains("\"deps\""), "deps must drop on publish");
329
330 let schema_paths: Vec<_> = entries
332 .schema_files
333 .iter()
334 .map(|s| s.archive_path.as_str())
335 .collect();
336 assert!(schema_paths.contains(&".memstead/schema/schema.yaml"));
337
338 let md_paths: Vec<_> = entries
340 .markdown_files
341 .iter()
342 .map(|m| m.path.as_str())
343 .collect();
344 assert!(md_paths.contains(&"first.md"));
345 assert!(md_paths.contains(&"second.md"));
346 }
347
348 #[test]
356 fn assemble_archive_embeds_and_redacts_anchors() {
357 let tmp = TempDir::new().unwrap();
358 let root = write_workspace(&tmp, "demo", true);
359 write_spec(&root, "first", "First");
360 std::fs::write(
361 root.join(".memstead").join("anchors.json"),
362 br#"{"version":1,"entities":{"demo--first":[{"artifact":"src/private.rs","grain":"file","class":"anchored","hash_stability":"stable","hash":"h1"}]}}"#,
363 )
364 .unwrap();
365
366 let bytes = assemble_archive(&root).expect("archive must build");
367 let limits = ValidatorLimits::default();
368 let entries = extract_entries(&bytes, &limits).expect("validator must accept");
369 let sidecar_bytes = entries.anchors_bytes.expect("anchors member embedded");
370 assert!(String::from_utf8_lossy(&sidecar_bytes).contains("src/private.rs"));
371
372 let redacted = redact_archive_anchors(&bytes).unwrap();
375 let entries = extract_entries(&redacted, &limits).expect("redacted archive validates");
376 let sidecar =
377 crate::anchor::AnchorSidecar::from_bytes(&entries.anchors_bytes.unwrap()).unwrap();
378 assert_eq!(
379 sidecar.get("demo--first")[0].artifact,
380 crate::anchor::REDACTED_ARTIFACT_SENTINEL
381 );
382 assert!(!String::from_utf8_lossy(&redacted).contains("src/private.rs"));
383
384 std::fs::write(
386 root.join(".memstead").join("anchors.json"),
387 br#"{"version":1,"entities":{}}"#,
388 )
389 .unwrap();
390 let bytes = assemble_archive(&root).unwrap();
391 assert!(
392 extract_entries(&bytes, &limits)
393 .unwrap()
394 .anchors_bytes
395 .is_none(),
396 "no anchors ⇒ no member"
397 );
398
399 std::fs::write(root.join(".memstead").join("anchors.json"), b"{ nope").unwrap();
401 assert!(matches!(
402 assemble_archive(&root),
403 Err(AssembleError::Anchors(_))
404 ));
405 }
406
407 #[test]
408 fn assemble_archive_resolves_installed_workspace_schema() {
409 let tmp = TempDir::new().unwrap();
416 let root = tmp.path().join("demo");
417 std::fs::create_dir_all(&root).unwrap();
418 let mut cfg = WorkspaceConfig::new("demo", versioned("cookbook", "0.1.0"));
419 cfg.description = Some("custom-schema mem".into());
420 write_workspace_config(&root, &cfg).unwrap();
421
422 let schema_dir = root
424 .join(".memstead")
425 .join("schemas")
426 .join("cookbook@0.1.0");
427 std::fs::create_dir_all(schema_dir.join("types")).unwrap();
428 std::fs::write(
429 schema_dir.join("schema.yaml"),
430 "name: cookbook\nversion: 0.1.0\ndescription: installed-cookbook-manifest\ntypes:\n - note\n",
431 )
432 .unwrap();
433 std::fs::write(
434 schema_dir.join("types").join("note.yaml"),
435 "name: note\ndescription: test\n",
436 )
437 .unwrap();
438
439 write_spec(&root, "only", "Only");
440
441 let bytes = assemble_archive(&root).expect("installed schema must resolve");
442 let limits = ValidatorLimits::default();
443 let entries = extract_entries(&bytes, &limits).expect("validator must accept");
444
445 let manifest = entries
447 .schema_files
448 .iter()
449 .find(|s| s.archive_path == ".memstead/schema/schema.yaml")
450 .expect("manifest must embed");
451 assert!(
452 manifest.content.contains("installed-cookbook-manifest"),
453 "embedded manifest must come from .memstead/schemas/cookbook@0.1.0"
454 );
455 assert!(
456 entries
457 .schema_files
458 .iter()
459 .any(|s| s.archive_path == ".memstead/schema/types/note.yaml"),
460 "installed type definitions must embed too"
461 );
462 }
463
464 #[test]
465 fn assemble_archive_rejects_workspace_without_version() {
466 let tmp = TempDir::new().unwrap();
467 let root = write_workspace(&tmp, "demo", false);
470
471 let err = assemble_archive(&root).expect_err("missing version must fail");
472 assert!(matches!(
473 err,
474 AssembleError::Config(PublishConversionError::MissingVersion)
475 ));
476 }
477
478 #[test]
479 fn assemble_archive_excludes_engine_internal_dirs() {
480 let tmp = TempDir::new().unwrap();
485 let root = write_workspace(&tmp, "demo", true);
486 std::fs::write(
487 root.join(".memstead").join("rogue.md"),
488 "---\ntype: spec\n---\n# Rogue\n\n## Identity\n\nNo.\n",
489 )
490 .unwrap();
491
492 write_spec(&root, "visible", "Visible");
493
494 let bytes = assemble_archive(&root).unwrap();
495 let limits = ValidatorLimits::default();
496 let entries = extract_entries(&bytes, &limits).unwrap();
497 let md_paths: Vec<_> = entries
498 .markdown_files
499 .iter()
500 .map(|m| m.path.as_str())
501 .collect();
502 assert!(md_paths.contains(&"visible.md"));
503 assert!(!md_paths.iter().any(|p| p.contains("rogue")));
504 }
505
506 #[test]
507 fn assemble_archive_is_deterministic_across_calls() {
508 let tmp = TempDir::new().unwrap();
509 let root = write_workspace(&tmp, "demo", true);
510 for (slug, title) in [("a", "A"), ("b", "B"), ("c", "C")] {
511 write_spec(&root, slug, title);
512 }
513
514 let bytes1 = assemble_archive(&root).unwrap();
515 let bytes2 = assemble_archive(&root).unwrap();
516 assert_eq!(
517 bytes1, bytes2,
518 "two assemble calls on the same workspace must yield byte-identical archives"
519 );
520 }
521}