Skip to main content

mecha10_templates/services/
behavior_generator.rs

1//! Behavior generator service
2//!
3//! Generates custom project-authored behavior trees (a declarative `config.json`, not a Rust crate).
4
5use crate::paths;
6use crate::templates::render::render;
7use anyhow::{Context, Result};
8use std::path::Path;
9
10const CONFIG_JSON_TEMPLATE: &str = include_str!("../../templates/configs/behaviors/config.json.template");
11
12pub struct BehaviorGeneratorService;
13
14impl BehaviorGeneratorService {
15    /// Create a new BehaviorGeneratorService
16    pub fn new() -> Self {
17        Self
18    }
19
20    /// Generate a new behavior in the project
21    ///
22    /// Creates the following structure:
23    pub async fn generate_behavior(&self, project_root: &Path, name: &str, description: Option<&str>) -> Result<()> {
24        self.validate_behavior_name(name)?;
25
26        let behavior_dir = project_root.join(paths::project::BEHAVIORS_DIR).join(name);
27        if behavior_dir.exists() {
28            anyhow::bail!("Behavior '{}' already exists at {}", name, behavior_dir.display());
29        }
30
31        tokio::fs::create_dir_all(&behavior_dir)
32            .await
33            .context("Failed to create behavior directory")?;
34
35        self.create_config_json(&behavior_dir, name, description).await?;
36
37        Ok(())
38    }
39
40    /// Validate behavior name — same rules as node/driver names.
41    fn validate_behavior_name(&self, name: &str) -> Result<()> {
42        crate::services::generator_name::validate_generator_name("Behavior", name)
43    }
44
45    /// Create configs/behaviors/<name>/config.json with the rendered behavior-tree composition
46    async fn create_config_json(&self, behavior_dir: &Path, name: &str, description: Option<&str>) -> Result<()> {
47        let description = description.unwrap_or("TODO: describe this behavior");
48        let content = render(CONFIG_JSON_TEMPLATE, &[("name", name), ("description", description)]);
49
50        tokio::fs::write(behavior_dir.join("config.json"), content).await?;
51        Ok(())
52    }
53}
54
55impl Default for BehaviorGeneratorService {
56    fn default() -> Self {
57        Self::new()
58    }
59}
60
61#[cfg(test)]
62mod tests {
63    use super::*;
64    use tempfile::TempDir;
65
66    async fn setup_project() -> TempDir {
67        TempDir::new().unwrap()
68    }
69
70    #[test]
71    fn validate_behavior_name_rejects_empty() {
72        let service = BehaviorGeneratorService::new();
73        let err = service.validate_behavior_name("").unwrap_err();
74        assert!(err.to_string().contains("cannot be empty"));
75    }
76
77    #[test]
78    fn validate_behavior_name_rejects_invalid_characters() {
79        let service = BehaviorGeneratorService::new();
80        let err = service.validate_behavior_name("my behavior!").unwrap_err();
81        assert!(err.to_string().contains("invalid characters"));
82    }
83
84    #[test]
85    fn validate_behavior_name_rejects_leading_digit() {
86        let service = BehaviorGeneratorService::new();
87        let err = service.validate_behavior_name("1patrol").unwrap_err();
88        assert!(err.to_string().contains("must start with a letter"));
89    }
90
91    #[test]
92    fn validate_behavior_name_accepts_kebab_and_snake_case() {
93        let service = BehaviorGeneratorService::new();
94        assert!(service.validate_behavior_name("my-patrol_behavior").is_ok());
95        assert!(service.validate_behavior_name("Patrol2").is_ok());
96    }
97
98    #[tokio::test]
99    async fn generate_behavior_rejects_invalid_name_before_touching_disk() {
100        let temp = setup_project().await;
101        let service = BehaviorGeneratorService::new();
102
103        let err = service
104            .generate_behavior(temp.path(), "1-bad-name", None)
105            .await
106            .unwrap_err();
107
108        assert!(err.to_string().contains("must start with a letter"));
109        assert!(!temp.path().join("configs/behaviors").exists());
110    }
111
112    #[tokio::test]
113    async fn generate_behavior_creates_expected_file_structure() {
114        let temp = setup_project().await;
115        let service = BehaviorGeneratorService::new();
116
117        service
118            .generate_behavior(temp.path(), "patrol", Some("desc"))
119            .await
120            .unwrap();
121
122        let behavior_dir = temp.path().join("configs/behaviors").join("patrol");
123        assert!(behavior_dir.join("config.json").exists());
124        assert!(!behavior_dir.join("src").exists());
125        assert!(!behavior_dir.join("Cargo.toml").exists());
126    }
127
128    #[tokio::test]
129    async fn generate_behavior_fails_if_behavior_already_exists() {
130        let temp = setup_project().await;
131        let service = BehaviorGeneratorService::new();
132
133        service.generate_behavior(temp.path(), "patrol", None).await.unwrap();
134        let err = service
135            .generate_behavior(temp.path(), "patrol", None)
136            .await
137            .unwrap_err();
138
139        assert!(err.to_string().contains("already exists"));
140    }
141
142    #[tokio::test]
143    async fn generate_behavior_does_not_touch_cargo_workspace_or_mecha10_json() {
144        let temp = setup_project().await;
145        let service = BehaviorGeneratorService::new();
146
147        service.generate_behavior(temp.path(), "patrol", None).await.unwrap();
148
149        assert!(!temp.path().join("Cargo.toml").exists());
150        assert!(!temp.path().join("mecha10.json").exists());
151    }
152
153    #[tokio::test]
154    async fn generate_behavior_output_content_uses_rendered_name_and_description() {
155        let temp = setup_project().await;
156        let service = BehaviorGeneratorService::new();
157
158        service
159            .generate_behavior(temp.path(), "my-patrol", Some("Patrols the perimeter"))
160            .await
161            .unwrap();
162
163        let config_json = tokio::fs::read_to_string(temp.path().join("configs/behaviors/my-patrol/config.json"))
164            .await
165            .unwrap();
166        assert_eq!(
167            config_json,
168            r#"{
169  "$schema": "https://mecha10.dev/schemas/behavior-composition-v1.json",
170  "name": "my-patrol",
171  "description": "Patrols the perimeter",
172  "root": {
173    "type": "node",
174    "node": "wander",
175    "config": {
176      "topic": "/motor/cmd_vel",
177      "linear_speed": 0.3,
178      "angular_speed": 0.5,
179      "change_interval_secs": 3.0
180    }
181  }
182}
183"#
184        );
185    }
186
187    #[tokio::test]
188    async fn generate_behavior_uses_placeholder_description_when_none_given() {
189        let temp = setup_project().await;
190        let service = BehaviorGeneratorService::new();
191
192        service.generate_behavior(temp.path(), "patrol", None).await.unwrap();
193
194        let config_json = tokio::fs::read_to_string(temp.path().join("configs/behaviors/patrol/config.json"))
195            .await
196            .unwrap();
197        assert!(config_json.contains("\"description\": \"TODO: describe this behavior\""));
198    }
199
200    #[tokio::test]
201    async fn generate_behavior_config_json_is_valid_json() {
202        let temp = setup_project().await;
203        let service = BehaviorGeneratorService::new();
204
205        service.generate_behavior(temp.path(), "patrol", None).await.unwrap();
206
207        let config_json = tokio::fs::read_to_string(temp.path().join("configs/behaviors/patrol/config.json"))
208            .await
209            .unwrap();
210        let _: serde_json::Value =
211            serde_json::from_str(&config_json).expect("generated config.json must be valid JSON");
212    }
213}