1use crate::paths;
6use anyhow::{Context, Result};
7use std::path::Path;
8
9pub struct NodeGeneratorService;
11
12impl NodeGeneratorService {
13 pub fn new() -> Self {
15 Self
16 }
17
18 pub async fn generate_node(&self, project_root: &Path, name: &str, description: Option<&str>) -> Result<()> {
33 self.validate_node_name(name)?;
35
36 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 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 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 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 self.update_mecha10_json(project_root, name, description).await?;
64
65 self.update_cargo_workspace(project_root, name).await?;
67
68 Ok(())
69 }
70
71 fn validate_node_name(&self, name: &str) -> Result<()> {
73 if name.is_empty() {
74 anyhow::bail!("Node name cannot be empty");
75 }
76
77 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 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 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 fn to_snake_case(&self, name: &str) -> String {
109 name.replace('-', "_")
110 }
111
112 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 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 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 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 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 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 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 let node_identifier = name.to_string();
316
317 if let Some(nodes) = config.get_mut("nodes") {
319 if let Some(arr) = nodes.as_array_mut() {
320 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 config["nodes"] = serde_json::json!([node_identifier.clone()]);
330 }
331
332 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 let updated_content = serde_json::to_string_pretty(&config)?;
350 tokio::fs::write(&config_path, updated_content).await?;
351
352 Ok(())
353 }
354
355 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 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 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 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 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 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 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}