mecha10_behavior_runtime/config/
loader.rs

1//! Configuration loading for behavior trees
2//!
3//! Supports environment-specific configuration with fallback chain:
4//! 1. configs/{env}/behaviors/{behavior_name}.json (environment-specific)
5//! 2. behaviors/{behavior_name}.json (template)
6
7use anyhow::{Context as _, Result};
8use serde_json::Value;
9use std::path::{Path, PathBuf};
10
11/// Load behavior configuration with environment-specific overrides
12///
13/// This function implements a fallback chain:
14/// 1. Try environment-specific config: `configs/{env}/behaviors/{name}.json`
15/// 2. Fall back to template: `behaviors/{name}.json`
16///
17/// # Arguments
18///
19/// * `behavior_name` - Name of the behavior (e.g., "idle_wander")
20/// * `project_root` - Root directory of the project
21/// * `environment` - Environment name (e.g., "dev", "production")
22///
23/// # Returns
24///
25/// Merged configuration with environment-specific overrides applied
26///
27/// # Example
28///
29/// ```rust,ignore
30/// use mecha10_behavior_runtime::config::load_behavior_config;
31///
32/// let config = load_behavior_config("idle_wander", "/path/to/project", "dev")?;
33/// ```
34pub async fn load_behavior_config(behavior_name: &str, project_root: &Path, environment: &str) -> Result<Value> {
35    // Build paths in priority order
36    let env_config_path = project_root
37        .join("configs")
38        .join(environment)
39        .join("behaviors")
40        .join(format!("{}.json", behavior_name));
41
42    let template_path = project_root.join("behaviors").join(format!("{}.json", behavior_name));
43
44    // Load base template (required)
45    let base_config = load_json_file(&template_path).await.with_context(|| {
46        format!(
47            "Failed to load behavior template '{}' from {}",
48            behavior_name,
49            template_path.display()
50        )
51    })?;
52
53    // Load environment-specific overrides (optional)
54    let final_config = if env_config_path.exists() {
55        let env_config = load_json_file(&env_config_path).await?;
56        merge_configs(&base_config, &env_config)
57    } else {
58        base_config
59    };
60
61    tracing::debug!(
62        "Loaded behavior config '{}' for environment '{}' (template: {}, env: {})",
63        behavior_name,
64        environment,
65        template_path.exists(),
66        env_config_path.exists()
67    );
68
69    Ok(final_config)
70}
71
72/// Load a JSON file from disk
73async fn load_json_file(path: &Path) -> Result<Value> {
74    let content = tokio::fs::read_to_string(path)
75        .await
76        .with_context(|| format!("Failed to read file: {}", path.display()))?;
77
78    serde_json::from_str(&content).with_context(|| format!("Failed to parse JSON from: {}", path.display()))
79}
80
81/// Merge two JSON configurations (override takes precedence)
82///
83/// This performs a deep merge where:
84/// - Objects are merged recursively
85/// - Arrays are replaced entirely (not merged)
86/// - Primitives are replaced
87fn merge_configs(base: &Value, override_val: &Value) -> Value {
88    match (base, override_val) {
89        (Value::Object(base_map), Value::Object(override_map)) => {
90            let mut result = base_map.clone();
91            for (key, value) in override_map {
92                result.insert(
93                    key.clone(),
94                    if let Some(base_value) = base_map.get(key) {
95                        merge_configs(base_value, value)
96                    } else {
97                        value.clone()
98                    },
99                );
100            }
101            Value::Object(result)
102        }
103        _ => override_val.clone(),
104    }
105}
106
107/// Get the current environment from environment variable
108///
109/// Falls back to "dev" if MECHA10_ENVIRONMENT is not set.
110pub fn get_current_environment() -> String {
111    std::env::var("MECHA10_ENVIRONMENT").unwrap_or_else(|_| "dev".to_string())
112}
113
114/// Detect project root by looking for mecha10.json
115///
116/// Walks up the directory tree from the current directory until it finds
117/// a directory containing mecha10.json.
118pub fn detect_project_root() -> Result<PathBuf> {
119    let mut current = std::env::current_dir()?;
120
121    loop {
122        let mecha10_json = current.join("mecha10.json");
123        if mecha10_json.exists() {
124            return Ok(current);
125        }
126
127        if !current.pop() {
128            anyhow::bail!("Could not find project root (no mecha10.json found in parent directories)");
129        }
130    }
131}
132
133#[cfg(test)]
134mod tests {
135    use super::*;
136    use serde_json::json;
137
138    #[test]
139    fn test_merge_configs_objects() {
140        let base = json!({
141            "name": "test",
142            "config": {
143                "speed": 1.0,
144                "angle": 0.5
145            }
146        });
147
148        let override_val = json!({
149            "config": {
150                "speed": 2.0
151            }
152        });
153
154        let result = merge_configs(&base, &override_val);
155
156        assert_eq!(
157            result,
158            json!({
159                "name": "test",
160                "config": {
161                    "speed": 2.0,
162                    "angle": 0.5
163                }
164            })
165        );
166    }
167
168    #[test]
169    fn test_merge_configs_arrays_replaced() {
170        let base = json!({
171            "items": [1, 2, 3]
172        });
173
174        let override_val = json!({
175            "items": [4, 5]
176        });
177
178        let result = merge_configs(&base, &override_val);
179
180        assert_eq!(
181            result,
182            json!({
183                "items": [4, 5]
184            })
185        );
186    }
187
188    #[test]
189    fn test_get_current_environment_default() {
190        std::env::remove_var("MECHA10_ENVIRONMENT");
191        assert_eq!(get_current_environment(), "dev");
192    }
193}