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