Skip to main content

mecha10_templates/services/
node_generator.rs

1//! Node generator service
2//!
3//! Generates custom node templates for Mecha10 projects.
4//!
5//! File content is rendered from `.template` files embedded at compile time via
6//! `include_str!()` (see `crate::templates::render`) rather than built with hand-rolled
7//! `format!()` string builders — see `crate::templates::render` for why (LAB-2015).
8
9use 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    /// Create a new NodeGeneratorService
24    pub fn new() -> Self {
25        Self
26    }
27
28    /// Generate a new node in the project
29    ///
30    /// Creates the following structure:
31    /// ```text
32    /// nodes/<name>/
33    /// ├── Cargo.toml
34    /// └── src/
35    ///     ├── lib.rs
36    ///     ├── main.rs
37    ///     └── config.rs
38    ///
39    /// configs/nodes/<name>/
40    /// └── config.json
41    /// ```
42    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    /// Validate node name. Shared with `BehaviorGeneratorService`/`DriverGeneratorService` via
74    /// `crate::services::generator_name::validate_generator_name` (was an exact triplicate,
75    /// LAB-2749).
76    fn validate_node_name(&self, name: &str) -> Result<()> {
77        crate::services::generator_name::validate_generator_name("Node", name)
78    }
79
80    /// Convert name to PascalCase (handles both kebab-case and snake_case)
81    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    /// Convert name to snake_case for Rust crate name
95    fn to_snake_case(&self, name: &str) -> String {
96        name.replace('-', "_")
97    }
98
99    /// Create Cargo.toml for the node
100    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    /// Write generated `content` to `path`, creating/truncating the file.
111    ///
112    /// Shared by the `create_*_rs` methods below so each one only needs to
113    /// build its template string and hand it off, instead of repeating the
114    /// `tokio::fs::write(...).await?; Ok(())` boilerplate.
115    async fn write_generated(&self, path: &Path, content: String) -> Result<()> {
116        tokio::fs::write(path, content).await?;
117        Ok(())
118    }
119
120    /// Render `template` with the `name`/`pascal_name` placeholders and write it to `path`.
121    ///
122    /// Shared by `create_lib_rs`/`create_config_rs` (cargo-dupes flagged them as an exact
123    /// duplicate, LAB-2749) — both files only ever need the name in its original and
124    /// PascalCase forms, unlike `create_main_rs`/`create_cargo_toml`, which need the
125    /// snake_case crate name instead.
126    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    /// Create src/lib.rs with node implementation
134    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    /// Create src/main.rs binary entrypoint
140    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    /// Create src/config.rs with configuration struct
149    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    /// Create configs/nodes/<name>/config.json with dev/production sections
155    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    /// Update mecha10.json to add the new node
161    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    /// Update Cargo.toml workspace members to include the new node
202    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    /// Set up a minimal fixture project root with a valid mecha10.json and
219    /// Cargo.toml, so `generate_node` can update both without erroring.
220    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    /// Regression test for LAB-2015 (originally): converting `create_cargo_toml`/
323    /// `create_lib_rs`/`create_main_rs`/`create_config_rs` from hand-rolled `format!()` builders
324    /// to `.template`-file rendering must not change a single byte of the generated output
325    /// (modulo the `{name}`/`{crate_name}`/`{pascal_name}` variable substitution itself). Now
326    /// doubles as a pinned snapshot of the exact generated bytes, updated for LAB-4471's
327    /// base+`custom` config schema shape.
328    #[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    /// LAB-4471: a freshly-scaffolded node's `config.rs`/`config.json` must follow the
449    /// base+`custom` contract documented on `mecha10_core::node_config_base::NodeConfigBase` -
450    /// `rate_hz`/`topics` nested under a `custom` key in `config.json`, and the generated
451    /// `Config` struct embedding `#[serde(flatten)] pub base: NodeConfigBase` at the top level.
452    /// The real merge logic that stitches the two back together at load time
453    /// (`split_base_and_custom` in `mecha10_core::context::node_config`) is a private fn behind
454    /// a private module, and loading through a full `Context` needs a live Redis connection, so
455    /// neither is reachable from here - this test instead duplicates a minimal version of that
456    /// merge inline against the real `NodeConfigBase` type, so a future change to either the
457    /// templates or `NodeConfigBase`'s own shape that breaks their compatibility still fails a
458    /// test here, not just silently drops fields at runtime (the bug this ticket exists to fix).
459    #[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}