use crate::commands::add::{AddBehaviorArgs, AddCommand, AddDriverArgs, AddDriverType, AddNodeArgs};
use crate::context::CliContext;
use crate::paths;
use crate::services::{
catalog_client, BehaviorGeneratorService, DriverGeneratorService, DriverType, NodeGeneratorService,
};
use crate::ui::prompts::is_interactive_terminal;
use anyhow::{Context, Result};
async fn fetch_framework_nodes() -> Result<Vec<catalog_client::NodeCatalogEntry>> {
catalog_client::fetch_node_catalog()
.await
.context("Failed to fetch known node list")
}
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
}
}
}
fn select_framework_node_names(names: &[String], interactive: bool, available: &[String]) -> 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: {}",
available.join(", ")
);
}
let options: Vec<&str> = available.iter().map(String::as_str).collect();
let selected = crate::ui::prompts::multiselect(
"Which framework node(s) would you like to add?",
&options,
&vec![false; options.len()],
)?;
Ok(selected.into_iter().map(|i| options[i].to_string()).collect())
}
fn add_node_to_json_config(config: &mut serde_json::Value, name: &str, targets: &[String]) -> 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"));
for target in targets {
let mut target_nodes = config["targets"][target]["nodes"].as_array().cloned().unwrap_or_default();
if !target_nodes.iter().any(|n| n == &identifier) {
target_nodes.push(serde_json::json!(identifier));
}
config["targets"][target]["nodes"] = serde_json::json!(target_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 catalog_entries = fetch_framework_nodes().await?;
let available_nodes: Vec<String> = catalog_entries.iter().map(|e| e.name.clone()).collect();
let names = select_framework_node_names(names, interactive, &available_nodes)?;
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 !available_nodes.iter().any(|n| n == name) {
anyhow::bail!(
"Unknown framework node '{}'. Available nodes:\n {}",
name,
available_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!();
let targets: &[String] = catalog_entries
.iter()
.find(|e| e.name == *name)
.map(|e| e.targets.as_slice())
.unwrap_or_default();
if add_node_to_json_config(&mut config, name, targets)? {
added.push(*name);
}
}
let updated = serde_json::to_string_pretty(&config)?;
tokio::fs::write(&config_path, updated)
.await
.context("Failed to write mecha10.json")?;
if !added.is_empty() {
let added_names: Vec<String> = added.iter().map(|n| n.to_string()).collect();
mecha10_robot_config::pull_node_catalog_configs(&ctx.working_dir, &added_names, None).await;
for name in &added {
let config_path = ctx
.working_dir
.join(paths::config::framework_node_dir(name))
.join("config.json");
if config_path.exists() {
println!(" ✓ Config written to configs/nodes/@mecha10/{}/", name);
} else {
println!(
" âš No config found for {}. Create configs/nodes/@mecha10/{}/config.json manually.",
name, name
);
}
}
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 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 std::io::{Read, Write};
use std::net::TcpListener;
use tempfile::tempdir;
fn spawn_fake_node_list_server(nodes: &[(&str, &[&str])]) -> std::net::SocketAddr {
let listener = TcpListener::bind("127.0.0.1:0").unwrap();
let addr = listener.local_addr().unwrap();
let entries: Vec<serde_json::Value> = nodes
.iter()
.map(|(name, targets)| serde_json::json!({"name": name, "recommended": true, "targets": targets}))
.collect();
let body = serde_json::to_string(&entries).unwrap();
std::thread::spawn(move || {
for stream in listener.incoming() {
let mut stream = match stream {
Ok(s) => s,
Err(_) => continue,
};
let mut buf = [0u8; 4096];
let _ = stream.read(&mut buf);
let header = format!(
"HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n",
body.len()
);
let _ = stream.write_all(header.as_bytes());
let _ = stream.write_all(body.as_bytes());
let _ = stream.flush();
}
});
addr
}
struct FakeNodeListServer;
impl Drop for FakeNodeListServer {
fn drop(&mut self) {
std::env::remove_var("MECHA10_API_URL");
}
}
fn start_fake_node_list_server(nodes: &[(&str, &[&str])]) -> FakeNodeListServer {
let addr = spawn_fake_node_list_server(nodes);
std::env::set_var("MECHA10_API_URL", format!("http://{}/api", addr));
FakeNodeListServer
}
const DEFAULT_CATALOG: &[(&str, &[&str])] = &[
("simulator", &["simulation"]),
("camera", &["robot"]),
("motor", &["robot"]),
("image-classifier", &["simulation", "remote"]),
];
#[test]
fn add_node_to_json_config_places_node_into_exactly_its_given_targets() {
let mut config = serde_json::json!({ "nodes": {} });
let added = add_node_to_json_config(
&mut config,
"image-classifier",
&["simulation".to_string(), "remote".to_string()],
)
.unwrap();
assert!(added);
assert!(config["nodes"].get("@mecha10/image-classifier").is_some());
assert_eq!(
config["targets"]["simulation"]["nodes"],
serde_json::json!(["@mecha10/image-classifier"])
);
assert_eq!(
config["targets"]["remote"]["nodes"],
serde_json::json!(["@mecha10/image-classifier"])
);
assert!(
config["targets"].get("robot").is_none(),
"image-classifier's targets list doesn't name \"robot\", so no targets.robot entry should be created"
);
}
#[test]
fn add_node_to_json_config_is_a_no_op_for_a_node_already_present() {
let mut config = serde_json::json!({ "nodes": { "@mecha10/motor": "0.1.0" } });
let added = add_node_to_json_config(&mut config, "motor", &["robot".to_string()]).unwrap();
assert!(!added);
assert!(
config["targets"].get("robot").is_none(),
"an already-present node must be a pure no-op, not retroactively placed into targets"
);
}
#[test]
fn add_node_to_json_config_does_not_duplicate_an_existing_target_entry() {
let mut config = serde_json::json!({
"nodes": {},
"targets": { "robot": { "nodes": ["@mecha10/camera"] } }
});
add_node_to_json_config(&mut config, "motor", &["robot".to_string()]).unwrap();
assert_eq!(
config["targets"]["robot"]["nodes"],
serde_json::json!(["@mecha10/camera", "@mecha10/motor"])
);
}
#[tokio::test]
#[serial_test::serial(mecha10_api_url_env_var)]
async fn handle_add_framework_node_pins_actual_framework_version_not_latest() {
let _server = start_fake_node_list_server(DEFAULT_CATALOG);
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]
#[serial_test::serial(mecha10_api_url_env_var)]
async fn handle_add_framework_node_places_node_into_every_target_its_live_catalog_entry_names() {
let _server = start_fake_node_list_server(DEFAULT_CATALOG);
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]
#[serial_test::serial(mecha10_api_url_env_var)]
async fn handle_add_framework_node_does_not_duplicate_node_already_present_in_mode() {
const CATALOG: &[(&str, &[&str])] = &[
("simulator", &["simulation"]),
("camera", &["robot"]),
("motor", &["robot"]),
("image-classifier", &["simulation", "remote"]),
("websocket-bridge", &["dev", "simulation", "robot", "remote"]),
];
let _server = start_fake_node_list_server(CATALOG);
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]
#[serial_test::serial(mecha10_api_url_env_var)]
async fn handle_add_framework_node_does_not_add_a_node_to_a_target_its_catalog_entry_does_not_name() {
let _server = start_fake_node_list_server(DEFAULT_CATALOG);
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's catalog entry only names \"robot\", must not be added to simulation, got {simulation_nodes:?}"
);
}
#[tokio::test]
#[serial_test::serial(mecha10_api_url_env_var)]
async fn handle_add_framework_node_places_node_into_its_live_catalog_robot_target() {
let _server = start_fake_node_list_server(DEFAULT_CATALOG);
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 available = vec!["camera".to_string(), "motor".to_string()];
let err = select_framework_node_names(&[], false, &available).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]
#[serial_test::serial(mecha10_api_url_env_var)]
async fn handle_add_framework_node_multiple_names_writes_all_three_sections_for_each() {
let _server = start_fake_node_list_server(DEFAULT_CATALOG);
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]
#[serial_test::serial(mecha10_api_url_env_var)]
async fn handle_add_framework_node_multiple_names_rejects_whole_batch_on_unknown_node() {
let _server = start_fake_node_list_server(DEFAULT_CATALOG);
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"
);
}
#[tokio::test]
#[serial_test::serial(mecha10_api_url_env_var)]
async fn fetch_framework_nodes_returns_the_live_list_from_mecha10_api() {
let _server = start_fake_node_list_server(DEFAULT_CATALOG);
let entries = fetch_framework_nodes().await.unwrap();
let names: Vec<&str> = entries.iter().map(|e| e.name.as_str()).collect();
assert!(
names.contains(&"simulator"),
"expected fetched node list to include simulator, got {names:?}"
);
assert!(
!names.contains(&"simulation-bridge"),
"fetched node list must not reference the deleted simulation-bridge node, got {names:?}"
);
let simulator_entry = entries.iter().find(|e| e.name == "simulator").unwrap();
assert_eq!(
simulator_entry.targets,
vec!["simulation".to_string()],
"expected simulator's live catalog entry to carry its targets field through"
);
}
#[tokio::test]
#[serial_test::serial(mecha10_api_url_env_var)]
async fn fetch_framework_nodes_fails_hard_when_mecha10_api_is_unreachable() {
std::env::set_var("MECHA10_API_URL", "http://127.0.0.1:1/api");
let result = fetch_framework_nodes().await;
std::env::remove_var("MECHA10_API_URL");
let err = result.unwrap_err();
assert!(
err.to_string().contains("Failed to fetch known node list"),
"got: {}",
err
);
}
#[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"));
}
#[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");
}
}