Skip to main content

memstead_base/filesystem/
publish.rs

1//! Filesystem-mem → `.mem` archive assembler.
2//!
3//! Walks a workspace root on disk, reads the workspace config,
4//! projects it to the strict archive shape, embeds the resolved schema
5//! source under `.memstead/schema/`, and packs every entity `.md` file
6//! into a deterministic zip.
7//!
8//! Engine-agnostic: the caller passes a path. Used by both
9//! `memstead publish` and `memstead export --format mem`, neither of which
10//! needs a live engine for the archive build (the workspace config
11//! and the entity walker both read from disk directly).
12//!
13//! ## Archive layout (matches `memstead-base::validator::archive`)
14//!
15//! ```text
16//! .memstead/config.json               # archive shape (PublishedMemConfig)
17//! .memstead/schema/schema.yaml        # schema manifest
18//! .memstead/schema/types/<name>.yaml  # per-type definitions
19//! <mem-relative entity path>.md     # one per entity in the workspace
20//! ```
21//!
22//! ## Determinism
23//!
24//! - Entity `.md` files are emitted in mem-relative path order — the
25//!   same order [`crate::entity::source::EntitySource::Directory`]
26//!   yields them on read.
27//! - Schema files come from
28//!   [`memstead_schema::collect_schema_source`], which already sorts by
29//!   `archive_path`.
30//! - The `.memstead/config.json` is serialised pretty-printed for human
31//!   inspection but with deterministic key order via
32//!   [`memstead_schema::PublishedMemConfig`]'s `Serialize` impl.
33//! - Compression is fixed at `Stored` so repeated assembly of the same
34//!   workspace yields byte-identical archive bytes (modulo zip-level
35//!   timestamps, which the writer leaves at zero by default).
36//!
37//! ## What this does NOT do
38//!
39//! - HTTP. The CLI's `commands::publish` posts the bytes; this module
40//!   stops at "build the byte buffer". Same separation as the
41//!   mem-repo `export_mem` → `commands::publish` flow.
42//! - Validate. The bytes go through `validator::archive::extract_entries`
43//!   on the registry side; doing it here too would double the work for
44//!   the same answer. Tests in this module *do* re-validate so future
45//!   layout changes surface as test failures rather than registry
46//!   rejections.
47
48use 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/// Errors surfaced by [`assemble_archive`].
63#[derive(Debug, thiserror::Error)]
64pub enum AssembleError {
65    /// The workspace config could not be read
66    /// or parsed (missing file, malformed JSON, format mismatch).
67    #[error("workspace config: {0}")]
68    WorkspaceConfig(#[from] WorkspaceConfigError),
69    /// The workspace config does not project cleanly to the archive
70    /// shape — typically because `version` is unset on the workspace
71    /// config.
72    #[error("config projection: {0}")]
73    Config(#[from] PublishConversionError),
74    /// Resolving the schema's source files failed — either the
75    /// `name@version` pin does not match any builtin (and there's no
76    /// workspace-local schema dir) or the on-disk schema directory is
77    /// malformed.
78    #[error("schema source: {0}")]
79    Schema(#[from] SchemaSourceError),
80    /// I/O while reading entity `.md` files from the workspace.
81    #[error("workspace io: {0}")]
82    Io(String),
83    /// Zip-level error while writing into the in-memory buffer.
84    /// Should not happen in practice — the buffer is unbounded — but
85    /// surfaces cleanly if a future zip version starts failing earlier.
86    #[error("zip writer: {0}")]
87    Zip(#[from] ZipError),
88    /// Serialising the archive's `.memstead/config.json`.
89    #[error("config serialisation: {0}")]
90    Serialise(#[from] serde_json::Error),
91}
92
93/// Build the archive bytes for the workspace at `workspace_root`.
94///
95/// The caller writes the bytes to a tempfile and POSTs them to the
96/// registry — this function does not touch the network. It reads
97/// the workspace config and walks every `.md` file
98/// under `workspace_root`; both reads are direct (no engine
99/// involvement).
100pub fn assemble_archive(workspace_root: &Path) -> Result<Vec<u8>, AssembleError> {
101    // 1. Read the workspace config and project it to the strict
102    //    archive shape.
103    let config = read_workspace_config(workspace_root)?;
104    let published = config.to_published()?;
105    // The projection guarantees a versioned schema pin; reuse it for
106    // the schema-source resolver.
107    let schema_ref: SchemaRef = published.schema.clone();
108
109    // 2. Resolve the schema source files. Installed packages live under
110    //    `.memstead/schemas/` (the fixed `memstead schema install`
111    //    destination — same wiring as `Engine::export_mem_to_bytes`);
112    //    the workspace root also enables the `.memstead.cache/schemas/`
113    //    layer. Builtins remain the final fallback.
114    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    // 3. Walk every entity `.md` under the workspace.
119    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    // 4. Pack into a zip. Sort entries by archive path for
134    //    determinism — the directory walker already sorts but
135    //    re-sorting here makes the contract explicit (a future change
136    //    in the walker won't break archive determinism).
137    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        // .memstead/config.json (archive shape — `deps` and other
146        // workspace-local fields are dropped by `to_published`).
147        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        // .memstead/schema/* — `collect_schema_source` returns paths
153        // rooted at the schema dir (`schema.yaml`, `types/<name>.yaml`).
154        // Prepend the archive's `.memstead/schema/` root so the validator
155        // picks them up under the right path.
156        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        // Entity .md files. Source-walked paths use the platform
164        // separator on Directory; normalise to forward-slash so the
165        // archive is portable across OSes.
166        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    /// Create the mem in a folder *named after it* (identity is path-derived
195    /// under the unified layout) and return the mem root.
196    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        // F1: `WorkspaceConfig::new` now seeds `version = Some(0.1.0)`
200        // by default. To simulate the pre-gate / externally-imported
201        // config in the no-version test path, clear it explicitly.
202        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    /// Write a minimal valid spec entity directly to disk.
214    /// `assemble_archive` walks the directory itself so the entity
215    /// just needs to exist on disk in canonical markdown form.
216    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        // Two entities so the archive has a non-empty markdown set.
230        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        // Round-trip through the same validator the registry uses.
237        let limits = ValidatorLimits::default();
238        let entries = extract_entries(&bytes, &limits).expect("validator must accept");
239
240        // Config: present and projects to the archive shape (no `deps`).
241        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        // Schema: at least the manifest is present.
247        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        // Entities: both markdown files made it in.
255        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        // Regression: bare `memstead publish` / `memstead export --format
267        // mem` on a folder workspace pinned to an INSTALLED custom schema
268        // used to fail with "schema <ref> not found — candidate paths
269        // tried: []" because the resolver ran built-in-only. The archive
270        // assembler must consult `.memstead/schemas/<name>@<version>/` —
271        // the `memstead schema install` destination.
272        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        // Install-shaped package dir, as `memstead schema install` writes it.
280        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        // The embedded schema is the *installed* package, not a builtin.
303        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        // Skip `version` on the workspace config — `to_published`
325        // surfaces `MissingVersion`.
326        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        // The walker already skips `.git/` and `.memstead/`; this is the
338        // contract test that the publish path inherits that behaviour. A
339        // stray markdown file inside the meta dir must NOT land in the
340        // archive's markdown set.
341        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}