use anyhow::Result;
use std::path::{Path, PathBuf};
use crate::services::ConfigService;
pub fn validate_config(path: Option<&Path>) -> Result<PathBuf> {
if let Some(p) = path {
if is_model_catalog_file(p) {
mecha10_schema_registry::validate_model_catalog_file(p)
.map_err(|e| anyhow::anyhow!("Configuration validation failed for {}: {}", p.display(), e))?;
return Ok(p.to_path_buf());
}
if is_behavior_config_file(p) {
let content = std::fs::read_to_string(p)
.map_err(|e| anyhow::anyhow!("Failed to read behavior config file {}: {}", p.display(), e))?;
let value: serde_json::Value = serde_json::from_str(&content)
.map_err(|e| anyhow::anyhow!("Failed to parse JSON from {}: {}", p.display(), e))?;
mecha10_behavior_runtime::config::validate_behavior_composition_json(&value)
.map_err(|e| anyhow::anyhow!("Configuration validation failed for {}: {}", p.display(), e))?;
return Ok(p.to_path_buf());
}
}
let target = ConfigService::resolve_and_validate_with_node_schema_resolver(
path,
Some(&mecha10_schema_registry::node_schema_resolver),
)?;
if path.is_none() {
let project_root = target.parent().unwrap_or_else(|| Path::new("."));
validate_simulation_config(project_root)?;
validate_rl_job_configs(project_root)?;
}
Ok(target)
}
fn validate_simulation_config(project_root: &Path) -> Result<()> {
let sim_path = project_root.join(mecha10_cli_core::paths::config::SIMULATION_CONFIG);
if !sim_path.exists() {
return Ok(());
}
let content = std::fs::read_to_string(&sim_path)
.map_err(|e| anyhow::anyhow!("Failed to read simulation config {}: {}", sim_path.display(), e))?;
let value: serde_json::Value = serde_json::from_str(&content)
.map_err(|e| anyhow::anyhow!("Failed to parse JSON from {}: {}", sim_path.display(), e))?;
if let Some(obj) = value
.as_object()
.filter(|o| o.contains_key("dev") || o.contains_key("production"))
{
for env in ["dev", "production"] {
if let Some(section) = obj.get(env) {
serde_json::from_value::<mecha10_cli_core::types::SimulationConfig>(section.clone()).map_err(|e| {
anyhow::anyhow!(
"Configuration validation failed for {} ({env} section): {}",
sim_path.display(),
e
)
})?;
}
}
return Ok(());
}
serde_json::from_value::<mecha10_cli_core::types::SimulationConfig>(value)
.map_err(|e| anyhow::anyhow!("Configuration validation failed for {}: {}", sim_path.display(), e))?;
Ok(())
}
fn validate_rl_job_configs(project_root: &Path) -> Result<()> {
crate::services::load_job_catalog(project_root)
.map_err(|e| anyhow::anyhow!("Configuration validation failed for RL job configs: {:#}", e))?;
Ok(())
}
fn is_model_catalog_file(path: &Path) -> bool {
path.file_name().and_then(|n| n.to_str()) == Some("model_catalog.toml")
}
fn is_behavior_config_file(path: &Path) -> bool {
if path.file_name().and_then(|n| n.to_str()) != Some("config.json") {
return false;
}
path.components().any(|c| c.as_os_str().to_str() == Some("behaviors"))
}
#[cfg(test)]
mod tests {
use super::*;
use std::fs;
use tempfile::tempdir;
#[test]
fn validate_config_validates_a_valid_model_catalog_file() {
let dir = tempdir().unwrap();
let path = dir.path().join("model_catalog.toml");
fs::write(
&path,
r#"
[[models]]
name = "yolov8n"
description = "YOLOv8 Nano"
task = "object-detection"
repo = "some/repo"
filename = "model.onnx"
"#,
)
.unwrap();
let result = validate_config(Some(&path));
assert!(result.is_ok(), "{:?}", result.err());
assert_eq!(result.unwrap(), path);
}
#[test]
fn validate_config_rejects_an_invalid_model_catalog_file() {
let dir = tempdir().unwrap();
let path = dir.path().join("model_catalog.toml");
fs::write(&path, "[[models]]\nname = \"bad\"\ndescription = \"d\"\n").unwrap();
assert!(validate_config(Some(&path)).is_err());
}
#[test]
fn validate_config_rejects_duplicate_model_names() {
let dir = tempdir().unwrap();
let path = dir.path().join("model_catalog.toml");
fs::write(
&path,
r#"
[[models]]
name = "dup"
description = "d1"
task = "object-detection"
repo = "r"
filename = "f.onnx"
[[models]]
name = "dup"
description = "d2"
task = "object-detection"
repo = "r"
filename = "f2.onnx"
"#,
)
.unwrap();
let err = validate_config(Some(&path)).unwrap_err();
assert!(err.to_string().to_lowercase().contains("duplicate"));
}
#[test]
fn validate_config_dispatches_a_built_in_node_type_to_its_specific_schema() {
let dir = tempdir().unwrap();
fs::create_dir_all(dir.path().join("config")).unwrap();
fs::write(dir.path().join("config/behavior.json"), "{}").unwrap();
let project_path = dir.path().join("mecha10.json");
fs::write(
&project_path,
serde_json::json!({
"name": "proj",
"version": "1.0.0",
"nodes": {
"custom": [
{"name": "behavior_executor", "path": "./nodes/behavior", "config": "./config/behavior.json"}
]
}
})
.to_string(),
)
.unwrap();
let err = validate_config(Some(&project_path)).unwrap_err();
assert!(
err.to_string().to_lowercase().contains("behavior_name"),
"expected the specific BehaviorExecutorConfig schema error, got: {}",
err
);
}
#[test]
fn validate_config_rejects_legacy_top_level_logging_block() {
let dir = tempdir().unwrap();
let project_path = dir.path().join("mecha10.json");
fs::write(
&project_path,
serde_json::json!({
"name": "proj",
"version": "1.0.0",
"logging": { "disk_logging_enabled": false }
})
.to_string(),
)
.unwrap();
let err = validate_config(Some(&project_path)).unwrap_err();
assert!(
err.to_string().contains("environments.<env>.logging"),
"expected the error to point at the new per-environment shape, got: {}",
err
);
}
#[test]
fn validate_config_falls_back_to_generic_schema_for_unrecognized_node_type() {
let dir = tempdir().unwrap();
fs::create_dir_all(dir.path().join("config")).unwrap();
fs::write(
dir.path().join("config/custom.json"),
serde_json::json!({"id": "custom_node", "executable": "./bin/custom"}).to_string(),
)
.unwrap();
let project_path = dir.path().join("mecha10.json");
fs::write(
&project_path,
serde_json::json!({
"name": "proj",
"version": "1.0.0",
"nodes": {
"custom": [
{"name": "my_totally_custom_node", "path": "./nodes/custom", "config": "./config/custom.json"}
]
}
})
.to_string(),
)
.unwrap();
assert!(validate_config(Some(&project_path)).is_ok());
}
#[test]
fn validate_simulation_config_ok_when_no_simulation_config_present() {
let dir = tempdir().unwrap();
assert!(validate_simulation_config(dir.path()).is_ok());
}
#[test]
fn validate_simulation_config_accepts_a_flat_valid_config() {
let dir = tempdir().unwrap();
fs::create_dir_all(dir.path().join("configs/simulation")).unwrap();
fs::write(
dir.path().join("configs/simulation/config.json"),
serde_json::json!({"model": "@mecha10/simulation-models/rover", "environment": "@mecha10/simulation-environments/basic_arena"})
.to_string(),
)
.unwrap();
assert!(validate_simulation_config(dir.path()).is_ok());
}
#[test]
fn validate_simulation_config_accepts_environment_aware_shape() {
let dir = tempdir().unwrap();
fs::create_dir_all(dir.path().join("configs/simulation")).unwrap();
fs::write(
dir.path().join("configs/simulation/config.json"),
serde_json::json!({
"dev": {"model": "@mecha10/simulation-models/rover", "environment": "@mecha10/simulation-environments/basic_arena"},
"production": {"model": "@mecha10/simulation-models/rover", "environment": "@mecha10/simulation-environments/basic_arena"}
})
.to_string(),
)
.unwrap();
assert!(validate_simulation_config(dir.path()).is_ok());
}
#[test]
fn validate_simulation_config_rejects_a_config_missing_required_fields() {
let dir = tempdir().unwrap();
fs::create_dir_all(dir.path().join("configs/simulation")).unwrap();
fs::write(
dir.path().join("configs/simulation/config.json"),
serde_json::json!({}).to_string(),
)
.unwrap();
assert!(validate_simulation_config(dir.path()).is_err());
}
#[test]
fn validate_rl_job_configs_ok_when_no_jobs_directory_present() {
let dir = tempdir().unwrap();
assert!(validate_rl_job_configs(dir.path()).is_ok());
}
#[test]
fn validate_rl_job_configs_rejects_a_malformed_job_definition() {
let dir = tempdir().unwrap();
fs::create_dir_all(dir.path().join("configs/rl/jobs")).unwrap();
fs::write(
dir.path().join("configs/rl/jobs/broken.yml"),
"not: [valid, job, definition",
)
.unwrap();
assert!(validate_rl_job_configs(dir.path()).is_err());
}
}