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/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 self.update_mecha10_json(project_root, name, description).await?;
62
63 self.update_cargo_workspace(project_root, name).await?;
65
66 Ok(())
67 }
68
69 fn validate_node_name(&self, name: &str) -> Result<()> {
71 if name.is_empty() {
72 anyhow::bail!("Node name cannot be empty");
73 }
74
75 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 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 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 fn to_snake_case(&self, name: &str) -> String {
107 name.replace('-', "_")
108 }
109
110 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 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 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 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 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 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 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 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 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 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 nodes["custom"] = serde_json::json!([node_entry]);
332 }
333 } else {
334 config["nodes"] = serde_json::json!({
336 "drivers": [],
337 "custom": [node_entry]
338 });
339 }
340
341 let updated_content = serde_json::to_string_pretty(&config)?;
343 tokio::fs::write(&config_path, updated_content).await?;
344
345 Ok(())
346 }
347
348 async fn update_cargo_workspace(&self, project_root: &Path, name: &str) -> Result<()> {
350 let cargo_path = project_root.join("Cargo.toml");
351
352 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 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 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 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 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 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}