use super::rules::validate_custom_rules;
use super::schemas::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 fn validate_project_json(config: &Value) -> Result<()> {
validate_json_against_schema(
config,
&get_project_schema(),
"Project configuration",
"Configuration validation failed",
)?;
validate_custom_rules(config)
}
pub fn validate_project_config<P: AsRef<Path>>(path: P) -> Result<()> {
let path = path.as_ref();
info!("Validating project configuration: {}", path.display());
let config: Value = load_value_from_file(path)?;
validate_project_json(&config)?;
let project_dir = path.parent().unwrap_or_else(|| Path::new("."));
validate_referenced_node_configs(project_dir, &config)?;
info!("Configuration validation passed");
Ok(())
}
pub fn validate_node_config_json(config: &Value, schema: &Value) -> Result<()> {
validate_json_against_schema(
config,
schema,
"Node configuration",
"Node configuration validation failed",
)
}
pub fn validate_node_config_file<P: AsRef<Path>>(path: P, schema: &Value) -> Result<()> {
validate_node_config_file_with_schema(path, schema)
}
fn validate_node_config_file_with_schema<P: AsRef<Path>>(path: P, schema: &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<()> {
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(path);
}
let value = load_value_from_file(path)?;
if looks_like_node_config(&value) {
return Err(Mecha10Error::Configuration(format!(
"{}: cannot validate a node configuration file on its own -- its declared schema can \
only be resolved via the owning project's node entry (name/path in mecha10.json). \
Validate the project instead (e.g. `mecha10 config validate`, or `--path` pointing at \
mecha10.json), which resolves this node's declared schema via its built-in type or a \
`<name>.schema.json` file shipped alongside it.",
path.display()
)));
}
validate_project_json(&value)?;
let project_dir = path.parent().unwrap_or_else(|| Path::new("."));
validate_referenced_node_configs(project_dir, &value)
}
fn validate_referenced_node_configs(project_dir: &Path, config: &Value) -> 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 declared_schema = match resolve_declared_node_schema(project_dir, node) {
Ok(Some(schema)) => schema,
Ok(None) => {
errors.push(format!(
"node '{}' ({}): no declared schema found -- ship a `{}.schema.json` file \
alongside this node's source (next to its `path`)",
node_label,
node_config_path.display(),
node_label,
));
continue;
}
Err(e) => {
errors.push(format!(
"node '{}': failed to load its declared schema: {}",
node_label, e
));
continue;
}
};
if let Err(e) = validate_node_config_file_with_schema(&node_config_path, &declared_schema) {
errors.push(format!("node '{}' ({}): {}", node_label, node_config_path.display(), e));
}
}
}
if let Some(nodes_map) = nodes.as_object() {
for identifier in nodes_map.keys() {
if matches!(identifier.as_str(), "drivers" | "standard" | "custom") {
continue;
}
let name = identifier.rsplit('/').next().unwrap_or(identifier);
let config_dir = match identifier.strip_prefix("@mecha10/") {
Some(rest) => format!("configs/nodes/@mecha10/{}", rest),
None => format!("configs/nodes/{}", identifier),
};
let config_rel_path = format!("{}/config.json", config_dir);
let node_config_path = project_dir.join(&config_rel_path);
let schema_lookup_path = format!("nodes/{}", identifier);
let synthetic_node = serde_json::json!({"name": name, "path": schema_lookup_path});
let declared_schema = match resolve_declared_node_schema(project_dir, &synthetic_node) {
Ok(Some(schema)) => schema,
Ok(None) => {
errors.push(format!(
"node '{}' ({}): no declared schema found -- ship a `{}.schema.json` file \
alongside this node's source (next to its `path`)",
name,
node_config_path.display(),
name,
));
continue;
}
Err(e) => {
errors.push(format!("node '{}': failed to load its declared schema: {}", name, e));
continue;
}
};
if let Err(e) = validate_node_config_file_with_schema(&node_config_path, &declared_schema) {
errors.push(format!("node '{}' ({}): {}", name, 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 resolve_declared_node_schema(project_dir: &Path, node: &Value) -> Result<Option<Value>> {
resolve_custom_node_schema_file(project_dir, node)
}
fn resolve_custom_node_schema_file(project_dir: &Path, node: &Value) -> Result<Option<Value>> {
let (Some(path), Some(name)) = (
node.get("path").and_then(Value::as_str),
node.get("name").and_then(Value::as_str),
) else {
return Ok(None);
};
let schema_path = project_dir.join(path).join(format!("{name}.schema.json"));
if !schema_path.exists() {
return Ok(None);
}
let content = fs::read_to_string(&schema_path)
.with_context(|| format!("Failed to read {}", schema_path.display()))
.map_err(|e| Mecha10Error::Configuration(format!("{:#}", e)))?;
let schema: Value = serde_json::from_str(&content)
.with_context(|| format!("Failed to parse JSON from {}", schema_path.display()))
.map_err(|e| Mecha10Error::Configuration(format!("{:#}", e)))?;
Ok(Some(schema))
}
fn validate_node_config_value_with_schema(value: &Value, path: &Path, schema: &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 env_sections = env_sections_present(obj);
if env_sections.is_empty() {
return validate_json_against_schema(
value,
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,
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 id_executable_schema() -> Value {
json!({
"type": "object",
"required": ["id", "executable"],
"properties": {
"id": { "type": "string" },
"executable": { "type": "string" }
}
})
}
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
}
]
}
})
}
fn write_custom_schema_file(project_dir: &std::path::Path, node_path: &str, node_name: &str, schema: &Value) {
let node_dir = project_dir.join(node_path);
fs::create_dir_all(&node_dir).unwrap();
fs::write(
node_dir.join(format!("{node_name}.schema.json")),
serde_json::to_string(schema).unwrap(),
)
.unwrap();
}
#[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();
write_custom_schema_file(dir.path(), "./drivers/camera", "camera", &id_executable_schema());
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();
write_custom_schema_file(dir.path(), "./drivers/camera", "camera", &id_executable_schema());
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();
write_custom_schema_file(dir.path(), "./drivers/camera", "camera", &id_executable_schema());
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_project_config_fails_when_node_has_no_declared_schema() {
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();
let err = validate_project_config(&project_path).unwrap_err().to_string();
assert!(err.contains("camera"), "error should name the node: {}", err);
assert!(
err.contains("no declared schema"),
"error should explain the node has no declared schema: {}",
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, &id_executable_schema()).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, &id_executable_schema())
.unwrap_err()
.to_string();
assert!(
err.contains("production"),
"error should name the failing env section: {}",
err
);
}
#[test]
fn validate_config_file_errors_on_a_bare_node_config_file_with_no_project_context() {
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();
let err = validate_config_file(&node_config_path).unwrap_err().to_string();
assert!(
err.contains("cannot validate a node configuration file on its own"),
"{}",
err
);
}
#[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);
}
#[test]
fn resolve_declared_node_schema_resolves_builtin_and_custom_nodes_through_the_identical_call() {
let dir = tempdir().unwrap();
write_custom_schema_file(
dir.path(),
"./nodes/builtin_like",
"builtin_like",
&id_executable_schema(),
);
write_custom_schema_file(
dir.path(),
"./nodes/custom_sensor",
"custom_sensor",
&id_executable_schema(),
);
let builtin_node = json!({"name": "builtin_like", "path": "./nodes/builtin_like"});
let custom_node = json!({"name": "custom_sensor", "path": "./nodes/custom_sensor"});
let builtin_schema = resolve_declared_node_schema(dir.path(), &builtin_node).unwrap();
let custom_schema = resolve_declared_node_schema(dir.path(), &custom_node).unwrap();
assert_eq!(builtin_schema, Some(id_executable_schema()));
assert_eq!(custom_schema, Some(id_executable_schema()));
}
#[test]
fn validate_project_config_validates_builtin_and_custom_nodes_through_the_same_path() {
let dir = tempdir().unwrap();
fs::create_dir_all(dir.path().join("config")).unwrap();
fs::write(
dir.path().join("config/builtin_like.json"),
serde_json::to_string(&valid_node_config()).unwrap(),
)
.unwrap();
fs::write(
dir.path().join("config/custom_sensor.json"),
serde_json::to_string(&valid_node_config()).unwrap(),
)
.unwrap();
write_custom_schema_file(
dir.path(),
"./nodes/builtin_like",
"builtin_like",
&id_executable_schema(),
);
write_custom_schema_file(
dir.path(),
"./nodes/custom_sensor",
"custom_sensor",
&id_executable_schema(),
);
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": {
"drivers": [
{"name": "builtin_like", "path": "./nodes/builtin_like", "config": "./config/builtin_like.json"}
],
"custom": [
{"name": "custom_sensor", "path": "./nodes/custom_sensor", "config": "./config/custom_sensor.json"}
]
}
}))
.unwrap(),
)
.unwrap();
assert!(validate_project_config(&project_path).is_ok());
}
#[test]
fn resolve_custom_node_schema_file_reads_schema_next_to_node_path() {
let dir = tempdir().unwrap();
write_custom_schema_file(dir.path(), "./nodes/my_sensor", "my_sensor", &id_executable_schema());
let node = json!({"name": "my_sensor", "path": "./nodes/my_sensor"});
let schema = resolve_custom_node_schema_file(dir.path(), &node).unwrap();
assert_eq!(schema, Some(id_executable_schema()));
}
#[test]
fn resolve_custom_node_schema_file_returns_none_when_absent() {
let dir = tempdir().unwrap();
let node = json!({"name": "my_sensor", "path": "./nodes/my_sensor"});
assert_eq!(resolve_custom_node_schema_file(dir.path(), &node).unwrap(), None);
}
#[test]
fn resolve_custom_node_schema_file_returns_none_when_node_has_no_path_or_name() {
let dir = tempdir().unwrap();
let node = json!({"package": "@mecha10/motor", "binary": "motor"});
assert_eq!(resolve_custom_node_schema_file(dir.path(), &node).unwrap(), None);
}
#[test]
fn resolve_custom_node_schema_file_errors_on_malformed_json() {
let dir = tempdir().unwrap();
let node_dir = dir.path().join("nodes/my_sensor");
fs::create_dir_all(&node_dir).unwrap();
fs::write(node_dir.join("my_sensor.schema.json"), "{ not valid json").unwrap();
let node = json!({"name": "my_sensor", "path": "./nodes/my_sensor"});
assert!(resolve_custom_node_schema_file(dir.path(), &node).is_err());
}
fn write_project_config(dir: &std::path::Path, config: &Value) -> std::path::PathBuf {
let project_path = dir.join("mecha10.json");
fs::write(&project_path, serde_json::to_string(config).unwrap()).unwrap();
project_path
}
#[test]
fn validate_project_config_validates_flat_map_framework_node() {
let dir = tempdir().unwrap();
write_custom_schema_file(dir.path(), "nodes/@mecha10/camera", "camera", &id_executable_schema());
fs::create_dir_all(dir.path().join("configs/nodes/@mecha10/camera")).unwrap();
fs::write(
dir.path().join("configs/nodes/@mecha10/camera/config.json"),
serde_json::to_string(&valid_node_config()).unwrap(),
)
.unwrap();
let project_path = write_project_config(
dir.path(),
&json!({
"name": "my-robot",
"version": "1.0.0",
"nodes": { "@mecha10/camera": "0.1.0" }
}),
);
assert!(validate_project_config(&project_path).is_ok());
}
#[test]
fn validate_project_config_validates_flat_map_project_node_schema_next_to_source() {
let dir = tempdir().unwrap();
write_custom_schema_file(dir.path(), "nodes/my-custom", "my-custom", &id_executable_schema());
fs::create_dir_all(dir.path().join("configs/nodes/my-custom")).unwrap();
fs::write(
dir.path().join("configs/nodes/my-custom/config.json"),
serde_json::to_string(&valid_node_config()).unwrap(),
)
.unwrap();
let project_path = write_project_config(
dir.path(),
&json!({
"name": "my-robot",
"version": "1.0.0",
"nodes": { "my-custom": "0.1.0" }
}),
);
assert!(validate_project_config(&project_path).is_ok());
fs::remove_file(dir.path().join("nodes/my-custom/my-custom.schema.json")).unwrap();
let err = validate_project_config(&project_path).unwrap_err().to_string();
assert!(
err.contains("no declared schema"),
"removing nodes/<name>/<name>.schema.json should break validation: {}",
err
);
}
#[test]
fn validate_project_config_fails_when_flat_map_node_has_no_declared_schema() {
let dir = tempdir().unwrap();
fs::create_dir_all(dir.path().join("configs/nodes/@mecha10/camera")).unwrap();
fs::write(
dir.path().join("configs/nodes/@mecha10/camera/config.json"),
serde_json::to_string(&valid_node_config()).unwrap(),
)
.unwrap();
let project_path = write_project_config(
dir.path(),
&json!({
"name": "my-robot",
"version": "1.0.0",
"nodes": { "@mecha10/camera": "0.1.0" }
}),
);
let err = validate_project_config(&project_path).unwrap_err().to_string();
assert!(err.contains("camera"), "error should name the node: {}", err);
assert!(
err.contains("no declared schema"),
"error should explain the node has no declared schema: {}",
err
);
}
#[test]
fn validate_project_config_fails_when_flat_map_node_config_missing() {
let dir = tempdir().unwrap();
write_custom_schema_file(dir.path(), "nodes/@mecha10/camera", "camera", &id_executable_schema());
let project_path = write_project_config(
dir.path(),
&json!({
"name": "my-robot",
"version": "1.0.0",
"nodes": { "@mecha10/camera": "0.1.0" }
}),
);
let err = validate_project_config(&project_path).unwrap_err().to_string();
assert!(
err.contains("not found"),
"error should say the config file was not found: {}",
err
);
}
#[test]
fn validate_project_config_validates_mixed_legacy_array_and_flat_map_shapes() {
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();
write_custom_schema_file(dir.path(), "./drivers/camera", "camera", &id_executable_schema());
write_custom_schema_file(dir.path(), "nodes/my-custom", "my-custom", &id_executable_schema());
fs::create_dir_all(dir.path().join("configs/nodes/my-custom")).unwrap();
fs::write(
dir.path().join("configs/nodes/my-custom/config.json"),
serde_json::to_string(&valid_node_config()).unwrap(),
)
.unwrap();
let project_path = write_project_config(
dir.path(),
&json!({
"name": "my-robot",
"version": "1.0.0",
"nodes": {
"drivers": [
{"name": "camera", "path": "./drivers/camera", "config": "./config/camera.json"}
],
"my-custom": "0.1.0"
}
}),
);
assert!(validate_project_config(&project_path).is_ok());
}
}