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)?;
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 fn validate_behavior_name(&self, name: &str) -> Result<()> {
73 crate::services::generator_name::validate_generator_name("Behavior", name)
74 }
75
76 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}