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