1use anyhow::{Context, Result};
6use std::path::Path;
7
8pub struct NodeGeneratorService;
10
11impl NodeGeneratorService {
12 pub fn new() -> Self {
14 Self
15 }
16
17 pub async fn generate_node(&self, project_root: &Path, name: &str, description: Option<&str>) -> Result<()> {
32 self.validate_node_name(name)?;
34
35 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 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 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 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 self.update_mecha10_json(project_root, name, description).await?;
63
64 self.update_cargo_workspace(project_root, name).await?;
66
67 Ok(())
68 }
69
70 fn validate_node_name(&self, name: &str) -> Result<()> {
72 if name.is_empty() {
73 anyhow::bail!("Node name cannot be empty");
74 }
75
76 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 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 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 fn to_snake_case(&self, name: &str) -> String {
108 name.replace('-', "_")
109 }
110
111 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 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 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 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 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 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 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 let node_identifier = format!("@local/{}", name);
311
312 if let Some(nodes) = config.get_mut("nodes") {
314 if let Some(arr) = nodes.as_array_mut() {
315 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 config["nodes"] = serde_json::json!([node_identifier]);
325 }
326
327 let updated_content = serde_json::to_string_pretty(&config)?;
329 tokio::fs::write(&config_path, updated_content).await?;
330
331 Ok(())
332 }
333
334 async fn update_cargo_workspace(&self, project_root: &Path, name: &str) -> Result<()> {
336 let cargo_path = project_root.join("Cargo.toml");
337
338 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 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 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 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 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 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}