use crate::error::{Mecha10Error, Result};
use serde_json::Value;
use std::collections::HashMap;
pub(crate) fn validate_custom_rules(config: &Value) -> Result<()> {
if let Some(nodes) = config.get("nodes") {
validate_no_circular_dependencies(nodes)?;
}
if let Some(nodes) = config.get("nodes") {
if let Some(targets) = config.get("run_targets") {
validate_run_target_references(nodes, targets)?;
}
}
if let Some(nodes) = config.get("nodes") {
validate_unique_node_names(nodes)?;
}
if let Some(targets) = config.get("run_targets") {
validate_resource_specs(targets)?;
}
if let Some(targets) = config.get("targets") {
validate_unique_node_instances_per_target(targets)?;
}
validate_no_top_level_logging(config)?;
Ok(())
}
fn validate_unique_node_instances_per_target(targets: &Value) -> Result<()> {
let Some(targets_obj) = targets.as_object() else {
return Ok(());
};
for (target_name, entry) in targets_obj {
let Some(nodes) = entry.get("nodes").and_then(Value::as_array) else {
continue;
};
let mut seen = std::collections::HashSet::new();
for node in nodes {
let (name, instance_id) = match node {
Value::String(name) => (name.as_str(), None),
Value::Object(obj) => match obj.get("name").and_then(Value::as_str) {
Some(name) => (name, obj.get("instance_id").and_then(Value::as_str)),
None => continue,
},
_ => continue,
};
if !seen.insert((name, instance_id)) {
return Err(Mecha10Error::Configuration(format!(
"Target '{}' lists node '{}' more than once{} - give each instance a distinct `instance_id`",
target_name,
name,
match instance_id {
Some(id) => format!(" with instance_id '{id}'"),
None => String::new(),
}
)));
}
}
}
Ok(())
}
fn validate_no_top_level_logging(config: &Value) -> Result<()> {
if config.get("logging").is_some() {
return Err(Mecha10Error::Configuration(
"Top-level `logging` is no longer supported in mecha10.json. Disk logging is now \
configured per-environment: move `disk_logging_enabled` under \
`environments.<env>.logging`, e.g. `environments.prod.logging.disk_logging_enabled`, \
for each environment that needs an override."
.to_string(),
));
}
Ok(())
}
fn validate_no_circular_dependencies(nodes: &Value) -> Result<()> {
let mut node_deps: HashMap<String, Vec<String>> = HashMap::new();
for category in ["drivers", "standard", "custom"] {
if let Some(node_list) = nodes.get(category).and_then(|v| v.as_array()) {
for node in node_list {
if let Some(name) = node.get("name").and_then(|n| n.as_str()) {
let deps = node
.get("depends_on")
.and_then(|d| d.as_array())
.map(|arr| arr.iter().filter_map(|v| v.as_str().map(String::from)).collect())
.unwrap_or_default();
node_deps.insert(name.to_string(), deps);
}
}
}
}
for node in node_deps.keys() {
let mut visited = std::collections::HashSet::new();
let mut stack = std::collections::HashSet::new();
if has_cycle(node, &node_deps, &mut visited, &mut stack) {
return Err(Mecha10Error::Configuration(format!(
"Circular dependency detected involving node '{}'",
node
)));
}
}
Ok(())
}
fn has_cycle(
node: &str,
deps: &HashMap<String, Vec<String>>,
visited: &mut std::collections::HashSet<String>,
stack: &mut std::collections::HashSet<String>,
) -> bool {
if stack.contains(node) {
return true;
}
if visited.contains(node) {
return false;
}
visited.insert(node.to_string());
stack.insert(node.to_string());
if let Some(dependencies) = deps.get(node) {
for dep in dependencies {
if has_cycle(dep, deps, visited, stack) {
return true;
}
}
}
stack.remove(node);
false
}
fn validate_run_target_references(nodes: &Value, targets: &Value) -> Result<()> {
let target_names: Vec<String> = targets
.as_object()
.map(|obj| obj.keys().cloned().collect())
.unwrap_or_default();
for category in ["drivers", "custom"] {
if let Some(node_list) = nodes.get(category).and_then(|v| v.as_array()) {
for node in node_list {
if let Some(run_target) = node.get("run_target").and_then(|t| t.as_str()) {
if !target_names.contains(&run_target.to_string()) {
let node_name = node.get("name").and_then(|n| n.as_str()).unwrap_or("<unnamed>");
return Err(Mecha10Error::Configuration(format!(
"Node '{}' references undefined run_target '{}'. Available targets: {}",
node_name,
run_target,
target_names.join(", ")
)));
}
}
}
}
}
Ok(())
}
fn validate_unique_node_names(nodes: &Value) -> Result<()> {
let mut seen_names = std::collections::HashSet::new();
for category in ["drivers", "standard", "custom"] {
if let Some(node_list) = nodes.get(category).and_then(|v| v.as_array()) {
for node in node_list {
if let Some(name) = node.get("name").and_then(|n| n.as_str()) {
if !seen_names.insert(name.to_string()) {
return Err(Mecha10Error::Configuration(format!(
"Duplicate node name '{}' found. Node names must be unique across all categories.",
name
)));
}
}
}
}
}
Ok(())
}
fn validate_resource_specs(targets: &Value) -> Result<()> {
if let Some(targets_obj) = targets.as_object() {
for (target_name, target_config) in targets_obj {
if let Some(resources) = target_config.get("resources") {
if let Some(mem_limit) = resources.get("memory_limit").and_then(|m| m.as_str()) {
if !is_valid_memory_spec(mem_limit) {
return Err(Mecha10Error::Configuration(format!(
"Invalid memory_limit '{}' in run_target '{}'. Expected format: <number><K|M|G|T>",
mem_limit, target_name
)));
}
}
if let Some(cpu_limit) = resources.get("cpu_limit").and_then(|c| c.as_str()) {
if cpu_limit.parse::<f64>().is_err() {
return Err(Mecha10Error::Configuration(format!(
"Invalid cpu_limit '{}' in run_target '{}'. Expected a number (e.g., '2.0')",
cpu_limit, target_name
)));
}
}
}
}
}
Ok(())
}
fn is_valid_memory_spec(spec: &str) -> bool {
let re = regex::Regex::new(r"^\d+(K|M|G|T)?$").unwrap();
re.is_match(spec)
}
#[cfg(test)]
mod logging_migration_tests {
use super::*;
#[test]
fn rejects_top_level_logging_block() {
let config = serde_json::json!({
"name": "proj",
"version": "1.0.0",
"logging": { "disk_logging_enabled": false }
});
let err = validate_custom_rules(&config).expect_err("top-level `logging` should be rejected");
let message = err.to_string();
assert!(
message.contains("environments.<env>.logging"),
"expected the error to point at the new per-environment shape, got: {}",
message
);
}
#[test]
fn rejects_empty_top_level_logging_block() {
let config = serde_json::json!({
"name": "proj",
"version": "1.0.0",
"logging": {}
});
assert!(validate_custom_rules(&config).is_err());
}
#[test]
fn allows_config_with_no_top_level_logging_block() {
let config = serde_json::json!({
"name": "proj",
"version": "1.0.0",
"environments": {
"prod": {
"control_plane": "https://example.com",
"logging": { "disk_logging_enabled": false }
}
}
});
assert!(validate_custom_rules(&config).is_ok());
}
}