Skip to main content

memstead_schema/
source.rs

1//! Schema source-file collection for publishing mem archives.
2//!
3//! A published `.mem` archive is portable only if the schema it pins
4//! travels inside it — otherwise opening the archive on a foreign
5//! machine without the matching schema registered would fail at
6//! resolve time. `collect_schema_source` resolves a `SchemaRef` to the
7//! raw YAML bytes callers need to zip into the archive's `schema/`
8//! tree.
9//!
10//! Resolution order (first match wins):
11//! 1. `<workspace>/<schemas_dir>/<name>/` — workspace-level shared
12//!    schemas (optional; when the caller supplies a workspace path)
13//! 2. Embedded built-in (via `include_dir!`) — ships inside the binary
14//!
15//! In every case the manifest's declared version is checked against the
16//! pin; a name collision at the wrong version falls through rather than
17//! silently embedding a mismatched schema.
18
19/// Filename of the install-time provenance stamp `memstead schema
20/// install` writes INTO the sealed package when the install source was
21/// an authoring directory: `{"authoring_path": "<canonical path>"}`.
22/// The stamp is the detection basis for the authoring-drift health
23/// axis — a schema without one (sealed pre-stamp, built-in, name- or
24/// archive-sourced install) is simply not checked, because a guessed
25/// provenance is worse than an absent one. The stamp is workspace-
26/// local by design: every package collector in this module reads
27/// selectively (`schema.yaml`, `types/*.yaml`, `mem-template.json`,
28/// `README.md`) and therefore never picks it up, and the git-ref
29/// export path excludes it by name — a published `.mem` archive never
30/// carries another machine's filesystem path.
31pub const INSTALL_PROVENANCE_FILE: &str = "install-provenance.json";
32
33use std::path::{Path, PathBuf};
34
35use crate::builtins::builtin_schemas_dir;
36use crate::config::SchemaRef;
37
38/// One source file destined for the archive's `.memstead/schema/` tree.
39///
40/// `archive_path` is the relative path *inside* the archive (e.g.
41/// `"schema.yaml"` or `"types/spec.yaml"`). Callers prefix it with
42/// whatever root they want (the archive writer uses `".memstead/schema/"`);
43/// the canonical re-pack uses the same prefix so byte-identical
44/// archives round-trip.
45#[derive(Debug, Clone)]
46pub struct SchemaSourceFile {
47    pub archive_path: String,
48    pub bytes: Vec<u8>,
49}
50
51#[derive(Debug, thiserror::Error)]
52pub enum SchemaSourceError {
53    #[error(
54        "schema {schema_ref} not found — candidate paths tried: [{}]",
55        .candidates.iter().map(|p| p.display().to_string()).collect::<Vec<_>>().join(", ")
56    )]
57    NotFound {
58        schema_ref: String,
59        /// Every filesystem location consulted before falling through
60        /// to the embedded builtins. Listed in resolution order so the
61        /// error message matches the precedence the resolver walked.
62        candidates: Vec<PathBuf>,
63    },
64
65    #[error("i/o error reading schema source at {}: {source}", .path.display())]
66    Io {
67        path: PathBuf,
68        #[source]
69        source: std::io::Error,
70    },
71
72    #[error(
73        "schema manifest at {} does not declare version '{expected}' (found '{found}')",
74        .path.display()
75    )]
76    VersionMismatch {
77        path: PathBuf,
78        expected: String,
79        found: String,
80    },
81
82    #[error("schema manifest at {} is malformed: {reason}", .path.display())]
83    MalformedManifest { path: PathBuf, reason: String },
84}
85
86/// Resolve the schema pinned by `schema_ref` to a sorted set of source
87/// files ready to embed under `.memstead/schema/` in a mem archive.
88///
89/// The returned vector is sorted by `archive_path` so archive bytes are
90/// deterministic — callers don't need to re-sort.
91///
92/// Resolution order (first match wins):
93/// 1. `<workspace_schemas_dir>/<name>/` (when provided)
94/// 2. Embedded builtins
95///
96/// A `<workspace_root>/.memstead.cache/schemas/` layer used to sit
97/// between them, meant to hold schemas extracted from installed
98/// archives. Nothing ever populated it; the install path stages into
99/// the backend's schema source instead. `workspace_root` is kept in
100/// the signature because callers thread it and a storage-rooted layer
101/// belongs here if one returns.
102///
103/// When every filesystem layer misses, the returned `NotFound` lists
104/// every concrete path that was consulted so callers (and agents) can
105/// inspect the resolution trace without re-walking the filesystem.
106pub fn collect_schema_source(
107    _workspace_root: Option<&Path>,
108    workspace_schemas_dir: Option<&Path>,
109    schema_ref: &SchemaRef,
110) -> Result<Vec<SchemaSourceFile>, SchemaSourceError> {
111    let mut candidates: Vec<PathBuf> = Vec::new();
112
113    if let Some(ws_dir) = workspace_schemas_dir {
114        // Two directory shapes: `<name>@<version>/` is what `memstead
115        // schema install` writes; the bare `<name>/` form predates the
116        // versioned layout and stays supported for hand-authored dirs.
117        let versioned_dir = ws_dir.join(format!("{}@{}", schema_ref.name, schema_ref.version));
118        candidates.push(versioned_dir.clone());
119        if versioned_dir.is_dir()
120            && let Some(files) = try_collect_dir(&versioned_dir, schema_ref)?
121        {
122            return Ok(files);
123        }
124        let ws_schema_dir = ws_dir.join(&schema_ref.name);
125        candidates.push(ws_schema_dir.clone());
126        if ws_schema_dir.is_dir()
127            && let Some(files) = try_collect_dir(&ws_schema_dir, schema_ref)?
128        {
129            return Ok(files);
130        }
131    }
132
133    if let Some(files) = collect_builtin_source(schema_ref)? {
134        return Ok(files);
135    }
136
137    Err(SchemaSourceError::NotFound {
138        schema_ref: schema_ref.as_display(),
139        candidates,
140    })
141}
142
143/// Read `<dir>/schema.yaml` + `<dir>/types/*.yaml` and return them
144/// only if the manifest's declared version matches `schema_ref`.
145/// A mismatched version returns `Ok(None)` so the caller can fall
146/// through to the next resolution layer rather than hard-failing.
147fn try_collect_dir(
148    dir: &Path,
149    schema_ref: &SchemaRef,
150) -> Result<Option<Vec<SchemaSourceFile>>, SchemaSourceError> {
151    let manifest_path = dir.join("schema.yaml");
152    let manifest_bytes = std::fs::read(&manifest_path).map_err(|e| SchemaSourceError::Io {
153        path: manifest_path.clone(),
154        source: e,
155    })?;
156
157    if !manifest_matches(&manifest_bytes, schema_ref, &manifest_path)? {
158        return Ok(None);
159    }
160
161    let mut out = vec![SchemaSourceFile {
162        archive_path: "schema.yaml".to_string(),
163        bytes: manifest_bytes,
164    }];
165
166    // The sealed format marker rides as-found — it records the
167    // package's metadata-polarity generation, so a collected source
168    // seals with the same reading its origin carries.
169    let marker_path = dir.join(crate::loader::SCHEMA_FORMAT_MARKER_FILE);
170    if marker_path.is_file() {
171        let bytes = std::fs::read(&marker_path).map_err(|e| SchemaSourceError::Io {
172            path: marker_path.clone(),
173            source: e,
174        })?;
175        out.push(SchemaSourceFile {
176            archive_path: crate::loader::SCHEMA_FORMAT_MARKER_FILE.to_string(),
177            bytes,
178        });
179    }
180
181    let types_dir = dir.join("types");
182    if types_dir.is_dir() {
183        let entries = std::fs::read_dir(&types_dir).map_err(|e| SchemaSourceError::Io {
184            path: types_dir.clone(),
185            source: e,
186        })?;
187        for entry in entries {
188            let entry = entry.map_err(|e| SchemaSourceError::Io {
189                path: types_dir.clone(),
190                source: e,
191            })?;
192            let path = entry.path();
193            if path.extension().and_then(|s| s.to_str()) != Some("yaml") {
194                continue;
195            }
196            let Some(stem) = path.file_stem().and_then(|s| s.to_str()) else {
197                continue;
198            };
199            let bytes = std::fs::read(&path).map_err(|e| SchemaSourceError::Io {
200                path: path.clone(),
201                source: e,
202            })?;
203            out.push(SchemaSourceFile {
204                archive_path: format!("types/{stem}.yaml"),
205                bytes,
206            });
207        }
208    }
209
210    out.sort_by(|a, b| a.archive_path.cmp(&b.archive_path));
211    Ok(Some(out))
212}
213
214fn collect_builtin_source(
215    schema_ref: &SchemaRef,
216) -> Result<Option<Vec<SchemaSourceFile>>, SchemaSourceError> {
217    // Name-exact directory first — the current generation lives there.
218    if let Some(schema_dir) = builtin_schemas_dir().get_dir(schema_ref.name.as_str())
219        && let Some(files) = collect_builtin_dir(schema_dir, schema_ref)?
220    {
221        return Ok(Some(files));
222    }
223    // Retained older generations live in suffixed sibling directories
224    // (`planning` vs `planning-0.2`, …) under the append-only
225    // retention pattern — the directory name is organisational only,
226    // identity comes from each package's manifest. Scan the whole
227    // embedded catalogue for the (name, version) the ref pins; the
228    // registry registers every retained version, so the collect path
229    // must resolve them all.
230    for schema_dir in builtin_schemas_dir().dirs() {
231        if let Some(files) = collect_builtin_dir(schema_dir, schema_ref)? {
232            return Ok(Some(files));
233        }
234    }
235    Ok(None)
236}
237
238/// Collect one embedded schema directory's files when its manifest
239/// matches `schema_ref` — `Ok(None)` on a manifest mismatch (or a
240/// directory without a manifest, e.g. a non-package entry) so the
241/// caller can keep scanning.
242fn collect_builtin_dir(
243    schema_dir: &include_dir::Dir<'static>,
244    schema_ref: &SchemaRef,
245) -> Result<Option<Vec<SchemaSourceFile>>, SchemaSourceError> {
246    // `include_dir`'s paths are always relative to the include root (the
247    // `builtins/schemas` dir), so every entry's path starts with the
248    // directory name. Build lookups by constructing the same prefix.
249    let prefix = schema_dir.path().display().to_string();
250    let manifest_key = format!("{prefix}/schema.yaml");
251    let Some(manifest_file) = schema_dir.get_file(manifest_key.as_str()) else {
252        return Ok(None);
253    };
254    let manifest_bytes = manifest_file.contents().to_vec();
255    if !manifest_matches(
256        &manifest_bytes,
257        schema_ref,
258        &PathBuf::from(format!("<builtin:{prefix}>/schema.yaml")),
259    )? {
260        return Ok(None);
261    }
262
263    let mut out = vec![SchemaSourceFile {
264        archive_path: "schema.yaml".to_string(),
265        bytes: manifest_bytes,
266    }];
267
268    // The sealed format marker rides as-found (new builtin
269    // generations ship it; retained older generations don't).
270    let marker_key = format!("{prefix}/{}", crate::loader::SCHEMA_FORMAT_MARKER_FILE);
271    if let Some(marker) = schema_dir.get_file(marker_key.as_str()) {
272        out.push(SchemaSourceFile {
273            archive_path: crate::loader::SCHEMA_FORMAT_MARKER_FILE.to_string(),
274            bytes: marker.contents().to_vec(),
275        });
276    }
277
278    let types_key = format!("{prefix}/types");
279    if let Some(types_dir) = schema_dir.get_dir(types_key.as_str()) {
280        for file in types_dir.files() {
281            if file.path().extension().and_then(|s| s.to_str()) != Some("yaml") {
282                continue;
283            }
284            let Some(stem) = file.path().file_stem().and_then(|s| s.to_str()) else {
285                continue;
286            };
287            out.push(SchemaSourceFile {
288                archive_path: format!("types/{stem}.yaml"),
289                bytes: file.contents().to_vec(),
290            });
291        }
292    }
293
294    out.sort_by(|a, b| a.archive_path.cmp(&b.archive_path));
295    Ok(Some(out))
296}
297
298/// True when `manifest_bytes` parses and its `name`+`version` match
299/// `schema_ref`. Uses `serde_yaml_ng` with a narrow intermediate struct
300/// rather than the full `SchemaManifest` so callers can compare versions
301/// without paying for the full manifest validation (the caller is often
302/// the publish path, where the real loader runs separately over the
303/// source directory for full validation).
304fn manifest_matches(
305    manifest_bytes: &[u8],
306    schema_ref: &SchemaRef,
307    source_path: &Path,
308) -> Result<bool, SchemaSourceError> {
309    #[derive(serde::Deserialize)]
310    struct ManifestId {
311        name: String,
312        version: String,
313    }
314    let id: ManifestId = serde_yaml_ng::from_slice(manifest_bytes).map_err(|e| {
315        SchemaSourceError::MalformedManifest {
316            path: source_path.to_path_buf(),
317            reason: e.to_string(),
318        }
319    })?;
320    if id.name != schema_ref.name {
321        return Ok(false);
322    }
323    let declared =
324        semver::Version::parse(&id.version).map_err(|e| SchemaSourceError::MalformedManifest {
325            path: source_path.to_path_buf(),
326            reason: format!("invalid semver '{}': {e}", id.version),
327        })?;
328    if declared != schema_ref.version {
329        // Surface the mismatch as a hard error only for the on-disk
330        // paths that selected `dir` by name — a workspace author bumping
331        // the pin without editing the manifest should hear about it
332        // loudly, not get a silent fallthrough to the next layer.
333        // Builtin retention keeps every generation in sibling
334        // directories (`planning`, `planning-0.2`, …), so the
335        // `collect_builtin_source` path turns `Ok(false)` into "keep
336        // scanning; NotFound only when no directory matches". String
337        // prefix, deliberately: `Path::starts_with` is
338        // component-based and never matched the `<builtin:…>` marker
339        // (which made every non-name-exact builtin version refuse
340        // with a hard VersionMismatch instead of falling through).
341        if source_path.to_string_lossy().starts_with("<builtin:") {
342            return Ok(false);
343        }
344        return Err(SchemaSourceError::VersionMismatch {
345            path: source_path.to_path_buf(),
346            expected: schema_ref.version.to_string(),
347            found: declared.to_string(),
348        });
349    }
350    Ok(true)
351}
352
353#[cfg(test)]
354mod tests {
355    use super::*;
356    use tempfile::TempDir;
357
358    fn write_schema(dir: &Path, name: &str, version: &str, types: &[&str]) {
359        let manifest = format!(
360            r#"name: {name}
361version: {version}
362description: test
363when_to_use: test
364types:
365  - {type_list}
366relationships:
367  mode: strict
368  definitions:
369    - name: _default
370      description: default
371      default_weight: 1.0
372    - name: PART_OF
373      description: hier
374      default_weight: 3.0
375community:
376  resolution: 1.0
377  seed: 42
378"#,
379            name = name,
380            version = version,
381            type_list = types.join("\n  - "),
382        );
383        std::fs::write(dir.join("schema.yaml"), manifest).unwrap();
384        for t in types {
385            let td = format!(
386                r#"name: {t}
387description: test
388when_to_use: test
389sections:
390  - key: body
391    heading: Body
392    required: true
393    search_weight: 10.0
394    catch_all: true
395metadata_fields: []
396title_weight: 1.0
397text_fields: [body]
398hierarchy_relationship: PART_OF
399no_self_loop_relationships: []
400updatable_fields: [title, body]
401health_required_fields: [body]
402staleness_threshold_days: 30
403write_rules: []
404"#
405            );
406            std::fs::write(dir.join(format!("types/{t}.yaml")), td).unwrap();
407        }
408    }
409
410    /// The collectors carry the sealed format marker as-found: the
411    /// current builtin generation ships it, a retained pre-flip
412    /// generation doesn't — so seals stay faithful in both directions.
413    #[test]
414    fn collectors_carry_format_marker_as_found() {
415        let marked: SchemaRef = "default@1.3.0".parse().unwrap();
416        let files = collect_schema_source(None, None, &marked).unwrap();
417        assert!(
418            files
419                .iter()
420                .any(|f| f.archive_path == crate::loader::SCHEMA_FORMAT_MARKER_FILE),
421            "current generation collects its marker"
422        );
423
424        let legacy: SchemaRef = "default@1.2.0".parse().unwrap();
425        let files = collect_schema_source(None, None, &legacy).unwrap();
426        assert!(
427            !files
428                .iter()
429                .any(|f| f.archive_path == crate::loader::SCHEMA_FORMAT_MARKER_FILE),
430            "retained pre-flip generation stays unmarked"
431        );
432    }
433
434    #[test]
435    fn collects_builtin_default_source() {
436        let schema_ref = SchemaRef::new("default", semver::Version::new(1, 0, 0));
437        let files = collect_schema_source(None, None, &schema_ref).unwrap();
438
439        assert!(
440            files.iter().any(|f| f.archive_path == "schema.yaml"),
441            "embedded builtin must expose schema.yaml"
442        );
443        let type_count = files
444            .iter()
445            .filter(|f| f.archive_path.starts_with("types/"))
446            .count();
447        assert_eq!(type_count, 10, "default schema has 10 types");
448        for pair in files.windows(2) {
449            assert!(pair[0].archive_path < pair[1].archive_path, "sorted");
450        }
451    }
452
453    /// The append-only retention pattern keeps older generations in
454    /// suffixed sibling directories (`planning` holds 0.1.0,
455    /// `planning-0.3` holds 0.3.0, …). Every RETAINED version the
456    /// registry registers must resolve through the collect path — the
457    /// historical name-exact-only lookup refused every version but
458    /// the one in the name-exact directory.
459    #[test]
460    fn collects_builtin_source_for_every_retained_version() {
461        for (name, version) in [
462            ("planning", semver::Version::new(0, 2, 0)),
463            ("planning", semver::Version::new(0, 4, 0)),
464            ("ingest", semver::Version::new(0, 1, 0)),
465            ("ingest", semver::Version::new(0, 5, 0)),
466        ] {
467            let schema_ref = SchemaRef::new(name, version.clone());
468            let files = collect_schema_source(None, None, &schema_ref)
469                .unwrap_or_else(|e| panic!("{name}@{version} must resolve: {e}"));
470            let manifest = files
471                .iter()
472                .find(|f| f.archive_path == "schema.yaml")
473                .expect("manifest present");
474            let text = String::from_utf8_lossy(&manifest.bytes);
475            assert!(
476                text.contains(&format!("version: {version}")),
477                "{name}@{version}: collected manifest must carry the requested version"
478            );
479        }
480
481        // An unregistered version still refuses — the scan resolves
482        // retained versions, it never invents one.
483        let ghost = SchemaRef::new("planning", semver::Version::new(9, 9, 9));
484        assert!(matches!(
485            collect_schema_source(None, None, &ghost),
486            Err(SchemaSourceError::NotFound { .. })
487        ));
488    }
489
490    #[test]
491    fn workspace_schema_wins_over_builtin() {
492        let tmp = TempDir::new().unwrap();
493        let ws_dir = tmp.path().join("schemas");
494        let schema_dir = ws_dir.join("default");
495        std::fs::create_dir_all(schema_dir.join("types")).unwrap();
496        // Override builtin default with a skeletal variant. collect_schema_source
497        // must return this, not the 10-type builtin.
498        write_schema(&schema_dir, "default", "1.0.0", &["spec"]);
499        let schema_ref = SchemaRef::new("default", semver::Version::new(1, 0, 0));
500        let files = collect_schema_source(Some(tmp.path()), Some(&ws_dir), &schema_ref).unwrap();
501        let type_count = files
502            .iter()
503            .filter(|f| f.archive_path.starts_with("types/"))
504            .count();
505        assert_eq!(type_count, 1, "workspace override takes priority");
506    }
507
508    #[test]
509    fn workspace_mismatched_version_errors() {
510        let tmp = TempDir::new().unwrap();
511        let ws_dir = tmp.path().join("schemas");
512        let schema_dir = ws_dir.join("recipe");
513        std::fs::create_dir_all(schema_dir.join("types")).unwrap();
514        write_schema(&schema_dir, "recipe", "1.0.0", &["spec"]);
515        let schema_ref = SchemaRef::new("recipe", semver::Version::new(2, 0, 0));
516        let err = collect_schema_source(Some(tmp.path()), Some(&ws_dir), &schema_ref).unwrap_err();
517        assert!(matches!(err, SchemaSourceError::VersionMismatch { .. }));
518    }
519
520    /// The retired `.memstead.cache/schemas/` layer contributes
521    /// nothing: nothing ever wrote it, and a package sitting there is
522    /// not a resolvable schema source. Publishing resolves from the
523    /// workspace layer or the built-ins.
524    #[test]
525    fn legacy_cache_directory_is_not_a_source() {
526        let tmp = TempDir::new().unwrap();
527        let dir = tmp.path().join(".memstead.cache/schemas/recipe-1.0.0");
528        std::fs::create_dir_all(dir.join("types")).unwrap();
529        write_schema(&dir, "recipe", "1.0.0", &["spec"]);
530        let schema_ref = SchemaRef::new("recipe", semver::Version::new(1, 0, 0));
531        let err = collect_schema_source(Some(tmp.path()), None, &schema_ref).unwrap_err();
532        assert!(
533            matches!(err, SchemaSourceError::NotFound { .. }),
534            "got {err:?}"
535        );
536    }
537
538    #[test]
539    fn unknown_schema_returns_not_found() {
540        let schema_ref = SchemaRef::new("nonexistent", semver::Version::new(1, 0, 0));
541        let err = collect_schema_source(None, None, &schema_ref).unwrap_err();
542        assert!(matches!(err, SchemaSourceError::NotFound { .. }));
543    }
544
545    /// The versioned install shape (`<name>@<version>/`) is consulted
546    /// before the bare hand-authored `<name>/` form, and both win over
547    /// the built-ins.
548    #[test]
549    fn versioned_workspace_shape_wins_over_the_bare_one() {
550        let tmp = TempDir::new().unwrap();
551        let ws_dir = tmp.path().join("schemas");
552        // Bare form carries a 2-type variant.
553        let bare = ws_dir.join("software");
554        std::fs::create_dir_all(bare.join("types")).unwrap();
555        write_schema(&bare, "software", "1.0.0", &["spec", "memo"]);
556        // Versioned form carries a 1-type variant — must win.
557        let versioned = ws_dir.join("software@1.0.0");
558        std::fs::create_dir_all(versioned.join("types")).unwrap();
559        write_schema(&versioned, "software", "1.0.0", &["spec"]);
560
561        let schema_ref = SchemaRef::new("software", semver::Version::new(1, 0, 0));
562        let files = collect_schema_source(Some(tmp.path()), Some(&ws_dir), &schema_ref).unwrap();
563        let type_count = files
564            .iter()
565            .filter(|f| f.archive_path.starts_with("types/"))
566            .count();
567        assert_eq!(type_count, 1, "the versioned install shape must win");
568    }
569
570    #[test]
571    fn not_found_lists_every_candidate_path() {
572        let tmp = TempDir::new().unwrap();
573        let ws_dir = tmp.path().join("schemas");
574        std::fs::create_dir_all(&ws_dir).unwrap();
575
576        let schema_ref = SchemaRef::new("missing", semver::Version::new(2, 3, 4));
577        let err = collect_schema_source(Some(tmp.path()), Some(&ws_dir), &schema_ref).unwrap_err();
578        match err {
579            SchemaSourceError::NotFound {
580                schema_ref: name,
581                candidates,
582            } => {
583                assert_eq!(name, "missing@2.3.4");
584                // Every filesystem candidate listed in resolution
585                // order: the versioned install shape first, then the
586                // bare workspace schemas dir.
587                assert_eq!(candidates.len(), 2, "got {candidates:?}");
588                assert!(candidates[0].ends_with("schemas/missing@2.3.4"));
589                assert!(candidates[1].ends_with("schemas/missing"));
590            }
591            other => panic!("expected NotFound, got {other:?}"),
592        }
593    }
594}