Skip to main content

mecha10_templates/services/
node_generator.rs

1//! Node generator service
2//!
3//! Generates custom node templates for Mecha10 projects.
4
5use 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    /// Create a new NodeGeneratorService
20    pub fn new() -> Self {
21        Self
22    }
23
24    /// Generate a new node in the project
25    ///
26    /// Creates the following structure:
27    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    /// Validate node name. Shared with `BehaviorGeneratorService`/`DriverGeneratorService` via
59    /// `crate::services::generator_name::validate_generator_name`.
60    fn validate_node_name(&self, name: &str) -> Result<()> {
61        crate::services::generator_name::validate_generator_name("Node", name)
62    }
63
64    /// Convert name to PascalCase (handles both kebab-case and snake_case)
65    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    /// Convert name to snake_case for Rust crate name
79    fn to_snake_case(&self, name: &str) -> String {
80        name.replace('-', "_")
81    }
82
83    /// Create Cargo.toml for the node
84    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    /// Write generated `content` to `path`, creating/truncating the file.
95    async fn write_generated(&self, path: &Path, content: String) -> Result<()> {
96        tokio::fs::write(path, content).await?;
97        Ok(())
98    }
99
100    /// Render `template` with the `name`/`pascal_name` placeholders and write it to `path`.
101    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    /// Create src/lib.rs with node implementation
109    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    /// Create src/main.rs binary entrypoint
115    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    /// Create src/config.rs with configuration struct
124    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    /// Create configs/nodes/<name>/config.json with dev/production sections
130    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    /// Update mecha10.json to add the new node
136    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    /// Update Cargo.toml workspace members to include the new node
177    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    /// Set up a minimal fixture project root with a valid mecha10.json and
194    /// Cargo.toml, so `generate_node` can update both without erroring.
195    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    /// Pinned snapshot of the exact generated bytes for each file `generate_node` produces.
298    #[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    /// A freshly-scaffolded node's `config.rs`/`config.json` must follow the base+`custom`
419    /// contract documented on `mecha10_core::node_config_base::NodeConfigBase`.
420    #[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}