Skip to main content

memstead_base/
schema_source.rs

1//! `SchemaSource` — a storage backend's schema-storage location.
2//!
3//! Each open storage backend owns a place its authored schema packages
4//! live: the folder backend's `<workspace>/.memstead/schemas/`, the
5//! git-branch backend's `__MEMSTEAD:schemas/` ref, an archive's sealed
6//! `schemas/` directory inside the zip. A `SchemaSource` abstracts read
7//! (and, for open backends, write) of that location behind one trait, so
8//! the resolution layer ([`crate::engine::SchemaResolver`]) and the
9//! authoring layer (`memstead schema install`) work against a uniform
10//! surface regardless of where "local storage" physically is.
11//!
12//! The folder source lives here in `memstead-base`. The git-branch
13//! source implements this trait in `memstead-git-branch` (where the
14//! `gix` read/write of the `__MEMSTEAD` ref lives); the archive source
15//! is read-only (sealed).
16
17use std::path::{Path, PathBuf};
18use std::sync::Arc;
19
20use memstead_schema::Schema;
21
22/// Failure reading or writing a schema source.
23#[derive(Debug, thiserror::Error)]
24pub enum SchemaSourceError {
25    /// Reading or parsing the source's schema packages failed.
26    #[error("schema source read failed: {0}")]
27    Read(String),
28    /// Writing a schema package into the source failed.
29    #[error("schema source write failed: {0}")]
30    Write(String),
31    /// The source is sealed/read-only and cannot accept a write (archive).
32    #[error("schema source is read-only: {0}")]
33    ReadOnly(&'static str),
34}
35
36/// A storage backend's schema-storage location.
37///
38/// `read_schemas` returns every schema package the source carries,
39/// parsed. `write_schema` installs an authored package; read-only
40/// sources (archive) return [`SchemaSourceError::ReadOnly`]. The engine
41/// consults sources in a fixed order — local storage (a backend's own
42/// `SchemaSource`), built-in, remote (reserved) — see
43/// [`crate::engine::SchemaResolver`] for the resolution side.
44pub trait SchemaSource {
45    /// Every schema package this source carries, parsed.
46    fn read_schemas(&self) -> Result<Vec<Arc<Schema>>, SchemaSourceError>;
47
48    /// Write a schema package — `(relative-path, bytes)` pairs such as
49    /// `("schema.yaml", …)`, `("types/<t>.yaml", …)`,
50    /// `("mem-template.json", …)` — into the source's storage.
51    /// Read-only sources return [`SchemaSourceError::ReadOnly`].
52    fn write_schema(
53        &self,
54        name: &str,
55        version: &str,
56        files: &[(String, Vec<u8>)],
57    ) -> Result<(), SchemaSourceError>;
58}
59
60/// The folder backend's schema source: schemas live under
61/// `<workspace>/.memstead/schemas/<name>@<version>/`.
62pub struct FolderSchemaSource {
63    /// The `<workspace>/.memstead/schemas` directory.
64    schemas_dir: PathBuf,
65}
66
67impl FolderSchemaSource {
68    /// Build a folder source rooted at a workspace.
69    pub fn for_workspace(workspace_root: &Path) -> Self {
70        Self {
71            schemas_dir: workspace_root.join(".memstead").join("schemas"),
72        }
73    }
74
75    /// The `.memstead/schemas` directory this source reads and writes.
76    pub fn schemas_dir(&self) -> &Path {
77        &self.schemas_dir
78    }
79}
80
81impl SchemaSource for FolderSchemaSource {
82    fn read_schemas(&self) -> Result<Vec<Arc<Schema>>, SchemaSourceError> {
83        // Same walker the boot path uses — absent dir resolves to empty.
84        crate::engine::boot::load_workspace_schemas(Some(&self.schemas_dir))
85            .map_err(|e| SchemaSourceError::Read(e.to_string()))
86    }
87
88    fn write_schema(
89        &self,
90        name: &str,
91        version: &str,
92        files: &[(String, Vec<u8>)],
93    ) -> Result<(), SchemaSourceError> {
94        let pkg_dir = self.schemas_dir.join(format!("{name}@{version}"));
95        for (rel, bytes) in files {
96            let dest = pkg_dir.join(rel);
97            if let Some(parent) = dest.parent() {
98                std::fs::create_dir_all(parent).map_err(|e| {
99                    SchemaSourceError::Write(format!("create {}: {e}", parent.display()))
100                })?;
101            }
102            std::fs::write(&dest, bytes)
103                .map_err(|e| SchemaSourceError::Write(format!("write {}: {e}", dest.display())))?;
104        }
105        Ok(())
106    }
107}
108
109/// The archive backend's **read-only** schema source — schemas are
110/// embedded in a sealed `.mem` archive at `.memstead/schema/`. The
111/// archive is frozen at seal time, so `write_schema` always refuses.
112pub struct ArchiveSchemaSource {
113    bytes: Vec<u8>,
114}
115
116impl ArchiveSchemaSource {
117    /// From the raw `.mem` archive bytes.
118    pub fn from_bytes(bytes: Vec<u8>) -> Self {
119        Self { bytes }
120    }
121
122    /// Read the `.mem` archive at `path`.
123    pub fn from_path(path: &Path) -> std::io::Result<Self> {
124        Ok(Self {
125            bytes: std::fs::read(path)?,
126        })
127    }
128}
129
130impl SchemaSource for ArchiveSchemaSource {
131    fn read_schemas(&self) -> Result<Vec<Arc<Schema>>, SchemaSourceError> {
132        let entries = crate::validator::archive::extract_entries(
133            &self.bytes,
134            &crate::validator::ValidatorLimits::default(),
135        )
136        .map_err(|e| SchemaSourceError::Read(e.to_string()))?;
137        crate::engine::archive::load_embedded_schemas(&entries.schema_files)
138            .map_err(|e| SchemaSourceError::Read(e.to_string()))
139    }
140
141    fn write_schema(
142        &self,
143        _name: &str,
144        _version: &str,
145        _files: &[(String, Vec<u8>)],
146    ) -> Result<(), SchemaSourceError> {
147        Err(SchemaSourceError::ReadOnly(
148            "archive backend is sealed — schemas are embedded at seal time and cannot be written",
149        ))
150    }
151}
152
153#[cfg(test)]
154mod tests {
155    use super::*;
156    use tempfile::TempDir;
157
158    #[test]
159    fn folder_source_round_trips_a_written_package() {
160        let tmp = TempDir::new().unwrap();
161        let source = FolderSchemaSource::for_workspace(tmp.path());
162
163        // Empty workspace → no schemas.
164        assert!(source.read_schemas().unwrap().is_empty());
165
166        // Write a minimal valid package, then read it back.
167        let manifest = br#"name: srctest
168version: 0.1.0
169description: A folder SchemaSource round-trip fixture.
170when_to_use: tests
171types:
172  - doc
173relationships:
174  mode: strict
175  definitions:
176    - name: _default
177      description: fallback
178      default_weight: 1.0
179community:
180  resolution: 1.0
181  seed: 42
182"#;
183        let doc_type = br#"name: doc
184description: t
185when_to_use: here
186sections:
187  - key: body
188    heading: Body
189    required: true
190    search_weight: 10.0
191    catch_all: true
192    write_rules: []
193metadata_fields: []
194title_weight: 100.0
195text_fields:
196  - body
197hierarchy_relationship: _default
198no_self_loop_relationships: []
199updatable_fields:
200  - title
201  - body
202health_required_fields:
203  - body
204staleness_threshold_days: 90
205write_rules: []
206"#;
207        source
208            .write_schema(
209                "srctest",
210                "0.1.0",
211                &[
212                    ("schema.yaml".to_string(), manifest.to_vec()),
213                    ("types/doc.yaml".to_string(), doc_type.to_vec()),
214                ],
215            )
216            .unwrap();
217
218        let schemas = source.read_schemas().unwrap();
219        assert_eq!(schemas.len(), 1);
220        assert_eq!(schemas[0].manifest.name, "srctest");
221    }
222
223    const TEST_MANIFEST: &[u8] = br#"name: archsrc
224version: 0.1.0
225description: An archive-embedded schema fixture.
226when_to_use: tests
227types:
228  - doc
229relationships:
230  mode: strict
231  definitions:
232    - name: _default
233      description: fallback
234      default_weight: 1.0
235community:
236  resolution: 1.0
237  seed: 42
238"#;
239    const TEST_DOC: &[u8] = br#"name: doc
240description: t
241when_to_use: here
242sections:
243  - key: body
244    heading: Body
245    required: true
246    search_weight: 10.0
247    catch_all: true
248    write_rules: []
249metadata_fields: []
250title_weight: 100.0
251text_fields:
252  - body
253hierarchy_relationship: _default
254no_self_loop_relationships: []
255updatable_fields:
256  - title
257  - body
258health_required_fields:
259  - body
260staleness_threshold_days: 90
261write_rules: []
262"#;
263
264    #[test]
265    fn archive_source_reads_embedded_schema_and_refuses_writes() {
266        use std::io::Write;
267
268        // Build a minimal `.mem` carrying a config + an embedded schema.
269        let mut bytes = Vec::new();
270        {
271            let mut zw = zip::ZipWriter::new(std::io::Cursor::new(&mut bytes));
272            let opts = zip::write::SimpleFileOptions::default();
273            zw.start_file(".memstead/config.json", opts).unwrap();
274            zw.write_all(br#"{"schema":"archsrc@0.1.0"}"#).unwrap();
275            zw.start_file(".memstead/schema/schema.yaml", opts).unwrap();
276            zw.write_all(TEST_MANIFEST).unwrap();
277            zw.start_file(".memstead/schema/types/doc.yaml", opts)
278                .unwrap();
279            zw.write_all(TEST_DOC).unwrap();
280            zw.finish().unwrap();
281        }
282
283        let source = ArchiveSchemaSource::from_bytes(bytes);
284        let schemas = source.read_schemas().unwrap();
285        assert_eq!(schemas.len(), 1);
286        assert_eq!(schemas[0].manifest.name, "archsrc");
287
288        // Sealed — writes always refuse.
289        let err = source
290            .write_schema("x", "0.1.0", &[("schema.yaml".to_string(), b"x".to_vec())])
291            .unwrap_err();
292        assert!(matches!(err, SchemaSourceError::ReadOnly(_)), "got {err:?}");
293    }
294}