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 } else {
290 cfg.version = None;
291 }
292 write_workspace_config(&root, &cfg).unwrap();
293 root
294 }
295
296 fn write_spec(root: &Path, slug: &str, title: &str) {
300 std::fs::write(
301 root.join(format!("{slug}.md")),
302 format!("---\ntype: spec\n---\n# {title}\n"),
303 )
304 .unwrap();
305 }
306
307 #[test]
308 fn assemble_archive_round_trips_through_validator() {
309 let tmp = TempDir::new().unwrap();
310 let root = write_workspace(&tmp, "demo", true);
311
312 write_spec(&root, "first", "First");
314 write_spec(&root, "second", "Second");
315
316 let bytes = assemble_archive(&root).expect("archive must build");
317 assert!(!bytes.is_empty());
318
319 let limits = ValidatorLimits::default();
321 let entries = extract_entries(&bytes, &limits).expect("validator must accept");
322
323 let cfg_text = String::from_utf8_lossy(&entries.config_bytes);
325 assert!(cfg_text.contains("\"name\": \"demo\""));
326 assert!(cfg_text.contains("\"version\": \"0.1.0\""));
327 assert!(!cfg_text.contains("\"deps\""), "deps must drop on publish");
328
329 let schema_paths: Vec<_> = entries
331 .schema_files
332 .iter()
333 .map(|s| s.archive_path.as_str())
334 .collect();
335 assert!(schema_paths.contains(&".memstead/schema/schema.yaml"));
336
337 let md_paths: Vec<_> = entries
339 .markdown_files
340 .iter()
341 .map(|m| m.path.as_str())
342 .collect();
343 assert!(md_paths.contains(&"first.md"));
344 assert!(md_paths.contains(&"second.md"));
345 }
346
347 #[test]
355 fn assemble_archive_embeds_and_redacts_anchors() {
356 let tmp = TempDir::new().unwrap();
357 let root = write_workspace(&tmp, "demo", true);
358 write_spec(&root, "first", "First");
359 std::fs::write(
360 root.join(".memstead").join("anchors.json"),
361 br#"{"version":1,"entities":{"demo--first":[{"artifact":"src/private.rs","grain":"file","class":"anchored","hash_stability":"stable","hash":"h1"}]}}"#,
362 )
363 .unwrap();
364
365 let bytes = assemble_archive(&root).expect("archive must build");
366 let limits = ValidatorLimits::default();
367 let entries = extract_entries(&bytes, &limits).expect("validator must accept");
368 let sidecar_bytes = entries.anchors_bytes.expect("anchors member embedded");
369 assert!(String::from_utf8_lossy(&sidecar_bytes).contains("src/private.rs"));
370
371 let redacted = redact_archive_anchors(&bytes).unwrap();
374 let entries = extract_entries(&redacted, &limits).expect("redacted archive validates");
375 let sidecar =
376 crate::anchor::AnchorSidecar::from_bytes(&entries.anchors_bytes.unwrap()).unwrap();
377 assert_eq!(
378 sidecar.get("demo--first")[0].artifact,
379 crate::anchor::REDACTED_ARTIFACT_SENTINEL
380 );
381 assert!(!String::from_utf8_lossy(&redacted).contains("src/private.rs"));
382
383 std::fs::write(
385 root.join(".memstead").join("anchors.json"),
386 br#"{"version":1,"entities":{}}"#,
387 )
388 .unwrap();
389 let bytes = assemble_archive(&root).unwrap();
390 assert!(
391 extract_entries(&bytes, &limits)
392 .unwrap()
393 .anchors_bytes
394 .is_none(),
395 "no anchors ⇒ no member"
396 );
397
398 std::fs::write(root.join(".memstead").join("anchors.json"), b"{ nope").unwrap();
400 assert!(matches!(
401 assemble_archive(&root),
402 Err(AssembleError::Anchors(_))
403 ));
404 }
405
406 #[test]
407 fn assemble_archive_resolves_installed_workspace_schema() {
408 let tmp = TempDir::new().unwrap();
415 let root = tmp.path().join("demo");
416 std::fs::create_dir_all(&root).unwrap();
417 let mut cfg = WorkspaceConfig::new("demo", versioned("cookbook", "0.1.0"));
418 cfg.description = Some("custom-schema mem".into());
419 write_workspace_config(&root, &cfg).unwrap();
420
421 let schema_dir = root
423 .join(".memstead")
424 .join("schemas")
425 .join("cookbook@0.1.0");
426 std::fs::create_dir_all(schema_dir.join("types")).unwrap();
427 std::fs::write(
428 schema_dir.join("schema.yaml"),
429 "name: cookbook\nversion: 0.1.0\ndescription: installed-cookbook-manifest\ntypes:\n - note\n",
430 )
431 .unwrap();
432 std::fs::write(
433 schema_dir.join("types").join("note.yaml"),
434 "name: note\ndescription: test\n",
435 )
436 .unwrap();
437
438 write_spec(&root, "only", "Only");
439
440 let bytes = assemble_archive(&root).expect("installed schema must resolve");
441 let limits = ValidatorLimits::default();
442 let entries = extract_entries(&bytes, &limits).expect("validator must accept");
443
444 let manifest = entries
446 .schema_files
447 .iter()
448 .find(|s| s.archive_path == ".memstead/schema/schema.yaml")
449 .expect("manifest must embed");
450 assert!(
451 manifest.content.contains("installed-cookbook-manifest"),
452 "embedded manifest must come from .memstead/schemas/cookbook@0.1.0"
453 );
454 assert!(
455 entries
456 .schema_files
457 .iter()
458 .any(|s| s.archive_path == ".memstead/schema/types/note.yaml"),
459 "installed type definitions must embed too"
460 );
461 }
462
463 #[test]
464 fn assemble_archive_rejects_workspace_without_version() {
465 let tmp = TempDir::new().unwrap();
466 let root = write_workspace(&tmp, "demo", false);
469
470 let err = assemble_archive(&root).expect_err("missing version must fail");
471 assert!(matches!(
472 err,
473 AssembleError::Config(PublishConversionError::MissingVersion)
474 ));
475 }
476
477 #[test]
478 fn assemble_archive_excludes_engine_internal_dirs() {
479 let tmp = TempDir::new().unwrap();
484 let root = write_workspace(&tmp, "demo", true);
485 std::fs::write(
486 root.join(".memstead").join("rogue.md"),
487 "---\ntype: spec\n---\n# Rogue\n\n## Identity\n\nNo.\n",
488 )
489 .unwrap();
490
491 write_spec(&root, "visible", "Visible");
492
493 let bytes = assemble_archive(&root).unwrap();
494 let limits = ValidatorLimits::default();
495 let entries = extract_entries(&bytes, &limits).unwrap();
496 let md_paths: Vec<_> = entries
497 .markdown_files
498 .iter()
499 .map(|m| m.path.as_str())
500 .collect();
501 assert!(md_paths.contains(&"visible.md"));
502 assert!(!md_paths.iter().any(|p| p.contains("rogue")));
503 }
504
505 #[test]
506 fn assemble_archive_is_deterministic_across_calls() {
507 let tmp = TempDir::new().unwrap();
508 let root = write_workspace(&tmp, "demo", true);
509 for (slug, title) in [("a", "A"), ("b", "B"), ("c", "C")] {
510 write_spec(&root, slug, title);
511 }
512
513 let bytes1 = assemble_archive(&root).unwrap();
514 let bytes2 = assemble_archive(&root).unwrap();
515 assert_eq!(
516 bytes1, bytes2,
517 "two assemble calls on the same workspace must yield byte-identical archives"
518 );
519 }
520}