1use 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
39pub 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
317pub 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
400pub 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 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
452pub 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 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_project_json(&config)?;
485
486 validate_custom_rules(&config)?;
488
489 info!("Configuration validation passed");
490 Ok(())
491}
492
493pub 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 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
520fn validate_custom_rules(config: &Value) -> Result<()> {
526 if let Some(nodes) = config.get("nodes") {
528 validate_no_circular_dependencies(nodes)?;
529 }
530
531 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 if let Some(nodes) = config.get("nodes") {
540 validate_unique_node_names(nodes)?;
541 }
542
543 if let Some(targets) = config.get("run_targets") {
545 validate_resource_specs(targets)?;
546 }
547
548 Ok(())
549}
550
551fn validate_no_circular_dependencies(nodes: &Value) -> Result<()> {
553 let mut node_deps: HashMap<String, Vec<String>> = HashMap::new();
554
555 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 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; }
597
598 if visited.contains(node) {
599 return false; }
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
617fn 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
646fn 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
668fn 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 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 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
704fn 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
720pub fn load_and_validate_project<P: AsRef<Path>>(path: P) -> Result<Value> {
737 let path = path.as_ref();
738
739 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 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_project_json(&config)?;
751 validate_custom_rules(&config)?;
752
753 Ok(config)
754}
755
756pub 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}