mecha10_templates/services/
behavior_generator.rs1use crate::paths;
26use crate::templates::render::render;
27use anyhow::{Context, Result};
28use std::path::Path;
29
30const CONFIG_JSON_TEMPLATE: &str = include_str!("../../templates/configs/behaviors/config.json.template");
33
34pub struct BehaviorGeneratorService;
36
37impl BehaviorGeneratorService {
38 pub fn new() -> Self {
40 Self
41 }
42
43 pub async fn generate_behavior(&self, project_root: &Path, name: &str, description: Option<&str>) -> Result<()> {
51 self.validate_behavior_name(name)?;
53
54 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 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 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 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 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}