mecha10-cli 0.6.5

Mecha10 CLI tool
Documentation
//! Wires `mecha10-schema-registry`'s per-node-type and model-catalog schemas into the
//! config-validation entry point `mecha10 config validate`/`mecha10 lint`/`mecha10 build`
//! share (LAB-1772).
//!
//! This lives in `packages/cli` rather than `mecha10-cli-core` because `mecha10-cli-core`
//! can't depend on `mecha10-schema-registry` (which depends on every node crate and
//! `mecha10-models`, both of which already depend on `mecha10-cli-core` -- see
//! `docs/validate-configs.md`'s "why this crate exists" note in
//! `mecha10-schema-registry/src/lib.rs`). `mecha10_core::schema_validation`'s generic
//! functions and `ConfigService::resolve_and_validate` are untouched by this module --
//! it's a thin dispatcher on top, not a reimplementation.

use anyhow::Result;
use std::path::{Path, PathBuf};

use crate::services::ConfigService;

/// Validate `path` (or, when `None`, the project's `mecha10.json` plus every node
/// config it references) the same way `mecha10 config validate` does, but with
/// LAB-1772's two accuracy improvements layered on top of the existing generic
/// validation:
///
/// * A file named `model_catalog.toml` is recognized and validated against the
///   `schemars`-derived model-catalog schema plus the entry-name-uniqueness rule,
///   instead of being routed through the generic project/node config detection in
///   `mecha10_core::schema_validation::validate_config_file` (which has no concept
///   of this config kind at all -- see the crate-level docs above for why this
///   dispatch can't live inside `mecha10_core` itself).
/// * Every other path is validated via
///   [`ConfigService::resolve_and_validate_with_node_schema_resolver`], passing
///   `mecha10_schema_registry::node_schema_resolver` so each node entry
///   referenced from a project config is checked against its specific built-in
///   type's schema when recognized, falling back to the generic node config
///   envelope for unrecognized/custom/third-party nodes.
/// * When `path` is `None` (whole-project validation), also validates
///   `configs/simulation/config.json` and every `configs/rl/jobs/*.yml` job
///   definition, if present -- neither is reachable through a node's `config`
///   reference, so without this they'd never be checked.
///
/// # Errors
///
/// Returns an error with the underlying schema-validation failure details if the
/// target file doesn't exist or fails validation.
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),
    )?;

    // Whole-project validation (no explicit `--path`) also covers the simulation
    // config and RL job definitions -- these aren't referenced from mecha10.json's
    // `nodes` list, so `resolve_and_validate_with_node_schema_resolver` above never
    // sees them, and they were previously validated by nothing at all.
    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)
}

/// Validate `configs/simulation/config.json`, if the project has one. Accepts
/// either the flat shape or the `{"dev": {...}, "production": {...}}`
/// environment-aware shape (see `mecha10_cli_core::types::SimulationConfig`).
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(())
}

/// Validate every `configs/rl/jobs/*.yml` job definition, if the project has any.
/// `load_job_catalog` already parses each file into `JobDefinition`, so a malformed
/// or schema-invalid job surfaces as a deserialization error here.
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(())
}

/// Whether `path`'s filename is `model_catalog.toml` (LAB-1772's second config kind --
/// see [`validate_config`]'s doc comment for why this is checked ahead of the
/// generic project/node dispatch rather than inside it).
fn is_model_catalog_file(path: &Path) -> bool {
    path.file_name().and_then(|n| n.to_str()) == Some("model_catalog.toml")
}

/// Whether `path` is a behavior composition file per the `configs/behaviors/**/config.json`
/// convention (LAB-2054's config kind -- see [`validate_config`]'s doc comment; the `behaviors/`
/// directory moved under `configs/` in LAB-2072 so `mecha10 config push`/`pull`/`list` actually
/// sync it, but this check only ever looked for a `behaviors` path component, not a top-level
/// one, so it still matches unchanged). Matches the same convention the git pre-commit hook and
/// `scripts/ci/config-lint.sh` use: a file named `config.json` somewhere under a `behaviors/`
/// directory.
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
        );
    }

    /// LAB-2041: a project config still using the old top-level `logging` block
    /// (superseded by `environments.<env>.logging`) must fail `mecha10 config
    /// validate`/`mecha10 lint` (both of which call [`validate_config`]) with a message
    /// pointing at the new shape, instead of silently ignoring the obsolete field.
    #[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());
    }
}