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