mecha10-behavior-runtime 0.1.25

Behavior tree runtime for Mecha10 - unified AI and logic composition system
Documentation
//! Configuration loading for behavior trees
//!
//! Supports environment-specific configuration with fallback chain:
//! 1. configs/{env}/behaviors/{behavior_name}.json (environment-specific)
//! 2. behaviors/{behavior_name}.json (template)

use anyhow::{Context as _, Result};
use serde_json::Value;
use std::path::{Path, PathBuf};

/// Load behavior configuration with environment-specific overrides
///
/// This function implements a fallback chain:
/// 1. Try environment-specific config: `configs/{env}/behaviors/{name}.json`
/// 2. Fall back to template: `behaviors/{name}.json`
///
/// # Arguments
///
/// * `behavior_name` - Name of the behavior (e.g., "idle_wander")
/// * `project_root` - Root directory of the project
/// * `environment` - Environment name (e.g., "dev", "production")
///
/// # Returns
///
/// Merged configuration with environment-specific overrides applied
///
/// # Example
///
/// ```rust,ignore
/// use mecha10_behavior_runtime::config::load_behavior_config;
///
/// let config = load_behavior_config("idle_wander", "/path/to/project", "dev")?;
/// ```
pub async fn load_behavior_config(behavior_name: &str, project_root: &Path, environment: &str) -> Result<Value> {
    // Build paths in priority order
    let env_config_path = project_root
        .join("configs")
        .join(environment)
        .join("behaviors")
        .join(format!("{}.json", behavior_name));

    let template_path = project_root.join("behaviors").join(format!("{}.json", behavior_name));

    // Load base template (required)
    let base_config = load_json_file(&template_path).await.with_context(|| {
        format!(
            "Failed to load behavior template '{}' from {}",
            behavior_name,
            template_path.display()
        )
    })?;

    // Load environment-specific overrides (optional)
    let final_config = if env_config_path.exists() {
        let env_config = load_json_file(&env_config_path).await?;
        merge_configs(&base_config, &env_config)
    } else {
        base_config
    };

    tracing::debug!(
        "Loaded behavior config '{}' for environment '{}' (template: {}, env: {})",
        behavior_name,
        environment,
        template_path.exists(),
        env_config_path.exists()
    );

    Ok(final_config)
}

/// Load a JSON file from disk
async fn load_json_file(path: &Path) -> Result<Value> {
    let content = tokio::fs::read_to_string(path)
        .await
        .with_context(|| format!("Failed to read file: {}", path.display()))?;

    serde_json::from_str(&content).with_context(|| format!("Failed to parse JSON from: {}", path.display()))
}

/// Merge two JSON configurations (override takes precedence)
///
/// This performs a deep merge where:
/// - Objects are merged recursively
/// - Arrays are replaced entirely (not merged)
/// - Primitives are replaced
fn merge_configs(base: &Value, override_val: &Value) -> Value {
    match (base, override_val) {
        (Value::Object(base_map), Value::Object(override_map)) => {
            let mut result = base_map.clone();
            for (key, value) in override_map {
                result.insert(
                    key.clone(),
                    if let Some(base_value) = base_map.get(key) {
                        merge_configs(base_value, value)
                    } else {
                        value.clone()
                    },
                );
            }
            Value::Object(result)
        }
        _ => override_val.clone(),
    }
}

/// Get the current environment from environment variable
///
/// Falls back to "dev" if MECHA10_ENVIRONMENT is not set.
pub fn get_current_environment() -> String {
    std::env::var("MECHA10_ENVIRONMENT").unwrap_or_else(|_| "dev".to_string())
}

/// Detect project root by looking for mecha10.json
///
/// Walks up the directory tree from the current directory until it finds
/// a directory containing mecha10.json.
pub fn detect_project_root() -> Result<PathBuf> {
    let mut current = std::env::current_dir()?;

    loop {
        let mecha10_json = current.join("mecha10.json");
        if mecha10_json.exists() {
            return Ok(current);
        }

        if !current.pop() {
            anyhow::bail!("Could not find project root (no mecha10.json found in parent directories)");
        }
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    use serde_json::json;

    #[test]
    fn test_merge_configs_objects() {
        let base = json!({
            "name": "test",
            "config": {
                "speed": 1.0,
                "angle": 0.5
            }
        });

        let override_val = json!({
            "config": {
                "speed": 2.0
            }
        });

        let result = merge_configs(&base, &override_val);

        assert_eq!(
            result,
            json!({
                "name": "test",
                "config": {
                    "speed": 2.0,
                    "angle": 0.5
                }
            })
        );
    }

    #[test]
    fn test_merge_configs_arrays_replaced() {
        let base = json!({
            "items": [1, 2, 3]
        });

        let override_val = json!({
            "items": [4, 5]
        });

        let result = merge_configs(&base, &override_val);

        assert_eq!(
            result,
            json!({
                "items": [4, 5]
            })
        );
    }

    #[test]
    fn test_get_current_environment_default() {
        std::env::remove_var("MECHA10_ENVIRONMENT");
        assert_eq!(get_current_environment(), "dev");
    }
}