1mod schemas;
4
5use jsonschema::JSONSchema;
6use serde_json::Value;
7
8use crate::types::plan::{Graph, GraphError, Plan};
9use crate::Resource;
10
11pub struct Validator {
13 goal_schema: JSONSchema,
14 plan_schema: JSONSchema,
15 capability_schema: JSONSchema,
16 binding_schema: JSONSchema,
17 execution_schema: JSONSchema,
18 gate_schema: JSONSchema,
19}
20
21impl Validator {
22 pub fn new() -> Result<Self, ValidationError> {
24 Ok(Self {
25 goal_schema: compile_schema(schemas::GOAL_SCHEMA)?,
26 plan_schema: compile_schema(schemas::PLAN_SCHEMA)?,
27 capability_schema: compile_schema(schemas::CAPABILITY_SCHEMA)?,
28 binding_schema: compile_schema(schemas::BINDING_SCHEMA)?,
29 execution_schema: compile_schema(schemas::EXECUTION_SCHEMA)?,
30 gate_schema: compile_schema(schemas::GATE_SCHEMA)?,
31 })
32 }
33
34 pub fn validate(&self, resource: &Resource) -> Result<(), Vec<ValidationError>> {
36 let value = serde_json::to_value(resource)
37 .map_err(|e| vec![ValidationError::SerializationError(e.to_string())])?;
38
39 self.validate_json(&value)?;
40
41 if let Resource::Plan(plan) = resource {
43 self.validate_plan_graph(plan)?;
44 }
45
46 Ok(())
47 }
48
49 pub fn validate_json(&self, value: &Value) -> Result<(), Vec<ValidationError>> {
51 let kind = value
52 .get("kind")
53 .and_then(|k| k.as_str())
54 .ok_or_else(|| vec![ValidationError::MissingKind])?;
55
56 let schema = match kind {
57 "Goal" => &self.goal_schema,
58 "Plan" => &self.plan_schema,
59 "Capability" => &self.capability_schema,
60 "Binding" => &self.binding_schema,
61 "Execution" => &self.execution_schema,
62 "Gate" => &self.gate_schema,
63 _ => return Err(vec![ValidationError::UnknownKind(kind.to_string())]),
64 };
65
66 let result = schema.validate(value);
67
68 if let Err(errors) = result {
69 let error_list: Vec<ValidationError> = errors
70 .map(|e| {
71 let path = e.instance_path.to_string();
72 let path_str = if path.is_empty() {
73 "(root)".to_string()
74 } else {
75 path
76 };
77 ValidationError::SchemaValidation {
78 path: path_str,
79 message: e.to_string(),
80 }
81 })
82 .collect();
83
84 if error_list.is_empty() {
85 Ok(())
86 } else {
87 Err(error_list)
88 }
89 } else {
90 self.validate_naming_conventions(value)?;
92
93 if kind == "Goal" {
95 self.validate_label_selector(value)?;
96 }
97
98 if kind == "Plan" {
100 self.validate_plan_graph_json(value)?;
101 self.validate_plan_node_ids(value)?;
102 self.validate_plan_series_version(value)?;
103 self.validate_plan_node_kinds(value)?;
104 }
105 Ok(())
106 }
107 }
108
109 fn validate_naming_conventions(&self, value: &Value) -> Result<(), Vec<ValidationError>> {
111 let metadata = value.get("metadata");
112
113 if let Some(meta) = metadata {
114 if let Some(name) = meta.get("name").and_then(|n| n.as_str()) {
116 if !is_valid_dns_label(name) {
117 return Err(vec![ValidationError::InvalidName {
118 field: "metadata.name".to_string(),
119 value: name.to_string(),
120 }]);
121 }
122 }
123
124 if let Some(namespace) = meta.get("namespace").and_then(|n| n.as_str()) {
126 if !is_valid_dns_label(namespace) {
127 return Err(vec![ValidationError::InvalidName {
128 field: "metadata.namespace".to_string(),
129 value: namespace.to_string(),
130 }]);
131 }
132 }
133 }
134
135 Ok(())
136 }
137
138 fn validate_label_selector(&self, value: &Value) -> Result<(), Vec<ValidationError>> {
140 let expressions = value
141 .get("spec")
142 .and_then(|s| s.get("planSelector"))
143 .and_then(|ps| ps.get("matchExpressions"))
144 .and_then(|me| me.as_array());
145
146 if let Some(expressions) = expressions {
147 for (i, expr) in expressions.iter().enumerate() {
148 let operator = expr.get("operator").and_then(|o| o.as_str()).unwrap_or("");
149 let has_values = expr
150 .get("values")
151 .and_then(|v| v.as_array())
152 .map(|arr| !arr.is_empty())
153 .unwrap_or(false);
154
155 match operator {
156 "In" | "NotIn" => {
157 if !has_values {
158 return Err(vec![ValidationError::SchemaValidation {
159 path: format!("/spec/planSelector/matchExpressions/{}", i),
160 message: format!(
161 "operator '{}' requires non-empty 'values' array",
162 operator
163 ),
164 }]);
165 }
166 }
167 "Exists" | "DoesNotExist" => {
168 if has_values {
169 return Err(vec![ValidationError::SchemaValidation {
170 path: format!("/spec/planSelector/matchExpressions/{}", i),
171 message: format!(
172 "operator '{}' must not have 'values' array",
173 operator
174 ),
175 }]);
176 }
177 }
178 _ => {}
179 }
180 }
181 }
182
183 Ok(())
184 }
185
186 fn validate_plan_node_ids(&self, value: &Value) -> Result<(), Vec<ValidationError>> {
188 let nodes = value
189 .get("spec")
190 .and_then(|s| s.get("graph"))
191 .and_then(|g| g.get("nodes"))
192 .and_then(|n| n.as_array());
193
194 if let Some(nodes) = nodes {
195 for node in nodes {
196 if let Some(id) = node.get("id").and_then(|i| i.as_str()) {
197 if !is_valid_node_id(id) {
198 return Err(vec![ValidationError::InvalidName {
199 field: "spec.graph.nodes[].id".to_string(),
200 value: id.to_string(),
201 }]);
202 }
203 }
204 }
205 }
206
207 Ok(())
208 }
209
210 fn validate_plan_series_version(&self, value: &Value) -> Result<(), Vec<ValidationError>> {
212 let spec = value.get("spec");
213 if let Some(spec) = spec {
214 let has_series = spec.get("series").and_then(|s| s.as_str()).is_some();
215 let has_version = spec.get("version").and_then(|v| v.as_str()).is_some();
216
217 match (has_series, has_version) {
218 (true, false) => {
219 return Err(vec![ValidationError::SchemaValidation {
220 path: "/spec".to_string(),
221 message: "'series' requires 'version' to also be specified".to_string(),
222 }]);
223 }
224 (false, true) => {
225 return Err(vec![ValidationError::SchemaValidation {
226 path: "/spec".to_string(),
227 message: "'version' requires 'series' to also be specified".to_string(),
228 }]);
229 }
230 _ => {}
231 }
232 }
233 Ok(())
234 }
235
236 fn validate_plan_node_kinds(&self, value: &Value) -> Result<(), Vec<ValidationError>> {
238 let nodes = value
239 .get("spec")
240 .and_then(|s| s.get("graph"))
241 .and_then(|g| g.get("nodes"))
242 .and_then(|n| n.as_array());
243
244 if let Some(nodes) = nodes {
245 for (i, node) in nodes.iter().enumerate() {
246 let kind = node.get("kind").and_then(|k| k.as_str()).unwrap_or("");
247 let default_id = format!("index {}", i);
248 let node_id = node
249 .get("id")
250 .and_then(|id| id.as_str())
251 .unwrap_or(&default_id);
252
253 match kind {
254 "Gate" => {
255 if node.get("gateRef").is_none() {
257 return Err(vec![ValidationError::SchemaValidation {
258 path: format!("/spec/graph/nodes/{}", i),
259 message: format!(
260 "Gate node '{}' requires 'gateRef' field",
261 node_id
262 ),
263 }]);
264 }
265 }
266 "Group" => {
267 let children = node.get("children").and_then(|c| c.as_array());
269 match children {
270 None => {
271 return Err(vec![ValidationError::SchemaValidation {
272 path: format!("/spec/graph/nodes/{}", i),
273 message: format!(
274 "Group node '{}' requires 'children' field",
275 node_id
276 ),
277 }]);
278 }
279 Some(c) if c.is_empty() => {
280 return Err(vec![ValidationError::SchemaValidation {
281 path: format!("/spec/graph/nodes/{}", i),
282 message: format!(
283 "Group node '{}' requires at least one child",
284 node_id
285 ),
286 }]);
287 }
288 _ => {}
289 }
290 }
291 "External" => {
292 if node.get("externalRef").is_none() {
294 return Err(vec![ValidationError::SchemaValidation {
295 path: format!("/spec/graph/nodes/{}", i),
296 message: format!(
297 "External node '{}' requires 'externalRef' field",
298 node_id
299 ),
300 }]);
301 }
302 }
303 _ => {} }
305 }
306 }
307
308 Ok(())
309 }
310
311 fn validate_plan_graph(&self, plan: &Plan) -> Result<(), Vec<ValidationError>> {
313 if let Some(cycle_node) = plan.spec.graph.detect_cycle() {
314 return Err(vec![ValidationError::CyclicGraph {
315 node_id: cycle_node,
316 }]);
317 }
318
319 self.validate_edge_references(&plan.spec.graph)?;
321
322 Ok(())
323 }
324
325 fn validate_plan_graph_json(&self, value: &Value) -> Result<(), Vec<ValidationError>> {
327 let graph = value.get("spec").and_then(|s| s.get("graph"));
328
329 if let Some(graph_value) = graph {
330 let nodes = graph_value
331 .get("nodes")
332 .and_then(|n| n.as_array())
333 .map(|arr| {
334 arr.iter()
335 .filter_map(|n| n.get("id").and_then(|id| id.as_str()))
336 .collect::<std::collections::HashSet<_>>()
337 })
338 .unwrap_or_default();
339
340 let empty_edges = vec![];
341 let edges = graph_value
342 .get("edges")
343 .and_then(|e| e.as_array())
344 .unwrap_or(&empty_edges);
345
346 for edge in edges {
348 let from = edge.get("from").and_then(|f| f.as_str()).unwrap_or("");
349 let to = edge.get("to").and_then(|t| t.as_str()).unwrap_or("");
350
351 if !nodes.contains(from) {
352 return Err(vec![ValidationError::InvalidEdgeReference {
353 edge_field: "from".to_string(),
354 node_id: from.to_string(),
355 }]);
356 }
357 if !nodes.contains(to) {
358 return Err(vec![ValidationError::InvalidEdgeReference {
359 edge_field: "to".to_string(),
360 node_id: to.to_string(),
361 }]);
362 }
363 }
364
365 let mut adj: std::collections::HashMap<&str, Vec<&str>> =
367 std::collections::HashMap::new();
368 for node_id in &nodes {
369 adj.entry(node_id).or_default();
370 }
371 for edge in edges {
372 let from = edge.get("from").and_then(|f| f.as_str()).unwrap_or("");
373 let to = edge.get("to").and_then(|t| t.as_str()).unwrap_or("");
374 adj.entry(from).or_default().push(to);
375 }
376
377 let mut visited = std::collections::HashSet::new();
378 let mut rec_stack = std::collections::HashSet::new();
379
380 for node_id in &nodes {
381 if let Some(cycle) = detect_cycle_dfs(node_id, &adj, &mut visited, &mut rec_stack) {
382 return Err(vec![ValidationError::CyclicGraph { node_id: cycle }]);
383 }
384 }
385 }
386
387 Ok(())
388 }
389
390 fn validate_edge_references(&self, graph: &Graph) -> Result<(), Vec<ValidationError>> {
392 let node_ids: std::collections::HashSet<&str> =
393 graph.nodes.iter().map(|n| n.id.as_str()).collect();
394
395 for edge in &graph.edges {
396 if !node_ids.contains(edge.from.as_str()) {
397 return Err(vec![ValidationError::InvalidEdgeReference {
398 edge_field: "from".to_string(),
399 node_id: edge.from.clone(),
400 }]);
401 }
402 if !node_ids.contains(edge.to.as_str()) {
403 return Err(vec![ValidationError::InvalidEdgeReference {
404 edge_field: "to".to_string(),
405 node_id: edge.to.clone(),
406 }]);
407 }
408 }
409
410 Ok(())
411 }
412}
413
414fn detect_cycle_dfs<'a>(
415 node: &'a str,
416 adj: &std::collections::HashMap<&str, Vec<&'a str>>,
417 visited: &mut std::collections::HashSet<&'a str>,
418 rec_stack: &mut std::collections::HashSet<&'a str>,
419) -> Option<String> {
420 if rec_stack.contains(node) {
421 return Some(node.to_string());
422 }
423 if visited.contains(node) {
424 return None;
425 }
426
427 visited.insert(node);
428 rec_stack.insert(node);
429
430 if let Some(neighbors) = adj.get(node) {
431 for neighbor in neighbors {
432 if let Some(cycle) = detect_cycle_dfs(neighbor, adj, visited, rec_stack) {
433 return Some(cycle);
434 }
435 }
436 }
437
438 rec_stack.remove(node);
439 None
440}
441
442fn compile_schema(schema_json: &str) -> Result<JSONSchema, ValidationError> {
443 let schema: Value = serde_json::from_str(schema_json).map_err(|e| {
444 ValidationError::SchemaCompilationError(format!("Failed to parse schema: {}", e))
445 })?;
446
447 JSONSchema::compile(&schema).map_err(|e| {
448 ValidationError::SchemaCompilationError(format!("Failed to compile schema: {}", e))
449 })
450}
451
452fn is_valid_dns_label(s: &str) -> bool {
456 if s.is_empty() || s.len() > 63 {
457 return false;
458 }
459
460 let mut chars = s.chars().peekable();
461
462 match chars.next() {
464 Some(c) if c.is_ascii_lowercase() => {}
465 _ => return false,
466 }
467
468 for c in chars {
470 if !c.is_ascii_lowercase() && !c.is_ascii_digit() && c != '-' {
471 return false;
472 }
473 }
474
475 if s.ends_with('-') {
477 return false;
478 }
479
480 true
481}
482
483fn is_valid_node_id(s: &str) -> bool {
486 if s.is_empty() || s.len() > 63 {
487 return false;
488 }
489
490 let mut chars = s.chars().peekable();
491
492 match chars.next() {
494 Some(c) if c.is_ascii_lowercase() => {}
495 _ => return false,
496 }
497
498 for c in chars {
500 if !c.is_ascii_lowercase() && !c.is_ascii_digit() && c != '-' && c != '_' {
501 return false;
502 }
503 }
504
505 true
506}
507
508#[cfg(test)]
509mod naming_tests {
510 use super::*;
511
512 #[test]
513 fn test_valid_dns_labels() {
514 assert!(is_valid_dns_label("my-goal"));
515 assert!(is_valid_dns_label("planspec"));
516 assert!(is_valid_dns_label("test-123"));
517 assert!(is_valid_dns_label("a"));
518 assert!(is_valid_dns_label("abc123"));
519 }
520
521 #[test]
522 fn test_invalid_dns_labels() {
523 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")); }
531
532 #[test]
533 fn test_valid_node_ids() {
534 assert!(is_valid_node_id("step-1"));
535 assert!(is_valid_node_id("my_task"));
536 assert!(is_valid_node_id("schema-and-conventions"));
537 assert!(is_valid_node_id("v0-ready"));
538 }
539
540 #[test]
541 fn test_invalid_node_ids() {
542 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")); }
547}
548
549#[derive(Debug, Clone, thiserror::Error)]
551pub enum ValidationError {
552 #[error("missing 'kind' field")]
554 MissingKind,
555
556 #[error("unknown resource kind: {0}")]
558 UnknownKind(String),
559
560 #[error("validation failed at {path}: {message}")]
562 SchemaValidation { path: String, message: String },
563
564 #[error("schema compilation error: {0}")]
566 SchemaCompilationError(String),
567
568 #[error("serialization error: {0}")]
570 SerializationError(String),
571
572 #[error("graph contains a cycle involving node '{node_id}'")]
574 CyclicGraph { node_id: String },
575
576 #[error("edge '{edge_field}' references non-existent node '{node_id}'")]
578 InvalidEdgeReference { edge_field: String, node_id: String },
579
580 #[error("invalid {field}: '{value}' - must be lowercase, start with letter, contain only letters, numbers, and hyphens")]
582 InvalidName { field: String, value: String },
583}
584
585impl From<GraphError> for ValidationError {
586 fn from(err: GraphError) -> Self {
587 match err {
588 GraphError::CyclicGraph { node_id } => ValidationError::CyclicGraph { node_id },
589 GraphError::NodeNotFound { node_id } => ValidationError::InvalidEdgeReference {
590 edge_field: "unknown".to_string(),
591 node_id,
592 },
593 }
594 }
595}