use std::path::{Path, PathBuf};
use std::sync::Arc;
use memstead_schema::Schema;
#[derive(Debug, thiserror::Error)]
pub enum SchemaSourceError {
#[error("schema source read failed: {0}")]
Read(String),
#[error("schema source write failed: {0}")]
Write(String),
#[error("schema source is read-only: {0}")]
ReadOnly(&'static str),
}
pub trait SchemaSource {
fn read_schemas(&self) -> Result<Vec<Arc<Schema>>, SchemaSourceError>;
fn write_schema(
&self,
name: &str,
version: &str,
files: &[(String, Vec<u8>)],
) -> Result<(), SchemaSourceError>;
}
pub struct FolderSchemaSource {
schemas_dir: PathBuf,
}
impl FolderSchemaSource {
pub fn for_workspace(workspace_root: &Path) -> Self {
Self {
schemas_dir: workspace_root.join(".memstead").join("schemas"),
}
}
pub fn schemas_dir(&self) -> &Path {
&self.schemas_dir
}
}
impl SchemaSource for FolderSchemaSource {
fn read_schemas(&self) -> Result<Vec<Arc<Schema>>, SchemaSourceError> {
crate::engine::boot::load_workspace_schemas(Some(&self.schemas_dir))
.map_err(|e| SchemaSourceError::Read(e.to_string()))
}
fn write_schema(
&self,
name: &str,
version: &str,
files: &[(String, Vec<u8>)],
) -> Result<(), SchemaSourceError> {
let pkg_dir = self.schemas_dir.join(format!("{name}@{version}"));
for (rel, bytes) in files {
let dest = pkg_dir.join(rel);
if let Some(parent) = dest.parent() {
std::fs::create_dir_all(parent).map_err(|e| {
SchemaSourceError::Write(format!("create {}: {e}", parent.display()))
})?;
}
std::fs::write(&dest, bytes)
.map_err(|e| SchemaSourceError::Write(format!("write {}: {e}", dest.display())))?;
}
Ok(())
}
}
pub struct ArchiveSchemaSource {
bytes: Vec<u8>,
}
impl ArchiveSchemaSource {
pub fn from_bytes(bytes: Vec<u8>) -> Self {
Self { bytes }
}
pub fn from_path(path: &Path) -> std::io::Result<Self> {
Ok(Self {
bytes: std::fs::read(path)?,
})
}
}
impl SchemaSource for ArchiveSchemaSource {
fn read_schemas(&self) -> Result<Vec<Arc<Schema>>, SchemaSourceError> {
let entries = crate::validator::archive::extract_entries(
&self.bytes,
&crate::validator::ValidatorLimits::default(),
)
.map_err(|e| SchemaSourceError::Read(e.to_string()))?;
crate::engine::archive::load_embedded_schemas(&entries.schema_files)
.map_err(|e| SchemaSourceError::Read(e.to_string()))
}
fn write_schema(
&self,
_name: &str,
_version: &str,
_files: &[(String, Vec<u8>)],
) -> Result<(), SchemaSourceError> {
Err(SchemaSourceError::ReadOnly(
"archive backend is sealed — schemas are embedded at seal time and cannot be written",
))
}
}
#[cfg(test)]
mod tests {
use super::*;
use tempfile::TempDir;
#[test]
fn folder_source_round_trips_a_written_package() {
let tmp = TempDir::new().unwrap();
let source = FolderSchemaSource::for_workspace(tmp.path());
assert!(source.read_schemas().unwrap().is_empty());
let manifest = br#"name: srctest
version: 0.1.0
description: A folder SchemaSource round-trip fixture.
when_to_use: tests
types:
- doc
relationships:
mode: strict
definitions:
- name: _default
description: fallback
default_weight: 1.0
community:
resolution: 1.0
seed: 42
"#;
let doc_type = br#"name: doc
description: t
when_to_use: here
sections:
- key: body
heading: Body
required: true
search_weight: 10.0
catch_all: true
write_rules: []
metadata_fields: []
title_weight: 100.0
text_fields:
- body
hierarchy_relationship: _default
no_self_loop_relationships: []
updatable_fields:
- title
- body
health_required_fields:
- body
staleness_threshold_days: 90
write_rules: []
"#;
source
.write_schema(
"srctest",
"0.1.0",
&[
("schema.yaml".to_string(), manifest.to_vec()),
("types/doc.yaml".to_string(), doc_type.to_vec()),
],
)
.unwrap();
let schemas = source.read_schemas().unwrap();
assert_eq!(schemas.len(), 1);
assert_eq!(schemas[0].manifest.name, "srctest");
}
const TEST_MANIFEST: &[u8] = br#"name: archsrc
version: 0.1.0
description: An archive-embedded schema fixture.
when_to_use: tests
types:
- doc
relationships:
mode: strict
definitions:
- name: _default
description: fallback
default_weight: 1.0
community:
resolution: 1.0
seed: 42
"#;
const TEST_DOC: &[u8] = br#"name: doc
description: t
when_to_use: here
sections:
- key: body
heading: Body
required: true
search_weight: 10.0
catch_all: true
write_rules: []
metadata_fields: []
title_weight: 100.0
text_fields:
- body
hierarchy_relationship: _default
no_self_loop_relationships: []
updatable_fields:
- title
- body
health_required_fields:
- body
staleness_threshold_days: 90
write_rules: []
"#;
#[test]
fn archive_source_reads_embedded_schema_and_refuses_writes() {
use std::io::Write;
let mut bytes = Vec::new();
{
let mut zw = zip::ZipWriter::new(std::io::Cursor::new(&mut bytes));
let opts = zip::write::SimpleFileOptions::default();
zw.start_file(".memstead/config.json", opts).unwrap();
zw.write_all(br#"{"schema":"archsrc@0.1.0"}"#).unwrap();
zw.start_file(".memstead/schema/schema.yaml", opts).unwrap();
zw.write_all(TEST_MANIFEST).unwrap();
zw.start_file(".memstead/schema/types/doc.yaml", opts)
.unwrap();
zw.write_all(TEST_DOC).unwrap();
zw.finish().unwrap();
}
let source = ArchiveSchemaSource::from_bytes(bytes);
let schemas = source.read_schemas().unwrap();
assert_eq!(schemas.len(), 1);
assert_eq!(schemas[0].manifest.name, "archsrc");
let err = source
.write_schema("x", "0.1.0", &[("schema.yaml".to_string(), b"x".to_vec())])
.unwrap_err();
assert!(matches!(err, SchemaSourceError::ReadOnly(_)), "got {err:?}");
}
}