use super::rules::validate_custom_rules;
use super::schemas::{get_node_config_schema, get_project_schema};
use crate::error::{Mecha10Error, Result};
use anyhow::Context as _;
use jsonschema::{Draft, ValidationError};
use serde_json::{Map, Value};
use std::fs;
use std::path::Path;
use tracing::{debug, info};
const PROJECT_CONFIG_FILENAME: &str = "mecha10.json";
const ENV_SECTION_KEYS: [&str; 6] = ["dev", "development", "prod", "production", "staging", "test"];
pub type NodeSchemaResolver<'a> = dyn Fn(&Value) -> Option<Value> + 'a;
pub fn validate_project_json(config: &Value) -> Result<()> {
validate_json_against_schema(
config,
&get_project_schema(),
"Project configuration",
"Configuration validation failed",
)
}
pub fn validate_project_config<P: AsRef<Path>>(path: P) -> Result<()> {
validate_project_config_with_node_schema_resolver(path, None)
}
pub fn validate_project_config_with_node_schema_resolver<P: AsRef<Path>>(
path: P,
node_schema_resolver: Option<&NodeSchemaResolver>,
) -> Result<()> {
let path = path.as_ref();
info!("Validating project configuration: {}", path.display());
let config: Value = load_value_from_file(path)?;
validate_project_json(&config)?;
validate_custom_rules(&config)?;
let project_dir = path.parent().unwrap_or_else(|| Path::new("."));
validate_referenced_node_configs(project_dir, &config, node_schema_resolver)?;
info!("Configuration validation passed");
Ok(())
}
pub fn validate_node_config_json(config: &Value) -> Result<()> {
validate_json_against_schema(
config,
&get_node_config_schema(),
"Node configuration",
"Node configuration validation failed",
)
}
pub fn validate_node_config_file<P: AsRef<Path>>(path: P) -> Result<()> {
validate_node_config_file_with_schema(path, None)
}
fn validate_node_config_file_with_schema<P: AsRef<Path>>(path: P, schema: Option<&Value>) -> Result<()> {
let path = path.as_ref();
if !path.exists() {
return Err(Mecha10Error::Configuration(format!(
"Referenced node config file not found: {}",
path.display()
)));
}
let value = load_value_from_file(path)?;
validate_node_config_value_with_schema(&value, path, schema)
}
pub fn validate_config_file<P: AsRef<Path>>(path: P) -> Result<()> {
validate_config_file_with_node_schema_resolver(path, None)
}
pub fn validate_config_file_with_node_schema_resolver<P: AsRef<Path>>(
path: P,
node_schema_resolver: Option<&NodeSchemaResolver>,
) -> Result<()> {
let path = path.as_ref();
if !path.exists() {
return Err(Mecha10Error::Configuration(format!(
"Config file not found: {}",
path.display()
)));
}
if path.file_name().and_then(|n| n.to_str()) == Some(PROJECT_CONFIG_FILENAME) {
return validate_project_config_with_node_schema_resolver(path, node_schema_resolver);
}
let value = load_value_from_file(path)?;
if looks_like_node_config(&value) {
return validate_node_config_value(&value, path);
}
validate_project_json(&value)?;
validate_custom_rules(&value)?;
let project_dir = path.parent().unwrap_or_else(|| Path::new("."));
validate_referenced_node_configs(project_dir, &value, node_schema_resolver)
}
fn validate_referenced_node_configs(
project_dir: &Path,
config: &Value,
node_schema_resolver: Option<&NodeSchemaResolver>,
) -> Result<()> {
let Some(nodes) = config.get("nodes") else {
return Ok(());
};
let mut errors = Vec::new();
for category in ["drivers", "standard", "custom"] {
let Some(node_list) = nodes.get(category).and_then(|v| v.as_array()) else {
continue;
};
for node in node_list {
let Some(config_rel_path) = node.get("config").and_then(|c| c.as_str()) else {
continue;
};
let node_label = node
.get("name")
.and_then(|n| n.as_str())
.or_else(|| node.get("package").and_then(|n| n.as_str()))
.unwrap_or("<unnamed>");
let node_config_path = project_dir.join(config_rel_path);
let specific_schema = node_schema_resolver.and_then(|resolve| resolve(node));
if let Err(e) = validate_node_config_file_with_schema(&node_config_path, specific_schema.as_ref()) {
errors.push(format!("node '{}' ({}): {}", node_label, node_config_path.display(), e));
}
}
}
if errors.is_empty() {
Ok(())
} else {
Err(Mecha10Error::Configuration(format!(
"Node configuration validation failed:\n{}",
errors
.iter()
.map(|e| format!(" - {}", e))
.collect::<Vec<_>>()
.join("\n")
)))
}
}
fn validate_node_config_value(value: &Value, path: &Path) -> Result<()> {
validate_node_config_value_with_schema(value, path, None)
}
fn validate_node_config_value_with_schema(value: &Value, path: &Path, schema: Option<&Value>) -> Result<()> {
let Some(obj) = value.as_object() else {
return Err(Mecha10Error::Configuration(format!(
"{}: expected a JSON object at the top level",
path.display()
)));
};
let owned_generic_schema;
let effective_schema = match schema {
Some(s) => s,
None => {
owned_generic_schema = get_node_config_schema();
&owned_generic_schema
}
};
let env_sections = env_sections_present(obj);
if env_sections.is_empty() {
return validate_json_against_schema(
value,
effective_schema,
"Node configuration",
"Node configuration validation failed",
)
.map_err(|e| Mecha10Error::Configuration(format!("{}: {}", path.display(), e)));
}
let mut errors = Vec::new();
for env_key in env_sections {
let section = &obj[env_key];
if let Err(e) = validate_json_against_schema(
section,
effective_schema,
"Node configuration",
"Node configuration validation failed",
) {
errors.push(format!("[{}] {}", env_key, e));
}
}
if errors.is_empty() {
Ok(())
} else {
Err(Mecha10Error::Configuration(format!(
"{}:\n{}",
path.display(),
errors.join("\n")
)))
}
}
fn env_sections_present(obj: &Map<String, Value>) -> Vec<&'static str> {
ENV_SECTION_KEYS
.iter()
.copied()
.filter(|key| obj.contains_key(*key))
.collect()
}
fn looks_like_node_config(value: &Value) -> bool {
let Some(obj) = value.as_object() else {
return false;
};
let has_node_field = |o: &Map<String, Value>| o.contains_key("id") || o.contains_key("executable");
if has_node_field(obj) {
return true;
}
env_sections_present(obj)
.into_iter()
.filter_map(|key| obj[key].as_object())
.any(has_node_field)
}
fn load_value_from_file(path: &Path) -> Result<Value> {
let content = fs::read_to_string(path)
.with_context(|| format!("Failed to read config file: {}", path.display()))
.map_err(|e| Mecha10Error::Configuration(format!("{:#}", e)))?;
match path.extension().and_then(|s| s.to_str()) {
Some("yaml") | Some("yml") => serde_yaml::from_str(&content)
.with_context(|| format!("Failed to parse YAML from: {}", path.display()))
.map_err(|e| Mecha10Error::Configuration(format!("{:#}", e))),
Some("toml") => {
let toml_value: toml::Value = toml::from_str(&content)
.with_context(|| format!("Failed to parse TOML from: {}", path.display()))
.map_err(|e| Mecha10Error::Configuration(format!("{:#}", e)))?;
serde_json::to_value(toml_value).map_err(|e| {
Mecha10Error::Configuration(format!("Failed to convert TOML from '{}': {}", path.display(), e))
})
}
_ => serde_json::from_str(&content)
.with_context(|| format!("Failed to parse JSON from: {}", path.display()))
.map_err(|e| Mecha10Error::Configuration(format!("{:#}", e))),
}
}
fn validate_json_against_schema(
config: &Value,
schema: &Value,
success_label: &str,
failure_prefix: &str,
) -> Result<()> {
let validator = jsonschema::options()
.with_draft(Draft::Draft7)
.build(schema)
.map_err(|e| Mecha10Error::Configuration(format!("Invalid schema: {}", e)))?;
if validator.is_valid(config) {
debug!("{} validation passed", success_label);
Ok(())
} else {
let error_messages: Vec<String> = validator
.iter_errors(config)
.map(|e| format_validation_error(&e))
.collect();
Err(Mecha10Error::Configuration(format!(
"{}:\n{}",
failure_prefix,
error_messages.join("\n")
)))
}
}
fn format_validation_error(error: &ValidationError) -> String {
let path_str = error.instance_path().to_string();
let path = if path_str.is_empty() || path_str == "/" {
"root".to_string()
} else {
path_str
};
format!(" - {}: {} ({})", path, error, error.instance())
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
use std::fs;
use tempfile::tempdir;
fn valid_node_config() -> Value {
json!({
"id": "camera_node",
"executable": "./target/release/camera_node",
})
}
fn project_config_with_node(node_config_rel_path: &str) -> Value {
json!({
"name": "my-robot",
"version": "1.0.0",
"nodes": {
"drivers": [
{
"name": "camera",
"path": "./drivers/camera",
"config": node_config_rel_path
}
]
}
})
}
#[test]
fn validate_project_config_passes_when_no_node_config_referenced() {
let dir = tempdir().unwrap();
let project_path = dir.path().join("mecha10.json");
fs::write(
&project_path,
serde_json::to_string(&json!({"name": "my-robot", "version": "1.0.0"})).unwrap(),
)
.unwrap();
assert!(validate_project_config(&project_path).is_ok());
}
#[test]
fn validate_project_config_accepts_known_robot_and_remote_build_target_triples() {
let dir = tempdir().unwrap();
let project_path = dir.path().join("mecha10.json");
fs::write(
&project_path,
serde_json::to_string(&json!({
"name": "my-robot",
"version": "1.0.0",
"robot_id": "robot-1",
"targets": {
"robot": { "nodes": [], "platforms": ["aarch64-unknown-linux-gnu"] },
"remote": { "nodes": [], "platforms": ["x86_64-unknown-linux-gnu", "aarch64-unknown-linux-gnu"] }
}
}))
.unwrap(),
)
.unwrap();
assert!(validate_project_config(&project_path).is_ok());
}
#[test]
fn validate_project_config_accepts_dev_robot_and_remote_node_placement_targets() {
let dir = tempdir().unwrap();
let project_path = dir.path().join("mecha10.json");
fs::write(
&project_path,
serde_json::to_string(&json!({
"name": "my-robot",
"version": "1.0.0",
"targets": {
"dev": { "nodes": ["@mecha10/simulator"] },
"robot": { "nodes": ["@mecha10/motor"] },
"remote": { "nodes": ["@mecha10/object-detector"] }
}
}))
.unwrap(),
)
.unwrap();
assert!(validate_project_config(&project_path).is_ok());
}
#[test]
fn validate_project_config_rejects_unknown_build_target_triple() {
let dir = tempdir().unwrap();
let project_path = dir.path().join("mecha10.json");
fs::write(
&project_path,
serde_json::to_string(&json!({
"name": "my-robot",
"version": "1.0.0",
"robot_id": "robot-1",
"targets": {
"robot": { "nodes": [], "platforms": ["not-a-real-triple"] }
}
}))
.unwrap(),
)
.unwrap();
assert!(validate_project_config(&project_path).is_err());
}
#[test]
fn validate_project_config_recurses_into_valid_node_config() {
let dir = tempdir().unwrap();
fs::create_dir_all(dir.path().join("config")).unwrap();
fs::write(
dir.path().join("config/camera.json"),
serde_json::to_string(&valid_node_config()).unwrap(),
)
.unwrap();
let project_path = dir.path().join("mecha10.json");
fs::write(
&project_path,
serde_json::to_string(&project_config_with_node("./config/camera.json")).unwrap(),
)
.unwrap();
assert!(validate_project_config(&project_path).is_ok());
}
#[test]
fn validate_project_config_fails_on_invalid_node_config() {
let dir = tempdir().unwrap();
fs::create_dir_all(dir.path().join("config")).unwrap();
fs::write(
dir.path().join("config/camera.json"),
serde_json::to_string(&json!({"id": "camera_node"})).unwrap(),
)
.unwrap();
let project_path = dir.path().join("mecha10.json");
fs::write(
&project_path,
serde_json::to_string(&project_config_with_node("./config/camera.json")).unwrap(),
)
.unwrap();
let err = validate_project_config(&project_path).unwrap_err().to_string();
assert!(err.contains("camera"), "error should name the node: {}", err);
assert!(
err.contains("executable"),
"error should mention the missing field: {}",
err
);
}
#[test]
fn validate_project_config_fails_when_referenced_node_config_missing() {
let dir = tempdir().unwrap();
let project_path = dir.path().join("mecha10.json");
fs::write(
&project_path,
serde_json::to_string(&project_config_with_node("./config/does-not-exist.json")).unwrap(),
)
.unwrap();
let err = validate_project_config(&project_path).unwrap_err().to_string();
assert!(
err.contains("does-not-exist.json"),
"error should name the missing file: {}",
err
);
}
#[test]
fn validate_node_config_file_supports_environment_nested_yaml() {
let dir = tempdir().unwrap();
let node_config_path = dir.path().join("camera.yaml");
fs::write(
&node_config_path,
serde_yaml::to_string(&json!({
"dev": valid_node_config(),
"production": valid_node_config(),
}))
.unwrap(),
)
.unwrap();
assert!(validate_node_config_file(&node_config_path).is_ok());
}
#[test]
fn validate_node_config_file_reports_invalid_environment_section() {
let dir = tempdir().unwrap();
let node_config_path = dir.path().join("camera.json");
fs::write(
&node_config_path,
serde_json::to_string(&json!({
"dev": valid_node_config(),
"production": {"id": "camera_node"},
}))
.unwrap(),
)
.unwrap();
let err = validate_node_config_file(&node_config_path).unwrap_err().to_string();
assert!(
err.contains("production"),
"error should name the failing env section: {}",
err
);
}
#[test]
fn validate_config_file_auto_detects_node_config_by_content() {
let dir = tempdir().unwrap();
let node_config_path = dir.path().join("node.json");
fs::write(&node_config_path, serde_json::to_string(&valid_node_config()).unwrap()).unwrap();
assert!(validate_config_file(&node_config_path).is_ok());
}
#[test]
fn validate_config_file_auto_detects_project_config_by_filename() {
let dir = tempdir().unwrap();
let project_path = dir.path().join("mecha10.json");
fs::write(
&project_path,
serde_json::to_string(&json!({"name": "my-robot", "version": "1.0.0"})).unwrap(),
)
.unwrap();
assert!(validate_config_file(&project_path).is_ok());
}
#[test]
fn validate_config_file_errors_on_missing_file() {
let dir = tempdir().unwrap();
let missing = dir.path().join("nope.json");
let err = validate_config_file(&missing).unwrap_err().to_string();
assert!(err.contains("not found"), "error should say not found: {}", err);
}
fn stub_resolver(node: &Value) -> Option<Value> {
if node.get("name").and_then(|n| n.as_str()) == Some("typed_node") {
Some(json!({
"type": "object",
"required": ["custom_field"],
"properties": {
"custom_field": { "type": "string" }
}
}))
} else {
None
}
}
#[test]
fn validate_project_config_with_node_schema_resolver_dispatches_to_specific_schema() {
let dir = tempdir().unwrap();
fs::create_dir_all(dir.path().join("config")).unwrap();
fs::write(
dir.path().join("config/typed.json"),
serde_json::to_string(&json!({"id": "typed_node", "executable": "./bin"})).unwrap(),
)
.unwrap();
let project_path = dir.path().join("mecha10.json");
fs::write(
&project_path,
serde_json::to_string(&json!({
"name": "my-robot",
"version": "1.0.0",
"nodes": {
"custom": [
{"name": "typed_node", "path": "./nodes/typed", "config": "./config/typed.json"}
]
}
}))
.unwrap(),
)
.unwrap();
let err = validate_project_config_with_node_schema_resolver(&project_path, Some(&stub_resolver)).unwrap_err();
assert!(
err.to_string().contains("custom_field"),
"expected the stub schema's error, got: {}",
err
);
}
#[test]
fn validate_project_config_with_node_schema_resolver_passes_when_specific_schema_satisfied() {
let dir = tempdir().unwrap();
fs::create_dir_all(dir.path().join("config")).unwrap();
fs::write(
dir.path().join("config/typed.json"),
serde_json::to_string(&json!({"custom_field": "hello"})).unwrap(),
)
.unwrap();
let project_path = dir.path().join("mecha10.json");
fs::write(
&project_path,
serde_json::to_string(&json!({
"name": "my-robot",
"version": "1.0.0",
"nodes": {
"custom": [
{"name": "typed_node", "path": "./nodes/typed", "config": "./config/typed.json"}
]
}
}))
.unwrap(),
)
.unwrap();
assert!(validate_project_config_with_node_schema_resolver(&project_path, Some(&stub_resolver)).is_ok());
}
#[test]
fn validate_project_config_with_node_schema_resolver_falls_back_to_generic_for_unmatched_node() {
let dir = tempdir().unwrap();
fs::create_dir_all(dir.path().join("config")).unwrap();
fs::write(
dir.path().join("config/other.json"),
serde_json::to_string(&valid_node_config()).unwrap(),
)
.unwrap();
let project_path = dir.path().join("mecha10.json");
fs::write(
&project_path,
serde_json::to_string(&project_config_with_node("./config/other.json")).unwrap(),
)
.unwrap();
assert!(validate_project_config_with_node_schema_resolver(&project_path, Some(&stub_resolver)).is_ok());
}
#[test]
fn validate_project_config_with_node_schema_resolver_none_matches_validate_project_config() {
let dir = tempdir().unwrap();
let project_path = dir.path().join("mecha10.json");
fs::write(
&project_path,
serde_json::to_string(&json!({"name": "my-robot", "version": "1.0.0"})).unwrap(),
)
.unwrap();
assert!(validate_project_config_with_node_schema_resolver(&project_path, None).is_ok());
}
#[test]
fn validate_config_file_with_node_schema_resolver_dispatches_for_project_config_path() {
let dir = tempdir().unwrap();
fs::create_dir_all(dir.path().join("config")).unwrap();
fs::write(
dir.path().join("config/typed.json"),
serde_json::to_string(&json!({"id": "typed_node", "executable": "./bin"})).unwrap(),
)
.unwrap();
let project_path = dir.path().join("mecha10.json");
fs::write(
&project_path,
serde_json::to_string(&json!({
"name": "my-robot",
"version": "1.0.0",
"nodes": {
"custom": [
{"name": "typed_node", "path": "./nodes/typed", "config": "./config/typed.json"}
]
}
}))
.unwrap(),
)
.unwrap();
let err = validate_config_file_with_node_schema_resolver(&project_path, Some(&stub_resolver)).unwrap_err();
assert!(err.to_string().contains("custom_field"));
}
}