Skip to main content

planspec_core/validate/
mod.rs

1//! Validation for PlanSpec resources.
2
3mod schemas;
4
5use jsonschema::JSONSchema;
6use serde_json::Value;
7
8use crate::types::plan::{Graph, GraphError, Plan};
9use crate::Resource;
10
11/// Validator for PlanSpec resources.
12pub 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    /// Create a new validator with embedded schemas.
23    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    /// Validate a resource.
35    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        // Additional validation for Plans
42        if let Resource::Plan(plan) = resource {
43            self.validate_plan_graph(plan)?;
44        }
45
46        Ok(())
47    }
48
49    /// Validate a JSON value as a resource.
50    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            // Validate naming conventions
91            self.validate_naming_conventions(value)?;
92
93            // For Goals, validate labelSelector semantics
94            if kind == "Goal" {
95                self.validate_label_selector(value)?;
96            }
97
98            // For Plans, also check for cycles, node IDs, series/version, and node kinds
99            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    /// Validate naming conventions for metadata.name and metadata.namespace.
110    fn validate_naming_conventions(&self, value: &Value) -> Result<(), Vec<ValidationError>> {
111        let metadata = value.get("metadata");
112
113        if let Some(meta) = metadata {
114            // Validate name
115            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            // Validate namespace
125            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    /// Validate labelSelector semantics (In/NotIn require values, Exists/DoesNotExist forbid values).
139    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    /// Validate Plan node IDs follow naming conventions.
187    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    /// Validate Plan series/version co-dependency (both must be present or both absent).
211    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    /// Validate kind-specific node constraints.
237    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                        // Gate nodes require gateRef
256                        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                        // Group nodes require non-empty children
268                        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                        // External nodes require externalRef
293                        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                    _ => {} // Task nodes have no required fields beyond id/kind
304                }
305            }
306        }
307
308        Ok(())
309    }
310
311    /// Validate a Plan's graph for cycles and other constraints.
312    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        // Validate that edge references exist
320        self.validate_edge_references(&plan.spec.graph)?;
321
322        Ok(())
323    }
324
325    /// Validate a Plan's graph from JSON.
326    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            // Check for invalid edge references
347            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            // Check for cycles using DFS
366            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    /// Validate that all edge references point to existing nodes.
391    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
452/// Check if a string is a valid DNS label (Kubernetes naming convention).
453/// Must be lowercase, start with a letter, contain only letters, numbers, and hyphens.
454/// Maximum 63 characters.
455fn 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    // Must start with a lowercase letter
463    match chars.next() {
464        Some(c) if c.is_ascii_lowercase() => {}
465        _ => return false,
466    }
467
468    // Rest must be lowercase letters, digits, or hyphens
469    for c in chars {
470        if !c.is_ascii_lowercase() && !c.is_ascii_digit() && c != '-' {
471            return false;
472        }
473    }
474
475    // Must not end with a hyphen
476    if s.ends_with('-') {
477        return false;
478    }
479
480    true
481}
482
483/// Check if a string is a valid node ID.
484/// Similar to DNS labels but slightly more permissive - allows underscores.
485fn 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    // Must start with a lowercase letter
493    match chars.next() {
494        Some(c) if c.is_ascii_lowercase() => {}
495        _ => return false,
496    }
497
498    // Rest must be lowercase letters, digits, hyphens, or underscores
499    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("")); // empty
524        assert!(!is_valid_dns_label("My-Goal")); // uppercase
525        assert!(!is_valid_dns_label("123-test")); // starts with number
526        assert!(!is_valid_dns_label("-test")); // starts with hyphen
527        assert!(!is_valid_dns_label("test-")); // ends with hyphen
528        assert!(!is_valid_dns_label("test_name")); // underscore not allowed
529        assert!(!is_valid_dns_label("test.name")); // dot not allowed
530    }
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("")); // empty
543        assert!(!is_valid_node_id("Step-1")); // uppercase
544        assert!(!is_valid_node_id("1-step")); // starts with number
545        assert!(!is_valid_node_id("step.one")); // dot not allowed
546    }
547}
548
549/// Error during validation.
550#[derive(Debug, Clone, thiserror::Error)]
551pub enum ValidationError {
552    /// Missing kind field.
553    #[error("missing 'kind' field")]
554    MissingKind,
555
556    /// Unknown resource kind.
557    #[error("unknown resource kind: {0}")]
558    UnknownKind(String),
559
560    /// Schema validation failed.
561    #[error("validation failed at {path}: {message}")]
562    SchemaValidation { path: String, message: String },
563
564    /// Schema compilation error.
565    #[error("schema compilation error: {0}")]
566    SchemaCompilationError(String),
567
568    /// Serialization error.
569    #[error("serialization error: {0}")]
570    SerializationError(String),
571
572    /// Graph contains a cycle.
573    #[error("graph contains a cycle involving node '{node_id}'")]
574    CyclicGraph { node_id: String },
575
576    /// Invalid edge reference.
577    #[error("edge '{edge_field}' references non-existent node '{node_id}'")]
578    InvalidEdgeReference { edge_field: String, node_id: String },
579
580    /// Invalid resource name (must be lowercase, alphanumeric, hyphens only).
581    #[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}