use crate::commands::add::{AddBehaviorArgs, AddCommand, AddDriverArgs, AddDriverType, AddNodeArgs};
use crate::context::CliContext;
use crate::paths;
use crate::services::{BehaviorGeneratorService, DriverGeneratorService, DriverType, NodeGeneratorService};
use crate::ui::prompts::is_interactive_terminal;
use anyhow::{Context, Result};
const FRAMEWORK_NODES: &[&str] = &[
"behavior-executor",
"camera",
"image-classifier",
"imu",
"listener",
"llm-command",
"motor",
"simulator",
"object-detector",
"rl",
"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 names: Vec<String> = args
.iter()
.filter(|a| !NON_INTERACTIVE_FLAGS.contains(&a.as_str()))
.cloned()
.collect();
handle_add_framework_node(ctx, &names, non_interactive).await
}
}
}
const ALL_LIFECYCLE_MODES: &[&str] = &["simulation"];
const ALL_TARGETS: &[&str] = &["robot", "remote"];
fn select_lifecycle_modes(interactive: bool, inferred: &[&'static str]) -> Result<Vec<String>> {
select_multiselect_options(
interactive,
"Add this node to which lifecycle mode(s)?",
ALL_LIFECYCLE_MODES,
inferred.iter().copied(),
|mode| inferred.contains(&mode),
)
}
fn select_targets(interactive: bool, inferred: Option<&'static str>) -> Result<Vec<String>> {
select_multiselect_options(
interactive,
"Add this node to which target(s)?",
ALL_TARGETS,
inferred.into_iter(),
|target| Some(target) == inferred,
)
}
fn select_multiselect_options(
interactive: bool,
prompt: &str,
options: &'static [&'static str],
inferred_defaults: impl Iterator<Item = &'static str>,
is_inferred: impl Fn(&'static str) -> bool,
) -> Result<Vec<String>> {
if !interactive {
return Ok(inferred_defaults.map(|s| s.to_string()).collect());
}
let checked: Vec<bool> = options.iter().map(|&o| is_inferred(o)).collect();
let selected = crate::ui::prompts::multiselect(prompt, options, &checked)?;
Ok(selected.into_iter().map(|i| options[i].to_string()).collect())
}
fn select_framework_node_names(names: &[String], interactive: bool) -> Result<Vec<String>> {
if !names.is_empty() {
return Ok(names.to_vec());
}
if !interactive {
anyhow::bail!(
"Node name is required. Usage: mecha10 add <node-name>\n\nAvailable nodes: {}",
FRAMEWORK_NODES.join(", ")
);
}
let selected = crate::ui::prompts::multiselect(
"Which framework node(s) would you like to add?",
FRAMEWORK_NODES,
&vec![false; FRAMEWORK_NODES.len()],
)?;
Ok(selected.into_iter().map(|i| FRAMEWORK_NODES[i].to_string()).collect())
}
fn add_node_to_json_config(config: &mut serde_json::Value, name: &str, interactive: bool) -> Result<bool> {
let identifier = format!("@mecha10/{}", name);
if config["nodes"].get(&identifier).is_some() {
println!(" {} is already in mecha10.json", identifier);
return Ok(false);
}
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 modes = select_lifecycle_modes(interactive, &inferred_modes)?;
let targets = select_targets(interactive, inferred_target)?;
for mode in modes.iter().chain(targets.iter()) {
let mut mode_nodes = config["targets"][mode]["nodes"].as_array().cloned().unwrap_or_default();
if !mode_nodes.iter().any(|n| n == &identifier) {
mode_nodes.push(serde_json::json!(identifier));
}
config["targets"][mode]["nodes"] = serde_json::json!(mode_nodes);
}
println!(" ✓ Added {} to mecha10.json", identifier);
Ok(true)
}
async fn handle_add_framework_node(ctx: &mut CliContext, names: &[String], non_interactive: bool) -> Result<()> {
if !ctx.is_project_initialized() {
anyhow::bail!("Not in a Mecha10 project directory");
}
let interactive = !non_interactive && is_interactive_terminal();
let names = select_framework_node_names(names, interactive)?;
let names: Vec<&str> = names.iter().map(|n| n.trim_start_matches("@mecha10/")).collect();
if names.is_empty() {
println!();
println!("No nodes selected.");
println!();
return Ok(());
}
for name in &names {
if !FRAMEWORK_NODES.contains(name) {
anyhow::bail!(
"Unknown framework node '{}'. Available nodes:\n {}",
name,
FRAMEWORK_NODES.join("\n ")
);
}
}
let config_path = ctx.working_dir.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 mut added = Vec::new();
for name in &names {
println!();
println!("Adding node: @mecha10/{}", name);
println!();
if add_node_to_json_config(&mut config, name, interactive)? {
added.push(*name);
}
}
let updated = serde_json::to_string_pretty(&config)?;
tokio::fs::write(&config_path, updated)
.await
.context("Failed to write mecha10.json")?;
for name in &added {
copy_node_configs(ctx, name).await?;
}
if !added.is_empty() {
println!();
println!("Next steps:");
for name in &added {
println!(
" - Edit configs/nodes/@mecha10/{}/config.json to configure the node",
name
);
}
println!(" - Run 'mecha10 dev' to start the node(s)");
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"]).unwrap();
assert_eq!(modes, vec!["simulation".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".to_string()], 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_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": {},
"targets": {
"simulation": { "nodes": ["@mecha10/simulator"] }
}
})
.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".to_string()], true)
.await
.unwrap();
let updated: serde_json::Value = serde_json::from_str(&std::fs::read_to_string(&config_path).unwrap()).unwrap();
let mode_nodes = updated["targets"]["simulation"]["nodes"]
.as_array()
.expect("expected 'simulation' 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 targets.simulation.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": {},
"targets": {
"simulation": { "nodes": ["@mecha10/simulator", "@mecha10/image-classifier"] },
"robot": { "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".to_string()], 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["targets"]["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": {},
"targets": {
"simulation": { "nodes": ["@mecha10/simulator"] }
}
})
.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".to_string()], 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["targets"]["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".to_string()], 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"]["nodes"].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.nodes, got {robot_targets:?}"
);
assert!(
updated["targets"].get("remote").is_none()
|| updated["targets"]["remote"]["nodes"].as_array().unwrap().is_empty(),
"motor should not be added to targets.remote"
);
}
#[test]
fn select_framework_node_names_non_interactive_with_no_names_errors() {
let err = select_framework_node_names(&[], false).unwrap_err();
assert!(err.to_string().contains("Node name is required"), "got: {}", err);
}
#[test]
fn select_framework_node_names_passes_through_given_names_unchanged() {
let names = select_framework_node_names(&["camera".to_string(), "motor".to_string()], false).unwrap();
assert_eq!(names, vec!["camera".to_string(), "motor".to_string()]);
}
#[tokio::test]
async fn handle_add_framework_node_multiple_names_writes_all_three_sections_for_each() {
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, &["image-classifier".to_string(), "motor".to_string()], true)
.await
.unwrap();
let updated: serde_json::Value = serde_json::from_str(&std::fs::read_to_string(&config_path).unwrap()).unwrap();
assert!(updated["nodes"].get("@mecha10/image-classifier").is_some());
assert!(updated["nodes"].get("@mecha10/motor").is_some());
let simulation_nodes = updated["targets"]["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/image-classifier"),
"expected @mecha10/image-classifier in targets.simulation.nodes, got {simulation_nodes:?}"
);
let robot_nodes = updated["targets"]["robot"]["nodes"].as_array().unwrap();
let robot_nodes: Vec<&str> = robot_nodes.iter().map(|v| v.as_str().unwrap()).collect();
assert!(
robot_nodes.contains(&"@mecha10/motor"),
"expected @mecha10/motor in targets.robot.nodes, got {robot_nodes:?}"
);
}
#[tokio::test]
async fn handle_add_framework_node_multiple_names_rejects_whole_batch_on_unknown_node() {
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();
let err = handle_add_framework_node(&mut ctx, &["camera".to_string(), "not-a-real-node".to_string()], true)
.await
.unwrap_err();
assert!(err.to_string().contains("Unknown framework node"));
let updated: serde_json::Value = serde_json::from_str(&std::fs::read_to_string(&config_path).unwrap()).unwrap();
assert!(
updated["nodes"].get("@mecha10/camera").is_none(),
"no node should be written when the batch contains an unknown node"
);
}
#[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");
}
}