Skip to main content

agentstategraph_core/
schema.rs

1//! Schema system — optional JSON Schema validation with merge hints.
2//!
3//! Schemas use x-agentstategraph-merge annotations to tell the merge engine
4//! how to handle each field. This enables CRDT-inspired auto-resolution
5//! of concurrent changes.
6
7use serde::{Deserialize, Serialize};
8use std::collections::HashMap;
9
10/// A schema definition with merge hints.
11#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
12pub struct Schema {
13    /// The raw JSON Schema document.
14    pub json_schema: serde_json::Value,
15    /// Extracted merge hints per path.
16    pub merge_hints: HashMap<String, MergeHint>,
17    /// Enforcement mode.
18    pub enforcement: EnforcementMode,
19}
20
21/// How a field should be merged when both sides change it.
22#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
23pub enum MergeHint {
24    /// Most recent commit's value wins.
25    LastWriterWins,
26    /// Merge arrays of records by a key field.
27    UnionById(String),
28    /// Union of both sets of values.
29    Union,
30    /// Add the deltas from both sides.
31    Sum,
32    /// Take the higher value.
33    Max,
34    /// Take the lower value.
35    Min,
36    /// Concatenate (source then target).
37    Concat,
38    /// Always flag as conflict.
39    Manual,
40    /// Invoke a named resolution function.
41    Custom(String),
42}
43
44/// How strictly the schema is enforced.
45#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize, Default)]
46pub enum EnforcementMode {
47    /// No validation. Schema is documentation only.
48    #[default]
49    None,
50    /// Validate on commit, log warnings, but allow.
51    Warn,
52    /// Reject commits that violate the schema.
53    Enforce,
54    /// Apply automatic migrations when schema changes.
55    Migrate,
56}
57
58/// Validation result for a state tree against a schema.
59#[derive(Debug, Clone, Serialize, Deserialize)]
60pub struct ValidationResult {
61    pub valid: bool,
62    pub errors: Vec<ValidationError>,
63    pub warnings: Vec<String>,
64}
65
66/// A schema validation error.
67#[derive(Debug, Clone, Serialize, Deserialize)]
68pub struct ValidationError {
69    pub path: String,
70    pub message: String,
71    pub expected: Option<String>,
72    pub actual: Option<String>,
73}
74
75impl Schema {
76    /// Create a new schema from a JSON Schema document.
77    /// Extracts x-agentstategraph-merge hints from the schema.
78    pub fn from_json_schema(schema: serde_json::Value, enforcement: EnforcementMode) -> Self {
79        let merge_hints = extract_merge_hints(&schema, "");
80        Self {
81            json_schema: schema,
82            merge_hints,
83            enforcement,
84        }
85    }
86
87    /// Get the merge hint for a specific path.
88    pub fn merge_hint_for(&self, path: &str) -> Option<&MergeHint> {
89        self.merge_hints.get(path)
90    }
91
92    /// Validate a JSON value against this schema.
93    /// Basic validation — checks required fields and types.
94    pub fn validate(&self, value: &serde_json::Value) -> ValidationResult {
95        let mut errors = Vec::new();
96        let mut warnings = Vec::new();
97
98        validate_recursive(&self.json_schema, value, "", &mut errors, &mut warnings);
99
100        ValidationResult {
101            valid: errors.is_empty(),
102            errors,
103            warnings,
104        }
105    }
106}
107
108/// Extract x-agentstategraph-merge hints from a JSON Schema document.
109fn extract_merge_hints(schema: &serde_json::Value, path: &str) -> HashMap<String, MergeHint> {
110    let mut hints = HashMap::new();
111
112    if let Some(hint_str) = schema
113        .get("x-agentstategraph-merge")
114        .and_then(|v| v.as_str())
115    {
116        let id_field = schema
117            .get("x-agentstategraph-id-field")
118            .and_then(|v| v.as_str())
119            .unwrap_or("id")
120            .to_string();
121
122        let hint = match hint_str {
123            "last-writer-wins" => MergeHint::LastWriterWins,
124            "union-by-id" => MergeHint::UnionById(id_field),
125            "union" => MergeHint::Union,
126            "sum" => MergeHint::Sum,
127            "max" => MergeHint::Max,
128            "min" => MergeHint::Min,
129            "concat" => MergeHint::Concat,
130            "manual" => MergeHint::Manual,
131            other => MergeHint::Custom(other.to_string()),
132        };
133        hints.insert(path.to_string(), hint);
134    }
135
136    // Recurse into properties
137    if let Some(props) = schema.get("properties").and_then(|v| v.as_object()) {
138        for (key, prop_schema) in props {
139            let child_path = if path.is_empty() {
140                format!("/{}", key)
141            } else {
142                format!("{}/{}", path, key)
143            };
144            hints.extend(extract_merge_hints(prop_schema, &child_path));
145        }
146    }
147
148    // Recurse into items (for arrays)
149    if let Some(items) = schema.get("items") {
150        let child_path = format!("{}/*", path);
151        hints.extend(extract_merge_hints(items, &child_path));
152    }
153
154    hints
155}
156
157/// Basic recursive validation against a JSON Schema.
158// `warnings` is threaded through so future validation rules can surface
159// non-blocking findings (deprecation notices etc.) without restructuring
160// call sites. Present but unused today.
161#[allow(clippy::only_used_in_recursion)]
162fn validate_recursive(
163    schema: &serde_json::Value,
164    value: &serde_json::Value,
165    path: &str,
166    errors: &mut Vec<ValidationError>,
167    warnings: &mut Vec<String>,
168) {
169    // Check type
170    if let Some(expected_type) = schema.get("type").and_then(|v| v.as_str()) {
171        let actual_type = json_type_name(value);
172        if expected_type != actual_type {
173            errors.push(ValidationError {
174                path: path.to_string(),
175                message: format!("expected type '{}', found '{}'", expected_type, actual_type),
176                expected: Some(expected_type.to_string()),
177                actual: Some(actual_type.to_string()),
178            });
179            return;
180        }
181    }
182
183    // Check required fields
184    if let Some(required) = schema.get("required").and_then(|v| v.as_array())
185        && let Some(obj) = value.as_object()
186    {
187        for req in required {
188            if let Some(key) = req.as_str()
189                && !obj.contains_key(key)
190            {
191                errors.push(ValidationError {
192                    path: format!("{}/{}", path, key),
193                    message: format!("required field '{}' is missing", key),
194                    expected: Some("present".to_string()),
195                    actual: Some("missing".to_string()),
196                });
197            }
198        }
199    }
200
201    // Check enum values
202    if let Some(enum_values) = schema.get("enum").and_then(|v| v.as_array())
203        && !enum_values.contains(value)
204    {
205        errors.push(ValidationError {
206            path: path.to_string(),
207            message: format!("value not in allowed enum: {:?}", value),
208            expected: Some(format!("{:?}", enum_values)),
209            actual: Some(format!("{:?}", value)),
210        });
211    }
212
213    // Recurse into properties
214    if let Some(props) = schema.get("properties").and_then(|v| v.as_object())
215        && let Some(obj) = value.as_object()
216    {
217        for (key, prop_schema) in props {
218            if let Some(prop_value) = obj.get(key) {
219                let child_path = format!("{}/{}", path, key);
220                validate_recursive(prop_schema, prop_value, &child_path, errors, warnings);
221            }
222        }
223    }
224
225    // Recurse into array items
226    if let Some(items_schema) = schema.get("items")
227        && let Some(arr) = value.as_array()
228    {
229        for (i, item) in arr.iter().enumerate() {
230            let child_path = format!("{}/{}", path, i);
231            validate_recursive(items_schema, item, &child_path, errors, warnings);
232        }
233    }
234}
235
236fn json_type_name(value: &serde_json::Value) -> &'static str {
237    match value {
238        serde_json::Value::Null => "null",
239        serde_json::Value::Bool(_) => "boolean",
240        serde_json::Value::Number(n) => {
241            if n.is_i64() || n.is_u64() {
242                "integer"
243            } else {
244                "number"
245            }
246        }
247        serde_json::Value::String(_) => "string",
248        serde_json::Value::Array(_) => "array",
249        serde_json::Value::Object(_) => "object",
250    }
251}
252
253#[cfg(test)]
254mod tests {
255    use super::*;
256
257    #[test]
258    fn test_extract_merge_hints() {
259        let schema = serde_json::json!({
260            "type": "object",
261            "properties": {
262                "nodes": {
263                    "type": "array",
264                    "x-agentstategraph-merge": "union-by-id",
265                    "x-agentstategraph-id-field": "node_id"
266                },
267                "request_count": {
268                    "type": "integer",
269                    "x-agentstategraph-merge": "sum"
270                },
271                "config": {
272                    "type": "object",
273                    "x-agentstategraph-merge": "last-writer-wins"
274                }
275            }
276        });
277
278        let s = Schema::from_json_schema(schema, EnforcementMode::None);
279        assert_eq!(
280            s.merge_hint_for("/nodes"),
281            Some(&MergeHint::UnionById("node_id".to_string()))
282        );
283        assert_eq!(s.merge_hint_for("/request_count"), Some(&MergeHint::Sum));
284        assert_eq!(
285            s.merge_hint_for("/config"),
286            Some(&MergeHint::LastWriterWins)
287        );
288    }
289
290    #[test]
291    fn test_validate_type_check() {
292        let schema = serde_json::json!({
293            "type": "object",
294            "properties": {
295                "name": { "type": "string" },
296                "count": { "type": "integer" }
297            },
298            "required": ["name"]
299        });
300
301        let s = Schema::from_json_schema(schema, EnforcementMode::Enforce);
302
303        // Valid
304        let valid = serde_json::json!({"name": "test", "count": 5});
305        let result = s.validate(&valid);
306        assert!(result.valid, "errors: {:?}", result.errors);
307
308        // Missing required field
309        let missing = serde_json::json!({"count": 5});
310        let result = s.validate(&missing);
311        assert!(!result.valid);
312        assert!(result.errors[0].message.contains("required"));
313
314        // Wrong type
315        let wrong_type = serde_json::json!({"name": 123, "count": 5});
316        let result = s.validate(&wrong_type);
317        assert!(!result.valid);
318        assert!(result.errors[0].message.contains("expected type"));
319    }
320
321    #[test]
322    fn test_validate_enum() {
323        let schema = serde_json::json!({
324            "type": "object",
325            "properties": {
326                "status": {
327                    "type": "string",
328                    "enum": ["healthy", "unhealthy", "draining"]
329                }
330            }
331        });
332
333        let s = Schema::from_json_schema(schema, EnforcementMode::Enforce);
334
335        let valid = serde_json::json!({"status": "healthy"});
336        assert!(s.validate(&valid).valid);
337
338        let invalid = serde_json::json!({"status": "unknown"});
339        assert!(!s.validate(&invalid).valid);
340    }
341
342    #[test]
343    fn test_validate_nested() {
344        let schema = serde_json::json!({
345            "type": "object",
346            "properties": {
347                "nodes": {
348                    "type": "array",
349                    "items": {
350                        "type": "object",
351                        "properties": {
352                            "hostname": { "type": "string" },
353                            "status": { "type": "string", "enum": ["healthy", "unhealthy"] }
354                        },
355                        "required": ["hostname", "status"]
356                    }
357                }
358            }
359        });
360
361        let s = Schema::from_json_schema(schema, EnforcementMode::Enforce);
362
363        let valid = serde_json::json!({
364            "nodes": [
365                {"hostname": "node-1", "status": "healthy"},
366                {"hostname": "node-2", "status": "unhealthy"}
367            ]
368        });
369        assert!(s.validate(&valid).valid);
370
371        let invalid = serde_json::json!({
372            "nodes": [
373                {"hostname": "node-1"}  // missing required "status"
374            ]
375        });
376        assert!(!s.validate(&invalid).valid);
377    }
378
379    // --- All merge hint variants ---
380
381    #[test]
382    fn test_extract_all_merge_hint_variants() {
383        let schema = serde_json::json!({
384            "type": "object",
385            "properties": {
386                "a": { "x-agentstategraph-merge": "last-writer-wins" },
387                "b": { "x-agentstategraph-merge": "union-by-id" }, // default id field
388                "c": { "x-agentstategraph-merge": "union" },
389                "d": { "x-agentstategraph-merge": "sum" },
390                "e": { "x-agentstategraph-merge": "max" },
391                "f": { "x-agentstategraph-merge": "min" },
392                "g": { "x-agentstategraph-merge": "concat" },
393                "h": { "x-agentstategraph-merge": "manual" },
394                "i": { "x-agentstategraph-merge": "my-custom-resolver" }
395            }
396        });
397
398        let s = Schema::from_json_schema(schema, EnforcementMode::None);
399
400        assert_eq!(s.merge_hint_for("/a"), Some(&MergeHint::LastWriterWins));
401        // union-by-id without explicit id-field defaults to "id"
402        assert_eq!(
403            s.merge_hint_for("/b"),
404            Some(&MergeHint::UnionById("id".to_string()))
405        );
406        assert_eq!(s.merge_hint_for("/c"), Some(&MergeHint::Union));
407        assert_eq!(s.merge_hint_for("/d"), Some(&MergeHint::Sum));
408        assert_eq!(s.merge_hint_for("/e"), Some(&MergeHint::Max));
409        assert_eq!(s.merge_hint_for("/f"), Some(&MergeHint::Min));
410        assert_eq!(s.merge_hint_for("/g"), Some(&MergeHint::Concat));
411        assert_eq!(s.merge_hint_for("/h"), Some(&MergeHint::Manual));
412        assert_eq!(
413            s.merge_hint_for("/i"),
414            Some(&MergeHint::Custom("my-custom-resolver".to_string()))
415        );
416    }
417
418    #[test]
419    fn test_merge_hint_for_unknown_path_returns_none() {
420        let schema = serde_json::json!({ "type": "object" });
421        let s = Schema::from_json_schema(schema, EnforcementMode::None);
422        assert_eq!(s.merge_hint_for("/nonexistent"), None);
423        assert_eq!(s.merge_hint_for(""), None);
424    }
425
426    #[test]
427    fn test_union_by_id_explicit_id_field() {
428        let schema = serde_json::json!({
429            "type": "object",
430            "properties": {
431                "records": {
432                    "x-agentstategraph-merge": "union-by-id",
433                    "x-agentstategraph-id-field": "record_id"
434                }
435            }
436        });
437        let s = Schema::from_json_schema(schema, EnforcementMode::None);
438        assert_eq!(
439            s.merge_hint_for("/records"),
440            Some(&MergeHint::UnionById("record_id".to_string()))
441        );
442    }
443
444    // --- items/* path for array schemas ---
445
446    #[test]
447    fn test_extract_merge_hints_from_items() {
448        let schema = serde_json::json!({
449            "type": "array",
450            "x-agentstategraph-merge": "concat",
451            "items": {
452                "x-agentstategraph-merge": "last-writer-wins"
453            }
454        });
455        let s = Schema::from_json_schema(schema, EnforcementMode::None);
456        // Root-level hint
457        assert_eq!(s.merge_hint_for(""), Some(&MergeHint::Concat));
458        // Items hint
459        assert_eq!(s.merge_hint_for("/*"), Some(&MergeHint::LastWriterWins));
460    }
461
462    // --- EnforcementMode default ---
463
464    #[test]
465    fn test_enforcement_mode_default_is_none() {
466        assert_eq!(EnforcementMode::default(), EnforcementMode::None);
467    }
468
469    // --- json_type_name coverage ---
470
471    #[test]
472    fn test_type_mismatch_null() {
473        let schema = serde_json::json!({ "type": "string" });
474        let s = Schema::from_json_schema(schema, EnforcementMode::Enforce);
475        let result = s.validate(&serde_json::Value::Null);
476        assert!(!result.valid);
477        let e = &result.errors[0];
478        assert_eq!(e.expected.as_deref(), Some("string"));
479        assert_eq!(e.actual.as_deref(), Some("null"));
480    }
481
482    #[test]
483    fn test_type_mismatch_boolean() {
484        let schema = serde_json::json!({ "type": "integer" });
485        let s = Schema::from_json_schema(schema, EnforcementMode::Enforce);
486        let result = s.validate(&serde_json::json!(true));
487        assert!(!result.valid);
488        assert_eq!(result.errors[0].actual.as_deref(), Some("boolean"));
489    }
490
491    #[test]
492    fn test_type_mismatch_array() {
493        let schema = serde_json::json!({ "type": "object" });
494        let s = Schema::from_json_schema(schema, EnforcementMode::Enforce);
495        let result = s.validate(&serde_json::json!([1, 2, 3]));
496        assert!(!result.valid);
497        assert_eq!(result.errors[0].actual.as_deref(), Some("array"));
498    }
499
500    #[test]
501    #[allow(clippy::approx_constant)] // 3.14 here is just arbitrary float test data, not PI
502    fn test_type_number_vs_integer() {
503        // float is "number", not "integer"
504        let schema = serde_json::json!({ "type": "number" });
505        let s = Schema::from_json_schema(schema, EnforcementMode::Enforce);
506        let result = s.validate(&serde_json::json!(3.14));
507        assert!(result.valid, "3.14 should satisfy type:number");
508
509        let schema = serde_json::json!({ "type": "integer" });
510        let s = Schema::from_json_schema(schema, EnforcementMode::Enforce);
511        let result = s.validate(&serde_json::json!(42));
512        assert!(result.valid, "42 should satisfy type:integer");
513    }
514
515    // --- Validation edge cases ---
516
517    #[test]
518    fn test_validate_empty_schema_accepts_anything() {
519        let schema = serde_json::json!({});
520        let s = Schema::from_json_schema(schema, EnforcementMode::Enforce);
521        assert!(s.validate(&serde_json::json!(42)).valid);
522        assert!(s.validate(&serde_json::json!({"a": 1})).valid);
523        assert!(s.validate(&serde_json::Value::Null).valid);
524    }
525
526    #[test]
527    fn test_validate_multiple_required_fields_missing() {
528        let schema = serde_json::json!({
529            "type": "object",
530            "required": ["a", "b", "c"]
531        });
532        let s = Schema::from_json_schema(schema, EnforcementMode::Enforce);
533        let result = s.validate(&serde_json::json!({"a": 1}));
534        assert!(!result.valid);
535        assert_eq!(
536            result.errors.len(),
537            2,
538            "b and c should both be reported missing"
539        );
540        let paths: Vec<_> = result.errors.iter().map(|e| e.path.as_str()).collect();
541        assert!(paths.iter().any(|p| p.contains("b")));
542        assert!(paths.iter().any(|p| p.contains("c")));
543    }
544
545    #[test]
546    fn test_validation_error_fields_populated() {
547        let schema = serde_json::json!({
548            "type": "object",
549            "properties": {
550                "x": { "type": "string" }
551            }
552        });
553        let s = Schema::from_json_schema(schema, EnforcementMode::Enforce);
554        let result = s.validate(&serde_json::json!({"x": 42}));
555        assert!(!result.valid);
556        let e = &result.errors[0];
557        assert!(!e.path.is_empty());
558        assert!(!e.message.is_empty());
559        assert!(e.expected.is_some());
560        assert!(e.actual.is_some());
561    }
562
563    #[test]
564    fn test_validate_array_items_recursively() {
565        let schema = serde_json::json!({
566            "type": "array",
567            "items": { "type": "integer" }
568        });
569        let s = Schema::from_json_schema(schema, EnforcementMode::Enforce);
570
571        assert!(s.validate(&serde_json::json!([1, 2, 3])).valid);
572
573        let result = s.validate(&serde_json::json!([1, "bad", 3]));
574        assert!(!result.valid);
575        assert_eq!(result.errors.len(), 1);
576        assert!(result.errors[0].path.contains("1")); // index 1 is "bad"
577    }
578
579    // --- Schema serialization ---
580
581    #[test]
582    fn test_schema_serializes_and_deserializes() {
583        let schema = Schema::from_json_schema(
584            serde_json::json!({
585                "type": "object",
586                "properties": {
587                    "count": { "x-agentstategraph-merge": "sum" }
588                }
589            }),
590            EnforcementMode::Warn,
591        );
592
593        let json = serde_json::to_string(&schema).unwrap();
594        let restored: Schema = serde_json::from_str(&json).unwrap();
595
596        assert_eq!(restored.enforcement, EnforcementMode::Warn);
597        assert_eq!(restored.merge_hint_for("/count"), Some(&MergeHint::Sum));
598    }
599
600    #[test]
601    fn test_merge_hint_serializes_and_deserializes() {
602        let hints = vec![
603            MergeHint::LastWriterWins,
604            MergeHint::UnionById("id".to_string()),
605            MergeHint::Union,
606            MergeHint::Sum,
607            MergeHint::Max,
608            MergeHint::Min,
609            MergeHint::Concat,
610            MergeHint::Manual,
611            MergeHint::Custom("custom-fn".to_string()),
612        ];
613        for hint in hints {
614            let json = serde_json::to_string(&hint).unwrap();
615            let restored: MergeHint = serde_json::from_str(&json).unwrap();
616            assert_eq!(restored, hint, "round-trip failed for {hint:?}");
617        }
618    }
619}