wallfacer-core 0.1.0

Dynamic validation harness for MCP servers
Documentation
use rand::RngCore;
use regex::Regex;
use serde_json::{json, Map, Number, Value};
use thiserror::Error;

use super::{
    dsl::{Assertion, Invariant, JsonType, ValueKind, ValueSpec},
    jsonpath,
};

#[derive(Debug, Error)]
pub enum RunnerError {
    #[error("{0}")]
    Assertion(String),
    #[error(transparent)]
    JsonPath(#[from] jsonpath::JsonPathError),
    #[error("invalid regex `{pattern}`: {source}")]
    Regex {
        pattern: String,
        source: regex::Error,
    },
}

pub type Result<T> = std::result::Result<T, RunnerError>;

pub fn input_for_case(invariant: &Invariant, case_index: u32, rng: &mut impl RngCore) -> Value {
    if let Some(fixed) = &invariant.fixed {
        return Value::Object(
            fixed
                .iter()
                .map(|(key, value)| (key.clone(), value.clone()))
                .collect(),
        );
    }

    let mut input = Map::new();
    if let Some(generate) = &invariant.generate {
        for (key, spec) in generate {
            let value = if case_index == 0 {
                boundary_value(spec)
            } else {
                generated_value(spec, rng)
            };
            input.insert(key.clone(), value);
        }
    }
    Value::Object(input)
}

pub fn evaluate(invariant: &Invariant, input: Value, response: Value) -> Result<()> {
    let context = json!({
        "input": input,
        "response": response,
    });

    for assertion in &invariant.assertions {
        evaluate_assertion(assertion, &context)?;
    }

    Ok(())
}

fn evaluate_assertion(assertion: &Assertion, context: &Value) -> Result<()> {
    match assertion {
        Assertion::Equals { lhs, rhs } => {
            let left = operand(lhs, context)?;
            let right = operand(rhs, context)?;
            if left == right {
                Ok(())
            } else {
                Err(RunnerError::Assertion(format!(
                    "expected {left} to equal {right}"
                )))
            }
        }
        Assertion::NotEquals { lhs, rhs } => {
            let left = operand(lhs, context)?;
            let right = operand(rhs, context)?;
            if left != right {
                Ok(())
            } else {
                Err(RunnerError::Assertion(format!(
                    "expected {left} to differ from {right}"
                )))
            }
        }
        Assertion::AtMost { path, value } => compare_number(path, value, context, |a, b| a <= b),
        Assertion::AtLeast { path, value } => compare_number(path, value, context, |a, b| a >= b),
        Assertion::LengthEq { path, value } => compare_length(path, value, context, |a, b| a == b),
        Assertion::LengthAtMost { path, value } => {
            compare_length(path, value, context, |a, b| a <= b)
        }
        Assertion::LengthAtLeast { path, value } => {
            compare_length(path, value, context, |a, b| a >= b)
        }
        Assertion::IsType { path, expected } => {
            let value = jsonpath::resolve_one(context, path)?;
            let actual = json_type(&value);
            if actual == *expected {
                Ok(())
            } else {
                Err(RunnerError::Assertion(format!(
                    "expected {path} to be {expected:?}, got {actual:?}"
                )))
            }
        }
        Assertion::MatchesRegex { path, pattern } => {
            let value = jsonpath::resolve_one(context, path)?;
            let Some(text) = value.as_str() else {
                return Err(RunnerError::Assertion(format!(
                    "expected {path} to resolve to a string"
                )));
            };
            let regex = Regex::new(pattern).map_err(|source| RunnerError::Regex {
                pattern: pattern.clone(),
                source,
            })?;
            if regex.is_match(text) {
                Ok(())
            } else {
                Err(RunnerError::Assertion(format!(
                    "expected {path} to match {pattern}"
                )))
            }
        }
    }
}

fn operand(value: &Value, context: &Value) -> Result<Value> {
    match value {
        Value::String(path) if path.starts_with('$') => Ok(jsonpath::resolve_one(context, path)?),
        literal => Ok(literal.clone()),
    }
}

fn compare_number(
    path: &str,
    value: &Value,
    context: &Value,
    compare: impl FnOnce(f64, f64) -> bool,
) -> Result<()> {
    let left = jsonpath::resolve_one(context, path)?;
    let right = operand(value, context)?;
    let Some(left) = left.as_f64() else {
        return Err(RunnerError::Assertion(format!(
            "expected {path} to resolve to a number"
        )));
    };
    let Some(right) = right.as_f64() else {
        return Err(RunnerError::Assertion(
            "expected comparison value to be a number".to_string(),
        ));
    };

    if compare(left, right) {
        Ok(())
    } else {
        Err(RunnerError::Assertion(format!(
            "numeric comparison failed: {left} vs {right}"
        )))
    }
}

fn compare_length(
    path: &str,
    value: &Value,
    context: &Value,
    compare: impl FnOnce(usize, usize) -> bool,
) -> Result<()> {
    let left = jsonpath::resolve_one(context, path)?;
    let right = operand(value, context)?;
    let Some(right) = right.as_u64().map(|value| value as usize) else {
        return Err(RunnerError::Assertion(
            "expected comparison value to be an integer".to_string(),
        ));
    };

    let Some(left) = length(&left) else {
        return Err(RunnerError::Assertion(format!(
            "expected {path} to resolve to an array or string"
        )));
    };

    if compare(left, right) {
        Ok(())
    } else {
        Err(RunnerError::Assertion(format!(
            "length comparison failed: {left} vs {right}"
        )))
    }
}

fn length(value: &Value) -> Option<usize> {
    match value {
        Value::Array(items) => Some(items.len()),
        Value::String(text) => Some(text.chars().count()),
        _ => None,
    }
}

fn json_type(value: &Value) -> JsonType {
    match value {
        Value::Null => JsonType::Null,
        Value::Bool(_) => JsonType::Boolean,
        Value::Number(number) if number.is_i64() || number.is_u64() => JsonType::Integer,
        Value::Number(_) => JsonType::Number,
        Value::String(_) => JsonType::String,
        Value::Array(_) => JsonType::Array,
        Value::Object(_) => JsonType::Object,
    }
}

fn boundary_value(spec: &ValueSpec) -> Value {
    match spec.kind {
        ValueKind::String => {
            let len = spec.max_length.or(spec.min_length).unwrap_or(8).min(1024);
            Value::String("x".repeat(len))
        }
        ValueKind::Integer => json!(spec.max.or(spec.min).unwrap_or(1)),
        ValueKind::Number => Number::from_f64(spec.max.or(spec.min).unwrap_or(1) as f64)
            .map(Value::Number)
            .unwrap_or(Value::Null),
        ValueKind::Boolean => Value::Bool(true),
        ValueKind::Array => {
            let len = spec.max_items.or(spec.min_items).unwrap_or(1).min(64);
            let item_spec = spec.items.as_deref();
            Value::Array(
                (0..len)
                    .map(|_| item_spec.map(boundary_value).unwrap_or(Value::Null))
                    .collect(),
            )
        }
    }
}

fn generated_value(spec: &ValueSpec, rng: &mut impl RngCore) -> Value {
    match spec.kind {
        ValueKind::String => {
            let min = spec.min_length.unwrap_or(0);
            let max = spec.max_length.unwrap_or(32).max(min).min(1024);
            let len = min + (rng.next_u64() as usize % (max - min + 1));
            Value::String("a".repeat(len))
        }
        ValueKind::Integer => {
            let min = spec.min.unwrap_or(-100);
            let max = spec.max.unwrap_or(100).max(min);
            let span = (max as i128 - min as i128 + 1) as u64;
            json!(min + (rng.next_u64() % span) as i64)
        }
        ValueKind::Number => {
            let min = spec.min.unwrap_or(-100) as f64;
            let max = (spec.max.unwrap_or(100) as f64).max(min);
            let unit = rng.next_u64() as f64 / u64::MAX as f64;
            Number::from_f64(min + (max - min) * unit)
                .map(Value::Number)
                .unwrap_or(Value::Null)
        }
        ValueKind::Boolean => Value::Bool(rng.next_u64() % 2 == 0),
        ValueKind::Array => {
            let min = spec.min_items.unwrap_or(0);
            let max = spec.max_items.unwrap_or(8).max(min).min(64);
            let len = min + (rng.next_u64() as usize % (max - min + 1));
            let item_spec = spec.items.as_deref();
            Value::Array(
                (0..len)
                    .map(|_| {
                        item_spec
                            .map(|item| generated_value(item, rng))
                            .unwrap_or(Value::Null)
                    })
                    .collect(),
            )
        }
    }
}