1use rmcp::model::JsonObject;
27use serde_json::{json, Value};
28
29#[derive(Debug, Clone)]
31pub struct FieldViolation {
32 pub field: String,
35 pub rule: &'static str,
39 pub message: String,
41 pub expected: Value,
45 pub got: Option<Value>,
47}
48
49#[derive(Debug, Clone)]
51pub enum ValidationResult {
52 Pass,
54 Fail(Vec<FieldViolation>),
57}
58
59impl ValidationResult {
60 pub fn is_pass(&self) -> bool {
62 matches!(self, ValidationResult::Pass)
63 }
64 pub fn to_payload(&self) -> Value {
66 match self {
67 ValidationResult::Pass => json!({"validation": "pass"}),
68 ValidationResult::Fail(violations) => {
69 let arr: Vec<Value> = violations
70 .iter()
71 .map(|v| {
72 let mut obj = json!({
73 "field": v.field,
74 "rule": v.rule,
75 "message": v.message,
76 "expected": v.expected,
77 });
78 if let Some(g) = &v.got {
79 obj["got"] = g.clone();
80 }
81 obj
82 })
83 .collect();
84 json!({"validation_failed": arr})
85 }
86 }
87 }
88}
89
90#[derive(Debug, Clone)]
93pub enum Rule {
94 Range {
97 field: &'static str,
98 min: Option<f64>,
99 max: Option<f64>,
100 },
101 OneOf {
103 field: &'static str,
104 values: &'static [&'static str],
105 },
106 Regex {
109 field: &'static str,
110 pattern: &'static str,
111 summary: &'static str,
112 },
113 Length {
115 field: &'static str,
116 min: Option<usize>,
117 max: Option<usize>,
118 },
119 ExactlyOne { fields: &'static [&'static str] },
121 AtLeastOne { fields: &'static [&'static str] },
123 All(&'static [Rule]),
125 Any(&'static [Rule]),
128 Not(&'static Rule),
130 Custom {
134 name: &'static str,
136 summary: &'static str,
138 eval: fn(&JsonObject) -> Result<(), FieldViolation>,
139 },
140}
141
142pub fn evaluate(rules: &[Rule], args: &JsonObject) -> ValidationResult {
144 let mut out: Vec<FieldViolation> = Vec::new();
145 for r in rules {
146 if let Err(mut v) = eval_one(r, args) {
147 out.append(&mut v);
148 }
149 }
150 if out.is_empty() {
151 ValidationResult::Pass
152 } else {
153 ValidationResult::Fail(out)
154 }
155}
156
157fn eval_one(rule: &Rule, args: &JsonObject) -> Result<(), Vec<FieldViolation>> {
158 match rule {
159 Rule::Range { field, min, max } => {
160 let v = lookup(args, field);
161 let Some(value) = v else { return Ok(()) };
163 let n = match value.as_f64() {
164 Some(n) => n,
165 None => {
166 return Err(vec![FieldViolation {
167 field: (*field).to_string(),
168 rule: "range",
169 message: format!("`{field}` must be a number"),
170 expected: json!({"type": "number"}),
171 got: Some(value.clone()),
172 }]);
173 }
174 };
175 let lo = min.unwrap_or(f64::NEG_INFINITY);
176 let hi = max.unwrap_or(f64::INFINITY);
177 if n < lo || n > hi {
178 return Err(vec![FieldViolation {
179 field: (*field).to_string(),
180 rule: "range",
181 message: format!(
182 "`{field}` must be in [{}..{}], got {n}",
183 min.map(|x| x.to_string()).unwrap_or_else(|| "-∞".into()),
184 max.map(|x| x.to_string()).unwrap_or_else(|| "+∞".into()),
185 ),
186 expected: json!({"min": min, "max": max}),
187 got: Some(json!(n)),
188 }]);
189 }
190 Ok(())
191 }
192 Rule::OneOf { field, values } => {
193 let v = lookup(args, field);
194 let Some(value) = v else { return Ok(()) };
195 let s = match value.as_str() {
196 Some(s) => s,
197 None => {
198 return Err(vec![FieldViolation {
199 field: (*field).to_string(),
200 rule: "one_of",
201 message: format!("`{field}` must be a string"),
202 expected: json!({"one_of": values}),
203 got: Some(value.clone()),
204 }]);
205 }
206 };
207 if values.contains(&s) {
208 Ok(())
209 } else {
210 Err(vec![FieldViolation {
211 field: (*field).to_string(),
212 rule: "one_of",
213 message: format!("`{field}` must be one of {values:?}, got `{s}`"),
214 expected: json!({"one_of": values}),
215 got: Some(json!(s)),
216 }])
217 }
218 }
219 Rule::Regex {
220 field,
221 pattern,
222 summary,
223 } => {
224 let v = lookup(args, field);
225 let Some(value) = v else { return Ok(()) };
226 let s = match value.as_str() {
227 Some(s) => s,
228 None => {
229 return Err(vec![FieldViolation {
230 field: (*field).to_string(),
231 rule: "regex",
232 message: format!("`{field}` must be a string"),
233 expected: json!({"pattern": pattern, "summary": summary}),
234 got: Some(value.clone()),
235 }]);
236 }
237 };
238 match regex::Regex::new(pattern) {
240 Ok(re) if re.is_match(s) => Ok(()),
241 Ok(_) => Err(vec![FieldViolation {
242 field: (*field).to_string(),
243 rule: "regex",
244 message: format!("`{field}` must match {summary} (regex `{pattern}`)"),
245 expected: json!({"pattern": pattern, "summary": summary}),
246 got: Some(json!(s)),
247 }]),
248 Err(_) => Ok(()), }
250 }
251 Rule::Length { field, min, max } => {
252 let v = lookup(args, field);
253 let Some(value) = v else { return Ok(()) };
254 let n = if let Some(s) = value.as_str() {
255 s.chars().count()
256 } else if let Some(arr) = value.as_array() {
257 arr.len()
258 } else {
259 return Err(vec![FieldViolation {
260 field: (*field).to_string(),
261 rule: "length",
262 message: format!("`{field}` must be a string or array"),
263 expected: json!({"min": min, "max": max}),
264 got: Some(value.clone()),
265 }]);
266 };
267 let lo = min.unwrap_or(0);
268 let hi = max.unwrap_or(usize::MAX);
269 if n < lo || n > hi {
270 return Err(vec![FieldViolation {
271 field: (*field).to_string(),
272 rule: "length",
273 message: format!(
274 "`{field}` length must be in [{}..{}], got {n}",
275 min.map(|x| x.to_string()).unwrap_or_else(|| "0".into()),
276 max.map(|x| x.to_string()).unwrap_or_else(|| "∞".into()),
277 ),
278 expected: json!({"min": min, "max": max}),
279 got: Some(json!(n)),
280 }]);
281 }
282 Ok(())
283 }
284 Rule::ExactlyOne { fields } => {
285 let present: Vec<&&str> = fields
286 .iter()
287 .filter(|f| lookup(args, f).is_some_and(|v| !v.is_null()))
288 .collect();
289 if present.len() == 1 {
290 Ok(())
291 } else {
292 Err(vec![FieldViolation {
293 field: fields.join(", "),
294 rule: "exactly_one",
295 message: format!("exactly one of {fields:?} must be supplied; got {present:?}"),
296 expected: json!({"exactly_one": fields}),
297 got: Some(json!(present)),
298 }])
299 }
300 }
301 Rule::AtLeastOne { fields } => {
302 let present: Vec<&&str> = fields
303 .iter()
304 .filter(|f| lookup(args, f).is_some_and(|v| !v.is_null()))
305 .collect();
306 if !present.is_empty() {
307 Ok(())
308 } else {
309 Err(vec![FieldViolation {
310 field: fields.join(", "),
311 rule: "at_least_one",
312 message: format!("at least one of {fields:?} must be supplied"),
313 expected: json!({"at_least_one": fields}),
314 got: Some(json!([])),
315 }])
316 }
317 }
318 Rule::All(sub) => {
319 let mut out: Vec<FieldViolation> = Vec::new();
322 for r in *sub {
323 if let Err(mut v) = eval_one(r, args) {
324 out.append(&mut v);
325 }
326 }
327 if out.is_empty() {
328 Ok(())
329 } else {
330 Err(out)
331 }
332 }
333 Rule::Any(sub) => {
334 let mut all_failures: Vec<FieldViolation> = Vec::new();
338 for r in *sub {
339 match eval_one(r, args) {
340 Ok(()) => return Ok(()),
341 Err(mut v) => all_failures.append(&mut v),
342 }
343 }
344 Err(all_failures)
345 }
346 Rule::Not(inner) => {
347 match eval_one(inner, args) {
350 Ok(()) => Err(vec![FieldViolation {
351 field: "<combinator>".into(),
352 rule: "not",
353 message: "negated rule unexpectedly matched".into(),
354 expected: json!({"not": format!("{inner:?}")}),
355 got: None,
356 }]),
357 Err(_) => Ok(()),
358 }
359 }
360 Rule::Custom { eval, .. } => match eval(args) {
361 Ok(()) => Ok(()),
362 Err(v) => Err(vec![v]),
363 },
364 }
365}
366
367fn lookup<'a>(args: &'a JsonObject, path: &str) -> Option<&'a Value> {
371 if !path.contains('.') {
373 return args.get(path);
374 }
375 let mut cur: Option<&Value> = args.get(path.split('.').next().unwrap_or(""));
377 for seg in path.split('.').skip(1) {
378 cur = cur.and_then(|v| v.get(seg));
379 }
380 cur
381}
382
383pub fn rules_to_json(rules: &[Rule]) -> Value {
385 Value::Array(rules.iter().map(rule_to_json).collect())
386}
387
388fn rule_to_json(r: &Rule) -> Value {
389 match r {
390 Rule::Range { field, min, max } => {
391 json!({"rule": "range", "field": field, "min": min, "max": max})
392 }
393 Rule::OneOf { field, values } => {
394 json!({"rule": "one_of", "field": field, "values": values})
395 }
396 Rule::Regex {
397 field,
398 pattern,
399 summary,
400 } => {
401 json!({"rule": "regex", "field": field, "pattern": pattern, "summary": summary})
402 }
403 Rule::Length { field, min, max } => {
404 json!({"rule": "length", "field": field, "min": min, "max": max})
405 }
406 Rule::ExactlyOne { fields } => json!({"rule": "exactly_one", "fields": fields}),
407 Rule::AtLeastOne { fields } => json!({"rule": "at_least_one", "fields": fields}),
408 Rule::All(sub) => {
409 json!({"rule": "all_of", "rules": sub.iter().map(rule_to_json).collect::<Vec<_>>()})
410 }
411 Rule::Any(sub) => {
412 json!({"rule": "any_of", "rules": sub.iter().map(rule_to_json).collect::<Vec<_>>()})
413 }
414 Rule::Not(inner) => json!({"rule": "not", "inner": rule_to_json(inner)}),
415 Rule::Custom { name, summary, .. } => {
416 json!({"rule": "custom", "name": name, "summary": summary})
417 }
418 }
419}
420
421#[cfg(test)]
422mod tests {
423 use super::*;
424 use serde_json::Map;
425
426 fn args(json: Value) -> JsonObject {
427 let Value::Object(m) = json else {
428 panic!("not an object")
429 };
430 m.into_iter().collect::<Map<_, _>>()
431 }
432
433 #[test]
434 fn range_in_bounds_passes() {
435 let r = [Rule::Range {
436 field: "code",
437 min: Some(100.0),
438 max: Some(599.0),
439 }];
440 let a = args(json!({"code": 200}));
441 assert!(evaluate(&r, &a).is_pass());
442 }
443
444 #[test]
445 fn range_out_of_bounds_fails() {
446 let r = [Rule::Range {
447 field: "code",
448 min: Some(100.0),
449 max: Some(599.0),
450 }];
451 let a = args(json!({"code": 700}));
452 match evaluate(&r, &a) {
453 ValidationResult::Fail(v) => {
454 assert_eq!(v[0].rule, "range");
455 assert_eq!(v[0].field, "code");
456 }
457 _ => panic!(),
458 }
459 }
460
461 #[test]
462 fn one_of_passes() {
463 let r = [Rule::OneOf {
464 field: "kind",
465 values: &["a", "b", "c"],
466 }];
467 let a = args(json!({"kind": "b"}));
468 assert!(evaluate(&r, &a).is_pass());
469 }
470
471 #[test]
472 fn one_of_rejects() {
473 let r = [Rule::OneOf {
474 field: "kind",
475 values: &["a", "b"],
476 }];
477 let a = args(json!({"kind": "x"}));
478 let res = evaluate(&r, &a);
479 assert!(matches!(res, ValidationResult::Fail(_)));
480 }
481
482 #[test]
483 fn length_bounds_enforced() {
484 let r = [Rule::Length {
485 field: "name",
486 min: Some(2),
487 max: Some(4),
488 }];
489 assert!(evaluate(&r, &args(json!({"name": "abc"}))).is_pass());
490 assert!(matches!(
491 evaluate(&r, &args(json!({"name": "a"}))),
492 ValidationResult::Fail(_)
493 ));
494 assert!(matches!(
495 evaluate(&r, &args(json!({"name": "abcde"}))),
496 ValidationResult::Fail(_)
497 ));
498 }
499
500 #[test]
501 fn regex_matches_and_rejects() {
502 let r = [Rule::Regex {
503 field: "cc",
504 pattern: "^[A-Z]{2}$",
505 summary: "ISO-3166 alpha-2",
506 }];
507 assert!(evaluate(&r, &args(json!({"cc": "US"}))).is_pass());
508 assert!(matches!(
509 evaluate(&r, &args(json!({"cc": "usa"}))),
510 ValidationResult::Fail(_)
511 ));
512 }
513
514 #[test]
515 fn nested_dotted_path_resolves() {
516 let r = [Rule::Range {
517 field: "config.timeout",
518 min: Some(1.0),
519 max: Some(60.0),
520 }];
521 assert!(evaluate(&r, &args(json!({"config": {"timeout": 30}}))).is_pass());
522 assert!(matches!(
523 evaluate(&r, &args(json!({"config": {"timeout": 120}}))),
524 ValidationResult::Fail(_)
525 ));
526 }
527
528 #[test]
529 fn exactly_one_enforced() {
530 let r = [Rule::ExactlyOne {
531 fields: &["a", "b"],
532 }];
533 let two = args(json!({"a": 1, "b": 2}));
535 assert!(matches!(evaluate(&r, &two), ValidationResult::Fail(_)));
536 let zero = args(json!({}));
538 assert!(matches!(evaluate(&r, &zero), ValidationResult::Fail(_)));
539 let one = args(json!({"a": 1}));
541 assert!(evaluate(&r, &one).is_pass());
542 }
543
544 #[test]
545 fn any_or_passes_when_one_branch_does() {
546 static SUB: &[Rule] = &[
547 Rule::OneOf {
548 field: "kind",
549 values: &["x"],
550 },
551 Rule::Range {
552 field: "code",
553 min: Some(0.0),
554 max: Some(10.0),
555 },
556 ];
557 let r = [Rule::Any(SUB)];
558 let a = args(json!({"kind": "wrong", "code": 5}));
559 assert!(evaluate(&r, &a).is_pass());
560 }
561
562 #[test]
563 fn any_or_fails_when_all_branches_do() {
564 static SUB: &[Rule] = &[
565 Rule::OneOf {
566 field: "kind",
567 values: &["x"],
568 },
569 Rule::Range {
570 field: "code",
571 min: Some(0.0),
572 max: Some(10.0),
573 },
574 ];
575 let r = [Rule::Any(SUB)];
576 let a = args(json!({"kind": "wrong", "code": 100}));
577 match evaluate(&r, &a) {
578 ValidationResult::Fail(v) => assert_eq!(v.len(), 2),
579 _ => panic!(),
580 }
581 }
582
583 #[test]
584 fn payload_shape() {
585 let r = [Rule::Range {
586 field: "p",
587 min: Some(0.0),
588 max: Some(100.0),
589 }];
590 let a = args(json!({"p": 150}));
591 let p = evaluate(&r, &a).to_payload();
592 assert!(p["validation_failed"].is_array());
593 assert_eq!(p["validation_failed"][0]["field"], "p");
594 assert_eq!(p["validation_failed"][0]["rule"], "range");
595 }
596
597 #[test]
598 fn rules_to_json_round_trips_shape() {
599 let r = [
600 Rule::OneOf {
601 field: "style",
602 values: &["a", "b"],
603 },
604 Rule::Range {
605 field: "n",
606 min: Some(0.0),
607 max: None,
608 },
609 ];
610 let j = rules_to_json(&r);
611 assert_eq!(j[0]["rule"], "one_of");
612 assert_eq!(j[0]["field"], "style");
613 assert_eq!(j[1]["rule"], "range");
614 }
615}