use serde_json::Value;
use std::path::{Path, PathBuf};
use crate::registry::{RegistryError, SchemaRegistry};
use crate::schema::Schema;
pub trait SchemaEnv: Send + Sync {
type Fs: FileSystem;
fn filesystem(&self) -> &Self::Fs;
}
pub trait FileSystem: Send + Sync {
type Error: std::error::Error + Send + Sync + 'static;
fn read_file(&self, path: &Path) -> Result<String, Self::Error>;
fn read_dir(&self, path: &Path) -> Result<Vec<PathBuf>, Self::Error>;
}
#[derive(Debug, thiserror::Error)]
pub enum SchemaLoadError {
#[error("IO error reading {0}: {1}")]
Io(PathBuf, Box<dyn std::error::Error + Send + Sync>),
#[error("Parse error in {0}: {1}")]
Parse(PathBuf, serde_json::Error),
#[error("Schema error in {0}: {1}")]
Schema(PathBuf, String),
#[error("Invalid filename: {0}")]
InvalidFileName(PathBuf),
#[error("Registry error: {0}")]
Registry(RegistryError),
#[error("Multiple errors: {0:?}")]
Multiple(Vec<SchemaLoadError>),
}
impl SchemaRegistry {
pub fn load_dir_with_env<E: SchemaEnv>(
&self,
path: impl AsRef<Path>,
env: &E,
) -> Result<(), SchemaLoadError> {
let path = path.as_ref();
let fs = env.filesystem();
let files = fs
.read_dir(path)
.map_err(|e| SchemaLoadError::Io(path.to_path_buf(), Box::new(e)))?;
let mut errors = Vec::new();
for file in files {
if file.extension().and_then(|s| s.to_str()) == Some("json") {
if let Err(e) = self.load_schema_file(&file, fs) {
errors.push(e);
}
}
}
if errors.is_empty() {
Ok(())
} else {
Err(SchemaLoadError::Multiple(errors))
}
}
fn load_schema_file<Fs: FileSystem>(
&self,
path: &Path,
fs: &Fs,
) -> Result<(), SchemaLoadError> {
let content = fs
.read_file(path)
.map_err(|e| SchemaLoadError::Io(path.to_path_buf(), Box::new(e)))?;
let json: Value = serde_json::from_str(&content)
.map_err(|e| SchemaLoadError::Parse(path.to_path_buf(), e))?;
let name = path
.file_stem()
.and_then(|s| s.to_str())
.ok_or_else(|| SchemaLoadError::InvalidFileName(path.to_path_buf()))?;
parse_and_register_schema(self, name, &json, path)?;
Ok(())
}
}
fn parse_and_register_schema(
registry: &SchemaRegistry,
name: &str,
json: &Value,
path: &Path,
) -> Result<(), SchemaLoadError> {
let schema_type = json.get("type").and_then(|v| v.as_str()).ok_or_else(|| {
SchemaLoadError::Schema(path.to_path_buf(), "Missing 'type' field".to_string())
})?;
match schema_type {
"string" => {
let mut schema = Schema::string();
if let Some(min_len) = json.get("minLength").and_then(|v| v.as_u64()) {
schema = schema.min_len(min_len as usize);
}
if let Some(max_len) = json.get("maxLength").and_then(|v| v.as_u64()) {
schema = schema.max_len(max_len as usize);
}
if let Some(pattern) = json.get("pattern").and_then(|v| v.as_str()) {
schema = schema
.pattern(pattern)
.map_err(|e| SchemaLoadError::Schema(path.to_path_buf(), e.to_string()))?;
}
registry
.register(name, schema)
.map_err(SchemaLoadError::Registry)
}
"integer" => {
let schema = Schema::integer();
registry
.register(name, schema)
.map_err(SchemaLoadError::Registry)
}
"object" => {
let schema = Schema::object();
registry
.register(name, schema)
.map_err(SchemaLoadError::Registry)
}
"array" => {
let schema = Schema::array(Schema::object());
registry
.register(name, schema)
.map_err(SchemaLoadError::Registry)
}
_ => Err(SchemaLoadError::Schema(
path.to_path_buf(),
format!("Unsupported schema type: {}", schema_type),
)),
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::collections::HashMap;
#[derive(Debug)]
struct MockFileSystemError(String);
impl std::fmt::Display for MockFileSystemError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "{}", self.0)
}
}
impl std::error::Error for MockFileSystemError {}
struct MockFileSystem {
files: HashMap<PathBuf, String>,
}
impl MockFileSystem {
fn new() -> Self {
Self {
files: HashMap::new(),
}
}
fn add_file(&mut self, path: impl Into<PathBuf>, content: impl Into<String>) {
self.files.insert(path.into(), content.into());
}
}
impl FileSystem for MockFileSystem {
type Error = MockFileSystemError;
fn read_file(&self, path: &Path) -> Result<String, Self::Error> {
self.files
.get(path)
.cloned()
.ok_or_else(|| MockFileSystemError(format!("File not found: {}", path.display())))
}
fn read_dir(&self, _path: &Path) -> Result<Vec<PathBuf>, Self::Error> {
Ok(self.files.keys().cloned().collect())
}
}
struct TestEnv {
fs: MockFileSystem,
}
impl SchemaEnv for TestEnv {
type Fs = MockFileSystem;
fn filesystem(&self) -> &Self::Fs {
&self.fs
}
}
#[test]
fn test_load_string_schema() {
let mut fs = MockFileSystem::new();
fs.add_file(
"test.json",
r#"{
"type": "string",
"minLength": 1,
"maxLength": 100
}"#,
);
let env = TestEnv { fs };
let registry = SchemaRegistry::new();
let result = registry.load_dir_with_env(".", &env);
assert!(result.is_ok());
assert!(registry.get("test").is_some());
}
#[test]
fn test_load_multiple_schemas() {
let mut fs = MockFileSystem::new();
fs.add_file("email.json", r#"{"type": "string"}"#);
fs.add_file("age.json", r#"{"type": "integer"}"#);
let env = TestEnv { fs };
let registry = SchemaRegistry::new();
let result = registry.load_dir_with_env(".", &env);
assert!(result.is_ok());
assert!(registry.get("email").is_some());
assert!(registry.get("age").is_some());
}
#[test]
fn test_parse_error_accumulation() {
let mut fs = MockFileSystem::new();
fs.add_file("valid.json", r#"{"type": "string"}"#);
fs.add_file("invalid.json", r#"not valid json"#);
let env = TestEnv { fs };
let registry = SchemaRegistry::new();
let result = registry.load_dir_with_env(".", &env);
assert!(result.is_err());
assert!(registry.get("valid").is_some());
}
}