use crate::paths;
use anyhow::{Context, Result};
use std::path::Path;
pub struct NodeGeneratorService;
impl NodeGeneratorService {
pub fn new() -> Self {
Self
}
pub async fn generate_node(&self, project_root: &Path, name: &str, description: Option<&str>) -> Result<()> {
self.validate_node_name(name)?;
let node_dir = project_root.join(paths::project::NODES_DIR).join(name);
if node_dir.exists() {
anyhow::bail!("Node '{}' already exists at {}", name, node_dir.display());
}
let src_dir = node_dir.join(paths::project::SRC_DIR);
tokio::fs::create_dir_all(&src_dir)
.await
.context("Failed to create node directory")?;
self.create_cargo_toml(&node_dir, name).await?;
self.create_lib_rs(&src_dir, name).await?;
self.create_main_rs(&src_dir, name).await?;
self.create_config_rs(&src_dir, name).await?;
let config_dir = project_root.join(paths::config::NODES_DIR).join(name);
tokio::fs::create_dir_all(&config_dir)
.await
.context("Failed to create config directory")?;
self.create_config_json(&config_dir).await?;
self.update_mecha10_json(project_root, name, description).await?;
self.update_cargo_workspace(project_root, name).await?;
Ok(())
}
fn validate_node_name(&self, name: &str) -> Result<()> {
if name.is_empty() {
anyhow::bail!("Node name cannot be empty");
}
if !name.chars().all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '_') {
anyhow::bail!(
"Node name '{}' contains invalid characters. Use only letters, numbers, hyphens, and underscores.",
name
);
}
if !name.chars().next().map(|c| c.is_ascii_alphabetic()).unwrap_or(false) {
anyhow::bail!("Node name must start with a letter");
}
Ok(())
}
fn to_pascal_case(&self, name: &str) -> String {
name.split(['-', '_'])
.filter(|s| !s.is_empty())
.map(|word| {
let mut chars = word.chars();
match chars.next() {
None => String::new(),
Some(first) => first.to_uppercase().chain(chars).collect(),
}
})
.collect()
}
fn to_snake_case(&self, name: &str) -> String {
name.replace('-', "_")
}
async fn create_cargo_toml(&self, node_dir: &Path, name: &str) -> Result<()> {
let content = format!(
r#"[package]
name = "{name}"
version = "0.1.0"
edition = "2021"
[lib]
name = "{crate_name}"
path = "src/lib.rs"
[[bin]]
name = "{name}"
path = "src/main.rs"
[dependencies]
anyhow = "1.0"
async-trait = "0.1"
mecha10-core = "0.1"
serde = {{ version = "1.0", features = ["derive"] }}
tokio = {{ version = "1.40", features = ["full"] }}
tracing = "0.1"
"#,
name = name,
crate_name = self.to_snake_case(name),
);
tokio::fs::write(node_dir.join(paths::rust::CARGO_TOML), content).await?;
Ok(())
}
async fn create_lib_rs(&self, src_dir: &Path, name: &str) -> Result<()> {
let pascal_name = self.to_pascal_case(name);
let content = format!(
r#"//! {pascal_name} Node
//!
//! Custom node generated by mecha10 CLI.
mod config;
pub use config::{pascal_name}Config;
use mecha10_core::prelude::*;
use mecha10_core::topics::Topic;
/// Message published by this node
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct HelloMessage {{
pub message: String,
pub count: u64,
}}
impl Message for HelloMessage {{}}
/// Topic for hello messages
pub const HELLO_TOPIC: Topic<HelloMessage> = Topic::new("/{name}/hello");
/// {pascal_name} node
#[derive(Debug, Node)]
#[node(name = "{name}")]
pub struct {pascal_name}Node {{
config: {pascal_name}Config,
count: u64,
}}
#[async_trait]
impl NodeImpl for {pascal_name}Node {{
type Config = {pascal_name}Config;
async fn init(config: Self::Config) -> Result<Self> {{
info!("Initializing {name} node (rate: {{}} Hz)", config.rate_hz);
Ok(Self {{ config, count: 0 }})
}}
async fn run(&mut self, ctx: &Context) -> Result<()> {{
let interval_ms = (1000.0 / self.config.rate_hz) as u64;
let mut interval = tokio::time::interval(tokio::time::Duration::from_millis(interval_ms));
info!("{pascal_name} node running");
loop {{
interval.tick().await;
self.count += 1;
let message = HelloMessage {{
message: format!("Hello from {name} #{{}}", self.count),
count: self.count,
}};
ctx.publish_to(HELLO_TOPIC, &message).await?;
info!("Published: {{}}", message.message);
}}
}}
}}
"#,
pascal_name = pascal_name,
name = name,
);
tokio::fs::write(src_dir.join("lib.rs"), content).await?;
Ok(())
}
async fn create_main_rs(&self, src_dir: &Path, name: &str) -> Result<()> {
let crate_name = self.to_snake_case(name);
let content = format!(
r#"//! {name} Node Binary
//!
//! Runs the {name} node as a standalone binary.
use mecha10_core::prelude::*;
#[tokio::main]
async fn main() -> anyhow::Result<()> {{
init_logging();
// Uses auto-generated run() from #[derive(Node)]
{crate_name}::run().await.map_err(|e| anyhow::anyhow!(e))
}}
"#,
name = name,
crate_name = crate_name,
);
tokio::fs::write(src_dir.join("main.rs"), content).await?;
Ok(())
}
async fn create_config_rs(&self, src_dir: &Path, name: &str) -> Result<()> {
let pascal_name = self.to_pascal_case(name);
let content = format!(
r#"//! {name} node configuration
use mecha10_core::prelude::*;
/// {pascal_name} node configuration
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct {pascal_name}Config {{
/// Rate at which to publish messages (Hz)
#[serde(default = "default_rate_hz")]
pub rate_hz: f32,
}}
fn default_rate_hz() -> f32 {{
1.0
}}
impl Default for {pascal_name}Config {{
fn default() -> Self {{
Self {{
rate_hz: default_rate_hz(),
}}
}}
}}
"#,
pascal_name = pascal_name,
name = name,
);
tokio::fs::write(src_dir.join("config.rs"), content).await?;
Ok(())
}
async fn create_config_json(&self, config_dir: &Path) -> Result<()> {
let content = r#"{
"dev": {
"rate_hz": 1.0,
"topics": {
"publishes": [],
"subscribes": []
}
},
"production": {
"rate_hz": 1.0,
"topics": {
"publishes": [],
"subscribes": []
}
}
}
"#;
tokio::fs::write(config_dir.join("config.json"), content).await?;
Ok(())
}
async fn update_mecha10_json(&self, project_root: &Path, name: &str, _description: Option<&str>) -> Result<()> {
let config_path = project_root.join(paths::PROJECT_CONFIG);
let content = tokio::fs::read_to_string(&config_path)
.await
.context("Failed to read mecha10.json")?;
let mut config: serde_json::Value = serde_json::from_str(&content).context("Failed to parse mecha10.json")?;
let node_identifier = name.to_string();
if let Some(nodes) = config.get_mut("nodes") {
if let Some(arr) = nodes.as_array_mut() {
let exists = arr.iter().any(|n| n.as_str() == Some(&node_identifier));
if exists {
anyhow::bail!("Node '{}' already exists in mecha10.json", name);
}
arr.push(serde_json::Value::String(node_identifier.clone()));
}
} else {
config["nodes"] = serde_json::json!([node_identifier.clone()]);
}
if let Some(lifecycle) = config.get_mut("lifecycle") {
if let Some(modes) = lifecycle.get_mut("modes") {
if let Some(dev) = modes.get_mut("dev") {
if let Some(dev_nodes) = dev.get_mut("nodes") {
if let Some(arr) = dev_nodes.as_array_mut() {
let exists = arr.iter().any(|n| n.as_str() == Some(&node_identifier));
if !exists {
arr.push(serde_json::Value::String(node_identifier));
}
}
}
}
}
}
let updated_content = serde_json::to_string_pretty(&config)?;
tokio::fs::write(&config_path, updated_content).await?;
Ok(())
}
async fn update_cargo_workspace(&self, project_root: &Path, name: &str) -> Result<()> {
let cargo_path = project_root.join(paths::rust::CARGO_TOML);
let content = tokio::fs::read_to_string(&cargo_path)
.await
.context("Failed to read Cargo.toml")?;
let mut doc: toml::Value = content.parse().context("Failed to parse Cargo.toml")?;
let node_path = format!("nodes/{}", name);
if let Some(workspace) = doc.get_mut("workspace") {
if let Some(members) = workspace.get_mut("members") {
if let Some(arr) = members.as_array_mut() {
let exists = arr.iter().any(|m| m.as_str() == Some(&node_path));
if !exists {
arr.push(toml::Value::String(node_path));
}
}
} else {
workspace.as_table_mut().unwrap().insert(
"members".to_string(),
toml::Value::Array(vec![toml::Value::String(node_path)]),
);
}
} else {
let mut workspace_table = toml::map::Map::new();
workspace_table.insert(
"members".to_string(),
toml::Value::Array(vec![toml::Value::String(node_path)]),
);
doc.as_table_mut()
.unwrap()
.insert("workspace".to_string(), toml::Value::Table(workspace_table));
}
let updated_content = toml::to_string_pretty(&doc)?;
tokio::fs::write(&cargo_path, updated_content).await?;
Ok(())
}
}
impl Default for NodeGeneratorService {
fn default() -> Self {
Self::new()
}
}