1use serde::{Deserialize, Serialize};
8use std::collections::HashMap;
9
10#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
12pub struct Schema {
13 pub json_schema: serde_json::Value,
15 pub merge_hints: HashMap<String, MergeHint>,
17 pub enforcement: EnforcementMode,
19}
20
21#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
23pub enum MergeHint {
24 LastWriterWins,
26 UnionById(String),
28 Union,
30 Sum,
32 Max,
34 Min,
36 Concat,
38 Manual,
40 Custom(String),
42}
43
44#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize, Default)]
46pub enum EnforcementMode {
47 #[default]
49 None,
50 Warn,
52 Enforce,
54 Migrate,
56}
57
58#[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#[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 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 pub fn merge_hint_for(&self, path: &str) -> Option<&MergeHint> {
89 self.merge_hints.get(path)
90 }
91
92 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
108fn 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 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 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#[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 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 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 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 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 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 let valid = serde_json::json!({"name": "test", "count": 5});
305 let result = s.validate(&valid);
306 assert!(result.valid, "errors: {:?}", result.errors);
307
308 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 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"} ]
375 });
376 assert!(!s.validate(&invalid).valid);
377 }
378
379 #[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" }, "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 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 #[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 assert_eq!(s.merge_hint_for(""), Some(&MergeHint::Concat));
458 assert_eq!(s.merge_hint_for("/*"), Some(&MergeHint::LastWriterWins));
460 }
461
462 #[test]
465 fn test_enforcement_mode_default_is_none() {
466 assert_eq!(EnforcementMode::default(), EnforcementMode::None);
467 }
468
469 #[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)] fn test_type_number_vs_integer() {
503 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 #[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")); }
578
579 #[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}