mecha10-behavior-runtime 0.7.0

Behavior tree runtime for Mecha10 - unified AI and logic composition system
Documentation
//! Configuration loading for behavior trees
//!
//! Behavior configs use a 2-tier `config.json` scope layout, rooted at `configs/behaviors/` (see
//! `mecha10_cli_core::paths::config`), with override precedence from lowest to highest:
//! 1. `configs/behaviors/@mecha10/{name}/config.json` (bundled framework default - lowest
//!    precedence)
//! 2. `configs/behaviors/{name}/config.json` (project-committed override - wins)
//!
//! There is no `@local` tier for behaviors - an earlier version of this scheme added one,
//! modeled on the node-config `@local` convention, but it had no real backing (nothing
//! gitignores it and `mecha10 config push` syncs it like any other config), so it was dropped
//! (LAB-1990). At the time LAB-1990 removed the `@local` tier, `mecha10 config push` did NOT
//! actually sync behaviors yet - `behaviors/` lived as a top-level directory sibling of
//! `configs/`, which `collect_configs_from_dir` never walked, so the justification was aspirational.
//! LAB-2072 moved behaviors under `configs/behaviors/` specifically so that claim would be true;
//! `mecha10_robot_config::sync` now genuinely collects/pushes/pulls behavior configs alongside
//! node configs, round-tripping correctly in both directions via a category-prefixed key scheme
//! (see that module's `derive_config_key`). This does not reopen the `@local`-tier question -
//! still out of scope here.
//!
//! Resolution picks the highest-precedence tier that exists on disk - there is no field-level
//! merging across tiers (this replaces the old, uncalled `configs/{env}/behaviors/`
//! env-tiered/deep-merge scheme).

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.
///
/// # Arguments
///
/// * `behavior_name` - Name of the behavior (e.g., "idle_wander")
/// * `project_root` - Root directory of the project
///
/// # Errors
///
/// 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`).
///
/// Used to answer "what can I switch to" (LAB-2270) without the caller needing to know the
/// 2-tier directory layout itself. A name present in both tiers is only listed once - this
/// reports availability, not which tier wins (see [`resolve_behavior_config_path`] for that).
/// Missing directories are treated as "no behaviors in this tier" rather than an error, since a
/// project need not have project-tier overrides at all.
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`].
///
/// # Example
///
/// ```rust,ignore
/// use mecha10_behavior_runtime::config::load_behavior_config;
///
/// let config = load_behavior_config("idle_wander", "/path/to/project")?;
/// ```
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());
    }
}