memstead_base/
schema_source.rs1use std::path::{Path, PathBuf};
18use std::sync::Arc;
19
20use memstead_schema::Schema;
21
22#[derive(Debug, thiserror::Error)]
24pub enum SchemaSourceError {
25 #[error("schema source read failed: {0}")]
27 Read(String),
28 #[error("schema source write failed: {0}")]
30 Write(String),
31 #[error("schema source is read-only: {0}")]
33 ReadOnly(&'static str),
34}
35
36pub trait SchemaSource {
45 fn read_schemas(&self) -> Result<Vec<Arc<Schema>>, SchemaSourceError>;
47
48 fn write_schema(
53 &self,
54 name: &str,
55 version: &str,
56 files: &[(String, Vec<u8>)],
57 ) -> Result<(), SchemaSourceError>;
58}
59
60pub struct FolderSchemaSource {
63 schemas_dir: PathBuf,
65}
66
67impl FolderSchemaSource {
68 pub fn for_workspace(workspace_root: &Path) -> Self {
70 Self {
71 schemas_dir: workspace_root.join(".memstead").join("schemas"),
72 }
73 }
74
75 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 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
109pub struct ArchiveSchemaSource {
113 bytes: Vec<u8>,
114}
115
116impl ArchiveSchemaSource {
117 pub fn from_bytes(bytes: Vec<u8>) -> Self {
119 Self { bytes }
120 }
121
122 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 assert!(source.read_schemas().unwrap().is_empty());
165
166 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 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 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}