mecha10_templates/services/
behavior_generator.rs1use 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 pub fn new() -> Self {
38 Self
39 }
40
41 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 fn validate_behavior_name(&self, name: &str) -> Result<()> {
71 crate::services::generator_name::validate_generator_name("Behavior", name)
72 }
73
74 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}