use crate::paths;
use crate::templates::render::render;
use anyhow::{Context, Result};
use std::path::Path;
const CARGO_TOML_TEMPLATE: &str = include_str!("../../templates/nodes/Cargo.toml.template");
const LIB_RS_TEMPLATE: &str = include_str!("../../templates/nodes/lib.rs.template");
const MAIN_RS_TEMPLATE: &str = include_str!("../../templates/nodes/main.rs.template");
const CONFIG_RS_TEMPLATE: &str = include_str!("../../templates/nodes/config.rs.template");
const CONFIG_JSON_TEMPLATE: &str = include_str!("../../templates/nodes/config.json.template");
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<()> {
crate::services::generator_name::validate_generator_name("Node", name)
}
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 = render(
CARGO_TOML_TEMPLATE,
&[("name", name), ("crate_name", &self.to_snake_case(name))],
);
tokio::fs::write(node_dir.join(paths::rust::CARGO_TOML), content).await?;
Ok(())
}
async fn write_generated(&self, path: &Path, content: String) -> Result<()> {
tokio::fs::write(path, content).await?;
Ok(())
}
async fn render_pascal_and_write(&self, path: &Path, template: &str, name: &str) -> Result<()> {
let pascal_name = self.to_pascal_case(name);
let content = render(template, &[("name", name), ("pascal_name", &pascal_name)]);
self.write_generated(path, content).await
}
async fn create_lib_rs(&self, src_dir: &Path, name: &str) -> Result<()> {
self.render_pascal_and_write(&src_dir.join("lib.rs"), LIB_RS_TEMPLATE, name)
.await
}
async fn create_main_rs(&self, src_dir: &Path, name: &str) -> Result<()> {
let crate_name = self.to_snake_case(name);
let content = render(MAIN_RS_TEMPLATE, &[("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<()> {
self.render_pascal_and_write(&src_dir.join("config.rs"), CONFIG_RS_TEMPLATE, name)
.await
}
async fn create_config_json(&self, config_dir: &Path) -> Result<()> {
tokio::fs::write(config_dir.join("config.json"), CONFIG_JSON_TEMPLATE).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(dev) = config.get_mut("targets").and_then(|t| t.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<()> {
crate::services::cargo_workspace::add_workspace_member(project_root, &format!("nodes/{}", name)).await
}
}
impl Default for NodeGeneratorService {
fn default() -> Self {
Self::new()
}
}
#[cfg(test)]
mod tests {
use super::*;
use tempfile::TempDir;
async fn setup_project() -> TempDir {
let temp = TempDir::new().unwrap();
let mecha10_json = r#"{
"nodes": [],
"targets": {
"dev": {
"nodes": []
}
}
}
"#;
tokio::fs::write(temp.path().join("mecha10.json"), mecha10_json)
.await
.unwrap();
let cargo_toml = "[workspace]\nmembers = []\n";
tokio::fs::write(temp.path().join("Cargo.toml"), cargo_toml)
.await
.unwrap();
let _: toml::Value = toml::from_str(cargo_toml).expect("fixture Cargo.toml must be valid TOML");
temp
}
#[test]
fn validate_node_name_rejects_empty() {
let service = NodeGeneratorService::new();
let err = service.validate_node_name("").unwrap_err();
assert!(err.to_string().contains("cannot be empty"));
}
#[test]
fn validate_node_name_rejects_invalid_characters() {
let service = NodeGeneratorService::new();
let err = service.validate_node_name("my node!").unwrap_err();
assert!(err.to_string().contains("invalid characters"));
}
#[test]
fn validate_node_name_rejects_leading_digit() {
let service = NodeGeneratorService::new();
let err = service.validate_node_name("1motor").unwrap_err();
assert!(err.to_string().contains("must start with a letter"));
}
#[test]
fn validate_node_name_accepts_kebab_and_snake_case() {
let service = NodeGeneratorService::new();
assert!(service.validate_node_name("my-motor_node").is_ok());
assert!(service.validate_node_name("Motor2").is_ok());
}
#[test]
fn to_pascal_case_handles_kebab_case() {
let service = NodeGeneratorService::new();
assert_eq!(service.to_pascal_case("my-motor-node"), "MyMotorNode");
}
#[test]
fn to_pascal_case_handles_snake_case() {
let service = NodeGeneratorService::new();
assert_eq!(service.to_pascal_case("my_motor_node"), "MyMotorNode");
}
#[test]
fn to_snake_case_replaces_hyphens() {
let service = NodeGeneratorService::new();
assert_eq!(service.to_snake_case("my-motor-node"), "my_motor_node");
}
#[tokio::test]
async fn generate_node_rejects_invalid_name_before_touching_disk() {
let temp = setup_project().await;
let service = NodeGeneratorService::new();
let err = service
.generate_node(temp.path(), "1-bad-name", None)
.await
.unwrap_err();
assert!(err.to_string().contains("must start with a letter"));
assert!(!temp.path().join("nodes").exists());
}
#[tokio::test]
async fn generate_node_creates_expected_file_structure() {
let temp = setup_project().await;
let service = NodeGeneratorService::new();
service.generate_node(temp.path(), "motor", Some("desc")).await.unwrap();
let node_dir = temp.path().join("nodes").join("motor");
assert!(node_dir.join("Cargo.toml").exists());
assert!(node_dir.join("src/lib.rs").exists());
assert!(node_dir.join("src/main.rs").exists());
assert!(node_dir.join("src/config.rs").exists());
let config_dir = temp.path().join("configs/nodes/motor");
assert!(config_dir.join("config.json").exists());
}
#[tokio::test]
async fn generate_node_output_is_byte_identical_to_hand_rolled_generator() {
let temp = setup_project().await;
let service = NodeGeneratorService::new();
service.generate_node(temp.path(), "my-motor", None).await.unwrap();
let node_dir = temp.path().join("nodes/my-motor");
let cargo_toml = tokio::fs::read_to_string(node_dir.join("Cargo.toml")).await.unwrap();
assert_eq!(
cargo_toml,
r#"[package]
name = "my-motor"
version = "0.1.0"
edition = "2021"
[lib]
name = "my_motor"
path = "src/lib.rs"
[[bin]]
name = "my-motor"
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"
"#
);
let main_rs = tokio::fs::read_to_string(node_dir.join("src/main.rs")).await.unwrap();
assert_eq!(
main_rs,
r#"//! my-motor Node Binary
//!
//! Runs the my-motor 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)]
my_motor::run().await.map_err(|e| anyhow::anyhow!(e))
}
"#
);
let config_rs = tokio::fs::read_to_string(node_dir.join("src/config.rs")).await.unwrap();
assert_eq!(
config_rs,
r#"//! my-motor node configuration
use mecha10_core::node_config_base::NodeConfigBase;
use mecha10_core::prelude::*;
/// MyMotor node configuration
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
pub struct MyMotorConfig {
/// Rate at which to publish messages (Hz)
#[serde(default = "default_rate_hz")]
#[schemars(example = default_rate_hz())]
pub rate_hz: f32,
/// Base/reserved fields common to every node's config - see
/// `mecha10_core::node_config_base::NodeConfigBase`.
#[serde(flatten)]
pub base: NodeConfigBase,
}
fn default_rate_hz() -> f32 {
1.0
}
impl Default for MyMotorConfig {
fn default() -> Self {
Self {
rate_hz: default_rate_hz(),
base: NodeConfigBase::default(),
}
}
}
"#
);
let config_json = tokio::fs::read_to_string(temp.path().join("configs/nodes/my-motor/config.json"))
.await
.unwrap();
assert_eq!(
config_json,
r#"{
"dev": {
"custom": {
"rate_hz": 1.0
},
"topics": {
"publishes": [],
"subscribes": []
}
},
"production": {
"custom": {
"rate_hz": 1.0
},
"topics": {
"publishes": [],
"subscribes": []
}
}
}
"#
);
}
#[tokio::test]
async fn generate_node_config_follows_the_base_and_custom_contract() {
let temp = setup_project().await;
let service = NodeGeneratorService::new();
service.generate_node(temp.path(), "my-motor", None).await.unwrap();
let config_rs = tokio::fs::read_to_string(temp.path().join("nodes/my-motor/src/config.rs"))
.await
.unwrap();
assert!(
config_rs.contains("#[serde(flatten)]\n pub base: NodeConfigBase,"),
"generated config.rs must embed NodeConfigBase via #[serde(flatten)]: {config_rs}"
);
assert!(
config_rs.contains("use mecha10_core::node_config_base::NodeConfigBase;"),
"generated config.rs must import NodeConfigBase: {config_rs}"
);
let config_json = tokio::fs::read_to_string(temp.path().join("configs/nodes/my-motor/config.json"))
.await
.unwrap();
let value: serde_json::Value = serde_json::from_str(&config_json).unwrap();
#[derive(serde::Deserialize)]
struct GeneratedConfigShape {
rate_hz: f32,
#[serde(flatten)]
#[allow(dead_code)]
base: mecha10_core::node_config_base::NodeConfigBase,
}
for env in ["dev", "production"] {
let section = &value[env];
let custom = section
.get("custom")
.unwrap_or_else(|| panic!("'{env}' section must nest node-specific fields under 'custom': {section}"));
assert!(custom.get("rate_hz").is_some());
assert!(
section.get("rate_hz").is_none(),
"'{env}' section must not also leave node-specific fields flat at the top level: {section}"
);
assert!(
custom.get("topics").is_none(),
"'{env}' section must not nest 'topics' under 'custom' anymore: {section}"
);
assert!(section.get("topics").is_some());
let base: mecha10_core::node_config_base::NodeConfigBase = serde_json::from_value(section.clone()).unwrap();
let mut merged = serde_json::to_value(&base).unwrap().as_object().unwrap().clone();
merged.extend(custom.as_object().unwrap().clone());
let parsed: GeneratedConfigShape = serde_json::from_value(serde_json::Value::Object(merged)).unwrap();
assert_eq!(parsed.rate_hz, 1.0);
}
}
#[tokio::test]
async fn generate_node_fails_if_node_already_exists() {
let temp = setup_project().await;
let service = NodeGeneratorService::new();
service.generate_node(temp.path(), "motor", None).await.unwrap();
let err = service.generate_node(temp.path(), "motor", None).await.unwrap_err();
assert!(err.to_string().contains("already exists"));
}
#[tokio::test]
async fn generate_node_lib_rs_uses_pascal_case_struct_name() {
let temp = setup_project().await;
let service = NodeGeneratorService::new();
service.generate_node(temp.path(), "my-motor", None).await.unwrap();
let lib_rs = tokio::fs::read_to_string(temp.path().join("nodes/my-motor/src/lib.rs"))
.await
.unwrap();
assert!(lib_rs.contains("pub struct MyMotorNode"));
assert!(lib_rs.contains("MyMotorConfig"));
assert!(lib_rs.contains("Topic::new(\"/my-motor/hello\")"));
}
#[tokio::test]
async fn generate_node_cargo_toml_uses_snake_case_lib_name() {
let temp = setup_project().await;
let service = NodeGeneratorService::new();
service.generate_node(temp.path(), "my-motor", None).await.unwrap();
let cargo_toml = tokio::fs::read_to_string(temp.path().join("nodes/my-motor/Cargo.toml"))
.await
.unwrap();
assert!(cargo_toml.contains("name = \"my-motor\""));
assert!(cargo_toml.contains("name = \"my_motor\""));
}
#[tokio::test]
async fn generate_node_updates_mecha10_json_nodes_and_dev_target() {
let temp = setup_project().await;
let service = NodeGeneratorService::new();
service.generate_node(temp.path(), "motor", None).await.unwrap();
let content = tokio::fs::read_to_string(temp.path().join("mecha10.json"))
.await
.unwrap();
let config: serde_json::Value = serde_json::from_str(&content).unwrap();
assert_eq!(config["nodes"], serde_json::json!(["motor"]));
assert_eq!(config["targets"]["dev"]["nodes"], serde_json::json!(["motor"]));
}
#[tokio::test]
async fn generate_node_fails_if_node_already_in_mecha10_json() {
let temp = setup_project().await;
let mecha10_json = r#"{
"nodes": ["motor"],
"targets": {
"dev": {
"nodes": []
}
}
}
"#;
tokio::fs::write(temp.path().join("mecha10.json"), mecha10_json)
.await
.unwrap();
let service = NodeGeneratorService::new();
let err = service.generate_node(temp.path(), "motor", None).await.unwrap_err();
assert!(err.to_string().contains("already exists in mecha10.json"));
}
#[tokio::test]
async fn generate_node_updates_cargo_workspace_members() {
let temp = setup_project().await;
let service = NodeGeneratorService::new();
service.generate_node(temp.path(), "motor", None).await.unwrap();
let content = tokio::fs::read_to_string(temp.path().join("Cargo.toml")).await.unwrap();
let doc: toml::Value = toml::from_str(&content).unwrap();
let members = doc["workspace"]["members"].as_array().unwrap();
assert!(members.iter().any(|m| m.as_str() == Some("nodes/motor")));
}
#[tokio::test]
async fn generate_node_errors_when_mecha10_json_missing() {
let temp = TempDir::new().unwrap();
tokio::fs::write(temp.path().join("Cargo.toml"), "[workspace]\nmembers = []\n")
.await
.unwrap();
let service = NodeGeneratorService::new();
let err = service.generate_node(temp.path(), "motor", None).await.unwrap_err();
assert!(err.to_string().contains("Failed to read mecha10.json"));
}
}