mecha10_templates/services/
behavior_generator.rs1use crate::paths;
6use crate::templates::render::render;
7use anyhow::{Context, Result};
8use std::path::Path;
9
10const CONFIG_JSON_TEMPLATE: &str = include_str!("../../templates/configs/behaviors/config.json.template");
11
12pub struct BehaviorGeneratorService;
13
14impl BehaviorGeneratorService {
15 pub fn new() -> Self {
17 Self
18 }
19
20 pub async fn generate_behavior(&self, project_root: &Path, name: &str, description: Option<&str>) -> Result<()> {
24 self.validate_behavior_name(name)?;
25
26 let behavior_dir = project_root.join(paths::project::BEHAVIORS_DIR).join(name);
27 if behavior_dir.exists() {
28 anyhow::bail!("Behavior '{}' already exists at {}", name, behavior_dir.display());
29 }
30
31 tokio::fs::create_dir_all(&behavior_dir)
32 .await
33 .context("Failed to create behavior directory")?;
34
35 self.create_config_json(&behavior_dir, name, description).await?;
36
37 Ok(())
38 }
39
40 fn validate_behavior_name(&self, name: &str) -> Result<()> {
42 crate::services::generator_name::validate_generator_name("Behavior", name)
43 }
44
45 async fn create_config_json(&self, behavior_dir: &Path, name: &str, description: Option<&str>) -> Result<()> {
47 let description = description.unwrap_or("TODO: describe this behavior");
48 let content = render(CONFIG_JSON_TEMPLATE, &[("name", name), ("description", description)]);
49
50 tokio::fs::write(behavior_dir.join("config.json"), content).await?;
51 Ok(())
52 }
53}
54
55impl Default for BehaviorGeneratorService {
56 fn default() -> Self {
57 Self::new()
58 }
59}
60
61#[cfg(test)]
62mod tests {
63 use super::*;
64 use tempfile::TempDir;
65
66 async fn setup_project() -> TempDir {
67 TempDir::new().unwrap()
68 }
69
70 #[test]
71 fn validate_behavior_name_rejects_empty() {
72 let service = BehaviorGeneratorService::new();
73 let err = service.validate_behavior_name("").unwrap_err();
74 assert!(err.to_string().contains("cannot be empty"));
75 }
76
77 #[test]
78 fn validate_behavior_name_rejects_invalid_characters() {
79 let service = BehaviorGeneratorService::new();
80 let err = service.validate_behavior_name("my behavior!").unwrap_err();
81 assert!(err.to_string().contains("invalid characters"));
82 }
83
84 #[test]
85 fn validate_behavior_name_rejects_leading_digit() {
86 let service = BehaviorGeneratorService::new();
87 let err = service.validate_behavior_name("1patrol").unwrap_err();
88 assert!(err.to_string().contains("must start with a letter"));
89 }
90
91 #[test]
92 fn validate_behavior_name_accepts_kebab_and_snake_case() {
93 let service = BehaviorGeneratorService::new();
94 assert!(service.validate_behavior_name("my-patrol_behavior").is_ok());
95 assert!(service.validate_behavior_name("Patrol2").is_ok());
96 }
97
98 #[tokio::test]
99 async fn generate_behavior_rejects_invalid_name_before_touching_disk() {
100 let temp = setup_project().await;
101 let service = BehaviorGeneratorService::new();
102
103 let err = service
104 .generate_behavior(temp.path(), "1-bad-name", None)
105 .await
106 .unwrap_err();
107
108 assert!(err.to_string().contains("must start with a letter"));
109 assert!(!temp.path().join("configs/behaviors").exists());
110 }
111
112 #[tokio::test]
113 async fn generate_behavior_creates_expected_file_structure() {
114 let temp = setup_project().await;
115 let service = BehaviorGeneratorService::new();
116
117 service
118 .generate_behavior(temp.path(), "patrol", Some("desc"))
119 .await
120 .unwrap();
121
122 let behavior_dir = temp.path().join("configs/behaviors").join("patrol");
123 assert!(behavior_dir.join("config.json").exists());
124 assert!(!behavior_dir.join("src").exists());
125 assert!(!behavior_dir.join("Cargo.toml").exists());
126 }
127
128 #[tokio::test]
129 async fn generate_behavior_fails_if_behavior_already_exists() {
130 let temp = setup_project().await;
131 let service = BehaviorGeneratorService::new();
132
133 service.generate_behavior(temp.path(), "patrol", None).await.unwrap();
134 let err = service
135 .generate_behavior(temp.path(), "patrol", None)
136 .await
137 .unwrap_err();
138
139 assert!(err.to_string().contains("already exists"));
140 }
141
142 #[tokio::test]
143 async fn generate_behavior_does_not_touch_cargo_workspace_or_mecha10_json() {
144 let temp = setup_project().await;
145 let service = BehaviorGeneratorService::new();
146
147 service.generate_behavior(temp.path(), "patrol", None).await.unwrap();
148
149 assert!(!temp.path().join("Cargo.toml").exists());
150 assert!(!temp.path().join("mecha10.json").exists());
151 }
152
153 #[tokio::test]
154 async fn generate_behavior_output_content_uses_rendered_name_and_description() {
155 let temp = setup_project().await;
156 let service = BehaviorGeneratorService::new();
157
158 service
159 .generate_behavior(temp.path(), "my-patrol", Some("Patrols the perimeter"))
160 .await
161 .unwrap();
162
163 let config_json = tokio::fs::read_to_string(temp.path().join("configs/behaviors/my-patrol/config.json"))
164 .await
165 .unwrap();
166 assert_eq!(
167 config_json,
168 r#"{
169 "$schema": "https://mecha10.dev/schemas/behavior-composition-v1.json",
170 "name": "my-patrol",
171 "description": "Patrols the perimeter",
172 "root": {
173 "type": "node",
174 "node": "wander",
175 "config": {
176 "topic": "/motor/cmd_vel",
177 "linear_speed": 0.3,
178 "angular_speed": 0.5,
179 "change_interval_secs": 3.0
180 }
181 }
182}
183"#
184 );
185 }
186
187 #[tokio::test]
188 async fn generate_behavior_uses_placeholder_description_when_none_given() {
189 let temp = setup_project().await;
190 let service = BehaviorGeneratorService::new();
191
192 service.generate_behavior(temp.path(), "patrol", None).await.unwrap();
193
194 let config_json = tokio::fs::read_to_string(temp.path().join("configs/behaviors/patrol/config.json"))
195 .await
196 .unwrap();
197 assert!(config_json.contains("\"description\": \"TODO: describe this behavior\""));
198 }
199
200 #[tokio::test]
201 async fn generate_behavior_config_json_is_valid_json() {
202 let temp = setup_project().await;
203 let service = BehaviorGeneratorService::new();
204
205 service.generate_behavior(temp.path(), "patrol", None).await.unwrap();
206
207 let config_json = tokio::fs::read_to_string(temp.path().join("configs/behaviors/patrol/config.json"))
208 .await
209 .unwrap();
210 let _: serde_json::Value =
211 serde_json::from_str(&config_json).expect("generated config.json must be valid JSON");
212 }
213}