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