mecha10-cli 4.0.2

Mecha10 CLI tool
Documentation
//! Integration tests for ConfigService
//!
//! These tests verify that the ConfigService correctly handles configuration
//! file loading, validation, and project detection.

mod common;

use common::TestProject;
use mecha10_cli::services::ConfigService;
use std::path::PathBuf;

#[test]
#[serial_test::serial(cwd)]
fn test_is_initialized_here() {
    let project = TestProject::new().unwrap();
    std::env::set_current_dir(&project.root).unwrap();

    assert!(!ConfigService::is_initialized_here());

    project
        .create_file(
            "mecha10.json",
            r#"{
        "robot_id": "test-001",
        "nodes": {}
    }"#,
        )
        .unwrap();

    assert!(ConfigService::is_initialized_here());
}

#[test]
fn test_is_initialized() {
    let project = TestProject::new().unwrap();

    assert!(!ConfigService::is_initialized(&project.root));

    project
        .create_file(
            "mecha10.json",
            r#"{
        "robot_id": "test-001",
        "nodes": {}
    }"#,
        )
        .unwrap();

    assert!(ConfigService::is_initialized(&project.root));
}

#[tokio::test]
async fn test_load_from_specific_path() {
    let project = TestProject::new().unwrap();

    let config_path = project
        .create_file(
            "custom.json",
            r#"{
        "name": "custom-robot",
        "version": "0.1.0",
        "robot_id": "custom-robot",
        "nodes": {}
    }"#,
        )
        .unwrap();

    let config = ConfigService::load_from(&config_path).await.unwrap();
    assert_eq!(config.robot.id, "custom-robot");
}

#[tokio::test]
async fn test_load_from_nonexistent_file() {
    let result = ConfigService::load_from(&PathBuf::from("/nonexistent/path/mecha10.json")).await;
    assert!(result.is_err());
    let err_msg = result.unwrap_err().to_string();
    assert!(err_msg.contains("not found"));
}

#[tokio::test]
async fn test_load_invalid_json() {
    let project = TestProject::new().unwrap();

    let config_path = project.create_file("mecha10.json", "{ invalid json ").unwrap();

    let result = ConfigService::load_from(&config_path).await;
    assert!(result.is_err());
    let err_msg = result.unwrap_err().to_string();
    assert!(err_msg.contains("Failed to parse"));
}

#[tokio::test]
async fn test_load_robot_id() {
    let project = TestProject::new().unwrap();

    let config_path = project
        .create_file(
            "mecha10.json",
            r#"{
        "name": "robot-123",
        "version": "0.1.0",
        "robot_id": "robot-123",
        "nodes": {}
    }"#,
        )
        .unwrap();

    let robot_id = ConfigService::load_robot_id(&config_path).await.unwrap();
    assert_eq!(robot_id, "robot-123");
}

#[test]
fn test_find_config_in_current_dir() {
    let project = TestProject::new().unwrap();

    project
        .create_file(
            "mecha10.json",
            r#"{
        "robot_id": "test",
        "nodes": {}
    }"#,
        )
        .unwrap();

    let found = ConfigService::find_config_from(&project.root).unwrap();
    assert_eq!(found, project.root.join("mecha10.json"));
}

#[test]
fn test_find_config_in_parent_dir() {
    let project = TestProject::new().unwrap();

    project
        .create_file(
            "mecha10.json",
            r#"{
        "robot_id": "test",
        "nodes": {}
    }"#,
        )
        .unwrap();

    let subdir = project.root.join("subdir/nested");
    std::fs::create_dir_all(&subdir).unwrap();

    let found = ConfigService::find_config_from(&subdir).unwrap();
    assert_eq!(found, project.root.join("mecha10.json"));
}

#[test]
fn test_find_config_not_found() {
    let project = TestProject::new().unwrap();

    let result = ConfigService::find_config_from(&project.root);
    assert!(result.is_err());
    let err_msg = result.unwrap_err().to_string();
    assert!(err_msg.contains("No mecha10.json found"));
}

#[test]
fn test_validate_nonexistent_file() {
    let result = ConfigService::validate(&PathBuf::from("/nonexistent/mecha10.json"));
    assert!(result.is_err());
    assert!(result.unwrap_err().to_string().contains("not found"));
}

#[test]
fn test_resolve_and_validate_missing_explicit_path() {
    let result = ConfigService::resolve_and_validate(Some(&PathBuf::from("/nonexistent/config.json")));
    assert!(result.is_err());
    assert!(result.unwrap_err().to_string().contains("not found"));
}

#[test]
fn test_resolve_and_validate_explicit_path_rejects_bare_node_config() {
    let project = TestProject::new().unwrap();
    let node_config_path = project
        .create_file(
            "config/camera.json",
            r#"{"id": "camera_node", "executable": "./bin/camera_node"}"#,
        )
        .unwrap();

    let result = ConfigService::resolve_and_validate(Some(&node_config_path));
    assert!(
        result.is_err(),
        "a bare node config has no project to resolve its schema from"
    );
    let err_chain = format!("{:#}", result.unwrap_err());
    assert!(
        err_chain.contains("cannot validate a node configuration file on its own"),
        "error chain should explain that a bare node config needs project context: {}",
        err_chain
    );
}

#[test]
fn test_resolve_and_validate_explicit_path_rejects_bare_node_config_even_when_invalid() {
    let project = TestProject::new().unwrap();
    let node_config_path = project
        .create_file("config/camera.json", r#"{"id": "camera_node"}"#)
        .unwrap();

    let result = ConfigService::resolve_and_validate(Some(&node_config_path));
    assert!(result.is_err());
    let err_chain = format!("{:#}", result.unwrap_err());
    assert!(
        err_chain.contains("cannot validate a node configuration file on its own"),
        "a bare node config fails on missing project context before its own fields are even \
         checked, regardless of whether those fields are otherwise valid: {}",
        err_chain
    );
}

#[test]
#[serial_test::serial(cwd)]
fn test_resolve_and_validate_explicit_path_to_project_config_recurses_into_referenced_node_configs() {
    let project = TestProject::new().unwrap();
    std::env::set_current_dir(&project.root).unwrap();

    project
        .create_file(
            "config/camera.json",
            r#"{"id": "camera_node", "executable": "./bin/camera_node"}"#,
        )
        .unwrap();
    project
        .create_file(
            "drivers/camera/camera.schema.json",
            r#"{
        "type": "object",
        "required": ["id", "executable"],
        "properties": {
            "id": { "type": "string" },
            "executable": { "type": "string" }
        }
    }"#,
        )
        .unwrap();

    let project_config_path = project
        .create_file(
            "mecha10.json",
            r#"{
        "name": "my-robot",
        "version": "1.0.0",
        "nodes": {
            "drivers": [
                {"name": "camera", "path": "./drivers/camera", "config": "./config/camera.json"}
            ]
        }
    }"#,
        )
        .unwrap();

    let result = ConfigService::resolve_and_validate(Some(&project_config_path));
    assert!(
        result.is_ok(),
        "expected project + referenced node config to pass: {:?}",
        result.err()
    );
    assert_eq!(result.unwrap(), project_config_path);
}

#[test]
#[serial_test::serial(cwd)]
fn test_resolve_and_validate_default_recurses_into_referenced_node_configs() {
    let project = TestProject::new().unwrap();
    std::env::set_current_dir(&project.root).unwrap();

    project
        .create_file(
            "config/camera.json",
            r#"{"id": "camera_node", "executable": "./bin/camera_node"}"#,
        )
        .unwrap();
    project
        .create_file(
            "drivers/camera/camera.schema.json",
            r#"{
        "type": "object",
        "required": ["id", "executable"],
        "properties": {
            "id": { "type": "string" },
            "executable": { "type": "string" }
        }
    }"#,
        )
        .unwrap();

    project
        .create_file(
            "mecha10.json",
            r#"{
        "name": "my-robot",
        "version": "1.0.0",
        "nodes": {
            "drivers": [
                {"name": "camera", "path": "./drivers/camera", "config": "./config/camera.json"}
            ]
        }
    }"#,
        )
        .unwrap();

    let result = ConfigService::resolve_and_validate(None);
    assert!(
        result.is_ok(),
        "expected project + referenced node config to pass: {:?}",
        result.err()
    );
}

#[tokio::test]
async fn test_config_with_targets() {
    let project = TestProject::new().unwrap();

    let config_path = project
        .create_file(
            "mecha10.json",
            r#"{
        "name": "target-robot",
        "version": "0.1.0",
        "robot_id": "target-robot",
        "nodes": {},
        "targets": {
            "dev": { "nodes": ["@mecha10/listener"] },
            "robot": { "nodes": ["@mecha10/motor"], "platforms": ["aarch64-unknown-linux-gnu"] }
        }
    }"#,
        )
        .unwrap();

    let config = ConfigService::load_from(&config_path).await.unwrap();
    assert_eq!(config.robot.id, "target-robot");
    assert_eq!(config.targets.nodes_for("dev"), ["@mecha10/listener".to_string()]);

    let robot = config.targets.robot.expect("targets.robot should be present");
    assert_eq!(robot.nodes, vec!["@mecha10/motor".to_string()]);
    assert_eq!(robot.platforms, Some(vec!["aarch64-unknown-linux-gnu".to_string()]));
}

#[tokio::test]
async fn test_config_with_nodes() {
    let project = TestProject::new().unwrap();

    let config_path = project
        .create_file(
            "mecha10.json",
            r#"{
        "name": "node-robot",
        "version": "0.1.0",
        "robot_id": "node-robot",
        "nodes": {
            "@mecha10/camera": "0.1.0",
            "custom-node": "0.1.0"
        }
    }"#,
        )
        .unwrap();

    let config = ConfigService::load_from(&config_path).await.unwrap();
    assert_eq!(config.nodes.len(), 2);
    assert!(config.nodes.contains("camera"));
    assert!(config.nodes.contains("custom-node"));
}