use rmcp::model::JsonObject;
use serde_json::{json, Value};
#[derive(Debug, Clone)]
pub struct FieldViolation {
pub field: String,
pub rule: &'static str,
pub message: String,
pub expected: Value,
pub got: Option<Value>,
}
#[derive(Debug, Clone)]
pub enum ValidationResult {
Pass,
Fail(Vec<FieldViolation>),
}
impl ValidationResult {
pub fn is_pass(&self) -> bool {
matches!(self, ValidationResult::Pass)
}
pub fn to_payload(&self) -> Value {
match self {
ValidationResult::Pass => json!({"validation": "pass"}),
ValidationResult::Fail(violations) => {
let arr: Vec<Value> = violations
.iter()
.map(|v| {
let mut obj = json!({
"field": v.field,
"rule": v.rule,
"message": v.message,
"expected": v.expected,
});
if let Some(g) = &v.got {
obj["got"] = g.clone();
}
obj
})
.collect();
json!({"validation_failed": arr})
}
}
}
}
#[derive(Debug, Clone)]
pub enum Rule {
Range {
field: &'static str,
min: Option<f64>,
max: Option<f64>,
},
OneOf {
field: &'static str,
values: &'static [&'static str],
},
Regex {
field: &'static str,
pattern: &'static str,
summary: &'static str,
},
Length {
field: &'static str,
min: Option<usize>,
max: Option<usize>,
},
ExactlyOne { fields: &'static [&'static str] },
AtLeastOne { fields: &'static [&'static str] },
All(&'static [Rule]),
Any(&'static [Rule]),
Not(&'static Rule),
Custom {
name: &'static str,
summary: &'static str,
eval: fn(&JsonObject) -> Result<(), FieldViolation>,
},
}
pub fn evaluate(rules: &[Rule], args: &JsonObject) -> ValidationResult {
let mut out: Vec<FieldViolation> = Vec::new();
for r in rules {
if let Err(mut v) = eval_one(r, args) {
out.append(&mut v);
}
}
if out.is_empty() {
ValidationResult::Pass
} else {
ValidationResult::Fail(out)
}
}
fn eval_one(rule: &Rule, args: &JsonObject) -> Result<(), Vec<FieldViolation>> {
match rule {
Rule::Range { field, min, max } => {
let v = lookup(args, field);
let Some(value) = v else { return Ok(()) };
let n = match value.as_f64() {
Some(n) => n,
None => {
return Err(vec![FieldViolation {
field: (*field).to_string(),
rule: "range",
message: format!("`{field}` must be a number"),
expected: json!({"type": "number"}),
got: Some(value.clone()),
}]);
}
};
let lo = min.unwrap_or(f64::NEG_INFINITY);
let hi = max.unwrap_or(f64::INFINITY);
if n < lo || n > hi {
return Err(vec![FieldViolation {
field: (*field).to_string(),
rule: "range",
message: format!(
"`{field}` must be in [{}..{}], got {n}",
min.map(|x| x.to_string()).unwrap_or_else(|| "-∞".into()),
max.map(|x| x.to_string()).unwrap_or_else(|| "+∞".into()),
),
expected: json!({"min": min, "max": max}),
got: Some(json!(n)),
}]);
}
Ok(())
}
Rule::OneOf { field, values } => {
let v = lookup(args, field);
let Some(value) = v else { return Ok(()) };
let s = match value.as_str() {
Some(s) => s,
None => {
return Err(vec![FieldViolation {
field: (*field).to_string(),
rule: "one_of",
message: format!("`{field}` must be a string"),
expected: json!({"one_of": values}),
got: Some(value.clone()),
}]);
}
};
if values.contains(&s) {
Ok(())
} else {
Err(vec![FieldViolation {
field: (*field).to_string(),
rule: "one_of",
message: format!("`{field}` must be one of {values:?}, got `{s}`"),
expected: json!({"one_of": values}),
got: Some(json!(s)),
}])
}
}
Rule::Regex {
field,
pattern,
summary,
} => {
let v = lookup(args, field);
let Some(value) = v else { return Ok(()) };
let s = match value.as_str() {
Some(s) => s,
None => {
return Err(vec![FieldViolation {
field: (*field).to_string(),
rule: "regex",
message: format!("`{field}` must be a string"),
expected: json!({"pattern": pattern, "summary": summary}),
got: Some(value.clone()),
}]);
}
};
match regex::Regex::new(pattern) {
Ok(re) if re.is_match(s) => Ok(()),
Ok(_) => Err(vec![FieldViolation {
field: (*field).to_string(),
rule: "regex",
message: format!("`{field}` must match {summary} (regex `{pattern}`)"),
expected: json!({"pattern": pattern, "summary": summary}),
got: Some(json!(s)),
}]),
Err(_) => Ok(()), }
}
Rule::Length { field, min, max } => {
let v = lookup(args, field);
let Some(value) = v else { return Ok(()) };
let n = if let Some(s) = value.as_str() {
s.chars().count()
} else if let Some(arr) = value.as_array() {
arr.len()
} else {
return Err(vec![FieldViolation {
field: (*field).to_string(),
rule: "length",
message: format!("`{field}` must be a string or array"),
expected: json!({"min": min, "max": max}),
got: Some(value.clone()),
}]);
};
let lo = min.unwrap_or(0);
let hi = max.unwrap_or(usize::MAX);
if n < lo || n > hi {
return Err(vec![FieldViolation {
field: (*field).to_string(),
rule: "length",
message: format!(
"`{field}` length must be in [{}..{}], got {n}",
min.map(|x| x.to_string()).unwrap_or_else(|| "0".into()),
max.map(|x| x.to_string()).unwrap_or_else(|| "∞".into()),
),
expected: json!({"min": min, "max": max}),
got: Some(json!(n)),
}]);
}
Ok(())
}
Rule::ExactlyOne { fields } => {
let present: Vec<&&str> = fields
.iter()
.filter(|f| lookup(args, f).is_some_and(|v| !v.is_null()))
.collect();
if present.len() == 1 {
Ok(())
} else {
Err(vec![FieldViolation {
field: fields.join(", "),
rule: "exactly_one",
message: format!("exactly one of {fields:?} must be supplied; got {present:?}"),
expected: json!({"exactly_one": fields}),
got: Some(json!(present)),
}])
}
}
Rule::AtLeastOne { fields } => {
let present: Vec<&&str> = fields
.iter()
.filter(|f| lookup(args, f).is_some_and(|v| !v.is_null()))
.collect();
if !present.is_empty() {
Ok(())
} else {
Err(vec![FieldViolation {
field: fields.join(", "),
rule: "at_least_one",
message: format!("at least one of {fields:?} must be supplied"),
expected: json!({"at_least_one": fields}),
got: Some(json!([])),
}])
}
}
Rule::All(sub) => {
let mut out: Vec<FieldViolation> = Vec::new();
for r in *sub {
if let Err(mut v) = eval_one(r, args) {
out.append(&mut v);
}
}
if out.is_empty() {
Ok(())
} else {
Err(out)
}
}
Rule::Any(sub) => {
let mut all_failures: Vec<FieldViolation> = Vec::new();
for r in *sub {
match eval_one(r, args) {
Ok(()) => return Ok(()),
Err(mut v) => all_failures.append(&mut v),
}
}
Err(all_failures)
}
Rule::Not(inner) => {
match eval_one(inner, args) {
Ok(()) => Err(vec![FieldViolation {
field: "<combinator>".into(),
rule: "not",
message: "negated rule unexpectedly matched".into(),
expected: json!({"not": format!("{inner:?}")}),
got: None,
}]),
Err(_) => Ok(()),
}
}
Rule::Custom { eval, .. } => match eval(args) {
Ok(()) => Ok(()),
Err(v) => Err(vec![v]),
},
}
}
fn lookup<'a>(args: &'a JsonObject, path: &str) -> Option<&'a Value> {
if !path.contains('.') {
return args.get(path);
}
let mut cur: Option<&Value> = args.get(path.split('.').next().unwrap_or(""));
for seg in path.split('.').skip(1) {
cur = cur.and_then(|v| v.get(seg));
}
cur
}
pub fn rules_to_json(rules: &[Rule]) -> Value {
Value::Array(rules.iter().map(rule_to_json).collect())
}
fn rule_to_json(r: &Rule) -> Value {
match r {
Rule::Range { field, min, max } => {
json!({"rule": "range", "field": field, "min": min, "max": max})
}
Rule::OneOf { field, values } => {
json!({"rule": "one_of", "field": field, "values": values})
}
Rule::Regex {
field,
pattern,
summary,
} => {
json!({"rule": "regex", "field": field, "pattern": pattern, "summary": summary})
}
Rule::Length { field, min, max } => {
json!({"rule": "length", "field": field, "min": min, "max": max})
}
Rule::ExactlyOne { fields } => json!({"rule": "exactly_one", "fields": fields}),
Rule::AtLeastOne { fields } => json!({"rule": "at_least_one", "fields": fields}),
Rule::All(sub) => {
json!({"rule": "all_of", "rules": sub.iter().map(rule_to_json).collect::<Vec<_>>()})
}
Rule::Any(sub) => {
json!({"rule": "any_of", "rules": sub.iter().map(rule_to_json).collect::<Vec<_>>()})
}
Rule::Not(inner) => json!({"rule": "not", "inner": rule_to_json(inner)}),
Rule::Custom { name, summary, .. } => {
json!({"rule": "custom", "name": name, "summary": summary})
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::Map;
fn args(json: Value) -> JsonObject {
let Value::Object(m) = json else {
panic!("not an object")
};
m.into_iter().collect::<Map<_, _>>()
}
#[test]
fn range_in_bounds_passes() {
let r = [Rule::Range {
field: "code",
min: Some(100.0),
max: Some(599.0),
}];
let a = args(json!({"code": 200}));
assert!(evaluate(&r, &a).is_pass());
}
#[test]
fn range_out_of_bounds_fails() {
let r = [Rule::Range {
field: "code",
min: Some(100.0),
max: Some(599.0),
}];
let a = args(json!({"code": 700}));
match evaluate(&r, &a) {
ValidationResult::Fail(v) => {
assert_eq!(v[0].rule, "range");
assert_eq!(v[0].field, "code");
}
_ => panic!(),
}
}
#[test]
fn one_of_passes() {
let r = [Rule::OneOf {
field: "kind",
values: &["a", "b", "c"],
}];
let a = args(json!({"kind": "b"}));
assert!(evaluate(&r, &a).is_pass());
}
#[test]
fn one_of_rejects() {
let r = [Rule::OneOf {
field: "kind",
values: &["a", "b"],
}];
let a = args(json!({"kind": "x"}));
let res = evaluate(&r, &a);
assert!(matches!(res, ValidationResult::Fail(_)));
}
#[test]
fn length_bounds_enforced() {
let r = [Rule::Length {
field: "name",
min: Some(2),
max: Some(4),
}];
assert!(evaluate(&r, &args(json!({"name": "abc"}))).is_pass());
assert!(matches!(
evaluate(&r, &args(json!({"name": "a"}))),
ValidationResult::Fail(_)
));
assert!(matches!(
evaluate(&r, &args(json!({"name": "abcde"}))),
ValidationResult::Fail(_)
));
}
#[test]
fn regex_matches_and_rejects() {
let r = [Rule::Regex {
field: "cc",
pattern: "^[A-Z]{2}$",
summary: "ISO-3166 alpha-2",
}];
assert!(evaluate(&r, &args(json!({"cc": "US"}))).is_pass());
assert!(matches!(
evaluate(&r, &args(json!({"cc": "usa"}))),
ValidationResult::Fail(_)
));
}
#[test]
fn nested_dotted_path_resolves() {
let r = [Rule::Range {
field: "config.timeout",
min: Some(1.0),
max: Some(60.0),
}];
assert!(evaluate(&r, &args(json!({"config": {"timeout": 30}}))).is_pass());
assert!(matches!(
evaluate(&r, &args(json!({"config": {"timeout": 120}}))),
ValidationResult::Fail(_)
));
}
#[test]
fn exactly_one_enforced() {
let r = [Rule::ExactlyOne {
fields: &["a", "b"],
}];
let two = args(json!({"a": 1, "b": 2}));
assert!(matches!(evaluate(&r, &two), ValidationResult::Fail(_)));
let zero = args(json!({}));
assert!(matches!(evaluate(&r, &zero), ValidationResult::Fail(_)));
let one = args(json!({"a": 1}));
assert!(evaluate(&r, &one).is_pass());
}
#[test]
fn any_or_passes_when_one_branch_does() {
static SUB: &[Rule] = &[
Rule::OneOf {
field: "kind",
values: &["x"],
},
Rule::Range {
field: "code",
min: Some(0.0),
max: Some(10.0),
},
];
let r = [Rule::Any(SUB)];
let a = args(json!({"kind": "wrong", "code": 5}));
assert!(evaluate(&r, &a).is_pass());
}
#[test]
fn any_or_fails_when_all_branches_do() {
static SUB: &[Rule] = &[
Rule::OneOf {
field: "kind",
values: &["x"],
},
Rule::Range {
field: "code",
min: Some(0.0),
max: Some(10.0),
},
];
let r = [Rule::Any(SUB)];
let a = args(json!({"kind": "wrong", "code": 100}));
match evaluate(&r, &a) {
ValidationResult::Fail(v) => assert_eq!(v.len(), 2),
_ => panic!(),
}
}
#[test]
fn payload_shape() {
let r = [Rule::Range {
field: "p",
min: Some(0.0),
max: Some(100.0),
}];
let a = args(json!({"p": 150}));
let p = evaluate(&r, &a).to_payload();
assert!(p["validation_failed"].is_array());
assert_eq!(p["validation_failed"][0]["field"], "p");
assert_eq!(p["validation_failed"][0]["rule"], "range");
}
#[test]
fn rules_to_json_round_trips_shape() {
let r = [
Rule::OneOf {
field: "style",
values: &["a", "b"],
},
Rule::Range {
field: "n",
min: Some(0.0),
max: None,
},
];
let j = rules_to_json(&r);
assert_eq!(j[0]["rule"], "one_of");
assert_eq!(j[0]["field"], "style");
assert_eq!(j[1]["rule"], "range");
}
}