mecha10_templates/services/
node_generator.rs1use crate::paths;
10use crate::templates::render::render;
11use anyhow::{Context, Result};
12use std::path::Path;
13
14const CARGO_TOML_TEMPLATE: &str = include_str!("../../templates/nodes/Cargo.toml.template");
16const LIB_RS_TEMPLATE: &str = include_str!("../../templates/nodes/lib.rs.template");
18const MAIN_RS_TEMPLATE: &str = include_str!("../../templates/nodes/main.rs.template");
20const CONFIG_RS_TEMPLATE: &str = include_str!("../../templates/nodes/config.rs.template");
22const CONFIG_JSON_TEMPLATE: &str = include_str!("../../templates/nodes/config.json.template");
26
27pub struct NodeGeneratorService;
29
30impl NodeGeneratorService {
31 pub fn new() -> Self {
33 Self
34 }
35
36 pub async fn generate_node(&self, project_root: &Path, name: &str, description: Option<&str>) -> Result<()> {
51 self.validate_node_name(name)?;
52
53 let node_dir = project_root.join(paths::project::NODES_DIR).join(name);
54 if node_dir.exists() {
55 anyhow::bail!("Node '{}' already exists at {}", name, node_dir.display());
56 }
57
58 let src_dir = node_dir.join(paths::project::SRC_DIR);
59 tokio::fs::create_dir_all(&src_dir)
60 .await
61 .context("Failed to create node directory")?;
62
63 self.create_cargo_toml(&node_dir, name).await?;
64 self.create_lib_rs(&src_dir, name).await?;
65 self.create_main_rs(&src_dir, name).await?;
66 self.create_config_rs(&src_dir, name).await?;
67
68 let config_dir = project_root.join(paths::config::NODES_DIR).join(name);
69 tokio::fs::create_dir_all(&config_dir)
70 .await
71 .context("Failed to create config directory")?;
72 self.create_config_json(&config_dir).await?;
73
74 self.update_mecha10_json(project_root, name, description).await?;
75
76 self.update_cargo_workspace(project_root, name).await?;
77
78 Ok(())
79 }
80
81 fn validate_node_name(&self, name: &str) -> Result<()> {
85 crate::services::generator_name::validate_generator_name("Node", name)
86 }
87
88 fn to_pascal_case(&self, name: &str) -> String {
90 name.split(['-', '_'])
91 .filter(|s| !s.is_empty())
92 .map(|word| {
93 let mut chars = word.chars();
94 match chars.next() {
95 None => String::new(),
96 Some(first) => first.to_uppercase().chain(chars).collect(),
97 }
98 })
99 .collect()
100 }
101
102 fn to_snake_case(&self, name: &str) -> String {
104 name.replace('-', "_")
105 }
106
107 async fn create_cargo_toml(&self, node_dir: &Path, name: &str) -> Result<()> {
109 let content = render(
110 CARGO_TOML_TEMPLATE,
111 &[("name", name), ("crate_name", &self.to_snake_case(name))],
112 );
113
114 tokio::fs::write(node_dir.join(paths::rust::CARGO_TOML), content).await?;
115 Ok(())
116 }
117
118 async fn write_generated(&self, path: &Path, content: String) -> Result<()> {
124 tokio::fs::write(path, content).await?;
125 Ok(())
126 }
127
128 async fn render_pascal_and_write(&self, path: &Path, template: &str, name: &str) -> Result<()> {
135 let pascal_name = self.to_pascal_case(name);
136 let content = render(template, &[("name", name), ("pascal_name", &pascal_name)]);
137
138 self.write_generated(path, content).await
139 }
140
141 async fn create_lib_rs(&self, src_dir: &Path, name: &str) -> Result<()> {
143 self.render_pascal_and_write(&src_dir.join("lib.rs"), LIB_RS_TEMPLATE, name)
144 .await
145 }
146
147 async fn create_main_rs(&self, src_dir: &Path, name: &str) -> Result<()> {
149 let crate_name = self.to_snake_case(name);
150 let content = render(MAIN_RS_TEMPLATE, &[("name", name), ("crate_name", &crate_name)]);
151
152 tokio::fs::write(src_dir.join("main.rs"), content).await?;
153 Ok(())
154 }
155
156 async fn create_config_rs(&self, src_dir: &Path, name: &str) -> Result<()> {
158 self.render_pascal_and_write(&src_dir.join("config.rs"), CONFIG_RS_TEMPLATE, name)
159 .await
160 }
161
162 async fn create_config_json(&self, config_dir: &Path) -> Result<()> {
164 tokio::fs::write(config_dir.join("config.json"), CONFIG_JSON_TEMPLATE).await?;
165 Ok(())
166 }
167
168 async fn update_mecha10_json(&self, project_root: &Path, name: &str, _description: Option<&str>) -> Result<()> {
170 let config_path = project_root.join(paths::PROJECT_CONFIG);
171
172 let content = tokio::fs::read_to_string(&config_path)
173 .await
174 .context("Failed to read mecha10.json")?;
175
176 let mut config: serde_json::Value = serde_json::from_str(&content).context("Failed to parse mecha10.json")?;
177
178 let node_identifier = name.to_string();
179
180 if let Some(nodes) = config.get_mut("nodes") {
181 if let Some(arr) = nodes.as_array_mut() {
182 let exists = arr.iter().any(|n| n.as_str() == Some(&node_identifier));
183 if exists {
184 anyhow::bail!("Node '{}' already exists in mecha10.json", name);
185 }
186 arr.push(serde_json::Value::String(node_identifier.clone()));
187 }
188 } else {
189 config["nodes"] = serde_json::json!([node_identifier.clone()]);
190 }
191
192 if let Some(dev) = config.get_mut("targets").and_then(|t| t.get_mut("dev")) {
193 if let Some(dev_nodes) = dev.get_mut("nodes") {
194 if let Some(arr) = dev_nodes.as_array_mut() {
195 let exists = arr.iter().any(|n| n.as_str() == Some(&node_identifier));
196 if !exists {
197 arr.push(serde_json::Value::String(node_identifier));
198 }
199 }
200 }
201 }
202
203 let updated_content = serde_json::to_string_pretty(&config)?;
204 tokio::fs::write(&config_path, updated_content).await?;
205
206 Ok(())
207 }
208
209 async fn update_cargo_workspace(&self, project_root: &Path, name: &str) -> Result<()> {
211 crate::services::cargo_workspace::add_workspace_member(project_root, &format!("nodes/{}", name)).await
212 }
213}
214
215impl Default for NodeGeneratorService {
216 fn default() -> Self {
217 Self::new()
218 }
219}
220
221#[cfg(test)]
222mod tests {
223 use super::*;
224 use tempfile::TempDir;
225
226 async fn setup_project() -> TempDir {
229 let temp = TempDir::new().unwrap();
230
231 let mecha10_json = r#"{
232 "nodes": [],
233 "targets": {
234 "dev": {
235 "nodes": []
236 }
237 }
238}
239"#;
240 tokio::fs::write(temp.path().join("mecha10.json"), mecha10_json)
241 .await
242 .unwrap();
243
244 let cargo_toml = "[workspace]\nmembers = []\n";
245 tokio::fs::write(temp.path().join("Cargo.toml"), cargo_toml)
246 .await
247 .unwrap();
248 let _: toml::Value = toml::from_str(cargo_toml).expect("fixture Cargo.toml must be valid TOML");
249
250 temp
251 }
252
253 #[test]
254 fn validate_node_name_rejects_empty() {
255 let service = NodeGeneratorService::new();
256 let err = service.validate_node_name("").unwrap_err();
257 assert!(err.to_string().contains("cannot be empty"));
258 }
259
260 #[test]
261 fn validate_node_name_rejects_invalid_characters() {
262 let service = NodeGeneratorService::new();
263 let err = service.validate_node_name("my node!").unwrap_err();
264 assert!(err.to_string().contains("invalid characters"));
265 }
266
267 #[test]
268 fn validate_node_name_rejects_leading_digit() {
269 let service = NodeGeneratorService::new();
270 let err = service.validate_node_name("1motor").unwrap_err();
271 assert!(err.to_string().contains("must start with a letter"));
272 }
273
274 #[test]
275 fn validate_node_name_accepts_kebab_and_snake_case() {
276 let service = NodeGeneratorService::new();
277 assert!(service.validate_node_name("my-motor_node").is_ok());
278 assert!(service.validate_node_name("Motor2").is_ok());
279 }
280
281 #[test]
282 fn to_pascal_case_handles_kebab_case() {
283 let service = NodeGeneratorService::new();
284 assert_eq!(service.to_pascal_case("my-motor-node"), "MyMotorNode");
285 }
286
287 #[test]
288 fn to_pascal_case_handles_snake_case() {
289 let service = NodeGeneratorService::new();
290 assert_eq!(service.to_pascal_case("my_motor_node"), "MyMotorNode");
291 }
292
293 #[test]
294 fn to_snake_case_replaces_hyphens() {
295 let service = NodeGeneratorService::new();
296 assert_eq!(service.to_snake_case("my-motor-node"), "my_motor_node");
297 }
298
299 #[tokio::test]
300 async fn generate_node_rejects_invalid_name_before_touching_disk() {
301 let temp = setup_project().await;
302 let service = NodeGeneratorService::new();
303
304 let err = service
305 .generate_node(temp.path(), "1-bad-name", None)
306 .await
307 .unwrap_err();
308
309 assert!(err.to_string().contains("must start with a letter"));
310 assert!(!temp.path().join("nodes").exists());
311 }
312
313 #[tokio::test]
314 async fn generate_node_creates_expected_file_structure() {
315 let temp = setup_project().await;
316 let service = NodeGeneratorService::new();
317
318 service.generate_node(temp.path(), "motor", Some("desc")).await.unwrap();
319
320 let node_dir = temp.path().join("nodes").join("motor");
321 assert!(node_dir.join("Cargo.toml").exists());
322 assert!(node_dir.join("src/lib.rs").exists());
323 assert!(node_dir.join("src/main.rs").exists());
324 assert!(node_dir.join("src/config.rs").exists());
325
326 let config_dir = temp.path().join("configs/nodes/motor");
327 assert!(config_dir.join("config.json").exists());
328 }
329
330 #[tokio::test]
337 async fn generate_node_output_is_byte_identical_to_hand_rolled_generator() {
338 let temp = setup_project().await;
339 let service = NodeGeneratorService::new();
340
341 service.generate_node(temp.path(), "my-motor", None).await.unwrap();
342
343 let node_dir = temp.path().join("nodes/my-motor");
344
345 let cargo_toml = tokio::fs::read_to_string(node_dir.join("Cargo.toml")).await.unwrap();
346 assert_eq!(
347 cargo_toml,
348 r#"[package]
349name = "my-motor"
350version = "0.1.0"
351edition = "2021"
352
353[lib]
354name = "my_motor"
355path = "src/lib.rs"
356
357[[bin]]
358name = "my-motor"
359path = "src/main.rs"
360
361[dependencies]
362anyhow = "1.0"
363async-trait = "0.1"
364mecha10-core = "0.1"
365serde = { version = "1.0", features = ["derive"] }
366tokio = { version = "1.40", features = ["full"] }
367tracing = "0.1"
368"#
369 );
370
371 let main_rs = tokio::fs::read_to_string(node_dir.join("src/main.rs")).await.unwrap();
372 assert_eq!(
373 main_rs,
374 r#"//! my-motor Node Binary
375//!
376//! Runs the my-motor node as a standalone binary.
377
378use mecha10_core::prelude::*;
379
380#[tokio::main]
381async fn main() -> anyhow::Result<()> {
382 init_logging();
383
384 // Uses auto-generated run() from #[derive(Node)]
385 my_motor::run().await.map_err(|e| anyhow::anyhow!(e))
386}
387"#
388 );
389
390 let config_rs = tokio::fs::read_to_string(node_dir.join("src/config.rs")).await.unwrap();
391 assert_eq!(
392 config_rs,
393 r#"//! my-motor node configuration
394
395use mecha10_core::prelude::*;
396
397/// MyMotor node configuration
398#[derive(Debug, Clone, Serialize, Deserialize)]
399pub struct MyMotorConfig {
400 /// Rate at which to publish messages (Hz)
401 #[serde(default = "default_rate_hz")]
402 pub rate_hz: f32,
403}
404
405fn default_rate_hz() -> f32 {
406 1.0
407}
408
409impl Default for MyMotorConfig {
410 fn default() -> Self {
411 Self {
412 rate_hz: default_rate_hz(),
413 }
414 }
415}
416"#
417 );
418
419 let config_json = tokio::fs::read_to_string(temp.path().join("configs/nodes/my-motor/config.json"))
420 .await
421 .unwrap();
422 assert_eq!(
423 config_json,
424 r#"{
425 "dev": {
426 "rate_hz": 1.0,
427 "topics": {
428 "publishes": [],
429 "subscribes": []
430 }
431 },
432 "production": {
433 "rate_hz": 1.0,
434 "topics": {
435 "publishes": [],
436 "subscribes": []
437 }
438 }
439}
440"#
441 );
442 }
443
444 #[tokio::test]
445 async fn generate_node_fails_if_node_already_exists() {
446 let temp = setup_project().await;
447 let service = NodeGeneratorService::new();
448
449 service.generate_node(temp.path(), "motor", None).await.unwrap();
450 let err = service.generate_node(temp.path(), "motor", None).await.unwrap_err();
451
452 assert!(err.to_string().contains("already exists"));
453 }
454
455 #[tokio::test]
456 async fn generate_node_lib_rs_uses_pascal_case_struct_name() {
457 let temp = setup_project().await;
458 let service = NodeGeneratorService::new();
459
460 service.generate_node(temp.path(), "my-motor", None).await.unwrap();
461
462 let lib_rs = tokio::fs::read_to_string(temp.path().join("nodes/my-motor/src/lib.rs"))
463 .await
464 .unwrap();
465 assert!(lib_rs.contains("pub struct MyMotorNode"));
466 assert!(lib_rs.contains("MyMotorConfig"));
467 assert!(lib_rs.contains("Topic::new(\"/my-motor/hello\")"));
468 }
469
470 #[tokio::test]
471 async fn generate_node_cargo_toml_uses_snake_case_lib_name() {
472 let temp = setup_project().await;
473 let service = NodeGeneratorService::new();
474
475 service.generate_node(temp.path(), "my-motor", None).await.unwrap();
476
477 let cargo_toml = tokio::fs::read_to_string(temp.path().join("nodes/my-motor/Cargo.toml"))
478 .await
479 .unwrap();
480 assert!(cargo_toml.contains("name = \"my-motor\""));
481 assert!(cargo_toml.contains("name = \"my_motor\""));
482 }
483
484 #[tokio::test]
485 async fn generate_node_updates_mecha10_json_nodes_and_dev_target() {
486 let temp = setup_project().await;
487 let service = NodeGeneratorService::new();
488
489 service.generate_node(temp.path(), "motor", None).await.unwrap();
490
491 let content = tokio::fs::read_to_string(temp.path().join("mecha10.json"))
492 .await
493 .unwrap();
494 let config: serde_json::Value = serde_json::from_str(&content).unwrap();
495 assert_eq!(config["nodes"], serde_json::json!(["motor"]));
496 assert_eq!(config["targets"]["dev"]["nodes"], serde_json::json!(["motor"]));
497 }
498
499 #[tokio::test]
500 async fn generate_node_fails_if_node_already_in_mecha10_json() {
501 let temp = setup_project().await;
502 let mecha10_json = r#"{
503 "nodes": ["motor"],
504 "targets": {
505 "dev": {
506 "nodes": []
507 }
508 }
509}
510"#;
511 tokio::fs::write(temp.path().join("mecha10.json"), mecha10_json)
512 .await
513 .unwrap();
514
515 let service = NodeGeneratorService::new();
516 let err = service.generate_node(temp.path(), "motor", None).await.unwrap_err();
517 assert!(err.to_string().contains("already exists in mecha10.json"));
518 }
519
520 #[tokio::test]
521 async fn generate_node_updates_cargo_workspace_members() {
522 let temp = setup_project().await;
523 let service = NodeGeneratorService::new();
524
525 service.generate_node(temp.path(), "motor", None).await.unwrap();
526
527 let content = tokio::fs::read_to_string(temp.path().join("Cargo.toml")).await.unwrap();
528 let doc: toml::Value = toml::from_str(&content).unwrap();
529 let members = doc["workspace"]["members"].as_array().unwrap();
530 assert!(members.iter().any(|m| m.as_str() == Some("nodes/motor")));
531 }
532
533 #[tokio::test]
534 async fn generate_node_errors_when_mecha10_json_missing() {
535 let temp = TempDir::new().unwrap();
536 tokio::fs::write(temp.path().join("Cargo.toml"), "[workspace]\nmembers = []\n")
537 .await
538 .unwrap();
539
540 let service = NodeGeneratorService::new();
541 let err = service.generate_node(temp.path(), "motor", None).await.unwrap_err();
542 assert!(err.to_string().contains("Failed to read mecha10.json"));
543 }
544}