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