use crate::paths;
use crate::templates::render::render;
use anyhow::{Context, Result};
use std::path::Path;
const CONFIG_JSON_TEMPLATE: &str = include_str!("../../templates/configs/behaviors/config.json.template");
pub struct BehaviorGeneratorService;
impl BehaviorGeneratorService {
pub fn new() -> Self {
Self
}
pub async fn generate_behavior(&self, project_root: &Path, name: &str, description: Option<&str>) -> Result<()> {
self.validate_behavior_name(name)?;
let behavior_dir = project_root.join(paths::project::BEHAVIORS_DIR).join(name);
if behavior_dir.exists() {
anyhow::bail!("Behavior '{}' already exists at {}", name, behavior_dir.display());
}
tokio::fs::create_dir_all(&behavior_dir)
.await
.context("Failed to create behavior directory")?;
self.create_config_json(&behavior_dir, name, description).await?;
Ok(())
}
fn validate_behavior_name(&self, name: &str) -> Result<()> {
crate::services::generator_name::validate_generator_name("Behavior", name)
}
async fn create_config_json(&self, behavior_dir: &Path, name: &str, description: Option<&str>) -> Result<()> {
let description = description.unwrap_or("TODO: describe this behavior");
let content = render(CONFIG_JSON_TEMPLATE, &[("name", name), ("description", description)]);
tokio::fs::write(behavior_dir.join("config.json"), content).await?;
Ok(())
}
}
impl Default for BehaviorGeneratorService {
fn default() -> Self {
Self::new()
}
}
#[cfg(test)]
mod tests {
use super::*;
use tempfile::TempDir;
async fn setup_project() -> TempDir {
TempDir::new().unwrap()
}
#[test]
fn validate_behavior_name_rejects_empty() {
let service = BehaviorGeneratorService::new();
let err = service.validate_behavior_name("").unwrap_err();
assert!(err.to_string().contains("cannot be empty"));
}
#[test]
fn validate_behavior_name_rejects_invalid_characters() {
let service = BehaviorGeneratorService::new();
let err = service.validate_behavior_name("my behavior!").unwrap_err();
assert!(err.to_string().contains("invalid characters"));
}
#[test]
fn validate_behavior_name_rejects_leading_digit() {
let service = BehaviorGeneratorService::new();
let err = service.validate_behavior_name("1patrol").unwrap_err();
assert!(err.to_string().contains("must start with a letter"));
}
#[test]
fn validate_behavior_name_accepts_kebab_and_snake_case() {
let service = BehaviorGeneratorService::new();
assert!(service.validate_behavior_name("my-patrol_behavior").is_ok());
assert!(service.validate_behavior_name("Patrol2").is_ok());
}
#[tokio::test]
async fn generate_behavior_rejects_invalid_name_before_touching_disk() {
let temp = setup_project().await;
let service = BehaviorGeneratorService::new();
let err = service
.generate_behavior(temp.path(), "1-bad-name", None)
.await
.unwrap_err();
assert!(err.to_string().contains("must start with a letter"));
assert!(!temp.path().join("configs/behaviors").exists());
}
#[tokio::test]
async fn generate_behavior_creates_expected_file_structure() {
let temp = setup_project().await;
let service = BehaviorGeneratorService::new();
service
.generate_behavior(temp.path(), "patrol", Some("desc"))
.await
.unwrap();
let behavior_dir = temp.path().join("configs/behaviors").join("patrol");
assert!(behavior_dir.join("config.json").exists());
assert!(!behavior_dir.join("src").exists());
assert!(!behavior_dir.join("Cargo.toml").exists());
}
#[tokio::test]
async fn generate_behavior_fails_if_behavior_already_exists() {
let temp = setup_project().await;
let service = BehaviorGeneratorService::new();
service.generate_behavior(temp.path(), "patrol", None).await.unwrap();
let err = service
.generate_behavior(temp.path(), "patrol", None)
.await
.unwrap_err();
assert!(err.to_string().contains("already exists"));
}
#[tokio::test]
async fn generate_behavior_does_not_touch_cargo_workspace_or_mecha10_json() {
let temp = setup_project().await;
let service = BehaviorGeneratorService::new();
service.generate_behavior(temp.path(), "patrol", None).await.unwrap();
assert!(!temp.path().join("Cargo.toml").exists());
assert!(!temp.path().join("mecha10.json").exists());
}
#[tokio::test]
async fn generate_behavior_output_content_uses_rendered_name_and_description() {
let temp = setup_project().await;
let service = BehaviorGeneratorService::new();
service
.generate_behavior(temp.path(), "my-patrol", Some("Patrols the perimeter"))
.await
.unwrap();
let config_json = tokio::fs::read_to_string(temp.path().join("configs/behaviors/my-patrol/config.json"))
.await
.unwrap();
assert_eq!(
config_json,
r#"{
"$schema": "https://mecha10.dev/schemas/behavior-composition-v1.json",
"name": "my-patrol",
"description": "Patrols the perimeter",
"root": {
"type": "node",
"node": "wander",
"config": {
"topic": "/motor/cmd_vel",
"linear_speed": 0.3,
"angular_speed": 0.5,
"change_interval_secs": 3.0
}
}
}
"#
);
}
#[tokio::test]
async fn generate_behavior_uses_placeholder_description_when_none_given() {
let temp = setup_project().await;
let service = BehaviorGeneratorService::new();
service.generate_behavior(temp.path(), "patrol", None).await.unwrap();
let config_json = tokio::fs::read_to_string(temp.path().join("configs/behaviors/patrol/config.json"))
.await
.unwrap();
assert!(config_json.contains("\"description\": \"TODO: describe this behavior\""));
}
#[tokio::test]
async fn generate_behavior_config_json_is_valid_json() {
let temp = setup_project().await;
let service = BehaviorGeneratorService::new();
service.generate_behavior(temp.path(), "patrol", None).await.unwrap();
let config_json = tokio::fs::read_to_string(temp.path().join("configs/behaviors/patrol/config.json"))
.await
.unwrap();
let _: serde_json::Value =
serde_json::from_str(&config_json).expect("generated config.json must be valid JSON");
}
}