mecha10-templates 0.8.2

Mecha10 project templates — code generation, init, and template services
Documentation
//! Behavior generator service
//!
//! Generates custom project-authored behavior trees (a declarative `config.json`, not a Rust crate).

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 {
    /// Create a new BehaviorGeneratorService
    pub fn new() -> Self {
        Self
    }

    /// Generate a new behavior in the project
    ///
    /// Creates the following structure:
    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(())
    }

    /// Validate behavior name — same rules as node/driver names.
    fn validate_behavior_name(&self, name: &str) -> Result<()> {
        crate::services::generator_name::validate_generator_name("Behavior", name)
    }

    /// Create configs/behaviors/<name>/config.json with the rendered behavior-tree composition
    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");
    }
}