Skip to main content

mecha10_templates/services/
behavior_generator.rs

1//! Behavior generator service
2//!
3//! Generates custom project-authored behavior trees for Mecha10 projects (LAB-2069).
4//!
5//! Unlike `NodeGeneratorService`/`DriverGeneratorService`, a behavior is **not** a Rust crate —
6//! investigation of `packages/nodes/behavior-executor` confirmed that a behavior is a purely
7//! declarative `configs/behaviors/<name>/config.json` behavior-tree composition (a `$schema` +
8//! `root`/`type`/`node`/`children` document, see `packages/mecha10-templates/templates/configs/
9//! behaviors/@mecha10/{idle-wander,instance-track,patrol-simple,rl,takeoff-land}/config.json` for
10//! the bundled framework examples - moved under `configs/` in LAB-2072 so `mecha10 config push`
11//! actually syncs behaviors), loaded at runtime by
12//! `mecha10_behavior_runtime::resolve_behavior_config_path` /
13//! `BehaviorLoader` and executed by the `behavior-executor` node against its registered built-in
14//! action nodes (`wander`, `move`, `timer`, `sensor_check`, ...) — there is no per-behavior Rust
15//! code to scaffold. So this service only ever writes a single `config.json` file; it does not
16//! create a `src/` tree, does not touch `Cargo.toml` workspace members, and does not touch
17//! `mecha10.json` (a behavior is selected by name from a node's own
18//! `configs/nodes/<behavior-executor-instance>/config.json` `behavior_name` field, not from a
19//! project-wide registry — mirrors why `DriverGeneratorService` doesn't touch `mecha10.json`
20//! either, see that service's module doc).
21//!
22//! Still uses the same `.template`-file + `include_str!()` + `{{var}}`-substitution rendering
23//! pattern as `NodeGeneratorService`/`DriverGeneratorService` (see `crate::templates::render`),
24//! for consistency and to keep the generated content out of hand-rolled `format!()` builders.
25
26use crate::paths;
27use crate::templates::render::render;
28use anyhow::{Context, Result};
29use std::path::Path;
30
31const CONFIG_JSON_TEMPLATE: &str = include_str!("../../templates/configs/behaviors/config.json.template");
32
33pub struct BehaviorGeneratorService;
34
35impl BehaviorGeneratorService {
36    /// Create a new BehaviorGeneratorService
37    pub fn new() -> Self {
38        Self
39    }
40
41    /// Generate a new behavior in the project
42    ///
43    /// Creates the following structure:
44    /// ```text
45    /// configs/behaviors/<name>/
46    /// └── config.json
47    /// ```
48    pub async fn generate_behavior(&self, project_root: &Path, name: &str, description: Option<&str>) -> Result<()> {
49        self.validate_behavior_name(name)?;
50
51        let behavior_dir = project_root.join(paths::project::BEHAVIORS_DIR).join(name);
52        if behavior_dir.exists() {
53            anyhow::bail!("Behavior '{}' already exists at {}", name, behavior_dir.display());
54        }
55
56        tokio::fs::create_dir_all(&behavior_dir)
57            .await
58            .context("Failed to create behavior directory")?;
59
60        self.create_config_json(&behavior_dir, name, description).await?;
61
62        Ok(())
63    }
64
65    /// Validate behavior name — same rules as node/driver names: it becomes both a directory
66    /// name and the behavior tree's own `name` field. Shared with
67    /// `DriverGeneratorService`/`NodeGeneratorService` via
68    /// `crate::services::generator_name::validate_generator_name` (was an exact triplicate,
69    /// LAB-2749).
70    fn validate_behavior_name(&self, name: &str) -> Result<()> {
71        crate::services::generator_name::validate_generator_name("Behavior", name)
72    }
73
74    /// Create configs/behaviors/<name>/config.json with the rendered behavior-tree composition
75    async fn create_config_json(&self, behavior_dir: &Path, name: &str, description: Option<&str>) -> Result<()> {
76        let description = description.unwrap_or("TODO: describe this behavior");
77        let content = render(CONFIG_JSON_TEMPLATE, &[("name", name), ("description", description)]);
78
79        tokio::fs::write(behavior_dir.join("config.json"), content).await?;
80        Ok(())
81    }
82}
83
84impl Default for BehaviorGeneratorService {
85    fn default() -> Self {
86        Self::new()
87    }
88}
89
90#[cfg(test)]
91mod tests {
92    use super::*;
93    use tempfile::TempDir;
94
95    async fn setup_project() -> TempDir {
96        TempDir::new().unwrap()
97    }
98
99    #[test]
100    fn validate_behavior_name_rejects_empty() {
101        let service = BehaviorGeneratorService::new();
102        let err = service.validate_behavior_name("").unwrap_err();
103        assert!(err.to_string().contains("cannot be empty"));
104    }
105
106    #[test]
107    fn validate_behavior_name_rejects_invalid_characters() {
108        let service = BehaviorGeneratorService::new();
109        let err = service.validate_behavior_name("my behavior!").unwrap_err();
110        assert!(err.to_string().contains("invalid characters"));
111    }
112
113    #[test]
114    fn validate_behavior_name_rejects_leading_digit() {
115        let service = BehaviorGeneratorService::new();
116        let err = service.validate_behavior_name("1patrol").unwrap_err();
117        assert!(err.to_string().contains("must start with a letter"));
118    }
119
120    #[test]
121    fn validate_behavior_name_accepts_kebab_and_snake_case() {
122        let service = BehaviorGeneratorService::new();
123        assert!(service.validate_behavior_name("my-patrol_behavior").is_ok());
124        assert!(service.validate_behavior_name("Patrol2").is_ok());
125    }
126
127    #[tokio::test]
128    async fn generate_behavior_rejects_invalid_name_before_touching_disk() {
129        let temp = setup_project().await;
130        let service = BehaviorGeneratorService::new();
131
132        let err = service
133            .generate_behavior(temp.path(), "1-bad-name", None)
134            .await
135            .unwrap_err();
136
137        assert!(err.to_string().contains("must start with a letter"));
138        assert!(!temp.path().join("configs/behaviors").exists());
139    }
140
141    #[tokio::test]
142    async fn generate_behavior_creates_expected_file_structure() {
143        let temp = setup_project().await;
144        let service = BehaviorGeneratorService::new();
145
146        service
147            .generate_behavior(temp.path(), "patrol", Some("desc"))
148            .await
149            .unwrap();
150
151        let behavior_dir = temp.path().join("configs/behaviors").join("patrol");
152        assert!(behavior_dir.join("config.json").exists());
153        assert!(!behavior_dir.join("src").exists());
154        assert!(!behavior_dir.join("Cargo.toml").exists());
155    }
156
157    #[tokio::test]
158    async fn generate_behavior_fails_if_behavior_already_exists() {
159        let temp = setup_project().await;
160        let service = BehaviorGeneratorService::new();
161
162        service.generate_behavior(temp.path(), "patrol", None).await.unwrap();
163        let err = service
164            .generate_behavior(temp.path(), "patrol", None)
165            .await
166            .unwrap_err();
167
168        assert!(err.to_string().contains("already exists"));
169    }
170
171    #[tokio::test]
172    async fn generate_behavior_does_not_touch_cargo_workspace_or_mecha10_json() {
173        let temp = setup_project().await;
174        let service = BehaviorGeneratorService::new();
175
176        service.generate_behavior(temp.path(), "patrol", None).await.unwrap();
177
178        assert!(!temp.path().join("Cargo.toml").exists());
179        assert!(!temp.path().join("mecha10.json").exists());
180    }
181
182    #[tokio::test]
183    async fn generate_behavior_output_content_uses_rendered_name_and_description() {
184        let temp = setup_project().await;
185        let service = BehaviorGeneratorService::new();
186
187        service
188            .generate_behavior(temp.path(), "my-patrol", Some("Patrols the perimeter"))
189            .await
190            .unwrap();
191
192        let config_json = tokio::fs::read_to_string(temp.path().join("configs/behaviors/my-patrol/config.json"))
193            .await
194            .unwrap();
195        assert_eq!(
196            config_json,
197            r#"{
198  "$schema": "https://mecha10.dev/schemas/behavior-composition-v1.json",
199  "name": "my-patrol",
200  "description": "Patrols the perimeter",
201  "root": {
202    "type": "node",
203    "node": "wander",
204    "config": {
205      "topic": "/motor/cmd_vel",
206      "linear_speed": 0.3,
207      "angular_speed": 0.5,
208      "change_interval_secs": 3.0
209    }
210  }
211}
212"#
213        );
214    }
215
216    #[tokio::test]
217    async fn generate_behavior_uses_placeholder_description_when_none_given() {
218        let temp = setup_project().await;
219        let service = BehaviorGeneratorService::new();
220
221        service.generate_behavior(temp.path(), "patrol", None).await.unwrap();
222
223        let config_json = tokio::fs::read_to_string(temp.path().join("configs/behaviors/patrol/config.json"))
224            .await
225            .unwrap();
226        assert!(config_json.contains("\"description\": \"TODO: describe this behavior\""));
227    }
228
229    #[tokio::test]
230    async fn generate_behavior_config_json_is_valid_json() {
231        let temp = setup_project().await;
232        let service = BehaviorGeneratorService::new();
233
234        service.generate_behavior(temp.path(), "patrol", None).await.unwrap();
235
236        let config_json = tokio::fs::read_to_string(temp.path().join("configs/behaviors/patrol/config.json"))
237            .await
238            .unwrap();
239        let _: serde_json::Value =
240            serde_json::from_str(&config_json).expect("generated config.json must be valid JSON");
241    }
242}