use salvor_core::{Event, EventEnvelope};
use salvor_graph::{GateNode, Graph, Node};
use serde_json::Value;
use std::fmt;
const MAX_VIOLATIONS: usize = 20;
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ApprovalViolation {
pub path: String,
pub message: String,
}
impl fmt::Display for ApprovalViolation {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "{}: {}", self.path, self.message)
}
}
#[must_use]
pub fn approval_violations(input: &Value, schema: &Value) -> Vec<ApprovalViolation> {
let mut violations: Vec<ApprovalViolation> = Vec::new();
if implies_object(schema) && !input.is_object() {
violations.push(ApprovalViolation {
path: "$".to_owned(),
message: format!(
"expected an object, got {}: this gate's approval_schema names the properties an \
approval must carry, so only an object can answer it",
type_name(input)
),
});
}
let Ok(validator) = jsonschema::validator_for(schema) else {
return violations;
};
violations.extend(
validator
.iter_errors(input)
.take(MAX_VIOLATIONS)
.map(|error| ApprovalViolation {
path: pointer_to_path(&error.instance_path().to_string()),
message: error.to_string(),
}),
);
violations.sort();
violations.dedup();
violations.truncate(MAX_VIOLATIONS);
violations
}
fn implies_object(schema: &Value) -> bool {
let Some(schema) = schema.as_object() else {
return false;
};
const SPEAKS_FOR_ITSELF: [&str; 9] = [
"type", "enum", "const", "$ref", "anyOf", "oneOf", "allOf", "not", "if",
];
if SPEAKS_FOR_ITSELF
.iter()
.any(|key| schema.contains_key(*key))
{
return false;
}
const OBJECT_SHAPE: [&str; 8] = [
"required",
"properties",
"patternProperties",
"additionalProperties",
"propertyNames",
"minProperties",
"maxProperties",
"dependentRequired",
];
OBJECT_SHAPE.iter().any(|key| schema.contains_key(*key))
}
fn type_name(value: &Value) -> &'static str {
match value {
Value::Null => "null",
Value::Bool(_) => "boolean",
Value::Number(_) => "number",
Value::String(_) => "string",
Value::Array(_) => "array",
Value::Object(_) => "object",
}
}
impl PartialOrd for ApprovalViolation {
fn partial_cmp(&self, other: &Self) -> Option<std::cmp::Ordering> {
Some(self.cmp(other))
}
}
impl Ord for ApprovalViolation {
fn cmp(&self, other: &Self) -> std::cmp::Ordering {
(&self.path, &self.message).cmp(&(&other.path, &other.message))
}
}
#[must_use]
pub fn parked_gate<'g>(log: &[EventEnvelope], graph: &'g Graph) -> Option<&'g GateNode> {
let node = log
.iter()
.rev()
.find_map(|envelope| match &envelope.event {
Event::NodeEntered { node } => Some(node.as_str()),
_ => None,
})?;
graph.nodes.iter().find_map(|candidate| match candidate {
Node::Gate(gate) if gate.id == node => Some(gate),
_ => None,
})
}
fn pointer_to_path(pointer: &str) -> String {
let mut path = String::from("$");
for segment in pointer.split('/').skip(1) {
if segment.parse::<usize>().is_ok() {
path.push('[');
path.push_str(segment);
path.push(']');
} else {
path.push('.');
path.push_str(&segment.replace("~1", "/").replace("~0", "~"));
}
}
path
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
#[test]
fn the_four_reproduced_inputs_are_violations() {
let schema = json!({
"required": ["approved"],
"properties": {"approved": {"type": "boolean"}}
});
for bad in [json!(null), json!(42), json!("nope"), json!({})] {
assert!(
!approval_violations(&bad, &schema).is_empty(),
"{bad} must not approve"
);
}
assert_eq!(approval_violations(&json!({"approved": true}), &schema), []);
assert_eq!(
approval_violations(&json!({"approved": false}), &schema),
[]
);
}
#[test]
fn violations_name_their_paths_and_are_all_reported() {
let schema = json!({
"type": "object",
"required": ["approved", "targets"],
"properties": {
"approved": {"type": "boolean"},
"targets": {"type": "array", "items": {"type": "string"}}
}
});
let violations = approval_violations(&json!({"approved": "yes", "targets": [1]}), &schema);
let paths: Vec<&str> = violations.iter().map(|v| v.path.as_str()).collect();
assert_eq!(paths, ["$.approved", "$.targets[0]"], "{violations:?}");
assert!(violations[0].message.contains("boolean"), "{violations:?}");
}
#[test]
fn a_schema_that_states_its_own_form_is_left_alone() {
let choice = json!({"enum": ["approve", "reject"]});
assert_eq!(approval_violations(&json!("approve"), &choice), []);
assert!(!approval_violations(&json!("maybe"), &choice).is_empty());
let either = json!({
"anyOf": [{"type": "boolean"}, {"type": "object", "required": ["approved"]}]
});
assert_eq!(approval_violations(&json!(true), &either), []);
assert_eq!(
approval_violations(&json!({"approved": false}), &either),
[]
);
assert!(!approval_violations(&json!("nope"), &either).is_empty());
let lenient = json!({"type": ["object", "null"], "properties": {"a": {}}});
assert_eq!(approval_violations(&json!(null), &lenient), []);
}
#[test]
fn an_uncompilable_schema_constrains_nothing() {
let schema = json!({"type": "not-a-json-type"});
assert_eq!(approval_violations(&json!(null), &schema), []);
}
#[test]
fn pointers_render_in_the_codebase_path_style() {
assert_eq!(pointer_to_path(""), "$");
assert_eq!(pointer_to_path("/approved"), "$.approved");
assert_eq!(pointer_to_path("/targets/0/url"), "$.targets[0].url");
assert_eq!(pointer_to_path("/a~1b"), "$.a/b");
}
}