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
21use std::path::{Path, PathBuf};
22
23use crate::builtins::builtin_schemas_dir;
24use crate::config::SchemaRef;
25
26/// One source file destined for the archive's `.memstead/schema/` tree.
27///
28/// `archive_path` is the relative path *inside* the archive (e.g.
29/// `"schema.yaml"` or `"types/spec.yaml"`). Callers prefix it with
30/// whatever root they want (the archive writer uses `".memstead/schema/"`);
31/// the canonical re-pack uses the same prefix so byte-identical
32/// archives round-trip.
33#[derive(Debug, Clone)]
34pub struct SchemaSourceFile {
35    pub archive_path: String,
36    pub bytes: Vec<u8>,
37}
38
39#[derive(Debug, thiserror::Error)]
40pub enum SchemaSourceError {
41    #[error(
42        "schema {schema_ref} not found — candidate paths tried: [{}]",
43        .candidates.iter().map(|p| p.display().to_string()).collect::<Vec<_>>().join(", ")
44    )]
45    NotFound {
46        schema_ref: String,
47        /// Every filesystem location (mem-local, workspace-level,
48        /// cache) consulted before falling through to the embedded
49        /// builtins. Listed in resolution order so the error message
50        /// matches the precedence the resolver walked.
51        candidates: Vec<PathBuf>,
52    },
53
54    #[error("i/o error reading schema source at {}: {source}", .path.display())]
55    Io {
56        path: PathBuf,
57        #[source]
58        source: std::io::Error,
59    },
60
61    /// Two distinct on-disk schema directories share the same
62    /// `<name>-<version>` cache key. Indicates a bug in the cache
63    /// extraction pipeline — silent overwrite would mask the issue.
64    #[error(
65        "schema cache collision: '{name}-{version}' has more than one source directory ({} and {})",
66        .first.display(),
67        .second.display()
68    )]
69    CacheCollision {
70        name: String,
71        version: String,
72        first: PathBuf,
73        second: PathBuf,
74    },
75
76    #[error(
77        "schema manifest at {} does not declare version '{expected}' (found '{found}')",
78        .path.display()
79    )]
80    VersionMismatch {
81        path: PathBuf,
82        expected: String,
83        found: String,
84    },
85
86    #[error("schema manifest at {} is malformed: {reason}", .path.display())]
87    MalformedManifest { path: PathBuf, reason: String },
88}
89
90/// Resolve the schema pinned by `schema_ref` to a sorted set of source
91/// files ready to embed under `.memstead/schema/` in a mem archive.
92///
93/// The returned vector is sorted by `archive_path` so archive bytes are
94/// deterministic — callers don't need to re-sort.
95///
96/// Resolution order (first match wins):
97/// 1. `<workspace_schemas_dir>/<name>/` (when provided)
98/// 2. `<workspace_root>/.memstead.cache/schemas/<name>-<version>/`
99///    (when `workspace_root` is provided)
100/// 3. Embedded builtins
101///
102/// When every filesystem layer misses, the returned `NotFound` lists
103/// every concrete path that was consulted so callers (and agents) can
104/// inspect the resolution trace without re-walking the filesystem.
105pub fn collect_schema_source(
106    workspace_root: Option<&Path>,
107    workspace_schemas_dir: Option<&Path>,
108    schema_ref: &SchemaRef,
109) -> Result<Vec<SchemaSourceFile>, SchemaSourceError> {
110    let mut candidates: Vec<PathBuf> = Vec::new();
111
112    if let Some(ws_dir) = workspace_schemas_dir {
113        // Two directory shapes: `<name>@<version>/` is what `memstead
114        // schema install` writes; the bare `<name>/` form predates the
115        // versioned layout and stays supported for hand-authored dirs.
116        let versioned_dir = ws_dir.join(format!("{}@{}", schema_ref.name, schema_ref.version));
117        candidates.push(versioned_dir.clone());
118        if versioned_dir.is_dir()
119            && let Some(files) = try_collect_dir(&versioned_dir, schema_ref)?
120        {
121            return Ok(files);
122        }
123        let ws_schema_dir = ws_dir.join(&schema_ref.name);
124        candidates.push(ws_schema_dir.clone());
125        if ws_schema_dir.is_dir()
126            && let Some(files) = try_collect_dir(&ws_schema_dir, schema_ref)?
127        {
128            return Ok(files);
129        }
130    }
131
132    if let Some(ws_root) = workspace_root {
133        let cache_dir = ws_root
134            .join(".memstead.cache/schemas")
135            .join(format!("{}-{}", schema_ref.name, schema_ref.version));
136        candidates.push(cache_dir.clone());
137        if cache_dir.is_dir()
138            && let Some(files) = try_collect_dir(&cache_dir, schema_ref)?
139        {
140            return Ok(files);
141        }
142    }
143
144    if let Some(files) = collect_builtin_source(schema_ref)? {
145        return Ok(files);
146    }
147
148    Err(SchemaSourceError::NotFound {
149        schema_ref: schema_ref.as_display(),
150        candidates,
151    })
152}
153
154/// Read `<dir>/schema.yaml` + `<dir>/types/*.yaml` and return them
155/// only if the manifest's declared version matches `schema_ref`.
156/// A mismatched version returns `Ok(None)` so the caller can fall
157/// through to the next resolution layer rather than hard-failing.
158fn try_collect_dir(
159    dir: &Path,
160    schema_ref: &SchemaRef,
161) -> Result<Option<Vec<SchemaSourceFile>>, SchemaSourceError> {
162    let manifest_path = dir.join("schema.yaml");
163    let manifest_bytes = std::fs::read(&manifest_path).map_err(|e| SchemaSourceError::Io {
164        path: manifest_path.clone(),
165        source: e,
166    })?;
167
168    if !manifest_matches(&manifest_bytes, schema_ref, &manifest_path)? {
169        return Ok(None);
170    }
171
172    let mut out = vec![SchemaSourceFile {
173        archive_path: "schema.yaml".to_string(),
174        bytes: manifest_bytes,
175    }];
176
177    let types_dir = dir.join("types");
178    if types_dir.is_dir() {
179        let entries = std::fs::read_dir(&types_dir).map_err(|e| SchemaSourceError::Io {
180            path: types_dir.clone(),
181            source: e,
182        })?;
183        for entry in entries {
184            let entry = entry.map_err(|e| SchemaSourceError::Io {
185                path: types_dir.clone(),
186                source: e,
187            })?;
188            let path = entry.path();
189            if path.extension().and_then(|s| s.to_str()) != Some("yaml") {
190                continue;
191            }
192            let Some(stem) = path.file_stem().and_then(|s| s.to_str()) else {
193                continue;
194            };
195            let bytes = std::fs::read(&path).map_err(|e| SchemaSourceError::Io {
196                path: path.clone(),
197                source: e,
198            })?;
199            out.push(SchemaSourceFile {
200                archive_path: format!("types/{stem}.yaml"),
201                bytes,
202            });
203        }
204    }
205
206    out.sort_by(|a, b| a.archive_path.cmp(&b.archive_path));
207    Ok(Some(out))
208}
209
210fn collect_builtin_source(
211    schema_ref: &SchemaRef,
212) -> Result<Option<Vec<SchemaSourceFile>>, SchemaSourceError> {
213    let Some(schema_dir) = builtin_schemas_dir().get_dir(schema_ref.name.as_str()) else {
214        return Ok(None);
215    };
216
217    // `include_dir`'s paths are always relative to the include root (the
218    // `builtins/schemas` dir), so every entry's path starts with the
219    // schema name. Build lookups by constructing the same prefix.
220    let schema_name = schema_ref.name.as_str();
221    let manifest_key = format!("{schema_name}/schema.yaml");
222    let manifest_file = schema_dir.get_file(manifest_key.as_str()).ok_or_else(|| {
223        SchemaSourceError::MalformedManifest {
224            path: PathBuf::from(&manifest_key),
225            reason: "embedded schema directory is missing schema.yaml".into(),
226        }
227    })?;
228    let manifest_bytes = manifest_file.contents().to_vec();
229    if !manifest_matches(
230        &manifest_bytes,
231        schema_ref,
232        &PathBuf::from(format!("<builtin:{schema_name}>/schema.yaml")),
233    )? {
234        return Ok(None);
235    }
236
237    let mut out = vec![SchemaSourceFile {
238        archive_path: "schema.yaml".to_string(),
239        bytes: manifest_bytes,
240    }];
241
242    let types_key = format!("{schema_name}/types");
243    if let Some(types_dir) = schema_dir.get_dir(types_key.as_str()) {
244        for file in types_dir.files() {
245            if file.path().extension().and_then(|s| s.to_str()) != Some("yaml") {
246                continue;
247            }
248            let Some(stem) = file.path().file_stem().and_then(|s| s.to_str()) else {
249                continue;
250            };
251            out.push(SchemaSourceFile {
252                archive_path: format!("types/{stem}.yaml"),
253                bytes: file.contents().to_vec(),
254            });
255        }
256    }
257
258    out.sort_by(|a, b| a.archive_path.cmp(&b.archive_path));
259    Ok(Some(out))
260}
261
262/// True when `manifest_bytes` parses and its `name`+`version` match
263/// `schema_ref`. Uses `serde_yaml_ng` with a narrow intermediate struct
264/// rather than the full `SchemaManifest` so callers can compare versions
265/// without paying for the full manifest validation (the caller is often
266/// the publish path, where the real loader runs separately over the
267/// source directory for full validation).
268fn manifest_matches(
269    manifest_bytes: &[u8],
270    schema_ref: &SchemaRef,
271    source_path: &Path,
272) -> Result<bool, SchemaSourceError> {
273    #[derive(serde::Deserialize)]
274    struct ManifestId {
275        name: String,
276        version: String,
277    }
278    let id: ManifestId = serde_yaml_ng::from_slice(manifest_bytes).map_err(|e| {
279        SchemaSourceError::MalformedManifest {
280            path: source_path.to_path_buf(),
281            reason: e.to_string(),
282        }
283    })?;
284    if id.name != schema_ref.name {
285        return Ok(false);
286    }
287    let declared =
288        semver::Version::parse(&id.version).map_err(|e| SchemaSourceError::MalformedManifest {
289            path: source_path.to_path_buf(),
290            reason: format!("invalid semver '{}': {e}", id.version),
291        })?;
292    if declared != schema_ref.version {
293        // Surface the mismatch as a hard error only for the on-disk
294        // paths that selected `dir` by name — a workspace author bumping
295        // the pin without editing the manifest should hear about it
296        // loudly, not get a silent fallthrough to the next layer.
297        // Builtin dirs are keyed by name only (one version per builtin),
298        // so the `collect_builtin_source` path turns `Ok(false)` into
299        // "no match; try NotFound" at the call site.
300        if source_path.starts_with("<builtin:") {
301            return Ok(false);
302        }
303        return Err(SchemaSourceError::VersionMismatch {
304            path: source_path.to_path_buf(),
305            expected: schema_ref.version.to_string(),
306            found: declared.to_string(),
307        });
308    }
309    Ok(true)
310}
311
312#[cfg(test)]
313mod tests {
314    use super::*;
315    use tempfile::TempDir;
316
317    fn write_schema(dir: &Path, name: &str, version: &str, types: &[&str]) {
318        let manifest = format!(
319            r#"name: {name}
320version: {version}
321description: test
322when_to_use: test
323types:
324  - {type_list}
325relationships:
326  mode: strict
327  definitions:
328    - name: _default
329      description: default
330      default_weight: 1.0
331    - name: PART_OF
332      description: hier
333      default_weight: 3.0
334community:
335  resolution: 1.0
336  seed: 42
337"#,
338            name = name,
339            version = version,
340            type_list = types.join("\n  - "),
341        );
342        std::fs::write(dir.join("schema.yaml"), manifest).unwrap();
343        for t in types {
344            let td = format!(
345                r#"name: {t}
346description: test
347when_to_use: test
348sections:
349  - key: body
350    heading: Body
351    required: true
352    search_weight: 10.0
353    catch_all: true
354metadata_fields: []
355title_weight: 1.0
356text_fields: [body]
357hierarchy_relationship: PART_OF
358propagating_relationships: []
359updatable_fields: [title, body]
360health_required_fields: [body]
361staleness_threshold_days: 30
362write_rules: []
363"#
364            );
365            std::fs::write(dir.join(format!("types/{t}.yaml")), td).unwrap();
366        }
367    }
368
369    #[test]
370    fn collects_builtin_default_source() {
371        let schema_ref = SchemaRef::new("default", semver::Version::new(1, 0, 0));
372        let files = collect_schema_source(None, None, &schema_ref).unwrap();
373
374        assert!(
375            files.iter().any(|f| f.archive_path == "schema.yaml"),
376            "embedded builtin must expose schema.yaml"
377        );
378        let type_count = files
379            .iter()
380            .filter(|f| f.archive_path.starts_with("types/"))
381            .count();
382        assert_eq!(type_count, 10, "default schema has 10 types");
383        for pair in files.windows(2) {
384            assert!(pair[0].archive_path < pair[1].archive_path, "sorted");
385        }
386    }
387
388    #[test]
389    fn workspace_schema_wins_over_builtin() {
390        let tmp = TempDir::new().unwrap();
391        let ws_dir = tmp.path().join("schemas");
392        let schema_dir = ws_dir.join("default");
393        std::fs::create_dir_all(schema_dir.join("types")).unwrap();
394        // Override builtin default with a skeletal variant. collect_schema_source
395        // must return this, not the 10-type builtin.
396        write_schema(&schema_dir, "default", "1.0.0", &["spec"]);
397        let schema_ref = SchemaRef::new("default", semver::Version::new(1, 0, 0));
398        let files = collect_schema_source(Some(tmp.path()), Some(&ws_dir), &schema_ref).unwrap();
399        let type_count = files
400            .iter()
401            .filter(|f| f.archive_path.starts_with("types/"))
402            .count();
403        assert_eq!(type_count, 1, "workspace override takes priority");
404    }
405
406    #[test]
407    fn workspace_mismatched_version_errors() {
408        let tmp = TempDir::new().unwrap();
409        let ws_dir = tmp.path().join("schemas");
410        let schema_dir = ws_dir.join("recipe");
411        std::fs::create_dir_all(schema_dir.join("types")).unwrap();
412        write_schema(&schema_dir, "recipe", "1.0.0", &["spec"]);
413        let schema_ref = SchemaRef::new("recipe", semver::Version::new(2, 0, 0));
414        let err = collect_schema_source(Some(tmp.path()), Some(&ws_dir), &schema_ref).unwrap_err();
415        assert!(matches!(err, SchemaSourceError::VersionMismatch { .. }));
416    }
417
418    #[test]
419    fn cache_schema_resolves_when_workspace_layer_absent() {
420        let tmp = TempDir::new().unwrap();
421        // Cache dir encodes version in the folder name: `<name>-<version>`.
422        let dir = tmp.path().join(".memstead.cache/schemas/recipe-1.0.0");
423        std::fs::create_dir_all(dir.join("types")).unwrap();
424        write_schema(&dir, "recipe", "1.0.0", &["spec"]);
425        let schema_ref = SchemaRef::new("recipe", semver::Version::new(1, 0, 0));
426        let files = collect_schema_source(Some(tmp.path()), None, &schema_ref).unwrap();
427        assert!(files.iter().any(|f| f.archive_path == "schema.yaml"));
428    }
429
430    #[test]
431    fn unknown_schema_returns_not_found() {
432        let schema_ref = SchemaRef::new("nonexistent", semver::Version::new(1, 0, 0));
433        let err = collect_schema_source(None, None, &schema_ref).unwrap_err();
434        assert!(matches!(err, SchemaSourceError::NotFound { .. }));
435    }
436
437    #[test]
438    fn workspace_schema_wins_over_cache() {
439        let tmp = TempDir::new().unwrap();
440        // Cache layer carries a 2-type variant.
441        let cache_dir = tmp.path().join(".memstead.cache/schemas/software-1.0.0");
442        std::fs::create_dir_all(cache_dir.join("types")).unwrap();
443        write_schema(&cache_dir, "software", "1.0.0", &["spec", "memo"]);
444        // Workspace layer carries a 1-type variant — must win.
445        let ws_dir = tmp.path().join("schemas");
446        let ws_schema = ws_dir.join("software");
447        std::fs::create_dir_all(ws_schema.join("types")).unwrap();
448        write_schema(&ws_schema, "software", "1.0.0", &["spec"]);
449
450        let schema_ref = SchemaRef::new("software", semver::Version::new(1, 0, 0));
451        let files = collect_schema_source(Some(tmp.path()), Some(&ws_dir), &schema_ref).unwrap();
452        let type_count = files
453            .iter()
454            .filter(|f| f.archive_path.starts_with("types/"))
455            .count();
456        assert_eq!(type_count, 1, "workspace layer must win over cache");
457    }
458
459    #[test]
460    fn not_found_lists_every_candidate_path() {
461        let tmp = TempDir::new().unwrap();
462        let ws_dir = tmp.path().join("schemas");
463        std::fs::create_dir_all(&ws_dir).unwrap();
464
465        let schema_ref = SchemaRef::new("missing", semver::Version::new(2, 3, 4));
466        let err = collect_schema_source(Some(tmp.path()), Some(&ws_dir), &schema_ref).unwrap_err();
467        match err {
468            SchemaSourceError::NotFound {
469                schema_ref: name,
470                candidates,
471            } => {
472                assert_eq!(name, "missing@2.3.4");
473                // Every filesystem candidate listed in resolution order:
474                // the versioned install shape first, then the bare
475                // workspace schemas dir, then the workspace cache dir.
476                assert_eq!(candidates.len(), 3);
477                assert!(candidates[0].ends_with("schemas/missing@2.3.4"));
478                assert!(candidates[1].ends_with("schemas/missing"));
479                assert!(
480                    candidates[2]
481                        .to_string_lossy()
482                        .contains(".memstead.cache/schemas/missing-2.3.4")
483                );
484            }
485            other => panic!("expected NotFound, got {other:?}"),
486        }
487    }
488}