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        self.validate_behavior_name(name)?;
52
53        let behavior_dir = project_root.join(paths::project::BEHAVIORS_DIR).join(name);
54        if behavior_dir.exists() {
55            anyhow::bail!("Behavior '{}' already exists at {}", name, behavior_dir.display());
56        }
57
58        tokio::fs::create_dir_all(&behavior_dir)
59            .await
60            .context("Failed to create behavior directory")?;
61
62        self.create_config_json(&behavior_dir, name, description).await?;
63
64        Ok(())
65    }
66
67    /// Validate behavior name — same rules as node/driver names: it becomes both a directory
68    /// name and the behavior tree's own `name` field. Shared with
69    /// `DriverGeneratorService`/`NodeGeneratorService` via
70    /// `crate::services::generator_name::validate_generator_name` (was an exact triplicate,
71    /// LAB-2749).
72    fn validate_behavior_name(&self, name: &str) -> Result<()> {
73        crate::services::generator_name::validate_generator_name("Behavior", name)
74    }
75
76    /// Create configs/behaviors/<name>/config.json with the rendered behavior-tree composition
77    async fn create_config_json(&self, behavior_dir: &Path, name: &str, description: Option<&str>) -> Result<()> {
78        let description = description.unwrap_or("TODO: describe this behavior");
79        let content = render(CONFIG_JSON_TEMPLATE, &[("name", name), ("description", description)]);
80
81        tokio::fs::write(behavior_dir.join("config.json"), content).await?;
82        Ok(())
83    }
84}
85
86impl Default for BehaviorGeneratorService {
87    fn default() -> Self {
88        Self::new()
89    }
90}
91
92#[cfg(test)]
93mod tests {
94    use super::*;
95    use tempfile::TempDir;
96
97    async fn setup_project() -> TempDir {
98        TempDir::new().unwrap()
99    }
100
101    #[test]
102    fn validate_behavior_name_rejects_empty() {
103        let service = BehaviorGeneratorService::new();
104        let err = service.validate_behavior_name("").unwrap_err();
105        assert!(err.to_string().contains("cannot be empty"));
106    }
107
108    #[test]
109    fn validate_behavior_name_rejects_invalid_characters() {
110        let service = BehaviorGeneratorService::new();
111        let err = service.validate_behavior_name("my behavior!").unwrap_err();
112        assert!(err.to_string().contains("invalid characters"));
113    }
114
115    #[test]
116    fn validate_behavior_name_rejects_leading_digit() {
117        let service = BehaviorGeneratorService::new();
118        let err = service.validate_behavior_name("1patrol").unwrap_err();
119        assert!(err.to_string().contains("must start with a letter"));
120    }
121
122    #[test]
123    fn validate_behavior_name_accepts_kebab_and_snake_case() {
124        let service = BehaviorGeneratorService::new();
125        assert!(service.validate_behavior_name("my-patrol_behavior").is_ok());
126        assert!(service.validate_behavior_name("Patrol2").is_ok());
127    }
128
129    #[tokio::test]
130    async fn generate_behavior_rejects_invalid_name_before_touching_disk() {
131        let temp = setup_project().await;
132        let service = BehaviorGeneratorService::new();
133
134        let err = service
135            .generate_behavior(temp.path(), "1-bad-name", None)
136            .await
137            .unwrap_err();
138
139        assert!(err.to_string().contains("must start with a letter"));
140        assert!(!temp.path().join("configs/behaviors").exists());
141    }
142
143    #[tokio::test]
144    async fn generate_behavior_creates_expected_file_structure() {
145        let temp = setup_project().await;
146        let service = BehaviorGeneratorService::new();
147
148        service
149            .generate_behavior(temp.path(), "patrol", Some("desc"))
150            .await
151            .unwrap();
152
153        let behavior_dir = temp.path().join("configs/behaviors").join("patrol");
154        assert!(behavior_dir.join("config.json").exists());
155        assert!(!behavior_dir.join("src").exists());
156        assert!(!behavior_dir.join("Cargo.toml").exists());
157    }
158
159    #[tokio::test]
160    async fn generate_behavior_fails_if_behavior_already_exists() {
161        let temp = setup_project().await;
162        let service = BehaviorGeneratorService::new();
163
164        service.generate_behavior(temp.path(), "patrol", None).await.unwrap();
165        let err = service
166            .generate_behavior(temp.path(), "patrol", None)
167            .await
168            .unwrap_err();
169
170        assert!(err.to_string().contains("already exists"));
171    }
172
173    #[tokio::test]
174    async fn generate_behavior_does_not_touch_cargo_workspace_or_mecha10_json() {
175        let temp = setup_project().await;
176        let service = BehaviorGeneratorService::new();
177
178        service.generate_behavior(temp.path(), "patrol", None).await.unwrap();
179
180        assert!(!temp.path().join("Cargo.toml").exists());
181        assert!(!temp.path().join("mecha10.json").exists());
182    }
183
184    #[tokio::test]
185    async fn generate_behavior_output_content_uses_rendered_name_and_description() {
186        let temp = setup_project().await;
187        let service = BehaviorGeneratorService::new();
188
189        service
190            .generate_behavior(temp.path(), "my-patrol", Some("Patrols the perimeter"))
191            .await
192            .unwrap();
193
194        let config_json = tokio::fs::read_to_string(temp.path().join("configs/behaviors/my-patrol/config.json"))
195            .await
196            .unwrap();
197        assert_eq!(
198            config_json,
199            r#"{
200  "$schema": "https://mecha10.dev/schemas/behavior-composition-v1.json",
201  "name": "my-patrol",
202  "description": "Patrols the perimeter",
203  "root": {
204    "type": "node",
205    "node": "wander",
206    "config": {
207      "topic": "/motor/cmd_vel",
208      "linear_speed": 0.3,
209      "angular_speed": 0.5,
210      "change_interval_secs": 3.0
211    }
212  }
213}
214"#
215        );
216    }
217
218    #[tokio::test]
219    async fn generate_behavior_uses_placeholder_description_when_none_given() {
220        let temp = setup_project().await;
221        let service = BehaviorGeneratorService::new();
222
223        service.generate_behavior(temp.path(), "patrol", None).await.unwrap();
224
225        let config_json = tokio::fs::read_to_string(temp.path().join("configs/behaviors/patrol/config.json"))
226            .await
227            .unwrap();
228        assert!(config_json.contains("\"description\": \"TODO: describe this behavior\""));
229    }
230
231    #[tokio::test]
232    async fn generate_behavior_config_json_is_valid_json() {
233        let temp = setup_project().await;
234        let service = BehaviorGeneratorService::new();
235
236        service.generate_behavior(temp.path(), "patrol", None).await.unwrap();
237
238        let config_json = tokio::fs::read_to_string(temp.path().join("configs/behaviors/patrol/config.json"))
239            .await
240            .unwrap();
241        let _: serde_json::Value =
242            serde_json::from_str(&config_json).expect("generated config.json must be valid JSON");
243    }
244}