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