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");
15const LIB_RS_TEMPLATE: &str = include_str!("../../templates/nodes/lib.rs.template");
16const MAIN_RS_TEMPLATE: &str = include_str!("../../templates/nodes/main.rs.template");
17const CONFIG_RS_TEMPLATE: &str = include_str!("../../templates/nodes/config.rs.template");
18const CONFIG_JSON_TEMPLATE: &str = include_str!("../../templates/nodes/config.json.template");
19
20pub struct NodeGeneratorService;
21
22impl NodeGeneratorService {
23 pub fn new() -> Self {
25 Self
26 }
27
28 pub async fn generate_node(&self, project_root: &Path, name: &str, description: Option<&str>) -> Result<()> {
43 self.validate_node_name(name)?;
44
45 let node_dir = project_root.join(paths::project::NODES_DIR).join(name);
46 if node_dir.exists() {
47 anyhow::bail!("Node '{}' already exists at {}", name, node_dir.display());
48 }
49
50 let src_dir = node_dir.join(paths::project::SRC_DIR);
51 tokio::fs::create_dir_all(&src_dir)
52 .await
53 .context("Failed to create node directory")?;
54
55 self.create_cargo_toml(&node_dir, name).await?;
56 self.create_lib_rs(&src_dir, name).await?;
57 self.create_main_rs(&src_dir, name).await?;
58 self.create_config_rs(&src_dir, name).await?;
59
60 let config_dir = project_root.join(paths::config::NODES_DIR).join(name);
61 tokio::fs::create_dir_all(&config_dir)
62 .await
63 .context("Failed to create config directory")?;
64 self.create_config_json(&config_dir).await?;
65
66 self.update_mecha10_json(project_root, name, description).await?;
67
68 self.update_cargo_workspace(project_root, name).await?;
69
70 Ok(())
71 }
72
73 fn validate_node_name(&self, name: &str) -> Result<()> {
77 crate::services::generator_name::validate_generator_name("Node", name)
78 }
79
80 fn to_pascal_case(&self, name: &str) -> String {
82 name.split(['-', '_'])
83 .filter(|s| !s.is_empty())
84 .map(|word| {
85 let mut chars = word.chars();
86 match chars.next() {
87 None => String::new(),
88 Some(first) => first.to_uppercase().chain(chars).collect(),
89 }
90 })
91 .collect()
92 }
93
94 fn to_snake_case(&self, name: &str) -> String {
96 name.replace('-', "_")
97 }
98
99 async fn create_cargo_toml(&self, node_dir: &Path, name: &str) -> Result<()> {
101 let content = render(
102 CARGO_TOML_TEMPLATE,
103 &[("name", name), ("crate_name", &self.to_snake_case(name))],
104 );
105
106 tokio::fs::write(node_dir.join(paths::rust::CARGO_TOML), content).await?;
107 Ok(())
108 }
109
110 async fn write_generated(&self, path: &Path, content: String) -> Result<()> {
116 tokio::fs::write(path, content).await?;
117 Ok(())
118 }
119
120 async fn render_pascal_and_write(&self, path: &Path, template: &str, name: &str) -> Result<()> {
127 let pascal_name = self.to_pascal_case(name);
128 let content = render(template, &[("name", name), ("pascal_name", &pascal_name)]);
129
130 self.write_generated(path, content).await
131 }
132
133 async fn create_lib_rs(&self, src_dir: &Path, name: &str) -> Result<()> {
135 self.render_pascal_and_write(&src_dir.join("lib.rs"), LIB_RS_TEMPLATE, name)
136 .await
137 }
138
139 async fn create_main_rs(&self, src_dir: &Path, name: &str) -> Result<()> {
141 let crate_name = self.to_snake_case(name);
142 let content = render(MAIN_RS_TEMPLATE, &[("name", name), ("crate_name", &crate_name)]);
143
144 tokio::fs::write(src_dir.join("main.rs"), content).await?;
145 Ok(())
146 }
147
148 async fn create_config_rs(&self, src_dir: &Path, name: &str) -> Result<()> {
150 self.render_pascal_and_write(&src_dir.join("config.rs"), CONFIG_RS_TEMPLATE, name)
151 .await
152 }
153
154 async fn create_config_json(&self, config_dir: &Path) -> Result<()> {
156 tokio::fs::write(config_dir.join("config.json"), CONFIG_JSON_TEMPLATE).await?;
157 Ok(())
158 }
159
160 async fn update_mecha10_json(&self, project_root: &Path, name: &str, _description: Option<&str>) -> Result<()> {
162 let config_path = project_root.join(paths::PROJECT_CONFIG);
163
164 let content = tokio::fs::read_to_string(&config_path)
165 .await
166 .context("Failed to read mecha10.json")?;
167
168 let mut config: serde_json::Value = serde_json::from_str(&content).context("Failed to parse mecha10.json")?;
169
170 let node_identifier = name.to_string();
171
172 if let Some(nodes) = config.get_mut("nodes") {
173 if let Some(arr) = nodes.as_array_mut() {
174 let exists = arr.iter().any(|n| n.as_str() == Some(&node_identifier));
175 if exists {
176 anyhow::bail!("Node '{}' already exists in mecha10.json", name);
177 }
178 arr.push(serde_json::Value::String(node_identifier.clone()));
179 }
180 } else {
181 config["nodes"] = serde_json::json!([node_identifier.clone()]);
182 }
183
184 if let Some(dev) = config.get_mut("targets").and_then(|t| t.get_mut("dev")) {
185 if let Some(dev_nodes) = dev.get_mut("nodes") {
186 if let Some(arr) = dev_nodes.as_array_mut() {
187 let exists = arr.iter().any(|n| n.as_str() == Some(&node_identifier));
188 if !exists {
189 arr.push(serde_json::Value::String(node_identifier));
190 }
191 }
192 }
193 }
194
195 let updated_content = serde_json::to_string_pretty(&config)?;
196 tokio::fs::write(&config_path, updated_content).await?;
197
198 Ok(())
199 }
200
201 async fn update_cargo_workspace(&self, project_root: &Path, name: &str) -> Result<()> {
203 crate::services::cargo_workspace::add_workspace_member(project_root, &format!("nodes/{}", name)).await
204 }
205}
206
207impl Default for NodeGeneratorService {
208 fn default() -> Self {
209 Self::new()
210 }
211}
212
213#[cfg(test)]
214mod tests {
215 use super::*;
216 use tempfile::TempDir;
217
218 async fn setup_project() -> TempDir {
221 let temp = TempDir::new().unwrap();
222
223 let mecha10_json = r#"{
224 "nodes": [],
225 "targets": {
226 "dev": {
227 "nodes": []
228 }
229 }
230}
231"#;
232 tokio::fs::write(temp.path().join("mecha10.json"), mecha10_json)
233 .await
234 .unwrap();
235
236 let cargo_toml = "[workspace]\nmembers = []\n";
237 tokio::fs::write(temp.path().join("Cargo.toml"), cargo_toml)
238 .await
239 .unwrap();
240 let _: toml::Value = toml::from_str(cargo_toml).expect("fixture Cargo.toml must be valid TOML");
241
242 temp
243 }
244
245 #[test]
246 fn validate_node_name_rejects_empty() {
247 let service = NodeGeneratorService::new();
248 let err = service.validate_node_name("").unwrap_err();
249 assert!(err.to_string().contains("cannot be empty"));
250 }
251
252 #[test]
253 fn validate_node_name_rejects_invalid_characters() {
254 let service = NodeGeneratorService::new();
255 let err = service.validate_node_name("my node!").unwrap_err();
256 assert!(err.to_string().contains("invalid characters"));
257 }
258
259 #[test]
260 fn validate_node_name_rejects_leading_digit() {
261 let service = NodeGeneratorService::new();
262 let err = service.validate_node_name("1motor").unwrap_err();
263 assert!(err.to_string().contains("must start with a letter"));
264 }
265
266 #[test]
267 fn validate_node_name_accepts_kebab_and_snake_case() {
268 let service = NodeGeneratorService::new();
269 assert!(service.validate_node_name("my-motor_node").is_ok());
270 assert!(service.validate_node_name("Motor2").is_ok());
271 }
272
273 #[test]
274 fn to_pascal_case_handles_kebab_case() {
275 let service = NodeGeneratorService::new();
276 assert_eq!(service.to_pascal_case("my-motor-node"), "MyMotorNode");
277 }
278
279 #[test]
280 fn to_pascal_case_handles_snake_case() {
281 let service = NodeGeneratorService::new();
282 assert_eq!(service.to_pascal_case("my_motor_node"), "MyMotorNode");
283 }
284
285 #[test]
286 fn to_snake_case_replaces_hyphens() {
287 let service = NodeGeneratorService::new();
288 assert_eq!(service.to_snake_case("my-motor-node"), "my_motor_node");
289 }
290
291 #[tokio::test]
292 async fn generate_node_rejects_invalid_name_before_touching_disk() {
293 let temp = setup_project().await;
294 let service = NodeGeneratorService::new();
295
296 let err = service
297 .generate_node(temp.path(), "1-bad-name", None)
298 .await
299 .unwrap_err();
300
301 assert!(err.to_string().contains("must start with a letter"));
302 assert!(!temp.path().join("nodes").exists());
303 }
304
305 #[tokio::test]
306 async fn generate_node_creates_expected_file_structure() {
307 let temp = setup_project().await;
308 let service = NodeGeneratorService::new();
309
310 service.generate_node(temp.path(), "motor", Some("desc")).await.unwrap();
311
312 let node_dir = temp.path().join("nodes").join("motor");
313 assert!(node_dir.join("Cargo.toml").exists());
314 assert!(node_dir.join("src/lib.rs").exists());
315 assert!(node_dir.join("src/main.rs").exists());
316 assert!(node_dir.join("src/config.rs").exists());
317
318 let config_dir = temp.path().join("configs/nodes/motor");
319 assert!(config_dir.join("config.json").exists());
320 }
321
322 #[tokio::test]
329 async fn generate_node_output_is_byte_identical_to_hand_rolled_generator() {
330 let temp = setup_project().await;
331 let service = NodeGeneratorService::new();
332
333 service.generate_node(temp.path(), "my-motor", None).await.unwrap();
334
335 let node_dir = temp.path().join("nodes/my-motor");
336
337 let cargo_toml = tokio::fs::read_to_string(node_dir.join("Cargo.toml")).await.unwrap();
338 assert_eq!(
339 cargo_toml,
340 r#"[package]
341name = "my-motor"
342version = "0.1.0"
343edition = "2021"
344
345[lib]
346name = "my_motor"
347path = "src/lib.rs"
348
349[[bin]]
350name = "my-motor"
351path = "src/main.rs"
352
353[dependencies]
354anyhow = "1.0"
355async-trait = "0.1"
356mecha10-core = "0.1"
357serde = { version = "1.0", features = ["derive"] }
358tokio = { version = "1.40", features = ["full"] }
359tracing = "0.1"
360"#
361 );
362
363 let main_rs = tokio::fs::read_to_string(node_dir.join("src/main.rs")).await.unwrap();
364 assert_eq!(
365 main_rs,
366 r#"//! my-motor Node Binary
367//!
368//! Runs the my-motor node as a standalone binary.
369
370use mecha10_core::prelude::*;
371
372#[tokio::main]
373async fn main() -> anyhow::Result<()> {
374 init_logging();
375
376 // Uses auto-generated run() from #[derive(Node)]
377 my_motor::run().await.map_err(|e| anyhow::anyhow!(e))
378}
379"#
380 );
381
382 let config_rs = tokio::fs::read_to_string(node_dir.join("src/config.rs")).await.unwrap();
383 assert_eq!(
384 config_rs,
385 r#"//! my-motor node configuration
386
387use mecha10_core::node_config_base::NodeConfigBase;
388use mecha10_core::prelude::*;
389
390/// MyMotor node configuration
391#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
392pub struct MyMotorConfig {
393 /// Rate at which to publish messages (Hz)
394 #[serde(default = "default_rate_hz")]
395 #[schemars(example = default_rate_hz())]
396 pub rate_hz: f32,
397
398 /// Base/reserved fields common to every node's config - see
399 /// `mecha10_core::node_config_base::NodeConfigBase`.
400 #[serde(flatten)]
401 pub base: NodeConfigBase,
402}
403
404fn default_rate_hz() -> f32 {
405 1.0
406}
407
408impl Default for MyMotorConfig {
409 fn default() -> Self {
410 Self {
411 rate_hz: default_rate_hz(),
412 base: NodeConfigBase::default(),
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 "custom": {
427 "rate_hz": 1.0
428 },
429 "topics": {
430 "publishes": [],
431 "subscribes": []
432 }
433 },
434 "production": {
435 "custom": {
436 "rate_hz": 1.0
437 },
438 "topics": {
439 "publishes": [],
440 "subscribes": []
441 }
442 }
443}
444"#
445 );
446 }
447
448 #[tokio::test]
460 async fn generate_node_config_follows_the_base_and_custom_contract() {
461 let temp = setup_project().await;
462 let service = NodeGeneratorService::new();
463
464 service.generate_node(temp.path(), "my-motor", None).await.unwrap();
465
466 let config_rs = tokio::fs::read_to_string(temp.path().join("nodes/my-motor/src/config.rs"))
467 .await
468 .unwrap();
469 assert!(
470 config_rs.contains("#[serde(flatten)]\n pub base: NodeConfigBase,"),
471 "generated config.rs must embed NodeConfigBase via #[serde(flatten)]: {config_rs}"
472 );
473 assert!(
474 config_rs.contains("use mecha10_core::node_config_base::NodeConfigBase;"),
475 "generated config.rs must import NodeConfigBase: {config_rs}"
476 );
477
478 let config_json = tokio::fs::read_to_string(temp.path().join("configs/nodes/my-motor/config.json"))
479 .await
480 .unwrap();
481 let value: serde_json::Value = serde_json::from_str(&config_json).unwrap();
482
483 #[derive(serde::Deserialize)]
484 struct GeneratedConfigShape {
485 rate_hz: f32,
486 #[serde(flatten)]
487 #[allow(dead_code)]
488 base: mecha10_core::node_config_base::NodeConfigBase,
489 }
490
491 for env in ["dev", "production"] {
492 let section = &value[env];
493 let custom = section
494 .get("custom")
495 .unwrap_or_else(|| panic!("'{env}' section must nest node-specific fields under 'custom': {section}"));
496 assert!(custom.get("rate_hz").is_some());
497 assert!(
498 section.get("rate_hz").is_none(),
499 "'{env}' section must not also leave node-specific fields flat at the top level: {section}"
500 );
501 assert!(
502 custom.get("topics").is_none(),
503 "'{env}' section must not nest 'topics' under 'custom' anymore: {section}"
504 );
505 assert!(section.get("topics").is_some());
506
507 let base: mecha10_core::node_config_base::NodeConfigBase = serde_json::from_value(section.clone()).unwrap();
508 let mut merged = serde_json::to_value(&base).unwrap().as_object().unwrap().clone();
509 merged.extend(custom.as_object().unwrap().clone());
510
511 let parsed: GeneratedConfigShape = serde_json::from_value(serde_json::Value::Object(merged)).unwrap();
512 assert_eq!(parsed.rate_hz, 1.0);
513 }
514 }
515
516 #[tokio::test]
517 async fn generate_node_fails_if_node_already_exists() {
518 let temp = setup_project().await;
519 let service = NodeGeneratorService::new();
520
521 service.generate_node(temp.path(), "motor", None).await.unwrap();
522 let err = service.generate_node(temp.path(), "motor", None).await.unwrap_err();
523
524 assert!(err.to_string().contains("already exists"));
525 }
526
527 #[tokio::test]
528 async fn generate_node_lib_rs_uses_pascal_case_struct_name() {
529 let temp = setup_project().await;
530 let service = NodeGeneratorService::new();
531
532 service.generate_node(temp.path(), "my-motor", None).await.unwrap();
533
534 let lib_rs = tokio::fs::read_to_string(temp.path().join("nodes/my-motor/src/lib.rs"))
535 .await
536 .unwrap();
537 assert!(lib_rs.contains("pub struct MyMotorNode"));
538 assert!(lib_rs.contains("MyMotorConfig"));
539 assert!(lib_rs.contains("Topic::new(\"/my-motor/hello\")"));
540 }
541
542 #[tokio::test]
543 async fn generate_node_cargo_toml_uses_snake_case_lib_name() {
544 let temp = setup_project().await;
545 let service = NodeGeneratorService::new();
546
547 service.generate_node(temp.path(), "my-motor", None).await.unwrap();
548
549 let cargo_toml = tokio::fs::read_to_string(temp.path().join("nodes/my-motor/Cargo.toml"))
550 .await
551 .unwrap();
552 assert!(cargo_toml.contains("name = \"my-motor\""));
553 assert!(cargo_toml.contains("name = \"my_motor\""));
554 }
555
556 #[tokio::test]
557 async fn generate_node_updates_mecha10_json_nodes_and_dev_target() {
558 let temp = setup_project().await;
559 let service = NodeGeneratorService::new();
560
561 service.generate_node(temp.path(), "motor", None).await.unwrap();
562
563 let content = tokio::fs::read_to_string(temp.path().join("mecha10.json"))
564 .await
565 .unwrap();
566 let config: serde_json::Value = serde_json::from_str(&content).unwrap();
567 assert_eq!(config["nodes"], serde_json::json!(["motor"]));
568 assert_eq!(config["targets"]["dev"]["nodes"], serde_json::json!(["motor"]));
569 }
570
571 #[tokio::test]
572 async fn generate_node_fails_if_node_already_in_mecha10_json() {
573 let temp = setup_project().await;
574 let mecha10_json = r#"{
575 "nodes": ["motor"],
576 "targets": {
577 "dev": {
578 "nodes": []
579 }
580 }
581}
582"#;
583 tokio::fs::write(temp.path().join("mecha10.json"), mecha10_json)
584 .await
585 .unwrap();
586
587 let service = NodeGeneratorService::new();
588 let err = service.generate_node(temp.path(), "motor", None).await.unwrap_err();
589 assert!(err.to_string().contains("already exists in mecha10.json"));
590 }
591
592 #[tokio::test]
593 async fn generate_node_updates_cargo_workspace_members() {
594 let temp = setup_project().await;
595 let service = NodeGeneratorService::new();
596
597 service.generate_node(temp.path(), "motor", None).await.unwrap();
598
599 let content = tokio::fs::read_to_string(temp.path().join("Cargo.toml")).await.unwrap();
600 let doc: toml::Value = toml::from_str(&content).unwrap();
601 let members = doc["workspace"]["members"].as_array().unwrap();
602 assert!(members.iter().any(|m| m.as_str() == Some("nodes/motor")));
603 }
604
605 #[tokio::test]
606 async fn generate_node_errors_when_mecha10_json_missing() {
607 let temp = TempDir::new().unwrap();
608 tokio::fs::write(temp.path().join("Cargo.toml"), "[workspace]\nmembers = []\n")
609 .await
610 .unwrap();
611
612 let service = NodeGeneratorService::new();
613 let err = service.generate_node(temp.path(), "motor", None).await.unwrap_err();
614 assert!(err.to_string().contains("Failed to read mecha10.json"));
615 }
616}