mod schemas;
use jsonschema::JSONSchema;
use serde_json::Value;
use crate::types::plan::{Graph, GraphError, Plan};
use crate::Resource;
pub struct Validator {
goal_schema: JSONSchema,
plan_schema: JSONSchema,
capability_schema: JSONSchema,
binding_schema: JSONSchema,
execution_schema: JSONSchema,
gate_schema: JSONSchema,
}
impl Validator {
pub fn new() -> Result<Self, ValidationError> {
Ok(Self {
goal_schema: compile_schema(schemas::GOAL_SCHEMA)?,
plan_schema: compile_schema(schemas::PLAN_SCHEMA)?,
capability_schema: compile_schema(schemas::CAPABILITY_SCHEMA)?,
binding_schema: compile_schema(schemas::BINDING_SCHEMA)?,
execution_schema: compile_schema(schemas::EXECUTION_SCHEMA)?,
gate_schema: compile_schema(schemas::GATE_SCHEMA)?,
})
}
pub fn validate(&self, resource: &Resource) -> Result<(), Vec<ValidationError>> {
let value = serde_json::to_value(resource)
.map_err(|e| vec![ValidationError::SerializationError(e.to_string())])?;
self.validate_json(&value)?;
if let Resource::Plan(plan) = resource {
self.validate_plan_graph(plan)?;
}
Ok(())
}
pub fn validate_json(&self, value: &Value) -> Result<(), Vec<ValidationError>> {
let kind = value
.get("kind")
.and_then(|k| k.as_str())
.ok_or_else(|| vec![ValidationError::MissingKind])?;
let schema = match kind {
"Goal" => &self.goal_schema,
"Plan" => &self.plan_schema,
"Capability" => &self.capability_schema,
"Binding" => &self.binding_schema,
"Execution" => &self.execution_schema,
"Gate" => &self.gate_schema,
_ => return Err(vec![ValidationError::UnknownKind(kind.to_string())]),
};
let result = schema.validate(value);
if let Err(errors) = result {
let error_list: Vec<ValidationError> = errors
.map(|e| {
let path = e.instance_path.to_string();
let path_str = if path.is_empty() {
"(root)".to_string()
} else {
path
};
ValidationError::SchemaValidation {
path: path_str,
message: e.to_string(),
}
})
.collect();
if error_list.is_empty() {
Ok(())
} else {
Err(error_list)
}
} else {
self.validate_naming_conventions(value)?;
if kind == "Goal" {
self.validate_label_selector(value)?;
}
if kind == "Plan" {
self.validate_plan_graph_json(value)?;
self.validate_plan_node_ids(value)?;
self.validate_plan_series_version(value)?;
self.validate_plan_node_kinds(value)?;
}
Ok(())
}
}
fn validate_naming_conventions(&self, value: &Value) -> Result<(), Vec<ValidationError>> {
let metadata = value.get("metadata");
if let Some(meta) = metadata {
if let Some(name) = meta.get("name").and_then(|n| n.as_str()) {
if !is_valid_dns_label(name) {
return Err(vec![ValidationError::InvalidName {
field: "metadata.name".to_string(),
value: name.to_string(),
}]);
}
}
if let Some(namespace) = meta.get("namespace").and_then(|n| n.as_str()) {
if !is_valid_dns_label(namespace) {
return Err(vec![ValidationError::InvalidName {
field: "metadata.namespace".to_string(),
value: namespace.to_string(),
}]);
}
}
}
Ok(())
}
fn validate_label_selector(&self, value: &Value) -> Result<(), Vec<ValidationError>> {
let expressions = value
.get("spec")
.and_then(|s| s.get("planSelector"))
.and_then(|ps| ps.get("matchExpressions"))
.and_then(|me| me.as_array());
if let Some(expressions) = expressions {
for (i, expr) in expressions.iter().enumerate() {
let operator = expr.get("operator").and_then(|o| o.as_str()).unwrap_or("");
let has_values = expr
.get("values")
.and_then(|v| v.as_array())
.map(|arr| !arr.is_empty())
.unwrap_or(false);
match operator {
"In" | "NotIn" => {
if !has_values {
return Err(vec![ValidationError::SchemaValidation {
path: format!("/spec/planSelector/matchExpressions/{}", i),
message: format!(
"operator '{}' requires non-empty 'values' array",
operator
),
}]);
}
}
"Exists" | "DoesNotExist" => {
if has_values {
return Err(vec![ValidationError::SchemaValidation {
path: format!("/spec/planSelector/matchExpressions/{}", i),
message: format!(
"operator '{}' must not have 'values' array",
operator
),
}]);
}
}
_ => {}
}
}
}
Ok(())
}
fn validate_plan_node_ids(&self, value: &Value) -> Result<(), Vec<ValidationError>> {
let nodes = value
.get("spec")
.and_then(|s| s.get("graph"))
.and_then(|g| g.get("nodes"))
.and_then(|n| n.as_array());
if let Some(nodes) = nodes {
for node in nodes {
if let Some(id) = node.get("id").and_then(|i| i.as_str()) {
if !is_valid_node_id(id) {
return Err(vec![ValidationError::InvalidName {
field: "spec.graph.nodes[].id".to_string(),
value: id.to_string(),
}]);
}
}
}
}
Ok(())
}
fn validate_plan_series_version(&self, value: &Value) -> Result<(), Vec<ValidationError>> {
let spec = value.get("spec");
if let Some(spec) = spec {
let has_series = spec.get("series").and_then(|s| s.as_str()).is_some();
let has_version = spec.get("version").and_then(|v| v.as_str()).is_some();
match (has_series, has_version) {
(true, false) => {
return Err(vec![ValidationError::SchemaValidation {
path: "/spec".to_string(),
message: "'series' requires 'version' to also be specified".to_string(),
}]);
}
(false, true) => {
return Err(vec![ValidationError::SchemaValidation {
path: "/spec".to_string(),
message: "'version' requires 'series' to also be specified".to_string(),
}]);
}
_ => {}
}
}
Ok(())
}
fn validate_plan_node_kinds(&self, value: &Value) -> Result<(), Vec<ValidationError>> {
let nodes = value
.get("spec")
.and_then(|s| s.get("graph"))
.and_then(|g| g.get("nodes"))
.and_then(|n| n.as_array());
if let Some(nodes) = nodes {
for (i, node) in nodes.iter().enumerate() {
let kind = node.get("kind").and_then(|k| k.as_str()).unwrap_or("");
let default_id = format!("index {}", i);
let node_id = node
.get("id")
.and_then(|id| id.as_str())
.unwrap_or(&default_id);
match kind {
"Gate" => {
if node.get("gateRef").is_none() {
return Err(vec![ValidationError::SchemaValidation {
path: format!("/spec/graph/nodes/{}", i),
message: format!(
"Gate node '{}' requires 'gateRef' field",
node_id
),
}]);
}
}
"Group" => {
let children = node.get("children").and_then(|c| c.as_array());
match children {
None => {
return Err(vec![ValidationError::SchemaValidation {
path: format!("/spec/graph/nodes/{}", i),
message: format!(
"Group node '{}' requires 'children' field",
node_id
),
}]);
}
Some(c) if c.is_empty() => {
return Err(vec![ValidationError::SchemaValidation {
path: format!("/spec/graph/nodes/{}", i),
message: format!(
"Group node '{}' requires at least one child",
node_id
),
}]);
}
_ => {}
}
}
"External" => {
if node.get("externalRef").is_none() {
return Err(vec![ValidationError::SchemaValidation {
path: format!("/spec/graph/nodes/{}", i),
message: format!(
"External node '{}' requires 'externalRef' field",
node_id
),
}]);
}
}
_ => {} }
}
}
Ok(())
}
fn validate_plan_graph(&self, plan: &Plan) -> Result<(), Vec<ValidationError>> {
if let Some(cycle_node) = plan.spec.graph.detect_cycle() {
return Err(vec![ValidationError::CyclicGraph {
node_id: cycle_node,
}]);
}
self.validate_edge_references(&plan.spec.graph)?;
Ok(())
}
fn validate_plan_graph_json(&self, value: &Value) -> Result<(), Vec<ValidationError>> {
let graph = value.get("spec").and_then(|s| s.get("graph"));
if let Some(graph_value) = graph {
let nodes = graph_value
.get("nodes")
.and_then(|n| n.as_array())
.map(|arr| {
arr.iter()
.filter_map(|n| n.get("id").and_then(|id| id.as_str()))
.collect::<std::collections::HashSet<_>>()
})
.unwrap_or_default();
let empty_edges = vec![];
let edges = graph_value
.get("edges")
.and_then(|e| e.as_array())
.unwrap_or(&empty_edges);
for edge in edges {
let from = edge.get("from").and_then(|f| f.as_str()).unwrap_or("");
let to = edge.get("to").and_then(|t| t.as_str()).unwrap_or("");
if !nodes.contains(from) {
return Err(vec![ValidationError::InvalidEdgeReference {
edge_field: "from".to_string(),
node_id: from.to_string(),
}]);
}
if !nodes.contains(to) {
return Err(vec![ValidationError::InvalidEdgeReference {
edge_field: "to".to_string(),
node_id: to.to_string(),
}]);
}
}
let mut adj: std::collections::HashMap<&str, Vec<&str>> =
std::collections::HashMap::new();
for node_id in &nodes {
adj.entry(node_id).or_default();
}
for edge in edges {
let from = edge.get("from").and_then(|f| f.as_str()).unwrap_or("");
let to = edge.get("to").and_then(|t| t.as_str()).unwrap_or("");
adj.entry(from).or_default().push(to);
}
let mut visited = std::collections::HashSet::new();
let mut rec_stack = std::collections::HashSet::new();
for node_id in &nodes {
if let Some(cycle) = detect_cycle_dfs(node_id, &adj, &mut visited, &mut rec_stack) {
return Err(vec![ValidationError::CyclicGraph { node_id: cycle }]);
}
}
}
Ok(())
}
fn validate_edge_references(&self, graph: &Graph) -> Result<(), Vec<ValidationError>> {
let node_ids: std::collections::HashSet<&str> =
graph.nodes.iter().map(|n| n.id.as_str()).collect();
for edge in &graph.edges {
if !node_ids.contains(edge.from.as_str()) {
return Err(vec![ValidationError::InvalidEdgeReference {
edge_field: "from".to_string(),
node_id: edge.from.clone(),
}]);
}
if !node_ids.contains(edge.to.as_str()) {
return Err(vec![ValidationError::InvalidEdgeReference {
edge_field: "to".to_string(),
node_id: edge.to.clone(),
}]);
}
}
Ok(())
}
}
fn detect_cycle_dfs<'a>(
node: &'a str,
adj: &std::collections::HashMap<&str, Vec<&'a str>>,
visited: &mut std::collections::HashSet<&'a str>,
rec_stack: &mut std::collections::HashSet<&'a str>,
) -> Option<String> {
if rec_stack.contains(node) {
return Some(node.to_string());
}
if visited.contains(node) {
return None;
}
visited.insert(node);
rec_stack.insert(node);
if let Some(neighbors) = adj.get(node) {
for neighbor in neighbors {
if let Some(cycle) = detect_cycle_dfs(neighbor, adj, visited, rec_stack) {
return Some(cycle);
}
}
}
rec_stack.remove(node);
None
}
fn compile_schema(schema_json: &str) -> Result<JSONSchema, ValidationError> {
let schema: Value = serde_json::from_str(schema_json).map_err(|e| {
ValidationError::SchemaCompilationError(format!("Failed to parse schema: {}", e))
})?;
JSONSchema::compile(&schema).map_err(|e| {
ValidationError::SchemaCompilationError(format!("Failed to compile schema: {}", e))
})
}
fn is_valid_dns_label(s: &str) -> bool {
if s.is_empty() || s.len() > 63 {
return false;
}
let mut chars = s.chars().peekable();
match chars.next() {
Some(c) if c.is_ascii_lowercase() => {}
_ => return false,
}
for c in chars {
if !c.is_ascii_lowercase() && !c.is_ascii_digit() && c != '-' {
return false;
}
}
if s.ends_with('-') {
return false;
}
true
}
fn is_valid_node_id(s: &str) -> bool {
if s.is_empty() || s.len() > 63 {
return false;
}
let mut chars = s.chars().peekable();
match chars.next() {
Some(c) if c.is_ascii_lowercase() => {}
_ => return false,
}
for c in chars {
if !c.is_ascii_lowercase() && !c.is_ascii_digit() && c != '-' && c != '_' {
return false;
}
}
true
}
#[cfg(test)]
mod naming_tests {
use super::*;
#[test]
fn test_valid_dns_labels() {
assert!(is_valid_dns_label("my-goal"));
assert!(is_valid_dns_label("planspec"));
assert!(is_valid_dns_label("test-123"));
assert!(is_valid_dns_label("a"));
assert!(is_valid_dns_label("abc123"));
}
#[test]
fn test_invalid_dns_labels() {
assert!(!is_valid_dns_label("")); assert!(!is_valid_dns_label("My-Goal")); assert!(!is_valid_dns_label("123-test")); assert!(!is_valid_dns_label("-test")); assert!(!is_valid_dns_label("test-")); assert!(!is_valid_dns_label("test_name")); assert!(!is_valid_dns_label("test.name")); }
#[test]
fn test_valid_node_ids() {
assert!(is_valid_node_id("step-1"));
assert!(is_valid_node_id("my_task"));
assert!(is_valid_node_id("schema-and-conventions"));
assert!(is_valid_node_id("v0-ready"));
}
#[test]
fn test_invalid_node_ids() {
assert!(!is_valid_node_id("")); assert!(!is_valid_node_id("Step-1")); assert!(!is_valid_node_id("1-step")); assert!(!is_valid_node_id("step.one")); }
}
#[derive(Debug, Clone, thiserror::Error)]
pub enum ValidationError {
#[error("missing 'kind' field")]
MissingKind,
#[error("unknown resource kind: {0}")]
UnknownKind(String),
#[error("validation failed at {path}: {message}")]
SchemaValidation { path: String, message: String },
#[error("schema compilation error: {0}")]
SchemaCompilationError(String),
#[error("serialization error: {0}")]
SerializationError(String),
#[error("graph contains a cycle involving node '{node_id}'")]
CyclicGraph { node_id: String },
#[error("edge '{edge_field}' references non-existent node '{node_id}'")]
InvalidEdgeReference { edge_field: String, node_id: String },
#[error("invalid {field}: '{value}' - must be lowercase, start with letter, contain only letters, numbers, and hyphens")]
InvalidName { field: String, value: String },
}
impl From<GraphError> for ValidationError {
fn from(err: GraphError) -> Self {
match err {
GraphError::CyclicGraph { node_id } => ValidationError::CyclicGraph { node_id },
GraphError::NodeNotFound { node_id } => ValidationError::InvalidEdgeReference {
edge_field: "unknown".to_string(),
node_id,
},
}
}
}