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};
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(", ")
)
}
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()
}
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()
)
})
}
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()))
}
pub fn get_current_environment() -> String {
mecha10_core::config::env_or_default("MECHA10_ENVIRONMENT", "dev")
}
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());
}
}