mecha10-core 0.8.0

Mecha10 core runtime and foundational types
Documentation
//! Custom validation rules beyond what JSON Schema alone can express.

use crate::error::{Mecha10Error, Result};
use serde_json::Value;
use std::collections::HashMap;

/// Apply custom validation rules beyond JSON Schema
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(())
}

/// Reject a `targets.<mode>.nodes` list with two entries naming the same `(name, instance_id)`
/// pair - e.g. two bare `"@mecha10/camera"` entries, or two
/// `{"name": "@mecha10/camera", "instance_id": "real"}` entries naming the same instance
/// (LAB-4131). Two entries that share a `name` but name *different* `instance_id`s - the whole
/// point of multi-instance nodes - are fine.
///
/// Mirrors `mecha10_cli_core::types::project::TargetsConfig::validate_no_duplicate_node_instances`,
/// which this crate can't call directly (`mecha10-core` is a dependency *of*
/// `mecha10-cli-core`, not the reverse) - both must stay in sync.
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(())
}

/// Reject a top-level `logging` key on the project config (LAB-2041 migration guard).
///
/// `logging.disk_logging_enabled` used to apply identically to every environment; it's
/// now `environments.<env>.logging.disk_logging_enabled`, set independently per
/// environment. A `mecha10.json` still using the old shape would otherwise pass schema
/// validation (the top-level key is simply unrecognized) and silently lose its setting,
/// so this fails loudly instead with a message pointing at the new location.
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(())
}

/// Check for circular dependencies in node definitions
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
}

/// Validate that all run_target references exist
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(())
}

/// Validate that node names are unique
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(())
}

/// Validate resource specifications
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());
    }
}