use crate::commands::add::{AddBehaviorArgs, AddCommand, AddDriverArgs, AddDriverType, AddNodeArgs};
use crate::context::CliContext;
use crate::paths;
use crate::services::{BehaviorGeneratorService, DriverGeneratorService, DriverType, NodeGeneratorService};
use anyhow::{Context, Result};
const FRAMEWORK_NODES: &[&str] = &[
"behavior-executor",
"camera",
"image-classifier",
"imu",
"listener",
"llm-command",
"motor",
"simulator",
"object-detector",
"speaker",
"teleop",
"video-streamer",
"websocket-bridge",
];
const NON_INTERACTIVE_FLAGS: &[&str] = &["--non-interactive", "--yes"];
pub async fn handle_add(ctx: &mut CliContext, command: &AddCommand) -> Result<()> {
match command {
AddCommand::Node(args) => handle_add_node(ctx, args).await,
AddCommand::Driver(args) => handle_add_driver(ctx, args).await,
AddCommand::Behavior(args) => handle_add_behavior(ctx, args).await,
AddCommand::Framework(args) => {
let non_interactive = args.iter().any(|a| NON_INTERACTIVE_FLAGS.contains(&a.as_str()));
let name = args
.iter()
.find(|a| !NON_INTERACTIVE_FLAGS.contains(&a.as_str()))
.map(|s| s.as_str())
.unwrap_or("");
handle_add_framework_node(ctx, name, non_interactive).await
}
}
}
const ALL_LIFECYCLE_MODES: &[&str] = &["simulation", "hardware"];
const ALL_TARGETS: &[&str] = &["robot", "remote"];
fn is_interactive_terminal() -> bool {
atty::is(atty::Stream::Stdin) && atty::is(atty::Stream::Stdout)
}
fn select_lifecycle_modes(interactive: bool, inferred: &[&'static str]) -> Result<Vec<String>> {
if !interactive {
return Ok(inferred.iter().map(|s| s.to_string()).collect());
}
let checked: Vec<bool> = ALL_LIFECYCLE_MODES.iter().map(|m| inferred.contains(m)).collect();
let selected = crate::ui::prompts::multiselect(
"Add this node to which lifecycle mode(s)?",
ALL_LIFECYCLE_MODES,
&checked,
)?;
Ok(selected
.into_iter()
.map(|i| ALL_LIFECYCLE_MODES[i].to_string())
.collect())
}
fn select_targets(interactive: bool, inferred: Option<&'static str>) -> Result<Vec<String>> {
if !interactive {
return Ok(inferred.into_iter().map(|s| s.to_string()).collect());
}
let checked: Vec<bool> = ALL_TARGETS.iter().map(|t| Some(*t) == inferred).collect();
let selected = crate::ui::prompts::multiselect("Add this node to which target(s)?", ALL_TARGETS, &checked)?;
Ok(selected.into_iter().map(|i| ALL_TARGETS[i].to_string()).collect())
}
async fn handle_add_framework_node(ctx: &mut CliContext, name: &str, non_interactive: bool) -> Result<()> {
let name = name.trim_start_matches("@mecha10/");
if name.is_empty() {
anyhow::bail!(
"Node name is required. Usage: mecha10 add <node-name>\n\nAvailable nodes: {}",
FRAMEWORK_NODES.join(", ")
);
}
if !FRAMEWORK_NODES.contains(&name) {
anyhow::bail!(
"Unknown framework node '{}'. Available nodes:\n {}",
name,
FRAMEWORK_NODES.join("\n ")
);
}
println!();
println!("Adding node: @mecha10/{}", name);
println!();
if !ctx.is_project_initialized() {
anyhow::bail!("Not in a Mecha10 project directory");
}
let config_path = ctx.working_dir.join(paths::PROJECT_CONFIG);
let identifier = format!("@mecha10/{}", name);
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")?;
if config["nodes"].get(&identifier).is_some() {
println!(" {} is already in mecha10.json", identifier);
println!();
return Ok(());
}
config["nodes"][&identifier] = serde_json::json!(env!("CARGO_PKG_VERSION"));
let component = crate::component_catalog::ComponentCatalog::new().get(name).cloned();
let inferred_modes = component
.as_ref()
.map(|c| c.relevant_lifecycle_modes())
.unwrap_or_default();
let inferred_target = component.as_ref().and_then(|c| c.relevant_target());
let interactive = !non_interactive && is_interactive_terminal();
let modes = select_lifecycle_modes(interactive, &inferred_modes)?;
let targets = select_targets(interactive, inferred_target)?;
for mode in &modes {
let mut mode_nodes = config["lifecycle"]["modes"][mode]["nodes"]
.as_array()
.cloned()
.unwrap_or_default();
if !mode_nodes.iter().any(|n| n == &identifier) {
mode_nodes.push(serde_json::json!(identifier));
}
config["lifecycle"]["modes"][mode]["nodes"] = serde_json::json!(mode_nodes);
}
for target in &targets {
let mut target_nodes = config["targets"][target].as_array().cloned().unwrap_or_default();
if !target_nodes.iter().any(|n| n == &identifier) {
target_nodes.push(serde_json::json!(identifier));
}
config["targets"][target] = serde_json::json!(target_nodes);
}
let updated = serde_json::to_string_pretty(&config)?;
tokio::fs::write(&config_path, updated)
.await
.context("Failed to write mecha10.json")?;
println!(" ✓ Added {} to mecha10.json", identifier);
copy_node_configs(ctx, name).await?;
println!();
println!("Next steps:");
println!(
" 1. Edit configs/nodes/@mecha10/{}/config.json to configure the node",
name
);
println!(" 2. Run 'mecha10 dev' to start the node");
println!();
Ok(())
}
async fn copy_node_configs(ctx: &mut CliContext, node_name: &str) -> Result<()> {
let dest = ctx.working_dir.join(paths::config::framework_node_dir(node_name));
if dest.exists() {
println!(" ✓ Config already exists at configs/nodes/@mecha10/{}/", node_name);
return Ok(());
}
let template_download = ctx.template_download();
match template_download.resolve_version().await {
Ok(version) => {
if template_download.ensure_templates(&version).await.is_ok() {
let template_rel = paths::config::framework_node_dir(node_name);
template_download.copy_template_dir(&version, &template_rel, &dest)?;
if dest.exists() {
println!(" ✓ Config written to configs/nodes/@mecha10/{}/", node_name);
return Ok(());
}
}
}
Err(_) => {
tracing::debug!("Could not fetch template version, falling back to manual-create warning");
}
}
println!(
" ⚠ No config template found for {}. Create configs/nodes/@mecha10/{}/config.json manually.",
node_name, node_name
);
Ok(())
}
async fn handle_add_node(ctx: &mut CliContext, args: &AddNodeArgs) -> Result<()> {
println!();
println!("Creating node: {}", args.name);
println!();
let project_root = &ctx.working_dir;
let generator = NodeGeneratorService::new();
generator
.generate_node(project_root, &args.name, args.description.as_deref())
.await?;
println!("Node created successfully!");
println!();
println!(" nodes/{}/", args.name);
println!(" ├── Cargo.toml");
println!(" └── src/");
println!(" ├── lib.rs");
println!(" ├── main.rs");
println!(" └── config.rs");
println!();
println!(" configs/nodes/{}/", args.name);
println!(" └── config.json");
println!();
println!("Node has been added to dev mode automatically.");
println!();
println!("Next steps:");
println!(" 1. Edit nodes/{}/src/lib.rs to implement your node logic", args.name);
println!(" 2. Update configs/nodes/{}/config.json with your settings", args.name);
println!(" 3. Run 'mecha10 dev' to test your node");
println!();
Ok(())
}
fn to_driver_type(arg_type: AddDriverType) -> DriverType {
match arg_type {
AddDriverType::Camera => DriverType::Camera,
AddDriverType::Motor => DriverType::Motor,
AddDriverType::Sensor => DriverType::Sensor,
AddDriverType::Imu => DriverType::Imu,
}
}
async fn handle_add_driver(ctx: &mut CliContext, args: &AddDriverArgs) -> Result<()> {
let driver_type = to_driver_type(args.r#type);
println!();
println!("Creating {} driver: {}", driver_type.as_str(), args.name);
println!();
let project_root = &ctx.working_dir;
let generator = DriverGeneratorService::new();
generator.generate_driver(project_root, &args.name, driver_type).await?;
println!("Driver created successfully!");
println!();
println!(" drivers/{}/", args.name);
println!(" ├── Cargo.toml");
println!(" └── src/");
println!(" ├── lib.rs");
println!(" └── main.rs");
println!();
println!("Next steps:");
println!(
" 1. Edit drivers/{}/src/lib.rs to talk to your actual hardware",
args.name
);
println!(
" 2. Depend on it from a node crate, or run it standalone with 'cargo run -p {}'",
args.name
);
println!();
Ok(())
}
async fn handle_add_behavior(ctx: &mut CliContext, args: &AddBehaviorArgs) -> Result<()> {
println!();
println!("Creating behavior: {}", args.name);
println!();
let project_root = &ctx.working_dir;
let generator = BehaviorGeneratorService::new();
generator
.generate_behavior(project_root, &args.name, args.description.as_deref())
.await?;
println!("Behavior created successfully!");
println!();
println!(" configs/behaviors/{}/", args.name);
println!(" └── config.json");
println!();
println!("Next steps:");
println!(
" 1. Edit configs/behaviors/{}/config.json to define your behavior tree",
args.name
);
println!(
" 2. Set \"behavior_name\": \"{}\" in a behavior-executor node's config.json to run it",
args.name
);
println!(" 3. Run 'mecha10 dev' to test your behavior");
println!();
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
use crate::context::CliContextBuilder;
use tempfile::tempdir;
#[test]
fn select_lifecycle_modes_non_interactive_uses_tag_inferred_modes_only() {
let modes = select_lifecycle_modes(false, &["simulation", "hardware"]).unwrap();
assert_eq!(modes, vec!["simulation".to_string(), "hardware".to_string()]);
}
#[test]
fn select_lifecycle_modes_non_interactive_with_no_inference_selects_nothing() {
let modes = select_lifecycle_modes(false, &[]).unwrap();
assert!(modes.is_empty());
}
#[test]
fn select_targets_non_interactive_uses_inferred_target_only() {
let targets = select_targets(false, Some("robot")).unwrap();
assert_eq!(targets, vec!["robot".to_string()]);
}
#[test]
fn select_targets_non_interactive_with_no_inference_selects_nothing() {
let targets = select_targets(false, None).unwrap();
assert!(targets.is_empty());
}
#[tokio::test]
async fn handle_add_framework_node_pins_actual_framework_version_not_latest() {
let dir = tempdir().unwrap();
let config_path = dir.path().join(paths::PROJECT_CONFIG);
std::fs::write(
&config_path,
serde_json::json!({
"name": "proj",
"version": "0.1.0",
"nodes": {},
})
.to_string(),
)
.unwrap();
let mut ctx = CliContextBuilder::new()
.working_dir(dir.path().to_path_buf())
.config_path(Some(config_path.clone()))
.build()
.unwrap();
handle_add_framework_node(&mut ctx, "simulator", true).await.unwrap();
let updated: serde_json::Value = serde_json::from_str(&std::fs::read_to_string(&config_path).unwrap()).unwrap();
let pinned_version = updated["nodes"]["@mecha10/simulator"]
.as_str()
.expect("version should be a string");
assert_ne!(pinned_version, "latest");
assert_eq!(pinned_version, env!("CARGO_PKG_VERSION"));
}
#[tokio::test]
async fn handle_add_framework_node_syncs_ai_perception_node_into_simulation_and_hardware_modes() {
let dir = tempdir().unwrap();
let config_path = dir.path().join(paths::PROJECT_CONFIG);
std::fs::write(
&config_path,
serde_json::json!({
"name": "proj",
"version": "0.1.0",
"nodes": {},
"lifecycle": {
"modes": {
"simulation": { "nodes": ["@mecha10/simulator"] },
"hardware": { "nodes": ["@mecha10/websocket-bridge"] }
}
}
})
.to_string(),
)
.unwrap();
let mut ctx = CliContextBuilder::new()
.working_dir(dir.path().to_path_buf())
.config_path(Some(config_path.clone()))
.build()
.unwrap();
handle_add_framework_node(&mut ctx, "image-classifier", true)
.await
.unwrap();
let updated: serde_json::Value = serde_json::from_str(&std::fs::read_to_string(&config_path).unwrap()).unwrap();
for mode in ["simulation", "hardware"] {
let mode_nodes = updated["lifecycle"]["modes"][mode]["nodes"]
.as_array()
.unwrap_or_else(|| panic!("expected '{mode}' nodes array"));
let mode_nodes: Vec<&str> = mode_nodes.iter().map(|v| v.as_str().unwrap()).collect();
assert!(
mode_nodes.contains(&"@mecha10/image-classifier"),
"expected @mecha10/image-classifier in lifecycle.modes.{mode}.nodes, got {mode_nodes:?}"
);
}
}
#[tokio::test]
async fn handle_add_framework_node_does_not_duplicate_node_already_present_in_mode() {
let dir = tempdir().unwrap();
let config_path = dir.path().join(paths::PROJECT_CONFIG);
std::fs::write(
&config_path,
serde_json::json!({
"name": "proj",
"version": "0.1.0",
"nodes": {},
"lifecycle": {
"modes": {
"simulation": { "nodes": ["@mecha10/simulator", "@mecha10/image-classifier"] },
"hardware": { "nodes": ["@mecha10/websocket-bridge"] }
}
}
})
.to_string(),
)
.unwrap();
let mut ctx = CliContextBuilder::new()
.working_dir(dir.path().to_path_buf())
.config_path(Some(config_path.clone()))
.build()
.unwrap();
handle_add_framework_node(&mut ctx, "image-classifier", true)
.await
.unwrap();
let updated: serde_json::Value = serde_json::from_str(&std::fs::read_to_string(&config_path).unwrap()).unwrap();
let simulation_nodes = updated["lifecycle"]["modes"]["simulation"]["nodes"].as_array().unwrap();
let occurrences = simulation_nodes
.iter()
.filter(|n| n.as_str() == Some("@mecha10/image-classifier"))
.count();
assert_eq!(
occurrences, 1,
"expected exactly one @mecha10/image-classifier entry in simulation mode, got {simulation_nodes:?}"
);
}
#[tokio::test]
async fn handle_add_framework_node_does_not_add_hardware_only_driver_node_to_simulation() {
let dir = tempdir().unwrap();
let config_path = dir.path().join(paths::PROJECT_CONFIG);
std::fs::write(
&config_path,
serde_json::json!({
"name": "proj",
"version": "0.1.0",
"nodes": {},
"lifecycle": {
"modes": {
"simulation": { "nodes": ["@mecha10/simulator"] },
"hardware": { "nodes": ["@mecha10/websocket-bridge"] }
}
}
})
.to_string(),
)
.unwrap();
let mut ctx = CliContextBuilder::new()
.working_dir(dir.path().to_path_buf())
.config_path(Some(config_path.clone()))
.build()
.unwrap();
handle_add_framework_node(&mut ctx, "motor", true).await.unwrap();
let updated: serde_json::Value = serde_json::from_str(&std::fs::read_to_string(&config_path).unwrap()).unwrap();
let hardware_nodes = updated["lifecycle"]["modes"]["hardware"]["nodes"].as_array().unwrap();
let hardware_nodes: Vec<&str> = hardware_nodes.iter().map(|v| v.as_str().unwrap()).collect();
assert!(
hardware_nodes.contains(&"@mecha10/motor"),
"expected @mecha10/motor in lifecycle.modes.hardware.nodes, got {hardware_nodes:?}"
);
let simulation_nodes = updated["lifecycle"]["modes"]["simulation"]["nodes"].as_array().unwrap();
let simulation_nodes: Vec<&str> = simulation_nodes.iter().map(|v| v.as_str().unwrap()).collect();
assert!(
!simulation_nodes.contains(&"@mecha10/motor"),
"motor is not AI/perception, must not be added to simulation mode, got {simulation_nodes:?}"
);
}
#[tokio::test]
async fn handle_add_framework_node_non_interactive_syncs_targets_from_run_target_inference() {
let dir = tempdir().unwrap();
let config_path = dir.path().join(paths::PROJECT_CONFIG);
std::fs::write(
&config_path,
serde_json::json!({
"name": "proj",
"version": "0.1.0",
"nodes": {},
})
.to_string(),
)
.unwrap();
let mut ctx = CliContextBuilder::new()
.working_dir(dir.path().to_path_buf())
.config_path(Some(config_path.clone()))
.build()
.unwrap();
handle_add_framework_node(&mut ctx, "motor", true).await.unwrap();
let updated: serde_json::Value = serde_json::from_str(&std::fs::read_to_string(&config_path).unwrap()).unwrap();
let robot_targets = updated["targets"]["robot"].as_array().unwrap();
let robot_targets: Vec<&str> = robot_targets.iter().map(|v| v.as_str().unwrap()).collect();
assert!(
robot_targets.contains(&"@mecha10/motor"),
"expected @mecha10/motor in targets.robot, got {robot_targets:?}"
);
assert!(
updated["targets"].get("remote").is_none() || updated["targets"]["remote"].as_array().unwrap().is_empty(),
"motor should not be added to targets.remote"
);
}
#[test]
fn framework_nodes_allowlist_includes_simulator() {
assert!(
FRAMEWORK_NODES.contains(&"simulator"),
"FRAMEWORK_NODES must include simulator, got {FRAMEWORK_NODES:?}"
);
}
#[test]
fn framework_nodes_allowlist_excludes_deleted_simulation_bridge() {
assert!(
!FRAMEWORK_NODES.contains(&"simulation-bridge"),
"FRAMEWORK_NODES must not reference the deleted simulation-bridge node"
);
}
#[tokio::test]
async fn handle_add_driver_creates_driver_crate() {
let dir = tempdir().unwrap();
std::fs::write(dir.path().join("Cargo.toml"), "[workspace]\nmembers = []\n").unwrap();
let mut ctx = CliContextBuilder::new()
.working_dir(dir.path().to_path_buf())
.build()
.unwrap();
let args = AddDriverArgs {
name: "front-camera".to_string(),
r#type: AddDriverType::Camera,
};
handle_add_driver(&mut ctx, &args).await.unwrap();
let driver_dir = dir.path().join("drivers/front-camera");
assert!(driver_dir.join("Cargo.toml").exists());
assert!(driver_dir.join("src/lib.rs").exists());
assert!(driver_dir.join("src/main.rs").exists());
}
#[tokio::test]
async fn handle_add_behavior_creates_behavior_config() {
let dir = tempdir().unwrap();
let mut ctx = CliContextBuilder::new()
.working_dir(dir.path().to_path_buf())
.build()
.unwrap();
let args = AddBehaviorArgs {
name: "patrol-perimeter".to_string(),
description: Some("Patrols the building perimeter".to_string()),
};
handle_add_behavior(&mut ctx, &args).await.unwrap();
let behavior_dir = dir.path().join("configs/behaviors/patrol-perimeter");
let config_path = behavior_dir.join("config.json");
assert!(config_path.exists());
assert!(!behavior_dir.join("src").exists());
assert!(!behavior_dir.join("Cargo.toml").exists());
let content = std::fs::read_to_string(&config_path).unwrap();
let config: serde_json::Value = serde_json::from_str(&content).unwrap();
assert_eq!(config["name"], serde_json::json!("patrol-perimeter"));
assert_eq!(
config["description"],
serde_json::json!("Patrols the building perimeter")
);
}
#[tokio::test]
async fn handle_add_behavior_fails_if_behavior_already_exists() {
let dir = tempdir().unwrap();
let mut ctx = CliContextBuilder::new()
.working_dir(dir.path().to_path_buf())
.build()
.unwrap();
let args = AddBehaviorArgs {
name: "patrol".to_string(),
description: None,
};
handle_add_behavior(&mut ctx, &args).await.unwrap();
let err = handle_add_behavior(&mut ctx, &args).await.unwrap_err();
assert!(err.to_string().contains("already exists"));
}
#[tokio::test]
#[serial_test::serial(mecha10_api_url_env_var)]
async fn copy_node_configs_falls_back_to_manual_create_warning_without_copying_stale_configs() {
std::env::set_var("MECHA10_API_URL", "http://127.0.0.1:1/api");
let dir = tempdir().unwrap();
let mut ctx = CliContextBuilder::new()
.working_dir(dir.path().to_path_buf())
.build()
.unwrap();
let result = copy_node_configs(&mut ctx, "teleop").await;
std::env::remove_var("MECHA10_API_URL");
result.unwrap();
let dest = dir.path().join(paths::config::framework_node_dir("teleop"));
assert!(
!dest.exists(),
"copy_node_configs must not create a config dir when no template source is available"
);
}
#[test]
fn to_driver_type_maps_every_clap_variant() {
assert_eq!(to_driver_type(AddDriverType::Camera).as_str(), "camera");
assert_eq!(to_driver_type(AddDriverType::Motor).as_str(), "motor");
assert_eq!(to_driver_type(AddDriverType::Sensor).as_str(), "sensor");
assert_eq!(to_driver_type(AddDriverType::Imu).as_str(), "imu");
}
}