use serde::{Deserialize, Serialize};
use std::collections::BTreeMap;
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SdlDocument {
pub schema: SdlSchemaBody,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SdlSchemaBody {
pub version: String,
#[serde(default)]
pub required: Vec<String>,
pub properties: BTreeMap<String, PropertyDef>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct PropertyDef {
#[serde(rename = "type")]
pub type_: PropertyType,
pub merge: MergeStrategyDef,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum PropertyType {
String,
Number,
Boolean,
Object,
Array,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct MergeStrategyDef {
#[serde(rename = "type")]
pub strategy_type: MergeStrategyType,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub identity: Option<IdentityRule>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub source_key: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub target_key: Option<String>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum MergeStrategyType {
Replace,
DeepMerge,
Atomic,
OrderedUnique,
SetUnion,
EdgeList,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(tag = "mode", rename_all = "snake_case")]
pub enum IdentityRule {
PrimitiveValue,
Key { key: String },
}
impl SdlDocument {
pub fn from_yaml(yaml: &str) -> Result<Self, SdlError> {
serde_yaml::from_str(yaml).map_err(|e| SdlError::ParseError {
message: e.to_string(),
})
}
pub fn to_yaml(&self) -> Result<String, SdlError> {
serde_yaml::to_string(self).map_err(|e| SdlError::SerializeError {
message: e.to_string(),
})
}
pub fn property_def(&self, path: &str) -> Option<&PropertyDef> {
self.schema.properties.get(path)
}
pub fn property_paths(&self) -> impl Iterator<Item = &String> {
self.schema.properties.keys()
}
}
#[derive(Debug, thiserror::Error)]
pub enum SdlError {
#[error("SDL parse error: {message}")]
ParseError { message: String },
#[error("SDL serialize error: {message}")]
SerializeError { message: String },
}
#[cfg(test)]
mod tests {
use super::*;
fn valid_sdl_yaml() -> &'static str {
r#"
schema:
version: "1.0"
required:
- id
properties:
name:
type: string
merge:
type: replace
tags:
type: array
merge:
type: ordered_unique
identity:
mode: primitive_value
characters:
type: array
merge:
type: ordered_unique
identity:
mode: key
key: id
appears_in:
type: array
merge:
type: edge_list
source_key: character_id
target_key: scene_id
identity:
mode: key
key: id
version:
type: number
merge:
type: atomic
metadata:
type: object
merge:
type: deep_merge
tags_set:
type: array
merge:
type: set_union
identity:
mode: primitive_value
"#
}
#[test]
fn test_parse_valid_sdl() {
let doc = SdlDocument::from_yaml(valid_sdl_yaml()).unwrap();
assert_eq!(doc.schema.version, "1.0");
assert_eq!(doc.schema.required, vec!["id"]);
assert_eq!(doc.schema.properties.len(), 7);
}
#[test]
fn test_parse_replace_strategy() {
let doc = SdlDocument::from_yaml(valid_sdl_yaml()).unwrap();
let def = doc.property_def("name").unwrap();
assert!(matches!(def.type_, PropertyType::String));
assert!(matches!(
def.merge.strategy_type,
MergeStrategyType::Replace
));
}
#[test]
fn test_parse_ordered_unique_primitive() {
let doc = SdlDocument::from_yaml(valid_sdl_yaml()).unwrap();
let def = doc.property_def("tags").unwrap();
assert!(matches!(def.type_, PropertyType::Array));
assert!(matches!(
def.merge.strategy_type,
MergeStrategyType::OrderedUnique
));
let identity = def.merge.identity.as_ref().unwrap();
assert!(matches!(identity, IdentityRule::PrimitiveValue));
}
#[test]
fn test_parse_ordered_unique_key() {
let doc = SdlDocument::from_yaml(valid_sdl_yaml()).unwrap();
let def = doc.property_def("characters").unwrap();
assert!(matches!(def.type_, PropertyType::Array));
let identity = def.merge.identity.as_ref().unwrap();
match identity {
IdentityRule::Key { key } => assert_eq!(key, "id"),
_ => panic!("expected Key identity"),
}
}
#[test]
fn test_parse_edge_list() {
let doc = SdlDocument::from_yaml(valid_sdl_yaml()).unwrap();
let def = doc.property_def("appears_in").unwrap();
assert!(matches!(
def.merge.strategy_type,
MergeStrategyType::EdgeList
));
assert_eq!(def.merge.source_key.as_deref(), Some("character_id"));
assert_eq!(def.merge.target_key.as_deref(), Some("scene_id"));
}
#[test]
fn test_parse_atomic() {
let doc = SdlDocument::from_yaml(valid_sdl_yaml()).unwrap();
let def = doc.property_def("version").unwrap();
assert!(matches!(def.type_, PropertyType::Number));
assert!(matches!(def.merge.strategy_type, MergeStrategyType::Atomic));
}
#[test]
fn test_parse_deep_merge() {
let doc = SdlDocument::from_yaml(valid_sdl_yaml()).unwrap();
let def = doc.property_def("metadata").unwrap();
assert!(matches!(def.type_, PropertyType::Object));
assert!(matches!(
def.merge.strategy_type,
MergeStrategyType::DeepMerge
));
}
#[test]
fn test_parse_set_union() {
let doc = SdlDocument::from_yaml(valid_sdl_yaml()).unwrap();
let def = doc.property_def("tags_set").unwrap();
assert!(matches!(
def.merge.strategy_type,
MergeStrategyType::SetUnion
));
}
#[test]
fn test_undefined_property_returns_none() {
let doc = SdlDocument::from_yaml(valid_sdl_yaml()).unwrap();
assert!(doc.property_def("nonexistent").is_none());
}
#[test]
fn test_invalid_yaml_returns_error() {
let result = SdlDocument::from_yaml("not: valid: yaml: [[[");
assert!(result.is_err());
}
#[test]
fn test_sdl_yaml_roundtrip() {
let doc = SdlDocument::from_yaml(valid_sdl_yaml()).unwrap();
let yaml = doc.to_yaml().unwrap();
let parsed = SdlDocument::from_yaml(&yaml).unwrap();
assert_eq!(parsed.schema.properties.len(), doc.schema.properties.len());
}
#[test]
fn test_missing_merge_strategy_is_parse_error() {
let yaml = r#"
schema:
version: "1.0"
required: []
properties:
bad_field:
type: string
"#;
let result = SdlDocument::from_yaml(yaml);
assert!(
result.is_err(),
"expected parse error for missing merge strategy"
);
}
}