Skip to main content

mecha10_behavior_runtime/config/
loader.rs

1//! Configuration loading for behavior trees
2//!
3//! Resolves `config.json` across a 2-tier scope layout rooted at `configs/behaviors/`, picking
4
5use 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
14/// Resolve the on-disk `config.json` path for a behavior, checking the 2 tiers in override
15/// precedence order (project -> framework) and returning the highest-precedence one that
16/// exists. Returns an error listing both attempted paths if neither exists.
17pub 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
38/// List the names of every behavior available to a project, across both tiers
39/// (`configs/behaviors/{name}/config.json` and `configs/behaviors/@mecha10/{name}/config.json`).
40/// A name present in both tiers is only listed once.
41pub 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
65/// List the names of every behavior available to a project (see [`list_available_behaviors`]),
66/// excluding any whose `requires_capabilities` isn't fully satisfied by
67/// `available_capabilities` -- e.g. so a robot with no `Flight` capability never reports a
68/// flight-only behavior as available on `/behavior/available`. A behavior whose config can't be
69/// loaded or parsed is excluded rather than reported as available.
70///
71/// NOT YET WIRED IN: nothing in *this* repo calls this function. The process that actually
72/// publishes the `/behavior/available` list to clients (dashboard, over a websocket relay) is
73/// the `behavior-executor` node runtime itself, which is NOT part of this workspace -- it's the
74/// separately-released `mecha10-nodes-behavior-executor` crate (see
75/// `scripts/release/yank-orphaned-node-crates.sh` and `packages/behavior-runtime/README.md`'s
76/// "integrates with... Dashboard: Real-time behavior visualization" note), which lives in the
77/// sibling `mecha10-nodes` repo. That node holds the live `ProjectConfig.capabilities` and the
78/// `Context`/relay it publishes status on, so the actual call site -- replacing whatever builds
79/// its available-behaviors list today with `list_capable_behaviors(project_root,
80/// &robot_capabilities)` -- has to be made there, not here. See that repo's behavior-executor
81/// node source for the status-publish path.
82pub 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
98/// Load a behavior's `config.json`, resolved via [`resolve_behavior_config_path`].
99pub 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
110/// Load a JSON file from disk
111async 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
119/// Get the current environment from environment variable
120///
121/// Falls back to "dev" if MECHA10_ENVIRONMENT is not set.
122pub fn get_current_environment() -> String {
123    mecha10_core::config::env_or_default("MECHA10_ENVIRONMENT", "dev")
124}
125
126/// Detect project root by looking for mecha10.json
127///
128/// Walks up the directory tree from the current directory until it finds a directory containing mecha10.json.
129pub 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}