mecha10_behavior_runtime/config/
loader.rs1use super::validator::behavior_capabilities_satisfied;
6use anyhow::{Context as _, Result};
7use mecha10_cli_core::paths::config::behavior_config_candidates;
8use mecha10_cli_core::paths::project::BEHAVIORS_DIR;
9use mecha10_core::node::Capability;
10use serde_json::Value;
11use std::collections::{BTreeSet, HashSet};
12use std::path::{Path, PathBuf};
13
14pub fn resolve_behavior_config_path(behavior_name: &str, project_root: &Path) -> Result<PathBuf> {
18 let tiers = behavior_config_candidates(behavior_name);
19
20 for tier in &tiers {
21 let path = project_root.join(tier);
22 if path.exists() {
23 return Ok(path);
24 }
25 }
26
27 anyhow::bail!(
28 "Behavior config '{}' not found - checked {}",
29 behavior_name,
30 tiers
31 .iter()
32 .map(|c| project_root.join(c).display().to_string())
33 .collect::<Vec<_>>()
34 .join(", ")
35 )
36}
37
38pub fn list_available_behaviors(project_root: &Path) -> Vec<String> {
42 let mut names = BTreeSet::new();
43
44 for tier_dir in [
45 project_root.join(BEHAVIORS_DIR),
46 project_root.join(BEHAVIORS_DIR).join("@mecha10"),
47 ] {
48 let Ok(entries) = std::fs::read_dir(&tier_dir) else {
49 continue;
50 };
51
52 for entry in entries.flatten() {
53 let path = entry.path();
54 if path.is_dir() && path.join("config.json").is_file() {
55 if let Some(name) = path.file_name().and_then(|n| n.to_str()) {
56 names.insert(name.to_string());
57 }
58 }
59 }
60 }
61
62 names.into_iter().collect()
63}
64
65pub async fn list_capable_behaviors(project_root: &Path, available_capabilities: &HashSet<Capability>) -> Vec<String> {
83 let mut names = Vec::new();
84
85 for name in list_available_behaviors(project_root) {
86 let Ok(config) = load_behavior_config(&name, project_root).await else {
87 continue;
88 };
89
90 if behavior_capabilities_satisfied(&config, available_capabilities) {
91 names.push(name);
92 }
93 }
94
95 names
96}
97
98pub async fn load_behavior_config(behavior_name: &str, project_root: &Path) -> Result<Value> {
100 let config_path = resolve_behavior_config_path(behavior_name, project_root)?;
101 load_json_file(&config_path).await.with_context(|| {
102 format!(
103 "Failed to load behavior config '{}' from {}",
104 behavior_name,
105 config_path.display()
106 )
107 })
108}
109
110async fn load_json_file(path: &Path) -> Result<Value> {
112 let content = tokio::fs::read_to_string(path)
113 .await
114 .with_context(|| format!("Failed to read file: {}", path.display()))?;
115
116 serde_json::from_str(&content).with_context(|| format!("Failed to parse JSON from: {}", path.display()))
117}
118
119pub fn get_current_environment() -> String {
123 mecha10_core::config::env_or_default("MECHA10_ENVIRONMENT", "dev")
124}
125
126pub fn detect_project_root() -> Result<PathBuf> {
130 let mut current = std::env::current_dir()?;
131
132 loop {
133 let mecha10_json = current.join("mecha10.json");
134 if mecha10_json.exists() {
135 return Ok(current);
136 }
137
138 if !current.pop() {
139 anyhow::bail!("Could not find project root (no mecha10.json found in parent directories)");
140 }
141 }
142}
143
144#[cfg(test)]
145mod tests {
146 use super::*;
147 use std::fs;
148 use tempfile::TempDir;
149
150 fn write_behavior_config(project_root: &Path, relative_path: &str, name: &str) {
151 let full_path = project_root.join(relative_path);
152 fs::create_dir_all(full_path.parent().unwrap()).unwrap();
153 fs::write(
154 full_path,
155 format!(r#"{{"name": "{name}", "root": {{"type": "node", "node": "noop"}}}}"#),
156 )
157 .unwrap();
158 }
159
160 #[test]
161 fn resolve_behavior_config_path_finds_framework_default() {
162 let project = TempDir::new().unwrap();
163 write_behavior_config(
164 project.path(),
165 "configs/behaviors/@mecha10/idle_wander/config.json",
166 "framework",
167 );
168
169 let resolved = resolve_behavior_config_path("idle_wander", project.path()).unwrap();
170
171 assert_eq!(
172 resolved,
173 project
174 .path()
175 .join("configs/behaviors/@mecha10/idle_wander/config.json")
176 );
177 }
178
179 #[test]
180 fn resolve_behavior_config_path_project_override_wins_over_framework() {
181 let project = TempDir::new().unwrap();
182 write_behavior_config(
183 project.path(),
184 "configs/behaviors/@mecha10/idle_wander/config.json",
185 "framework",
186 );
187 write_behavior_config(project.path(), "configs/behaviors/idle_wander/config.json", "project");
188
189 let resolved = resolve_behavior_config_path("idle_wander", project.path()).unwrap();
190
191 assert_eq!(
192 resolved,
193 project.path().join("configs/behaviors/idle_wander/config.json")
194 );
195 }
196
197 #[test]
198 fn resolve_behavior_config_path_errors_when_no_tier_exists() {
199 let project = TempDir::new().unwrap();
200
201 let err = resolve_behavior_config_path("missing_behavior", project.path()).unwrap_err();
202
203 let message = err.to_string();
204 assert!(message.contains("missing_behavior"));
205 assert!(message.contains("configs/behaviors/missing_behavior/config.json"));
206 assert!(message.contains("configs/behaviors/@mecha10/missing_behavior/config.json"));
207 }
208
209 #[tokio::test]
210 async fn load_behavior_config_reads_resolved_tier() {
211 let project = TempDir::new().unwrap();
212 write_behavior_config(
213 project.path(),
214 "configs/behaviors/@mecha10/idle_wander/config.json",
215 "framework",
216 );
217 write_behavior_config(project.path(), "configs/behaviors/idle_wander/config.json", "project");
218
219 let config = load_behavior_config("idle_wander", project.path()).await.unwrap();
220
221 assert_eq!(config["name"], "project");
222 }
223
224 #[test]
225 fn test_get_current_environment_default() {
226 std::env::remove_var("MECHA10_ENVIRONMENT");
227 assert_eq!(get_current_environment(), "dev");
228 }
229
230 #[test]
231 fn list_available_behaviors_returns_empty_for_project_with_no_behaviors_dir() {
232 let project = TempDir::new().unwrap();
233 assert_eq!(list_available_behaviors(project.path()), Vec::<String>::new());
234 }
235
236 #[test]
237 fn list_available_behaviors_collects_both_tiers_deduped_and_sorted() {
238 let project = TempDir::new().unwrap();
239 write_behavior_config(
240 project.path(),
241 "configs/behaviors/@mecha10/idle_wander/config.json",
242 "f",
243 );
244 write_behavior_config(
245 project.path(),
246 "configs/behaviors/@mecha10/patrol_simple/config.json",
247 "f",
248 );
249 write_behavior_config(project.path(), "configs/behaviors/patrol_simple/config.json", "p");
250 write_behavior_config(project.path(), "configs/behaviors/custom_dance/config.json", "p");
251
252 assert_eq!(
253 list_available_behaviors(project.path()),
254 vec!["custom_dance", "idle_wander", "patrol_simple"]
255 );
256 }
257
258 #[test]
259 fn list_available_behaviors_ignores_directories_without_config_json() {
260 let project = TempDir::new().unwrap();
261 fs::create_dir_all(project.path().join("configs/behaviors/incomplete")).unwrap();
262
263 assert_eq!(list_available_behaviors(project.path()), Vec::<String>::new());
264 }
265
266 fn write_behavior_config_requiring(project_root: &Path, name: &str, requires_capabilities: &[&str]) {
267 let full_path = project_root.join("configs/behaviors").join(name).join("config.json");
268 fs::create_dir_all(full_path.parent().unwrap()).unwrap();
269 fs::write(
270 full_path,
271 serde_json::json!({
272 "name": name,
273 "root": {"type": "node", "node": "noop"},
274 "requires_capabilities": requires_capabilities,
275 })
276 .to_string(),
277 )
278 .unwrap();
279 }
280
281 #[tokio::test]
282 async fn list_capable_behaviors_excludes_behavior_requiring_missing_capability() {
283 let project = TempDir::new().unwrap();
284 write_behavior_config_requiring(project.path(), "fly_around", &["flight"]);
285 write_behavior_config_requiring(project.path(), "patrol", &["locomotion"]);
286
287 let available = HashSet::from([Capability::Locomotion]);
288 let capable = list_capable_behaviors(project.path(), &available).await;
289
290 assert_eq!(capable, vec!["patrol".to_string()]);
291 }
292
293 #[tokio::test]
294 async fn list_capable_behaviors_includes_behavior_whose_requirements_are_fully_met() {
295 let project = TempDir::new().unwrap();
296 write_behavior_config_requiring(project.path(), "patrol", &["locomotion", "vision"]);
297
298 let available = HashSet::from([Capability::Locomotion, Capability::Vision]);
299 let capable = list_capable_behaviors(project.path(), &available).await;
300
301 assert_eq!(capable, vec!["patrol".to_string()]);
302 }
303}