mecha10_cli/services/
node_generator.rs

1//! Node generator service
2//!
3//! Generates custom node templates for Mecha10 projects.
4
5use crate::paths;
6use anyhow::{Context, Result};
7use std::path::Path;
8
9/// Service for generating custom node templates
10pub struct NodeGeneratorService;
11
12impl NodeGeneratorService {
13    /// Create a new NodeGeneratorService
14    pub fn new() -> Self {
15        Self
16    }
17
18    /// Generate a new node in the project
19    ///
20    /// Creates the following structure:
21    /// ```text
22    /// nodes/<name>/
23    /// ├── Cargo.toml
24    /// └── src/
25    ///     ├── lib.rs
26    ///     ├── main.rs
27    ///     └── config.rs
28    ///
29    /// configs/nodes/<name>/
30    /// └── config.json
31    /// ```
32    pub async fn generate_node(&self, project_root: &Path, name: &str, description: Option<&str>) -> Result<()> {
33        // Validate name
34        self.validate_node_name(name)?;
35
36        // Check if node already exists
37        let node_dir = project_root.join(paths::project::NODES_DIR).join(name);
38        if node_dir.exists() {
39            anyhow::bail!("Node '{}' already exists at {}", name, node_dir.display());
40        }
41
42        // Create node directory structure
43        let src_dir = node_dir.join(paths::project::SRC_DIR);
44        tokio::fs::create_dir_all(&src_dir)
45            .await
46            .context("Failed to create node directory")?;
47
48        // Generate files
49        self.create_cargo_toml(&node_dir, name).await?;
50        self.create_lib_rs(&src_dir, name).await?;
51        self.create_main_rs(&src_dir, name).await?;
52        self.create_config_rs(&src_dir, name).await?;
53
54        // Create config directory and file
55        // Config path: configs/nodes/<name>/config.json (no @local prefix)
56        let config_dir = project_root.join(paths::config::NODES_DIR).join(name);
57        tokio::fs::create_dir_all(&config_dir)
58            .await
59            .context("Failed to create config directory")?;
60        self.create_config_json(&config_dir).await?;
61
62        // Update mecha10.json
63        self.update_mecha10_json(project_root, name, description).await?;
64
65        // Update Cargo.toml workspace members
66        self.update_cargo_workspace(project_root, name).await?;
67
68        Ok(())
69    }
70
71    /// Validate node name
72    fn validate_node_name(&self, name: &str) -> Result<()> {
73        if name.is_empty() {
74            anyhow::bail!("Node name cannot be empty");
75        }
76
77        // Check for valid characters (alphanumeric, hyphens, underscores)
78        if !name.chars().all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '_') {
79            anyhow::bail!(
80                "Node name '{}' contains invalid characters. Use only letters, numbers, hyphens, and underscores.",
81                name
82            );
83        }
84
85        // Must start with a letter
86        if !name.chars().next().map(|c| c.is_ascii_alphabetic()).unwrap_or(false) {
87            anyhow::bail!("Node name must start with a letter");
88        }
89
90        Ok(())
91    }
92
93    /// Convert name to PascalCase (handles both kebab-case and snake_case)
94    fn to_pascal_case(&self, name: &str) -> String {
95        name.split(['-', '_'])
96            .filter(|s| !s.is_empty())
97            .map(|word| {
98                let mut chars = word.chars();
99                match chars.next() {
100                    None => String::new(),
101                    Some(first) => first.to_uppercase().chain(chars).collect(),
102                }
103            })
104            .collect()
105    }
106
107    /// Convert name to snake_case for Rust crate name
108    fn to_snake_case(&self, name: &str) -> String {
109        name.replace('-', "_")
110    }
111
112    /// Create Cargo.toml for the node
113    async fn create_cargo_toml(&self, node_dir: &Path, name: &str) -> Result<()> {
114        let content = format!(
115            r#"[package]
116name = "{name}"
117version = "0.1.0"
118edition = "2021"
119
120[lib]
121name = "{crate_name}"
122path = "src/lib.rs"
123
124[[bin]]
125name = "{name}"
126path = "src/main.rs"
127
128[dependencies]
129anyhow = "1.0"
130async-trait = "0.1"
131mecha10-core = "0.1"
132serde = {{ version = "1.0", features = ["derive"] }}
133tokio = {{ version = "1.40", features = ["full"] }}
134tracing = "0.1"
135"#,
136            name = name,
137            crate_name = self.to_snake_case(name),
138        );
139
140        tokio::fs::write(node_dir.join(paths::rust::CARGO_TOML), content).await?;
141        Ok(())
142    }
143
144    /// Create src/lib.rs with node implementation
145    async fn create_lib_rs(&self, src_dir: &Path, name: &str) -> Result<()> {
146        let pascal_name = self.to_pascal_case(name);
147        let content = format!(
148            r#"//! {pascal_name} Node
149//!
150//! Custom node generated by mecha10 CLI.
151
152mod config;
153
154pub use config::{pascal_name}Config;
155use mecha10_core::prelude::*;
156use mecha10_core::topics::Topic;
157
158/// Message published by this node
159#[derive(Debug, Clone, Serialize, Deserialize)]
160pub struct HelloMessage {{
161    pub message: String,
162    pub count: u64,
163}}
164
165impl Message for HelloMessage {{}}
166
167/// Topic for hello messages
168pub const HELLO_TOPIC: Topic<HelloMessage> = Topic::new("/{name}/hello");
169
170/// {pascal_name} node
171#[derive(Debug, Node)]
172#[node(name = "{name}")]
173pub struct {pascal_name}Node {{
174    config: {pascal_name}Config,
175    count: u64,
176}}
177
178#[async_trait]
179impl NodeImpl for {pascal_name}Node {{
180    type Config = {pascal_name}Config;
181
182    async fn init(config: Self::Config) -> Result<Self> {{
183        info!("Initializing {name} node (rate: {{}} Hz)", config.rate_hz);
184        Ok(Self {{ config, count: 0 }})
185    }}
186
187    async fn run(&mut self, ctx: &Context) -> Result<()> {{
188        let interval_ms = (1000.0 / self.config.rate_hz) as u64;
189        let mut interval = tokio::time::interval(tokio::time::Duration::from_millis(interval_ms));
190
191        info!("{pascal_name} node running");
192
193        loop {{
194            interval.tick().await;
195
196            self.count += 1;
197
198            let message = HelloMessage {{
199                message: format!("Hello from {name} #{{}}", self.count),
200                count: self.count,
201            }};
202
203            ctx.publish_to(HELLO_TOPIC, &message).await?;
204            info!("Published: {{}}", message.message);
205        }}
206    }}
207}}
208"#,
209            pascal_name = pascal_name,
210            name = name,
211        );
212
213        tokio::fs::write(src_dir.join("lib.rs"), content).await?;
214        Ok(())
215    }
216
217    /// Create src/main.rs binary entrypoint
218    async fn create_main_rs(&self, src_dir: &Path, name: &str) -> Result<()> {
219        let crate_name = self.to_snake_case(name);
220        let content = format!(
221            r#"//! {name} Node Binary
222//!
223//! Runs the {name} node as a standalone binary.
224
225use mecha10_core::prelude::*;
226
227#[tokio::main]
228async fn main() -> anyhow::Result<()> {{
229    init_logging();
230
231    // Uses auto-generated run() from #[derive(Node)]
232    {crate_name}::run().await.map_err(|e| anyhow::anyhow!(e))
233}}
234"#,
235            name = name,
236            crate_name = crate_name,
237        );
238
239        tokio::fs::write(src_dir.join("main.rs"), content).await?;
240        Ok(())
241    }
242
243    /// Create src/config.rs with configuration struct
244    async fn create_config_rs(&self, src_dir: &Path, name: &str) -> Result<()> {
245        let pascal_name = self.to_pascal_case(name);
246        let content = format!(
247            r#"//! {name} node configuration
248
249use mecha10_core::prelude::*;
250
251/// {pascal_name} node configuration
252#[derive(Debug, Clone, Serialize, Deserialize)]
253pub struct {pascal_name}Config {{
254    /// Rate at which to publish messages (Hz)
255    #[serde(default = "default_rate_hz")]
256    pub rate_hz: f32,
257}}
258
259fn default_rate_hz() -> f32 {{
260    1.0
261}}
262
263impl Default for {pascal_name}Config {{
264    fn default() -> Self {{
265        Self {{
266            rate_hz: default_rate_hz(),
267        }}
268    }}
269}}
270"#,
271            pascal_name = pascal_name,
272            name = name,
273        );
274
275        tokio::fs::write(src_dir.join("config.rs"), content).await?;
276        Ok(())
277    }
278
279    /// Create configs/nodes/@local/<name>/config.json with dev/production sections
280    async fn create_config_json(&self, config_dir: &Path) -> Result<()> {
281        let content = r#"{
282  "dev": {
283    "rate_hz": 1.0,
284    "topics": {
285      "publishes": [],
286      "subscribes": []
287    }
288  },
289  "production": {
290    "rate_hz": 1.0,
291    "topics": {
292      "publishes": [],
293      "subscribes": []
294    }
295  }
296}
297"#;
298
299        tokio::fs::write(config_dir.join("config.json"), content).await?;
300        Ok(())
301    }
302
303    /// Update mecha10.json to add the new node
304    async fn update_mecha10_json(&self, project_root: &Path, name: &str, _description: Option<&str>) -> Result<()> {
305        let config_path = project_root.join(paths::PROJECT_CONFIG);
306
307        // Read existing config
308        let content = tokio::fs::read_to_string(&config_path)
309            .await
310            .context("Failed to read mecha10.json")?;
311
312        let mut config: serde_json::Value = serde_json::from_str(&content).context("Failed to parse mecha10.json")?;
313
314        // Node identifier: just the name (no @local/ prefix)
315        let node_identifier = name.to_string();
316
317        // Add to nodes array
318        if let Some(nodes) = config.get_mut("nodes") {
319            if let Some(arr) = nodes.as_array_mut() {
320                // Check if node already exists
321                let exists = arr.iter().any(|n| n.as_str() == Some(&node_identifier));
322                if exists {
323                    anyhow::bail!("Node '{}' already exists in mecha10.json", name);
324                }
325                arr.push(serde_json::Value::String(node_identifier.clone()));
326            }
327        } else {
328            // Create nodes section
329            config["nodes"] = serde_json::json!([node_identifier.clone()]);
330        }
331
332        // Also add to lifecycle.modes.dev.nodes for immediate use in dev mode
333        if let Some(lifecycle) = config.get_mut("lifecycle") {
334            if let Some(modes) = lifecycle.get_mut("modes") {
335                if let Some(dev) = modes.get_mut("dev") {
336                    if let Some(dev_nodes) = dev.get_mut("nodes") {
337                        if let Some(arr) = dev_nodes.as_array_mut() {
338                            let exists = arr.iter().any(|n| n.as_str() == Some(&node_identifier));
339                            if !exists {
340                                arr.push(serde_json::Value::String(node_identifier));
341                            }
342                        }
343                    }
344                }
345            }
346        }
347
348        // Write updated config
349        let updated_content = serde_json::to_string_pretty(&config)?;
350        tokio::fs::write(&config_path, updated_content).await?;
351
352        Ok(())
353    }
354
355    /// Update Cargo.toml workspace members to include the new node
356    async fn update_cargo_workspace(&self, project_root: &Path, name: &str) -> Result<()> {
357        let cargo_path = project_root.join(paths::rust::CARGO_TOML);
358
359        // Read existing Cargo.toml
360        let content = tokio::fs::read_to_string(&cargo_path)
361            .await
362            .context("Failed to read Cargo.toml")?;
363
364        let mut doc: toml::Value = content.parse().context("Failed to parse Cargo.toml")?;
365
366        // Get or create workspace.members array
367        let node_path = format!("nodes/{}", name);
368
369        if let Some(workspace) = doc.get_mut("workspace") {
370            if let Some(members) = workspace.get_mut("members") {
371                if let Some(arr) = members.as_array_mut() {
372                    // Check if already exists
373                    let exists = arr.iter().any(|m| m.as_str() == Some(&node_path));
374                    if !exists {
375                        arr.push(toml::Value::String(node_path));
376                    }
377                }
378            } else {
379                // Create members array
380                workspace.as_table_mut().unwrap().insert(
381                    "members".to_string(),
382                    toml::Value::Array(vec![toml::Value::String(node_path)]),
383                );
384            }
385        } else {
386            // Create workspace section with members
387            let mut workspace_table = toml::map::Map::new();
388            workspace_table.insert(
389                "members".to_string(),
390                toml::Value::Array(vec![toml::Value::String(node_path)]),
391            );
392            doc.as_table_mut()
393                .unwrap()
394                .insert("workspace".to_string(), toml::Value::Table(workspace_table));
395        }
396
397        // Write updated Cargo.toml
398        let updated_content = toml::to_string_pretty(&doc)?;
399        tokio::fs::write(&cargo_path, updated_content).await?;
400
401        Ok(())
402    }
403}
404
405impl Default for NodeGeneratorService {
406    fn default() -> Self {
407        Self::new()
408    }
409}