mecha10-cli 0.6.3

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;

// LAB-2054: a behavior composition file (`behaviors/**/config.json`) is a third
// special case alongside `model_catalog.toml`, validated against the
// `schemars`-derived `BehaviorConfig` schema via
// `mecha10_behavior_runtime::config::validate_behavior_composition_json` --
// structural/schema validation only, no live `NodeRegistry` involved (this layer
// has no project context to build one from; see that function's doc comment, and
// `docs/validate-configs.md`'s config-kind table for why `mecha10 config
// validate`'s default *recursion* still doesn't reach into a project's active
// behavior tree file the way it does for referenced node configs).

/// 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.
///
/// # 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());
        }
    }

    ConfigService::resolve_and_validate_with_node_schema_resolver(
        path,
        Some(&mecha10_schema_registry::node_schema_resolver),
    )
}

/// 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");
        // Missing required fields (task/repo/filename).
        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();
        // Missing `behavior_name`, required by `BehaviorExecutorConfig` -- would pass
        // the generic node envelope schema (no required fields overlap) but must fail
        // once dispatched to the node-type-specific schema.
        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());
    }
}