mecha10_core/
schema_validation.rs

1//! JSON Schema Validation for Mecha10 Configuration
2//!
3//! Provides validation for mecha10.json project configuration files using JSON Schema.
4//! This catches configuration errors early with helpful error messages.
5//!
6//! # Features
7//!
8//! - JSON Schema v7 validation
9//! - Detailed error reporting with line numbers and field paths
10//! - Built-in schema for mecha10.json
11//! - Custom validation rules for Mecha10-specific constraints
12//! - Support for $schema references
13//!
14//! # Example
15//!
16//! ```rust
17//! use mecha10::prelude::*;
18//! use mecha10::schema_validation::{validate_project_config, load_and_validate_project};
19//!
20//! # async fn example() -> Result<()> {
21//! // Validate a config file
22//! validate_project_config("mecha10.json")?;
23//!
24//! // Load and validate in one step
25//! let config = load_and_validate_project("mecha10.json")?;
26//! # Ok(())
27//! # }
28//! ```
29
30use crate::error::{Mecha10Error, Result};
31use anyhow::Context as _;
32use jsonschema::{Draft, JSONSchema, ValidationError};
33use serde_json::Value;
34use std::collections::HashMap;
35use std::fs;
36use std::path::Path;
37use tracing::{debug, info};
38
39// ============================================================================
40// Schema Definitions
41// ============================================================================
42
43/// Get the built-in JSON Schema for mecha10.json project configuration
44pub fn get_project_schema() -> Value {
45    serde_json::json!({
46        "$schema": "http://json-schema.org/draft-07/schema#",
47        "$id": "https://mecha10.dev/schemas/project.schema.json",
48        "title": "Mecha10 Project Configuration",
49        "description": "Configuration file for Mecha10 robotics projects",
50        "type": "object",
51        "required": ["name", "version"],
52        "properties": {
53            "$schema": {
54                "type": "string",
55                "description": "JSON Schema reference"
56            },
57            "name": {
58                "type": "string",
59                "pattern": "^[a-z0-9][a-z0-9-]*$",
60                "minLength": 1,
61                "maxLength": 64,
62                "description": "Project name (lowercase, alphanumeric, hyphens)"
63            },
64            "version": {
65                "type": "string",
66                "pattern": "^\\d+\\.\\d+\\.\\d+(-.+)?$",
67                "description": "Semantic version (e.g., '1.0.0' or '1.0.0-beta')"
68            },
69            "description": {
70                "type": "string",
71                "maxLength": 500,
72                "description": "Project description"
73            },
74            "author": {
75                "type": "string",
76                "maxLength": 100,
77                "description": "Project author"
78            },
79            "license": {
80                "type": "string",
81                "maxLength": 50,
82                "description": "License identifier (e.g., MIT, Apache-2.0)"
83            },
84            "robot": {
85                "type": "object",
86                "required": ["id", "type"],
87                "properties": {
88                    "id": {
89                        "type": "string",
90                        "pattern": "^[a-zA-Z0-9_\\-${}]+$",
91                        "description": "Robot identifier (can use ${ROBOT_ID} env var)"
92                    },
93                    "type": {
94                        "type": "string",
95                        "enum": ["service", "delivery", "industrial", "mobile", "manipulator", "custom"],
96                        "description": "Robot type classification"
97                    },
98                    "platform": {
99                        "type": "string",
100                        "description": "Hardware platform (e.g., differential-drive, mecanum, ackermann)"
101                    }
102                },
103                "description": "Robot hardware configuration"
104            },
105            "nodes": {
106                "type": "object",
107                "properties": {
108                    "drivers": {
109                        "type": "array",
110                        "items": { "$ref": "#/definitions/node" },
111                        "description": "Hardware driver nodes"
112                    },
113                    "standard": {
114                        "type": "array",
115                        "items": { "$ref": "#/definitions/standardNode" },
116                        "description": "Standard/pre-built nodes from packages"
117                    },
118                    "custom": {
119                        "type": "array",
120                        "items": { "$ref": "#/definitions/node" },
121                        "description": "Custom application nodes"
122                    }
123                },
124                "description": "Node definitions"
125            },
126            "run_targets": {
127                "type": "object",
128                "patternProperties": {
129                    "^[a-z][a-z0-9_-]*$": {
130                        "$ref": "#/definitions/runTarget"
131                    }
132                },
133                "description": "Execution targets (robot, remote, dev, etc.)"
134            },
135            "behaviors": {
136                "type": "object",
137                "properties": {
138                    "default": {
139                        "type": "string",
140                        "description": "Default behavior tree file"
141                    }
142                },
143                "additionalProperties": {
144                    "type": "string"
145                },
146                "description": "Behavior tree configurations"
147            },
148            "simulation": {
149                "type": "object",
150                "properties": {
151                    "enabled": {
152                        "type": "boolean",
153                        "description": "Enable simulation mode"
154                    },
155                    "environment": {
156                        "type": "string",
157                        "description": "Path to simulation environment file"
158                    }
159                },
160                "description": "Simulation settings"
161            },
162            "scripts": {
163                "type": "object",
164                "patternProperties": {
165                    "^[a-z][a-z0-9:_-]*$": {
166                        "type": "string"
167                    }
168                },
169                "description": "Script shortcuts (npm scripts-like)"
170            }
171        },
172        "definitions": {
173            "node": {
174                "type": "object",
175                "required": ["name", "path"],
176                "properties": {
177                    "name": {
178                        "type": "string",
179                        "pattern": "^[a-z][a-z0-9_]*$",
180                        "description": "Node name (snake_case)"
181                    },
182                    "path": {
183                        "type": "string",
184                        "description": "Path to node source code"
185                    },
186                    "config": {
187                        "type": "string",
188                        "description": "Path to node configuration file"
189                    },
190                    "description": {
191                        "type": "string",
192                        "maxLength": 200,
193                        "description": "Node description"
194                    },
195                    "run_target": {
196                        "type": "string",
197                        "description": "Where to run this node (robot, remote, etc.)"
198                    },
199                    "enabled": {
200                        "type": "boolean",
201                        "default": true,
202                        "description": "Whether node is enabled"
203                    },
204                    "auto_restart": {
205                        "type": "boolean",
206                        "default": true,
207                        "description": "Auto-restart on crash"
208                    },
209                    "depends_on": {
210                        "type": "array",
211                        "items": {
212                            "type": "string"
213                        },
214                        "description": "Node dependencies (other nodes that must start first)"
215                    }
216                }
217            },
218            "standardNode": {
219                "type": "object",
220                "required": ["package", "binary"],
221                "properties": {
222                    "package": {
223                        "type": "string",
224                        "pattern": "^@[a-z0-9-]+/[a-z0-9/-]+$",
225                        "description": "Package identifier (e.g., @mecha10/drivers/camera-fake)"
226                    },
227                    "binary": {
228                        "type": "string",
229                        "description": "Binary name within package"
230                    },
231                    "config": {
232                        "type": "string",
233                        "description": "Path to configuration file"
234                    }
235                }
236            },
237            "runTarget": {
238                "type": "object",
239                "properties": {
240                    "description": {
241                        "type": "string",
242                        "maxLength": 200,
243                        "description": "Target description"
244                    },
245                    "includes": {
246                        "type": "array",
247                        "items": {
248                            "type": "string"
249                        },
250                        "description": "Node groups to include"
251                    },
252                    "resources": {
253                        "type": "object",
254                        "properties": {
255                            "cpu_limit": {
256                                "type": "string",
257                                "pattern": "^\\d+(\\.\\d+)?$",
258                                "description": "CPU limit (e.g., '2.0')"
259                            },
260                            "memory_limit": {
261                                "type": "string",
262                                "pattern": "^\\d+(K|M|G|T)?$",
263                                "description": "Memory limit (e.g., '512M', '16G')"
264                            },
265                            "gpu": {
266                                "oneOf": [
267                                    { "type": "boolean" },
268                                    {
269                                        "type": "object",
270                                        "properties": {
271                                            "enabled": { "type": "boolean" },
272                                            "vendor": {
273                                                "type": "string",
274                                                "enum": ["nvidia", "amd", "intel"]
275                                            },
276                                            "count": {
277                                                "type": "integer",
278                                                "minimum": 1
279                                            },
280                                            "optional": { "type": "boolean" }
281                                        }
282                                    }
283                                ],
284                                "description": "GPU requirements"
285                            },
286                            "storage_type": {
287                                "type": "string",
288                                "enum": ["local", "ssd", "hdd", "any"],
289                                "description": "Storage type requirement"
290                            },
291                            "storage_size_gb": {
292                                "type": "integer",
293                                "minimum": 1,
294                                "description": "Storage size in GB"
295                            },
296                            "model_cache": {
297                                "type": "string",
298                                "description": "Path to model cache directory"
299                            },
300                            "devices": {
301                                "type": "array",
302                                "items": {
303                                    "type": "string",
304                                    "pattern": "^/dev/.+$"
305                                },
306                                "description": "Required device files (e.g., /dev/video0)"
307                            }
308                        },
309                        "description": "Resource requirements"
310                    }
311                }
312            }
313        }
314    })
315}
316
317/// Get the JSON Schema for NodeConfig (runtime node configuration)
318pub fn get_node_config_schema() -> Value {
319    serde_json::json!({
320        "$schema": "http://json-schema.org/draft-07/schema#",
321        "title": "Mecha10 Node Configuration",
322        "description": "Runtime configuration for individual nodes",
323        "type": "object",
324        "required": ["id", "executable"],
325        "properties": {
326            "id": {
327                "type": "string",
328                "pattern": "^[a-z][a-z0-9_-]*$",
329                "description": "Unique node identifier"
330            },
331            "name": {
332                "type": "string",
333                "maxLength": 100,
334                "description": "Human-readable name"
335            },
336            "executable": {
337                "type": "string",
338                "minLength": 1,
339                "description": "Path to executable"
340            },
341            "args": {
342                "type": "array",
343                "items": { "type": "string" },
344                "description": "Command-line arguments"
345            },
346            "env": {
347                "type": "object",
348                "patternProperties": {
349                    "^[A-Z_][A-Z0-9_]*$": {
350                        "type": "string"
351                    }
352                },
353                "description": "Environment variables"
354            },
355            "mode": {
356                "type": "string",
357                "enum": ["local", "remote", "simulation"],
358                "default": "local",
359                "description": "Execution mode"
360            },
361            "auto_restart": {
362                "type": "boolean",
363                "default": true,
364                "description": "Auto-restart on crash"
365            },
366            "health_check_interval_s": {
367                "type": "integer",
368                "minimum": 1,
369                "maximum": 300,
370                "default": 5,
371                "description": "Health check interval in seconds"
372            },
373            "publishes": {
374                "type": "array",
375                "items": {
376                    "type": "string",
377                    "pattern": "^/[a-z0-9/_-]+$"
378                },
379                "description": "Topics this node publishes to"
380            },
381            "subscribes": {
382                "type": "array",
383                "items": {
384                    "type": "string",
385                    "pattern": "^/[a-z0-9/_-]+$"
386                },
387                "description": "Topics this node subscribes to"
388            },
389            "depends_on": {
390                "type": "array",
391                "items": {
392                    "type": "string"
393                },
394                "description": "Node dependencies"
395            }
396        }
397    })
398}
399
400// ============================================================================
401// Validation Functions
402// ============================================================================
403
404/// Validate a JSON value against the project schema
405///
406/// # Arguments
407///
408/// * `config` - The configuration to validate (as serde_json::Value)
409///
410/// # Returns
411///
412/// Ok(()) if valid, Err with detailed validation errors if invalid
413///
414/// # Example
415///
416/// ```rust
417/// use mecha10::schema_validation::validate_project_json;
418///
419/// let config = serde_json::json!({
420///     "name": "my-robot",
421///     "version": "1.0.0"
422/// });
423///
424/// validate_project_json(&config)?;
425/// ```
426pub fn validate_project_json(config: &Value) -> Result<()> {
427    let schema = get_project_schema();
428
429    let compiled_schema = JSONSchema::options()
430        .with_draft(Draft::Draft7)
431        .compile(&schema)
432        .map_err(|e| Mecha10Error::Configuration(format!("Invalid schema: {}", e)))?;
433
434    if compiled_schema.is_valid(config) {
435        debug!("Project configuration validation passed");
436        Ok(())
437    } else {
438        // Collect errors before compiled_schema is dropped
439        let error_messages: Vec<String> = compiled_schema
440            .validate(config)
441            .unwrap_err()
442            .map(|e| format_validation_error(&e))
443            .collect();
444
445        Err(Mecha10Error::Configuration(format!(
446            "Configuration validation failed:\n{}",
447            error_messages.join("\n")
448        )))
449    }
450}
451
452/// Validate a project configuration file
453///
454/// # Arguments
455///
456/// * `path` - Path to mecha10.json file
457///
458/// # Returns
459///
460/// Ok(()) if valid, Err with detailed validation errors
461///
462/// # Example
463///
464/// ```rust
465/// use mecha10::schema_validation::validate_project_config;
466///
467/// validate_project_config("mecha10.json")?;
468/// ```
469pub fn validate_project_config<P: AsRef<Path>>(path: P) -> Result<()> {
470    let path = path.as_ref();
471
472    info!("Validating project configuration: {}", path.display());
473
474    // Read and parse file
475    let content = fs::read_to_string(path)
476        .with_context(|| format!("Failed to read config file: {}", path.display()))
477        .map_err(|e| Mecha10Error::Configuration(format!("{:#}", e)))?;
478
479    let config: Value = serde_json::from_str(&content)
480        .with_context(|| format!("Failed to parse JSON from: {}", path.display()))
481        .map_err(|e| Mecha10Error::Configuration(format!("{:#}", e)))?;
482
483    // Validate against schema
484    validate_project_json(&config)?;
485
486    // Additional custom validations
487    validate_custom_rules(&config)?;
488
489    info!("Configuration validation passed");
490    Ok(())
491}
492
493/// Validate NodeConfig JSON against schema
494pub fn validate_node_config_json(config: &Value) -> Result<()> {
495    let schema = get_node_config_schema();
496
497    let compiled_schema = JSONSchema::options()
498        .with_draft(Draft::Draft7)
499        .compile(&schema)
500        .map_err(|e| Mecha10Error::Configuration(format!("Invalid schema: {}", e)))?;
501
502    if compiled_schema.is_valid(config) {
503        debug!("Node configuration validation passed");
504        Ok(())
505    } else {
506        // Collect errors before compiled_schema is dropped
507        let error_messages: Vec<String> = compiled_schema
508            .validate(config)
509            .unwrap_err()
510            .map(|e| format_validation_error(&e))
511            .collect();
512
513        Err(Mecha10Error::Configuration(format!(
514            "Node configuration validation failed:\n{}",
515            error_messages.join("\n")
516        )))
517    }
518}
519
520// ============================================================================
521// Custom Validation Rules
522// ============================================================================
523
524/// Apply custom validation rules beyond JSON Schema
525fn validate_custom_rules(config: &Value) -> Result<()> {
526    // Rule 1: Check for circular dependencies in nodes
527    if let Some(nodes) = config.get("nodes") {
528        validate_no_circular_dependencies(nodes)?;
529    }
530
531    // Rule 2: Check run_target references exist
532    if let Some(nodes) = config.get("nodes") {
533        if let Some(targets) = config.get("run_targets") {
534            validate_run_target_references(nodes, targets)?;
535        }
536    }
537
538    // Rule 3: Check node names are unique across all categories
539    if let Some(nodes) = config.get("nodes") {
540        validate_unique_node_names(nodes)?;
541    }
542
543    // Rule 4: Validate resource specifications
544    if let Some(targets) = config.get("run_targets") {
545        validate_resource_specs(targets)?;
546    }
547
548    Ok(())
549}
550
551/// Check for circular dependencies in node definitions
552fn validate_no_circular_dependencies(nodes: &Value) -> Result<()> {
553    let mut node_deps: HashMap<String, Vec<String>> = HashMap::new();
554
555    // Collect all dependencies
556    for category in ["drivers", "standard", "custom"] {
557        if let Some(node_list) = nodes.get(category).and_then(|v| v.as_array()) {
558            for node in node_list {
559                if let Some(name) = node.get("name").and_then(|n| n.as_str()) {
560                    let deps = node
561                        .get("depends_on")
562                        .and_then(|d| d.as_array())
563                        .map(|arr| arr.iter().filter_map(|v| v.as_str().map(String::from)).collect())
564                        .unwrap_or_default();
565
566                    node_deps.insert(name.to_string(), deps);
567                }
568            }
569        }
570    }
571
572    // Check for cycles using DFS
573    for node in node_deps.keys() {
574        let mut visited = std::collections::HashSet::new();
575        let mut stack = std::collections::HashSet::new();
576
577        if has_cycle(node, &node_deps, &mut visited, &mut stack) {
578            return Err(Mecha10Error::Configuration(format!(
579                "Circular dependency detected involving node '{}'",
580                node
581            )));
582        }
583    }
584
585    Ok(())
586}
587
588fn has_cycle(
589    node: &str,
590    deps: &HashMap<String, Vec<String>>,
591    visited: &mut std::collections::HashSet<String>,
592    stack: &mut std::collections::HashSet<String>,
593) -> bool {
594    if stack.contains(node) {
595        return true; // Cycle detected
596    }
597
598    if visited.contains(node) {
599        return false; // Already checked
600    }
601
602    visited.insert(node.to_string());
603    stack.insert(node.to_string());
604
605    if let Some(dependencies) = deps.get(node) {
606        for dep in dependencies {
607            if has_cycle(dep, deps, visited, stack) {
608                return true;
609            }
610        }
611    }
612
613    stack.remove(node);
614    false
615}
616
617/// Validate that all run_target references exist
618fn validate_run_target_references(nodes: &Value, targets: &Value) -> Result<()> {
619    let target_names: Vec<String> = targets
620        .as_object()
621        .map(|obj| obj.keys().cloned().collect())
622        .unwrap_or_default();
623
624    for category in ["drivers", "custom"] {
625        if let Some(node_list) = nodes.get(category).and_then(|v| v.as_array()) {
626            for node in node_list {
627                if let Some(run_target) = node.get("run_target").and_then(|t| t.as_str()) {
628                    if !target_names.contains(&run_target.to_string()) {
629                        let node_name = node.get("name").and_then(|n| n.as_str()).unwrap_or("<unnamed>");
630
631                        return Err(Mecha10Error::Configuration(format!(
632                            "Node '{}' references undefined run_target '{}'. Available targets: {}",
633                            node_name,
634                            run_target,
635                            target_names.join(", ")
636                        )));
637                    }
638                }
639            }
640        }
641    }
642
643    Ok(())
644}
645
646/// Validate that node names are unique
647fn validate_unique_node_names(nodes: &Value) -> Result<()> {
648    let mut seen_names = std::collections::HashSet::new();
649
650    for category in ["drivers", "standard", "custom"] {
651        if let Some(node_list) = nodes.get(category).and_then(|v| v.as_array()) {
652            for node in node_list {
653                if let Some(name) = node.get("name").and_then(|n| n.as_str()) {
654                    if !seen_names.insert(name.to_string()) {
655                        return Err(Mecha10Error::Configuration(format!(
656                            "Duplicate node name '{}' found. Node names must be unique across all categories.",
657                            name
658                        )));
659                    }
660                }
661            }
662        }
663    }
664
665    Ok(())
666}
667
668/// Validate resource specifications
669fn validate_resource_specs(targets: &Value) -> Result<()> {
670    if let Some(targets_obj) = targets.as_object() {
671        for (target_name, target_config) in targets_obj {
672            if let Some(resources) = target_config.get("resources") {
673                // Validate memory format
674                if let Some(mem_limit) = resources.get("memory_limit").and_then(|m| m.as_str()) {
675                    if !is_valid_memory_spec(mem_limit) {
676                        return Err(Mecha10Error::Configuration(format!(
677                            "Invalid memory_limit '{}' in run_target '{}'. Expected format: <number><K|M|G|T>",
678                            mem_limit, target_name
679                        )));
680                    }
681                }
682
683                // Validate CPU format
684                if let Some(cpu_limit) = resources.get("cpu_limit").and_then(|c| c.as_str()) {
685                    if cpu_limit.parse::<f64>().is_err() {
686                        return Err(Mecha10Error::Configuration(format!(
687                            "Invalid cpu_limit '{}' in run_target '{}'. Expected a number (e.g., '2.0')",
688                            cpu_limit, target_name
689                        )));
690                    }
691                }
692            }
693        }
694    }
695
696    Ok(())
697}
698
699fn is_valid_memory_spec(spec: &str) -> bool {
700    let re = regex::Regex::new(r"^\d+(K|M|G|T)?$").unwrap();
701    re.is_match(spec)
702}
703
704// ============================================================================
705// Helper Functions
706// ============================================================================
707
708/// Format a validation error into a human-readable message
709fn format_validation_error(error: &ValidationError) -> String {
710    let path_str = error.instance_path.to_string();
711    let path = if path_str.is_empty() || path_str == "/" {
712        "root".to_string()
713    } else {
714        path_str
715    };
716
717    format!("  - {}: {} ({})", path, error, error.instance)
718}
719
720// ============================================================================
721// High-Level API
722// ============================================================================
723
724/// Load and validate a project configuration file
725///
726/// This combines loading and validation into one convenient function.
727///
728/// # Example
729///
730/// ```rust
731/// use mecha10::schema_validation::load_and_validate_project;
732///
733/// let config = load_and_validate_project("mecha10.json")?;
734/// println!("Project: {}", config["name"]);
735/// ```
736pub fn load_and_validate_project<P: AsRef<Path>>(path: P) -> Result<Value> {
737    let path = path.as_ref();
738
739    // Read file
740    let content = fs::read_to_string(path)
741        .with_context(|| format!("Failed to read config file: {}", path.display()))
742        .map_err(|e| Mecha10Error::Configuration(format!("{:#}", e)))?;
743
744    // Parse JSON
745    let config: Value = serde_json::from_str(&content)
746        .with_context(|| format!("Failed to parse JSON: {}", path.display()))
747        .map_err(|e| Mecha10Error::Configuration(format!("{:#}", e)))?;
748
749    // Validate
750    validate_project_json(&config)?;
751    validate_custom_rules(&config)?;
752
753    Ok(config)
754}
755
756/// Export the JSON Schema to a file
757///
758/// Useful for IDE integration and documentation.
759///
760/// # Example
761///
762/// ```rust
763/// use mecha10::schema_validation::export_project_schema;
764///
765/// export_project_schema("project.schema.json")?;
766/// ```
767pub fn export_project_schema<P: AsRef<Path>>(path: P) -> Result<()> {
768    let schema = get_project_schema();
769    let json = serde_json::to_string_pretty(&schema)
770        .map_err(|e| Mecha10Error::Configuration(format!("Failed to serialize schema: {}", e)))?;
771
772    fs::write(path.as_ref(), json)
773        .with_context(|| format!("Failed to write schema to: {}", path.as_ref().display()))
774        .map_err(|e| Mecha10Error::Configuration(format!("{:#}", e)))?;
775
776    info!("Exported schema to: {}", path.as_ref().display());
777    Ok(())
778}