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
14/// `Cargo.toml` for a generated node — package/lib/bin sections plus dependencies.
15const CARGO_TOML_TEMPLATE: &str = include_str!("../../templates/nodes/Cargo.toml.template");
16/// `src/lib.rs` for a generated node — the node struct, its `NodeImpl`, and a demo topic.
17const LIB_RS_TEMPLATE: &str = include_str!("../../templates/nodes/lib.rs.template");
18/// `src/main.rs` for a generated node — the binary entrypoint.
19const MAIN_RS_TEMPLATE: &str = include_str!("../../templates/nodes/main.rs.template");
20/// `src/config.rs` for a generated node — the node's `Config` struct.
21const CONFIG_RS_TEMPLATE: &str = include_str!("../../templates/nodes/config.rs.template");
22/// `configs/nodes/<name>/config.json` for a generated node — static dev/production defaults,
23/// with no `{{var}}` placeholders (no per-node variation needed), embedded as a template file
24/// anyway for consistency with the other four generated files.
25const CONFIG_JSON_TEMPLATE: &str = include_str!("../../templates/nodes/config.json.template");
26
27/// Service for generating custom node templates
28pub struct NodeGeneratorService;
29
30impl NodeGeneratorService {
31    /// Create a new NodeGeneratorService
32    pub fn new() -> Self {
33        Self
34    }
35
36    /// Generate a new node in the project
37    ///
38    /// Creates the following structure:
39    /// ```text
40    /// nodes/<name>/
41    /// ├── Cargo.toml
42    /// └── src/
43    ///     ├── lib.rs
44    ///     ├── main.rs
45    ///     └── config.rs
46    ///
47    /// configs/nodes/<name>/
48    /// └── config.json
49    /// ```
50    pub async fn generate_node(&self, project_root: &Path, name: &str, description: Option<&str>) -> Result<()> {
51        self.validate_node_name(name)?;
52
53        let node_dir = project_root.join(paths::project::NODES_DIR).join(name);
54        if node_dir.exists() {
55            anyhow::bail!("Node '{}' already exists at {}", name, node_dir.display());
56        }
57
58        let src_dir = node_dir.join(paths::project::SRC_DIR);
59        tokio::fs::create_dir_all(&src_dir)
60            .await
61            .context("Failed to create node directory")?;
62
63        self.create_cargo_toml(&node_dir, name).await?;
64        self.create_lib_rs(&src_dir, name).await?;
65        self.create_main_rs(&src_dir, name).await?;
66        self.create_config_rs(&src_dir, name).await?;
67
68        let config_dir = project_root.join(paths::config::NODES_DIR).join(name);
69        tokio::fs::create_dir_all(&config_dir)
70            .await
71            .context("Failed to create config directory")?;
72        self.create_config_json(&config_dir).await?;
73
74        self.update_mecha10_json(project_root, name, description).await?;
75
76        self.update_cargo_workspace(project_root, name).await?;
77
78        Ok(())
79    }
80
81    /// Validate node name. Shared with `BehaviorGeneratorService`/`DriverGeneratorService` via
82    /// `crate::services::generator_name::validate_generator_name` (was an exact triplicate,
83    /// LAB-2749).
84    fn validate_node_name(&self, name: &str) -> Result<()> {
85        crate::services::generator_name::validate_generator_name("Node", name)
86    }
87
88    /// Convert name to PascalCase (handles both kebab-case and snake_case)
89    fn to_pascal_case(&self, name: &str) -> String {
90        name.split(['-', '_'])
91            .filter(|s| !s.is_empty())
92            .map(|word| {
93                let mut chars = word.chars();
94                match chars.next() {
95                    None => String::new(),
96                    Some(first) => first.to_uppercase().chain(chars).collect(),
97                }
98            })
99            .collect()
100    }
101
102    /// Convert name to snake_case for Rust crate name
103    fn to_snake_case(&self, name: &str) -> String {
104        name.replace('-', "_")
105    }
106
107    /// Create Cargo.toml for the node
108    async fn create_cargo_toml(&self, node_dir: &Path, name: &str) -> Result<()> {
109        let content = render(
110            CARGO_TOML_TEMPLATE,
111            &[("name", name), ("crate_name", &self.to_snake_case(name))],
112        );
113
114        tokio::fs::write(node_dir.join(paths::rust::CARGO_TOML), content).await?;
115        Ok(())
116    }
117
118    /// Write generated `content` to `path`, creating/truncating the file.
119    ///
120    /// Shared by the `create_*_rs` methods below so each one only needs to
121    /// build its template string and hand it off, instead of repeating the
122    /// `tokio::fs::write(...).await?; Ok(())` boilerplate.
123    async fn write_generated(&self, path: &Path, content: String) -> Result<()> {
124        tokio::fs::write(path, content).await?;
125        Ok(())
126    }
127
128    /// Render `template` with the `name`/`pascal_name` placeholders and write it to `path`.
129    ///
130    /// Shared by `create_lib_rs`/`create_config_rs` (cargo-dupes flagged them as an exact
131    /// duplicate, LAB-2749) — both files only ever need the name in its original and
132    /// PascalCase forms, unlike `create_main_rs`/`create_cargo_toml`, which need the
133    /// snake_case crate name instead.
134    async fn render_pascal_and_write(&self, path: &Path, template: &str, name: &str) -> Result<()> {
135        let pascal_name = self.to_pascal_case(name);
136        let content = render(template, &[("name", name), ("pascal_name", &pascal_name)]);
137
138        self.write_generated(path, content).await
139    }
140
141    /// Create src/lib.rs with node implementation
142    async fn create_lib_rs(&self, src_dir: &Path, name: &str) -> Result<()> {
143        self.render_pascal_and_write(&src_dir.join("lib.rs"), LIB_RS_TEMPLATE, name)
144            .await
145    }
146
147    /// Create src/main.rs binary entrypoint
148    async fn create_main_rs(&self, src_dir: &Path, name: &str) -> Result<()> {
149        let crate_name = self.to_snake_case(name);
150        let content = render(MAIN_RS_TEMPLATE, &[("name", name), ("crate_name", &crate_name)]);
151
152        tokio::fs::write(src_dir.join("main.rs"), content).await?;
153        Ok(())
154    }
155
156    /// Create src/config.rs with configuration struct
157    async fn create_config_rs(&self, src_dir: &Path, name: &str) -> Result<()> {
158        self.render_pascal_and_write(&src_dir.join("config.rs"), CONFIG_RS_TEMPLATE, name)
159            .await
160    }
161
162    /// Create configs/nodes/<name>/config.json with dev/production sections
163    async fn create_config_json(&self, config_dir: &Path) -> Result<()> {
164        tokio::fs::write(config_dir.join("config.json"), CONFIG_JSON_TEMPLATE).await?;
165        Ok(())
166    }
167
168    /// Update mecha10.json to add the new node
169    async fn update_mecha10_json(&self, project_root: &Path, name: &str, _description: Option<&str>) -> Result<()> {
170        let config_path = project_root.join(paths::PROJECT_CONFIG);
171
172        let content = tokio::fs::read_to_string(&config_path)
173            .await
174            .context("Failed to read mecha10.json")?;
175
176        let mut config: serde_json::Value = serde_json::from_str(&content).context("Failed to parse mecha10.json")?;
177
178        let node_identifier = name.to_string();
179
180        if let Some(nodes) = config.get_mut("nodes") {
181            if let Some(arr) = nodes.as_array_mut() {
182                let exists = arr.iter().any(|n| n.as_str() == Some(&node_identifier));
183                if exists {
184                    anyhow::bail!("Node '{}' already exists in mecha10.json", name);
185                }
186                arr.push(serde_json::Value::String(node_identifier.clone()));
187            }
188        } else {
189            config["nodes"] = serde_json::json!([node_identifier.clone()]);
190        }
191
192        if let Some(dev) = config.get_mut("targets").and_then(|t| t.get_mut("dev")) {
193            if let Some(dev_nodes) = dev.get_mut("nodes") {
194                if let Some(arr) = dev_nodes.as_array_mut() {
195                    let exists = arr.iter().any(|n| n.as_str() == Some(&node_identifier));
196                    if !exists {
197                        arr.push(serde_json::Value::String(node_identifier));
198                    }
199                }
200            }
201        }
202
203        let updated_content = serde_json::to_string_pretty(&config)?;
204        tokio::fs::write(&config_path, updated_content).await?;
205
206        Ok(())
207    }
208
209    /// Update Cargo.toml workspace members to include the new node
210    async fn update_cargo_workspace(&self, project_root: &Path, name: &str) -> Result<()> {
211        crate::services::cargo_workspace::add_workspace_member(project_root, &format!("nodes/{}", name)).await
212    }
213}
214
215impl Default for NodeGeneratorService {
216    fn default() -> Self {
217        Self::new()
218    }
219}
220
221#[cfg(test)]
222mod tests {
223    use super::*;
224    use tempfile::TempDir;
225
226    /// Set up a minimal fixture project root with a valid mecha10.json and
227    /// Cargo.toml, so `generate_node` can update both without erroring.
228    async fn setup_project() -> TempDir {
229        let temp = TempDir::new().unwrap();
230
231        let mecha10_json = r#"{
232  "nodes": [],
233  "targets": {
234    "dev": {
235      "nodes": []
236    }
237  }
238}
239"#;
240        tokio::fs::write(temp.path().join("mecha10.json"), mecha10_json)
241            .await
242            .unwrap();
243
244        let cargo_toml = "[workspace]\nmembers = []\n";
245        tokio::fs::write(temp.path().join("Cargo.toml"), cargo_toml)
246            .await
247            .unwrap();
248        let _: toml::Value = toml::from_str(cargo_toml).expect("fixture Cargo.toml must be valid TOML");
249
250        temp
251    }
252
253    #[test]
254    fn validate_node_name_rejects_empty() {
255        let service = NodeGeneratorService::new();
256        let err = service.validate_node_name("").unwrap_err();
257        assert!(err.to_string().contains("cannot be empty"));
258    }
259
260    #[test]
261    fn validate_node_name_rejects_invalid_characters() {
262        let service = NodeGeneratorService::new();
263        let err = service.validate_node_name("my node!").unwrap_err();
264        assert!(err.to_string().contains("invalid characters"));
265    }
266
267    #[test]
268    fn validate_node_name_rejects_leading_digit() {
269        let service = NodeGeneratorService::new();
270        let err = service.validate_node_name("1motor").unwrap_err();
271        assert!(err.to_string().contains("must start with a letter"));
272    }
273
274    #[test]
275    fn validate_node_name_accepts_kebab_and_snake_case() {
276        let service = NodeGeneratorService::new();
277        assert!(service.validate_node_name("my-motor_node").is_ok());
278        assert!(service.validate_node_name("Motor2").is_ok());
279    }
280
281    #[test]
282    fn to_pascal_case_handles_kebab_case() {
283        let service = NodeGeneratorService::new();
284        assert_eq!(service.to_pascal_case("my-motor-node"), "MyMotorNode");
285    }
286
287    #[test]
288    fn to_pascal_case_handles_snake_case() {
289        let service = NodeGeneratorService::new();
290        assert_eq!(service.to_pascal_case("my_motor_node"), "MyMotorNode");
291    }
292
293    #[test]
294    fn to_snake_case_replaces_hyphens() {
295        let service = NodeGeneratorService::new();
296        assert_eq!(service.to_snake_case("my-motor-node"), "my_motor_node");
297    }
298
299    #[tokio::test]
300    async fn generate_node_rejects_invalid_name_before_touching_disk() {
301        let temp = setup_project().await;
302        let service = NodeGeneratorService::new();
303
304        let err = service
305            .generate_node(temp.path(), "1-bad-name", None)
306            .await
307            .unwrap_err();
308
309        assert!(err.to_string().contains("must start with a letter"));
310        assert!(!temp.path().join("nodes").exists());
311    }
312
313    #[tokio::test]
314    async fn generate_node_creates_expected_file_structure() {
315        let temp = setup_project().await;
316        let service = NodeGeneratorService::new();
317
318        service.generate_node(temp.path(), "motor", Some("desc")).await.unwrap();
319
320        let node_dir = temp.path().join("nodes").join("motor");
321        assert!(node_dir.join("Cargo.toml").exists());
322        assert!(node_dir.join("src/lib.rs").exists());
323        assert!(node_dir.join("src/main.rs").exists());
324        assert!(node_dir.join("src/config.rs").exists());
325
326        let config_dir = temp.path().join("configs/nodes/motor");
327        assert!(config_dir.join("config.json").exists());
328    }
329
330    /// Regression test for LAB-2015: converting `create_cargo_toml`/`create_lib_rs`/
331    /// `create_main_rs`/`create_config_rs` from hand-rolled `format!()` builders to
332    /// `.template`-file rendering must not change a single byte of the generated output
333    /// (modulo the `{name}`/`{crate_name}`/`{pascal_name}` variable substitution itself) — this
334    /// is a refactor of *how* the content is produced, not a behavior change. These are the
335    /// exact strings the old hand-rolled builders produced for node name "my-motor".
336    #[tokio::test]
337    async fn generate_node_output_is_byte_identical_to_hand_rolled_generator() {
338        let temp = setup_project().await;
339        let service = NodeGeneratorService::new();
340
341        service.generate_node(temp.path(), "my-motor", None).await.unwrap();
342
343        let node_dir = temp.path().join("nodes/my-motor");
344
345        let cargo_toml = tokio::fs::read_to_string(node_dir.join("Cargo.toml")).await.unwrap();
346        assert_eq!(
347            cargo_toml,
348            r#"[package]
349name = "my-motor"
350version = "0.1.0"
351edition = "2021"
352
353[lib]
354name = "my_motor"
355path = "src/lib.rs"
356
357[[bin]]
358name = "my-motor"
359path = "src/main.rs"
360
361[dependencies]
362anyhow = "1.0"
363async-trait = "0.1"
364mecha10-core = "0.1"
365serde = { version = "1.0", features = ["derive"] }
366tokio = { version = "1.40", features = ["full"] }
367tracing = "0.1"
368"#
369        );
370
371        let main_rs = tokio::fs::read_to_string(node_dir.join("src/main.rs")).await.unwrap();
372        assert_eq!(
373            main_rs,
374            r#"//! my-motor Node Binary
375//!
376//! Runs the my-motor node as a standalone binary.
377
378use mecha10_core::prelude::*;
379
380#[tokio::main]
381async fn main() -> anyhow::Result<()> {
382    init_logging();
383
384    // Uses auto-generated run() from #[derive(Node)]
385    my_motor::run().await.map_err(|e| anyhow::anyhow!(e))
386}
387"#
388        );
389
390        let config_rs = tokio::fs::read_to_string(node_dir.join("src/config.rs")).await.unwrap();
391        assert_eq!(
392            config_rs,
393            r#"//! my-motor node configuration
394
395use mecha10_core::prelude::*;
396
397/// MyMotor node configuration
398#[derive(Debug, Clone, Serialize, Deserialize)]
399pub struct MyMotorConfig {
400    /// Rate at which to publish messages (Hz)
401    #[serde(default = "default_rate_hz")]
402    pub rate_hz: f32,
403}
404
405fn default_rate_hz() -> f32 {
406    1.0
407}
408
409impl Default for MyMotorConfig {
410    fn default() -> Self {
411        Self {
412            rate_hz: default_rate_hz(),
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    "rate_hz": 1.0,
427    "topics": {
428      "publishes": [],
429      "subscribes": []
430    }
431  },
432  "production": {
433    "rate_hz": 1.0,
434    "topics": {
435      "publishes": [],
436      "subscribes": []
437    }
438  }
439}
440"#
441        );
442    }
443
444    #[tokio::test]
445    async fn generate_node_fails_if_node_already_exists() {
446        let temp = setup_project().await;
447        let service = NodeGeneratorService::new();
448
449        service.generate_node(temp.path(), "motor", None).await.unwrap();
450        let err = service.generate_node(temp.path(), "motor", None).await.unwrap_err();
451
452        assert!(err.to_string().contains("already exists"));
453    }
454
455    #[tokio::test]
456    async fn generate_node_lib_rs_uses_pascal_case_struct_name() {
457        let temp = setup_project().await;
458        let service = NodeGeneratorService::new();
459
460        service.generate_node(temp.path(), "my-motor", None).await.unwrap();
461
462        let lib_rs = tokio::fs::read_to_string(temp.path().join("nodes/my-motor/src/lib.rs"))
463            .await
464            .unwrap();
465        assert!(lib_rs.contains("pub struct MyMotorNode"));
466        assert!(lib_rs.contains("MyMotorConfig"));
467        assert!(lib_rs.contains("Topic::new(\"/my-motor/hello\")"));
468    }
469
470    #[tokio::test]
471    async fn generate_node_cargo_toml_uses_snake_case_lib_name() {
472        let temp = setup_project().await;
473        let service = NodeGeneratorService::new();
474
475        service.generate_node(temp.path(), "my-motor", None).await.unwrap();
476
477        let cargo_toml = tokio::fs::read_to_string(temp.path().join("nodes/my-motor/Cargo.toml"))
478            .await
479            .unwrap();
480        assert!(cargo_toml.contains("name = \"my-motor\""));
481        assert!(cargo_toml.contains("name = \"my_motor\""));
482    }
483
484    #[tokio::test]
485    async fn generate_node_updates_mecha10_json_nodes_and_dev_target() {
486        let temp = setup_project().await;
487        let service = NodeGeneratorService::new();
488
489        service.generate_node(temp.path(), "motor", None).await.unwrap();
490
491        let content = tokio::fs::read_to_string(temp.path().join("mecha10.json"))
492            .await
493            .unwrap();
494        let config: serde_json::Value = serde_json::from_str(&content).unwrap();
495        assert_eq!(config["nodes"], serde_json::json!(["motor"]));
496        assert_eq!(config["targets"]["dev"]["nodes"], serde_json::json!(["motor"]));
497    }
498
499    #[tokio::test]
500    async fn generate_node_fails_if_node_already_in_mecha10_json() {
501        let temp = setup_project().await;
502        let mecha10_json = r#"{
503  "nodes": ["motor"],
504  "targets": {
505    "dev": {
506      "nodes": []
507    }
508  }
509}
510"#;
511        tokio::fs::write(temp.path().join("mecha10.json"), mecha10_json)
512            .await
513            .unwrap();
514
515        let service = NodeGeneratorService::new();
516        let err = service.generate_node(temp.path(), "motor", None).await.unwrap_err();
517        assert!(err.to_string().contains("already exists in mecha10.json"));
518    }
519
520    #[tokio::test]
521    async fn generate_node_updates_cargo_workspace_members() {
522        let temp = setup_project().await;
523        let service = NodeGeneratorService::new();
524
525        service.generate_node(temp.path(), "motor", None).await.unwrap();
526
527        let content = tokio::fs::read_to_string(temp.path().join("Cargo.toml")).await.unwrap();
528        let doc: toml::Value = toml::from_str(&content).unwrap();
529        let members = doc["workspace"]["members"].as_array().unwrap();
530        assert!(members.iter().any(|m| m.as_str() == Some("nodes/motor")));
531    }
532
533    #[tokio::test]
534    async fn generate_node_errors_when_mecha10_json_missing() {
535        let temp = TempDir::new().unwrap();
536        tokio::fs::write(temp.path().join("Cargo.toml"), "[workspace]\nmembers = []\n")
537            .await
538            .unwrap();
539
540        let service = NodeGeneratorService::new();
541        let err = service.generate_node(temp.path(), "motor", None).await.unwrap_err();
542        assert!(err.to_string().contains("Failed to read mecha10.json"));
543    }
544}