mecha10-behavior-runtime 0.9.0

Behavior tree runtime for Mecha10 - unified AI and logic composition system
Documentation
//! Configuration loading for behavior trees
//!
//! Resolves `config.json` across a 2-tier scope layout rooted at `configs/behaviors/`, picking

use anyhow::{Context as _, Result};
use mecha10_cli_core::paths::config::behavior_config_candidates;
use mecha10_cli_core::paths::project::BEHAVIORS_DIR;
use serde_json::Value;
use std::collections::BTreeSet;
use std::path::{Path, PathBuf};

/// Resolve the on-disk `config.json` path for a behavior, checking the 2 tiers in override
/// precedence order (project -> framework) and returning the highest-precedence one that
/// exists. Returns an error listing both attempted paths if neither exists.
pub fn resolve_behavior_config_path(behavior_name: &str, project_root: &Path) -> Result<PathBuf> {
    let tiers = behavior_config_candidates(behavior_name);

    for tier in &tiers {
        let path = project_root.join(tier);
        if path.exists() {
            return Ok(path);
        }
    }

    anyhow::bail!(
        "Behavior config '{}' not found - checked {}",
        behavior_name,
        tiers
            .iter()
            .map(|c| project_root.join(c).display().to_string())
            .collect::<Vec<_>>()
            .join(", ")
    )
}

/// List the names of every behavior available to a project, across both tiers
/// (`configs/behaviors/{name}/config.json` and `configs/behaviors/@mecha10/{name}/config.json`).
/// A name present in both tiers is only listed once.
pub fn list_available_behaviors(project_root: &Path) -> Vec<String> {
    let mut names = BTreeSet::new();

    for tier_dir in [
        project_root.join(BEHAVIORS_DIR),
        project_root.join(BEHAVIORS_DIR).join("@mecha10"),
    ] {
        let Ok(entries) = std::fs::read_dir(&tier_dir) else {
            continue;
        };

        for entry in entries.flatten() {
            let path = entry.path();
            if path.is_dir() && path.join("config.json").is_file() {
                if let Some(name) = path.file_name().and_then(|n| n.to_str()) {
                    names.insert(name.to_string());
                }
            }
        }
    }

    names.into_iter().collect()
}

/// Load a behavior's `config.json`, resolved via [`resolve_behavior_config_path`].
pub async fn load_behavior_config(behavior_name: &str, project_root: &Path) -> Result<Value> {
    let config_path = resolve_behavior_config_path(behavior_name, project_root)?;
    load_json_file(&config_path).await.with_context(|| {
        format!(
            "Failed to load behavior config '{}' from {}",
            behavior_name,
            config_path.display()
        )
    })
}

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

/// Get the current environment from environment variable
///
/// Falls back to "dev" if MECHA10_ENVIRONMENT is not set.
pub fn get_current_environment() -> String {
    mecha10_core::config::env_or_default("MECHA10_ENVIRONMENT", "dev")
}

/// 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 std::fs;
    use tempfile::TempDir;

    fn write_behavior_config(project_root: &Path, relative_path: &str, name: &str) {
        let full_path = project_root.join(relative_path);
        fs::create_dir_all(full_path.parent().unwrap()).unwrap();
        fs::write(
            full_path,
            format!(r#"{{"name": "{name}", "root": {{"type": "node", "node": "noop"}}}}"#),
        )
        .unwrap();
    }

    #[test]
    fn resolve_behavior_config_path_finds_framework_default() {
        let project = TempDir::new().unwrap();
        write_behavior_config(
            project.path(),
            "configs/behaviors/@mecha10/idle_wander/config.json",
            "framework",
        );

        let resolved = resolve_behavior_config_path("idle_wander", project.path()).unwrap();

        assert_eq!(
            resolved,
            project
                .path()
                .join("configs/behaviors/@mecha10/idle_wander/config.json")
        );
    }

    #[test]
    fn resolve_behavior_config_path_project_override_wins_over_framework() {
        let project = TempDir::new().unwrap();
        write_behavior_config(
            project.path(),
            "configs/behaviors/@mecha10/idle_wander/config.json",
            "framework",
        );
        write_behavior_config(project.path(), "configs/behaviors/idle_wander/config.json", "project");

        let resolved = resolve_behavior_config_path("idle_wander", project.path()).unwrap();

        assert_eq!(
            resolved,
            project.path().join("configs/behaviors/idle_wander/config.json")
        );
    }

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

        let err = resolve_behavior_config_path("missing_behavior", project.path()).unwrap_err();

        let message = err.to_string();
        assert!(message.contains("missing_behavior"));
        assert!(message.contains("configs/behaviors/missing_behavior/config.json"));
        assert!(message.contains("configs/behaviors/@mecha10/missing_behavior/config.json"));
    }

    #[tokio::test]
    async fn load_behavior_config_reads_resolved_tier() {
        let project = TempDir::new().unwrap();
        write_behavior_config(
            project.path(),
            "configs/behaviors/@mecha10/idle_wander/config.json",
            "framework",
        );
        write_behavior_config(project.path(), "configs/behaviors/idle_wander/config.json", "project");

        let config = load_behavior_config("idle_wander", project.path()).await.unwrap();

        assert_eq!(config["name"], "project");
    }

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

    #[test]
    fn list_available_behaviors_returns_empty_for_project_with_no_behaviors_dir() {
        let project = TempDir::new().unwrap();
        assert_eq!(list_available_behaviors(project.path()), Vec::<String>::new());
    }

    #[test]
    fn list_available_behaviors_collects_both_tiers_deduped_and_sorted() {
        let project = TempDir::new().unwrap();
        write_behavior_config(
            project.path(),
            "configs/behaviors/@mecha10/idle_wander/config.json",
            "f",
        );
        write_behavior_config(
            project.path(),
            "configs/behaviors/@mecha10/patrol_simple/config.json",
            "f",
        );
        write_behavior_config(project.path(), "configs/behaviors/patrol_simple/config.json", "p");
        write_behavior_config(project.path(), "configs/behaviors/custom_dance/config.json", "p");

        assert_eq!(
            list_available_behaviors(project.path()),
            vec!["custom_dance", "idle_wander", "patrol_simple"]
        );
    }

    #[test]
    fn list_available_behaviors_ignores_directories_without_config_json() {
        let project = TempDir::new().unwrap();
        fs::create_dir_all(project.path().join("configs/behaviors/incomplete")).unwrap();

        assert_eq!(list_available_behaviors(project.path()), Vec::<String>::new());
    }
}