mecha10_cli/services/
node_generator.rs

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