Skip to main content

nap_core/merge/
sdl.rs

1//! SDL — Schema Definition Language for merge behavior.
2//!
3//! This module defines the YAML-based schema format that controls how
4//! each property is merged.  Every property MUST define a type and a
5//! merge strategy.  No inferred types, no fallback heuristics.
6//!
7//! # SDL is schema + merge-strategy metadata only.
8//!
9//! Protocol invariants (missing ≠ null, normalize before merge, etc.)
10//! live in `merge-semantics-v2.md` and are hardcoded in the engine.
11//! They do NOT appear in SDL.
12//!
13//! # SDL Example
14//!
15//! ```yaml
16//! schema:
17//!   version: "1.0"
18//!   required:
19//!     - id
20//!   properties:
21//!     name:
22//!       type: string
23//!       merge:
24//!         type: replace
25//!     tags:
26//!       type: array
27//!       merge:
28//!         type: ordered_unique
29//!         identity:
30//!           mode: primitive_value
31//!     characters:
32//!       type: array
33//!       merge:
34//!         type: ordered_unique
35//!         identity:
36//!           mode: key
37//!           key: id
38//!     appears_in:
39//!       type: array
40//!       merge:
41//!         type: edge_list
42//!         source_key: character_id
43//!         target_key: scene_id
44//!         identity:
45//!           mode: key
46//!           key: id
47//! ```
48
49use serde::{Deserialize, Serialize};
50use std::collections::BTreeMap;
51
52// ── Top-level SDL Document ─────────────────────────────────────────────
53
54/// A parsed SDL document.
55#[derive(Debug, Clone, Serialize, Deserialize)]
56pub struct SdlDocument {
57    pub schema: SdlSchemaBody,
58}
59
60/// The body of an SDL document.
61#[derive(Debug, Clone, Serialize, Deserialize)]
62pub struct SdlSchemaBody {
63    pub version: String,
64
65    /// Property paths that MUST be present in every manifest.
66    #[serde(default)]
67    pub required: Vec<String>,
68
69    /// Property definitions keyed by canonical path (e.g. `"name"`,
70    /// `"properties.homeworld"`).
71    pub properties: BTreeMap<String, PropertyDef>,
72}
73
74// ── Property Definition ────────────────────────────────────────────────
75
76/// The definition of a single property in the SDL.
77#[derive(Debug, Clone, Serialize, Deserialize)]
78pub struct PropertyDef {
79    /// The property type (string, number, boolean, object, array).
80    #[serde(rename = "type")]
81    pub type_: PropertyType,
82
83    /// The merge strategy for this property.
84    pub merge: MergeStrategyDef,
85}
86
87// ── Property Type ──────────────────────────────────────────────────────
88
89/// Allowed property types in SDL.
90#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
91#[serde(rename_all = "lowercase")]
92pub enum PropertyType {
93    String,
94    Number,
95    Boolean,
96    Object,
97    Array,
98}
99
100// ── Merge Strategy Definition ─────────────────────────────────────────
101
102/// The full merge strategy definition for a property.
103#[derive(Debug, Clone, Serialize, Deserialize)]
104pub struct MergeStrategyDef {
105    /// The strategy type.
106    #[serde(rename = "type")]
107    pub strategy_type: MergeStrategyType,
108
109    /// Identity rules (required for ordered_unique, set_union, edge_list).
110    #[serde(default, skip_serializing_if = "Option::is_none")]
111    pub identity: Option<IdentityRule>,
112
113    /// Source key for edge_list strategies.
114    #[serde(default, skip_serializing_if = "Option::is_none")]
115    pub source_key: Option<String>,
116
117    /// Target key for edge_list strategies.
118    #[serde(default, skip_serializing_if = "Option::is_none")]
119    pub target_key: Option<String>,
120}
121
122/// The kind of merge strategy.
123#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
124#[serde(rename_all = "snake_case")]
125pub enum MergeStrategyType {
126    /// Simple value overwrite per conflict matrix.
127    Replace,
128    /// Recursive object merge.  Only valid for `object` properties.
129    DeepMerge,
130    /// Whole value treated as atomic unit — any divergent change = conflict.
131    Atomic,
132    /// Ordered list with identity deduplication.  Preserves base order.
133    OrderedUnique,
134    /// Unordered set with identity deduplication.
135    SetUnion,
136    /// Graph edge list with source, target, and identity.
137    EdgeList,
138}
139
140// ── Identity Rule ──────────────────────────────────────────────────────
141
142/// Rules for determining identity in array elements.
143#[derive(Debug, Clone, Serialize, Deserialize)]
144#[serde(tag = "mode", rename_all = "snake_case")]
145pub enum IdentityRule {
146    /// Identity is the primitive value itself (for arrays of strings/numbers).
147    PrimitiveValue,
148    /// Identity is a named key within each object element.
149    Key { key: String },
150}
151
152// ── Parsing ────────────────────────────────────────────────────────────
153
154impl SdlDocument {
155    /// Parse an SDL document from a YAML string.
156    pub fn from_yaml(yaml: &str) -> Result<Self, SdlError> {
157        serde_yaml::from_str(yaml).map_err(|e| SdlError::ParseError {
158            message: e.to_string(),
159        })
160    }
161
162    /// Serialize this SDL document to YAML.
163    pub fn to_yaml(&self) -> Result<String, SdlError> {
164        serde_yaml::to_string(self).map_err(|e| SdlError::SerializeError {
165            message: e.to_string(),
166        })
167    }
168
169    /// Look up the property definition for a given canonical path.
170    ///
171    /// Returns `None` if the path is not explicitly defined in the schema.
172    /// (The engine should reject schema-less properties — this is a
173    /// validation concern, not a fallback.)
174    pub fn property_def(&self, path: &str) -> Option<&PropertyDef> {
175        self.schema.properties.get(path)
176    }
177
178    /// Returns the set of all defined property paths.
179    pub fn property_paths(&self) -> impl Iterator<Item = &String> {
180        self.schema.properties.keys()
181    }
182}
183
184// ── Error Type ─────────────────────────────────────────────────────────
185
186/// Errors that can occur during SDL parsing or serialization.
187#[derive(Debug, thiserror::Error)]
188pub enum SdlError {
189    #[error("SDL parse error: {message}")]
190    ParseError { message: String },
191
192    #[error("SDL serialize error: {message}")]
193    SerializeError { message: String },
194}
195
196// ── Tests ──────────────────────────────────────────────────────────────
197
198#[cfg(test)]
199mod tests {
200    use super::*;
201
202    fn valid_sdl_yaml() -> &'static str {
203        r#"
204schema:
205  version: "1.0"
206  required:
207    - id
208  properties:
209    name:
210      type: string
211      merge:
212        type: replace
213    tags:
214      type: array
215      merge:
216        type: ordered_unique
217        identity:
218          mode: primitive_value
219    characters:
220      type: array
221      merge:
222        type: ordered_unique
223        identity:
224          mode: key
225          key: id
226    appears_in:
227      type: array
228      merge:
229        type: edge_list
230        source_key: character_id
231        target_key: scene_id
232        identity:
233          mode: key
234          key: id
235    version:
236      type: number
237      merge:
238        type: atomic
239    metadata:
240      type: object
241      merge:
242        type: deep_merge
243    tags_set:
244      type: array
245      merge:
246        type: set_union
247        identity:
248          mode: primitive_value
249"#
250    }
251
252    #[test]
253    fn test_parse_valid_sdl() {
254        let doc = SdlDocument::from_yaml(valid_sdl_yaml()).unwrap();
255        assert_eq!(doc.schema.version, "1.0");
256        assert_eq!(doc.schema.required, vec!["id"]);
257        assert_eq!(doc.schema.properties.len(), 7);
258    }
259
260    #[test]
261    fn test_parse_replace_strategy() {
262        let doc = SdlDocument::from_yaml(valid_sdl_yaml()).unwrap();
263        let def = doc.property_def("name").unwrap();
264        assert!(matches!(def.type_, PropertyType::String));
265        assert!(matches!(
266            def.merge.strategy_type,
267            MergeStrategyType::Replace
268        ));
269    }
270
271    #[test]
272    fn test_parse_ordered_unique_primitive() {
273        let doc = SdlDocument::from_yaml(valid_sdl_yaml()).unwrap();
274        let def = doc.property_def("tags").unwrap();
275        assert!(matches!(def.type_, PropertyType::Array));
276        assert!(matches!(
277            def.merge.strategy_type,
278            MergeStrategyType::OrderedUnique
279        ));
280        let identity = def.merge.identity.as_ref().unwrap();
281        assert!(matches!(identity, IdentityRule::PrimitiveValue));
282    }
283
284    #[test]
285    fn test_parse_ordered_unique_key() {
286        let doc = SdlDocument::from_yaml(valid_sdl_yaml()).unwrap();
287        let def = doc.property_def("characters").unwrap();
288        assert!(matches!(def.type_, PropertyType::Array));
289        let identity = def.merge.identity.as_ref().unwrap();
290        match identity {
291            IdentityRule::Key { key } => assert_eq!(key, "id"),
292            _ => panic!("expected Key identity"),
293        }
294    }
295
296    #[test]
297    fn test_parse_edge_list() {
298        let doc = SdlDocument::from_yaml(valid_sdl_yaml()).unwrap();
299        let def = doc.property_def("appears_in").unwrap();
300        assert!(matches!(
301            def.merge.strategy_type,
302            MergeStrategyType::EdgeList
303        ));
304        assert_eq!(def.merge.source_key.as_deref(), Some("character_id"));
305        assert_eq!(def.merge.target_key.as_deref(), Some("scene_id"));
306    }
307
308    #[test]
309    fn test_parse_atomic() {
310        let doc = SdlDocument::from_yaml(valid_sdl_yaml()).unwrap();
311        let def = doc.property_def("version").unwrap();
312        assert!(matches!(def.type_, PropertyType::Number));
313        assert!(matches!(def.merge.strategy_type, MergeStrategyType::Atomic));
314    }
315
316    #[test]
317    fn test_parse_deep_merge() {
318        let doc = SdlDocument::from_yaml(valid_sdl_yaml()).unwrap();
319        let def = doc.property_def("metadata").unwrap();
320        assert!(matches!(def.type_, PropertyType::Object));
321        assert!(matches!(
322            def.merge.strategy_type,
323            MergeStrategyType::DeepMerge
324        ));
325    }
326
327    #[test]
328    fn test_parse_set_union() {
329        let doc = SdlDocument::from_yaml(valid_sdl_yaml()).unwrap();
330        let def = doc.property_def("tags_set").unwrap();
331        assert!(matches!(
332            def.merge.strategy_type,
333            MergeStrategyType::SetUnion
334        ));
335    }
336
337    #[test]
338    fn test_undefined_property_returns_none() {
339        let doc = SdlDocument::from_yaml(valid_sdl_yaml()).unwrap();
340        assert!(doc.property_def("nonexistent").is_none());
341    }
342
343    #[test]
344    fn test_invalid_yaml_returns_error() {
345        let result = SdlDocument::from_yaml("not: valid: yaml: [[[");
346        assert!(result.is_err());
347    }
348
349    #[test]
350    fn test_sdl_yaml_roundtrip() {
351        let doc = SdlDocument::from_yaml(valid_sdl_yaml()).unwrap();
352        let yaml = doc.to_yaml().unwrap();
353        let parsed = SdlDocument::from_yaml(&yaml).unwrap();
354        assert_eq!(parsed.schema.properties.len(), doc.schema.properties.len());
355    }
356
357    #[test]
358    fn test_missing_merge_strategy_is_parse_error() {
359        // Missing `merge` field causes a serde_yaml parse error because
360        // MergeStrategyDef has no default — every property MUST declare
361        // a merge strategy.
362        let yaml = r#"
363schema:
364  version: "1.0"
365  required: []
366  properties:
367    bad_field:
368      type: string
369"#;
370        let result = SdlDocument::from_yaml(yaml);
371        assert!(
372            result.is_err(),
373            "expected parse error for missing merge strategy"
374        );
375    }
376}