use crate::commands::config::{ConfigListArgs, ConfigPullArgs, ConfigPushArgs, ConfigValidateArgs};
use crate::context::CliContext;
use crate::paths;
use crate::services::credentials::CredentialsService;
use crate::ui::style;
use anyhow::{Context, Result};
use colored_json::to_colored_json_auto;
use mecha10_robot_config::{collect_config_files, write_config_value, RobotConfigClient};
use mecha10_templates::services::assets_download::copy_dir_recursive;
use std::path::Path;
pub(crate) const ROBOT_CONFIG_PATH: &str = "/api/robots";
pub async fn handle_config_push(ctx: &mut CliContext, args: &ConfigPushArgs) -> Result<()> {
println!();
println!("Pushing configurations...");
println!();
mecha10_core::relay_topics::regenerate_websocket_bridge_relay_config(&ctx.working_dir)
.context("Failed to regenerate websocket-bridge relay topics")?;
let robot_id = get_robot_id(ctx, args.robot_id.as_deref()).await?;
let configs_dir = match &args.path {
Some(path) => path.clone(),
None => ctx.working_dir.join("configs"),
};
if !configs_dir.exists() {
println!("No configs/ directory found");
println!();
println!("Expected:");
println!(" - configs/ directory with node/behavior/simulation/rl configs");
println!(" - mecha10.json at project root");
return Ok(());
}
println!("Robot ID: {}", robot_id);
println!("Configs dir: {}", configs_dir.display());
println!();
println!("Validating configs...");
let assets_dir = if crate::config_validation::needs_environment_schema(&ctx.working_dir) {
Some(
ctx.assets_download()
.fetch_and_extract()
.await
.context("Failed to download mecha10-assets for config validation")?,
)
} else {
None
};
let schemas_dir = assets_dir
.as_ref()
.map(|dir| dir.path().join("configs/simulation/schemas"));
match crate::config_validation::validate_configs(
&ctx.working_dir,
crate::config_validation::ConfigKind::ALL,
schemas_dir.as_deref(),
) {
Ok(()) => println!("{}", style::success("✅ All configs are valid")),
Err(e) => {
println!("{}", style::error("❌ Config validation failed:"));
println!();
println!("{:#}", e);
println!();
return Err(e.context("Refusing to push: fix the invalid config(s) above and try again"));
}
}
println!();
let mecha10_json = ctx.working_dir.join("mecha10.json");
let configs = collect_config_files(&configs_dir, Some(&mecha10_json))
.await
.map_err(|e| anyhow::anyhow!("Failed to collect configs: {}", e))?;
if configs.is_empty() {
println!("No config files found");
println!();
return Ok(());
}
println!("Found {} config(s):", configs.len());
for (key, _) in &configs {
println!(" - {}", key);
}
println!();
if args.dry_run {
println!("DRY RUN - No changes will be made");
return Ok(());
}
let client = create_config_client(ctx).await?;
for (key, value) in &configs {
client
.upsert_config(&robot_id, key, value.clone())
.await
.map_err(|e| anyhow::anyhow!("Failed to push config '{}': {}", key, e))?;
}
println!("Pushed {} config(s)", configs.len());
println!();
Ok(())
}
pub async fn handle_config_pull(ctx: &mut CliContext, args: &ConfigPullArgs) -> Result<()> {
println!();
println!("Pulling configurations...");
println!();
let robot_id = get_robot_id(ctx, args.robot_id.as_deref()).await?;
let output_dir = args.output.clone().unwrap_or_else(|| ctx.working_dir.join("configs"));
println!("Robot ID: {}", robot_id);
println!("Output dir: {}", output_dir.display());
println!();
if output_dir.exists() && output_dir.read_dir().is_ok_and(|mut d| d.next().is_some()) && !args.force {
return Err(anyhow::anyhow!(
"{} already exists and is not empty - pass --force to overwrite",
output_dir.display()
));
}
let client = create_config_client(ctx).await?;
let configs = client
.get_configs(&robot_id)
.await
.map_err(|e| anyhow::anyhow!("Failed to fetch configs: {}", e))?;
if configs.is_empty() {
println!("No configs found for robot {}", robot_id);
return Ok(());
}
let mecha10_json_dest = ctx.working_dir.join("mecha10.json");
let mut wrote_mecha10_json = false;
for config in &configs {
write_config_value(
&output_dir,
Some(&mecha10_json_dest),
&config.config_key,
&config.config_value,
)
.await
.map_err(|e| anyhow::anyhow!("Failed to write config '{}': {}", config.config_key, e))?;
if config.config_key == "mecha10.json" {
wrote_mecha10_json = true;
}
}
println!(
"Pulled {} config(s){}",
configs.len(),
if wrote_mecha10_json {
" (incl. mecha10.json)"
} else {
""
}
);
println!();
Ok(())
}
pub async fn handle_config_list(ctx: &mut CliContext, args: &ConfigListArgs) -> Result<()> {
println!();
let robot_id = get_robot_id(ctx, args.robot_id.as_deref()).await?;
println!("Configs for robot: {}", robot_id);
println!();
let client = create_config_client(ctx).await?;
let configs = match client.get_configs(&robot_id).await {
Ok(configs) => configs,
Err(e) => {
return Err(anyhow::anyhow!("Failed to fetch configs: {}", e));
}
};
if configs.is_empty() {
println!("No configs found");
println!();
return Ok(());
}
for config in configs {
println!("{}:", config.config_key);
println!(" Updated: {}", config.updated_at.format("%Y-%m-%d %H:%M:%S UTC"));
println!(" Created by: {}", config.created_by);
if args.verbose {
let value_str = to_colored_json_auto(&config.config_value).unwrap_or_else(|_| "{}".to_string());
for line in value_str.lines() {
println!(" {}", line);
}
}
println!();
}
Ok(())
}
pub async fn push_configs(ctx: &mut CliContext) -> Result<()> {
mecha10_core::relay_topics::regenerate_websocket_bridge_relay_config(&ctx.working_dir)
.context("Failed to regenerate websocket-bridge relay topics")?;
let configs_dir = ctx.working_dir.join("configs");
if !configs_dir.exists() {
println!(" No configs/ directory found, skipping config push");
return Ok(());
}
let robot_id = get_robot_id(ctx, None).await?;
let mecha10_json = ctx.working_dir.join("mecha10.json");
let configs = collect_config_files(&configs_dir, Some(&mecha10_json))
.await
.map_err(|e| anyhow::anyhow!("Failed to collect configs: {}", e))?;
if configs.is_empty() {
println!(" No configs found in configs/ directory");
return Ok(());
}
let client = create_config_client(ctx).await?;
let remote = client
.get_configs(&robot_id)
.await
.map_err(|e| anyhow::anyhow!("Failed to fetch current configs for robot {}: {}", robot_id, e))?;
let remote_by_key: std::collections::HashMap<&str, &mecha10_robot_config::ConfigValue> = remote
.iter()
.map(|c| (c.config_key.as_str(), &c.config_value))
.collect();
let to_push: Vec<&(String, mecha10_robot_config::ConfigValue)> = configs
.iter()
.filter(|(key, value)| remote_by_key.get(key.as_str()) != Some(&value))
.collect();
if to_push.is_empty() {
println!(" Configs unchanged, skipping push");
return Ok(());
}
println!(
" Pushing {} changed config(s) for robot {}...",
to_push.len(),
robot_id
);
for (key, value) in &to_push {
client
.upsert_config(&robot_id, key, value.clone())
.await
.map_err(|e| anyhow::anyhow!("Failed to push config '{}': {}", key, e))?;
}
println!(" {}", style::success(format!("✅ Pushed {} config(s)", to_push.len())));
Ok(())
}
pub async fn handle_config_show(ctx: &mut CliContext) -> Result<()> {
println!();
if !ctx.is_project_initialized() {
println!("Not inside a mecha10 project (no mecha10.json found).");
println!();
println!("Run `mecha10 init` to create a new project.");
println!();
return Ok(());
}
let project_config = ctx.load_project_config().await?;
let env_name = project_config.environments.current_env_name();
let control_plane_url = project_config.environments.control_plane_url();
let auth_status = mecha10_auth::commands::auth_status_line()?;
let configs_dir = ctx.working_dir.join("configs");
println!("Project name: {}", project_config.name);
println!("Robot ID: {}", project_config.robot.id);
println!("Environment: {}", env_name);
println!("Control plane URL: {}", control_plane_url);
println!("Auth status: {}", auth_status);
println!("Configs dir: {}", configs_dir.display());
println!();
Ok(())
}
pub async fn handle_config_validate(ctx: &mut CliContext, args: &ConfigValidateArgs) -> Result<()> {
println!();
println!("{}", style::heading("🔎 Config Validation"));
println!("{}", style::separator(47));
println!();
if args.path.is_none() && !ctx.is_project_initialized() {
println!(
"{}",
style::error("❌ Not in a Mecha10 project directory (no mecha10.json found).")
);
println!();
println!("Run `mecha10 init` to create a new project, or pass --path to validate a single file.");
println!();
return Err(anyhow::anyhow!("Not in a Mecha10 project directory"));
}
let needs_schemas = args.path.is_none() && crate::config_validation::needs_environment_schema(&ctx.working_dir);
let assets_dir = if needs_schemas {
Some(
ctx.assets_download()
.fetch_and_extract()
.await
.context("Failed to download mecha10-assets for config validation")?,
)
} else {
None
};
let schemas_dir = assets_dir
.as_ref()
.map(|dir| dir.path().join("configs/simulation/schemas"));
match crate::config_validation::validate_config(args.path.as_deref(), schemas_dir.as_deref()) {
Ok(target) => {
println!("{}", style::success(format!("✅ {} is valid", target.display())));
println!();
Ok(())
}
Err(e) => {
println!("{}", style::error("❌ Config validation failed:"));
println!();
println!("{:#}", e);
println!();
Err(e)
}
}
}
pub async fn handle_config_edit(ctx: &mut CliContext) -> Result<()> {
println!();
if !ctx.is_project_initialized() {
println!("Not inside a mecha10 project (no mecha10.json found).");
println!();
println!("Run `mecha10 init` to create a new project.");
println!();
return Ok(());
}
let entries = crate::ui::discover_editable_configs(&ctx.working_dir);
if entries.is_empty() {
println!("No editable configs found under configs/, mecha10.json, or simulation/.");
println!();
println!("Run `mecha10 init` to scaffold a project, or `mecha10 config pull` to fetch existing configs.");
println!();
return Ok(());
}
let selected_path = run_config_edit_tui(entries)?;
let Some(path) = selected_path else {
return Ok(());
};
let editor_command = std::env::var("VISUAL")
.or_else(|_| std::env::var("EDITOR"))
.unwrap_or_else(|_| "vi".to_string());
let mut parts = editor_command.split_whitespace();
let editor = parts
.next()
.ok_or_else(|| anyhow::anyhow!("$VISUAL/$EDITOR is set but empty"))?;
let status = std::process::Command::new(editor)
.args(parts)
.arg(&path)
.status()
.with_context(|| format!("Failed to launch editor '{editor_command}'"))?;
if !status.success() {
return Err(anyhow::anyhow!(
"Editor '{editor}' exited with {}",
status
.code()
.map_or_else(|| "a signal".to_string(), |code| code.to_string())
));
}
Ok(())
}
fn run_config_edit_tui(entries: Vec<crate::ui::EditableConfigEntry>) -> Result<Option<std::path::PathBuf>> {
use crate::ui::{ConfigEditOutcome, ConfigEditTui};
use crossterm::{
event::{self, Event},
execute,
terminal::{disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen},
};
use ratatui::{backend::CrosstermBackend, Terminal};
use std::io::stdout;
enable_raw_mode()?;
let mut stdout_handle = stdout();
execute!(stdout_handle, EnterAlternateScreen)?;
let backend = CrosstermBackend::new(stdout_handle);
let mut terminal = Terminal::new(backend)?;
let mut tui = ConfigEditTui::new(entries);
let result: Result<Option<ConfigEditOutcome>> = loop {
if let Err(e) = terminal.draw(|f| tui.draw(f)) {
break Err(e.into());
}
match event::poll(std::time::Duration::from_millis(100)) {
Ok(true) => match event::read() {
Ok(Event::Key(key_event)) => tui.handle_key(key_event),
Ok(_) => {}
Err(e) => break Err(e.into()),
},
Ok(false) => {}
Err(e) => break Err(e.into()),
}
if tui.should_quit() {
break Ok(tui.take_outcome());
}
};
disable_raw_mode()?;
execute!(terminal.backend_mut(), LeaveAlternateScreen)?;
terminal.show_cursor()?;
let outcome = result?;
Ok(match outcome {
Some(ConfigEditOutcome::Selected(path)) => Some(path),
Some(ConfigEditOutcome::Cancelled) | None => None,
})
}
const FRAMEWORK_BEHAVIORS_ASSETS_DIR: &str = "configs/behaviors/@mecha10";
pub async fn handle_config_sync(ctx: &mut CliContext) -> Result<()> {
println!();
println!("Syncing framework behaviors...");
println!();
if !ctx.is_project_initialized() {
anyhow::bail!("Not in a Mecha10 project directory");
}
let project_config = ctx.load_project_config().await?;
let platform = project_config.robot.platform.as_deref().unwrap_or("rover").to_string();
let working_dir = ctx.working_dir.clone();
let assets_dir = ctx
.assets_download()
.fetch_and_extract()
.await
.context("Failed to fetch mecha10-assets")?;
let framework_behaviors_root = assets_dir.path().join(FRAMEWORK_BEHAVIORS_ASSETS_DIR);
let added = sync_framework_behaviors(&framework_behaviors_root, &working_dir)?;
println!("Platform: {}", platform);
println!();
if added.is_empty() {
println!("✅ Framework behaviors are already up to date.");
} else {
println!("Added {} new framework behavior(s):", added.len());
for name in &added {
println!(" - @mecha10/{}", name);
}
}
println!();
Ok(())
}
fn sync_framework_behaviors(framework_behaviors_root: &Path, project_root: &Path) -> Result<Vec<String>> {
if !framework_behaviors_root.exists() {
return Ok(Vec::new());
}
let entries = std::fs::read_dir(framework_behaviors_root).context("Failed to list bundled framework behaviors")?;
let mut behavior_names: Vec<String> = entries
.filter_map(|entry| entry.ok())
.filter(|entry| entry.path().is_dir())
.filter_map(|entry| entry.file_name().to_str().map(str::to_string))
.collect();
behavior_names.sort();
let mut added = Vec::new();
for name in behavior_names {
let dest = project_root.join(paths::config::framework_behavior_dir(&name));
if dest.exists() {
continue;
}
copy_dir_recursive(&framework_behaviors_root.join(&name), &dest)
.with_context(|| format!("Failed to copy bundled behavior '{}'", name))?;
added.push(name);
}
Ok(added)
}
pub(crate) async fn get_robot_id(ctx: &mut CliContext, arg_robot_id: Option<&str>) -> Result<String> {
if let Some(id) = arg_robot_id {
return Ok(id.to_string());
}
if ctx.is_project_initialized() {
let project_config = ctx.load_project_config().await?;
return Ok(project_config.robot.id);
}
Err(anyhow::anyhow!(
"No robot ID specified. Use --robot-id or run from a mecha10 project directory."
))
}
pub(crate) async fn create_config_client(ctx: &mut CliContext) -> Result<RobotConfigClient> {
let credentials_service = CredentialsService::new();
let credentials = if ctx.is_ci() {
credentials_service
.load()?
.ok_or_else(|| anyhow::anyhow!("Not logged in. Run `mecha10 auth login` first."))?
} else {
mecha10_auth::ensure_authenticated_interactive(&credentials_service).await?
};
let project_config = ctx.load_project_config().await?;
let control_plane_url = project_config.environments.control_plane_url();
let config_url = format!("{}{}", control_plane_url, ROBOT_CONFIG_PATH);
Ok(RobotConfigClient::with_user_id(
config_url,
credentials.api_key,
Some(credentials.user_id),
))
}
#[cfg(test)]
mod tests {
use super::*;
use crate::context::CliContextBuilder;
use std::io::{Read, Write};
use tempfile::{tempdir, TempDir};
fn build_fake_assets_tarball() -> Vec<u8> {
let fixtures_dir = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("tests/fixtures/schemas");
let mut tar_bytes = Vec::new();
{
let mut builder = tar::Builder::new(&mut tar_bytes);
for file_name in ["environment-config.schema.json", "preset-config.schema.json"] {
let content = std::fs::read(fixtures_dir.join(file_name)).unwrap();
let mut header = tar::Header::new_gnu();
header
.set_path(format!("configs/simulation/schemas/{file_name}"))
.unwrap();
header.set_size(content.len() as u64);
header.set_mode(0o644);
header.set_cksum();
builder.append(&header, content.as_slice()).unwrap();
}
builder.finish().unwrap();
}
let mut gz = flate2::write::GzEncoder::new(Vec::new(), flate2::Compression::default());
gz.write_all(&tar_bytes).unwrap();
gz.finish().unwrap()
}
struct FakeAssetsServer;
impl Drop for FakeAssetsServer {
fn drop(&mut self) {
std::env::remove_var("MECHA10_API_URL");
}
}
fn start_fake_assets_server() -> FakeAssetsServer {
let body = build_fake_assets_tarball();
let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
let addr = listener.local_addr().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/gzip\r\nContent-Length: {}\r\nConnection: close\r\n\r\n",
body.len()
);
let _ = stream.write_all(header.as_bytes());
let _ = stream.write_all(&body);
let _ = stream.flush();
}
});
std::env::set_var("MECHA10_API_URL", format!("http://{}/api", addr));
FakeAssetsServer
}
fn write_project_config(dir: &std::path::Path, robot_id: &str) -> std::path::PathBuf {
let config_path = dir.join("mecha10.json");
std::fs::write(
&config_path,
serde_json::json!({
"name": "proj",
"version": "0.1.0",
"robot_id": robot_id,
})
.to_string(),
)
.unwrap();
config_path
}
#[tokio::test]
async fn get_robot_id_prefers_explicit_arg_over_project_config() {
let dir = tempdir().unwrap();
let config_path = write_project_config(dir.path(), "from-config");
let mut ctx = CliContextBuilder::new()
.working_dir(dir.path().to_path_buf())
.config_path(Some(config_path))
.build()
.unwrap();
let robot_id = get_robot_id(&mut ctx, Some("from-arg")).await.unwrap();
assert_eq!(robot_id, "from-arg");
}
#[tokio::test]
async fn get_robot_id_falls_back_to_project_config() {
let dir = tempdir().unwrap();
let config_path = write_project_config(dir.path(), "from-config");
let mut ctx = CliContextBuilder::new()
.working_dir(dir.path().to_path_buf())
.config_path(Some(config_path))
.build()
.unwrap();
let robot_id = get_robot_id(&mut ctx, None).await.unwrap();
assert_eq!(robot_id, "from-config");
}
#[tokio::test]
async fn get_robot_id_errors_when_no_arg_and_no_project() {
let dir = tempdir().unwrap();
let config_path = dir.path().join("mecha10.json");
let mut ctx = CliContextBuilder::new()
.working_dir(dir.path().to_path_buf())
.config_path(Some(config_path))
.build()
.unwrap();
let err = get_robot_id(&mut ctx, None).await.unwrap_err();
assert!(err.to_string().contains("No robot ID specified"));
}
#[tokio::test]
async fn config_push_fails_cleanly_and_does_not_push_when_a_config_is_invalid() {
let _assets_server = start_fake_assets_server();
let dir = tempdir().unwrap();
std::fs::write(
dir.path().join("mecha10.json"),
serde_json::json!({"name": "proj", "version": "1.0.0"}).to_string(),
)
.unwrap();
std::fs::create_dir_all(dir.path().join("configs/simulation/environments/broken_env")).unwrap();
std::fs::write(
dir.path()
.join("configs/simulation/environments/broken_env/environment.json"),
serde_json::json!({}).to_string(),
)
.unwrap();
let mut ctx = CliContextBuilder::new()
.working_dir(dir.path().to_path_buf())
.config_path(Some(dir.path().join("mecha10.json")))
.build()
.unwrap();
let args = ConfigPushArgs {
path: None,
robot_id: Some("test-robot".to_string()),
dry_run: true,
};
let err = handle_config_push(&mut ctx, &args).await.unwrap_err();
assert!(
err.to_string().contains("Refusing to push"),
"expected the push to be refused, got: {}",
err
);
}
#[tokio::test]
async fn config_push_proceeds_past_validation_when_every_config_is_valid() {
let _assets_server = start_fake_assets_server();
let dir = tempdir().unwrap();
std::fs::write(
dir.path().join("mecha10.json"),
serde_json::json!({"name": "proj", "version": "1.0.0"}).to_string(),
)
.unwrap();
std::fs::create_dir_all(dir.path().join("configs/simulation/environments/basic_arena")).unwrap();
std::fs::write(
dir.path()
.join("configs/simulation/environments/basic_arena/environment.json"),
serde_json::json!({
"id": "basic_arena",
"version": "1.0.0",
"name": "Basic Arena",
"description": "A test arena",
"scene_path": "res://basic_arena.tscn",
"dimensions": {"width": 20.0, "length": 20.0, "height": 3.0, "unit": "meters"},
"features": {}
})
.to_string(),
)
.unwrap();
let mut ctx = CliContextBuilder::new()
.working_dir(dir.path().to_path_buf())
.config_path(Some(dir.path().join("mecha10.json")))
.build()
.unwrap();
let args = ConfigPushArgs {
path: None,
robot_id: Some("test-robot".to_string()),
dry_run: true,
};
let result = handle_config_push(&mut ctx, &args).await;
assert!(result.is_ok(), "{:?}", result.err());
}
#[tokio::test]
async fn per_key_push_round_trips_nodes_and_simulation_images_together() {
let dir = tempdir().unwrap();
let configs_dir = dir.path().join("configs");
std::fs::create_dir_all(configs_dir.join("nodes/@mecha10/motor")).unwrap();
std::fs::write(configs_dir.join("nodes/@mecha10/motor/config.json"), r#"{"speed": 1}"#).unwrap();
std::fs::create_dir_all(configs_dir.join("simulation/images")).unwrap();
std::fs::write(configs_dir.join("simulation/images/aiko.jpg"), [0xFF, 0xD8, 0xFF, 0x00]).unwrap();
let collected = collect_config_files(&configs_dir, None).await.unwrap();
assert_eq!(collected.len(), 2);
let extract_dir = tempdir().unwrap();
for (key, value) in &collected {
write_config_value(&extract_dir.path().join("configs"), None, key, value)
.await
.unwrap();
}
assert_eq!(
std::fs::read_to_string(extract_dir.path().join("configs/nodes/@mecha10/motor/config.json")).unwrap(),
"{\n \"speed\": 1\n}"
);
assert_eq!(
std::fs::read(extract_dir.path().join("configs/simulation/images/aiko.jpg")).unwrap(),
vec![0xFF, 0xD8, 0xFF, 0x00]
);
}
fn stage_bundled_behaviors(behaviors: &[(&str, &str)]) -> TempDir {
let assets_root = tempdir().unwrap();
let framework_behaviors_dir = assets_root.path().join(FRAMEWORK_BEHAVIORS_ASSETS_DIR);
for (name, config_json) in behaviors {
let dir = framework_behaviors_dir.join(name);
std::fs::create_dir_all(&dir).unwrap();
std::fs::write(dir.join("config.json"), config_json).unwrap();
}
assets_root
}
#[test]
fn sync_framework_behaviors_adds_a_missing_bundled_behavior() {
let assets_root = stage_bundled_behaviors(&[
("idle-wander", r#"{"name": "idle-wander"}"#),
("instance-track", r#"{"name": "instance-track"}"#),
]);
let framework_behaviors_dir = assets_root.path().join(FRAMEWORK_BEHAVIORS_ASSETS_DIR);
let project = tempdir().unwrap();
std::fs::create_dir_all(project.path().join("configs/behaviors/@mecha10/idle-wander")).unwrap();
std::fs::write(
project
.path()
.join("configs/behaviors/@mecha10/idle-wander/config.json"),
r#"{"name": "idle-wander"}"#,
)
.unwrap();
let added = sync_framework_behaviors(&framework_behaviors_dir, project.path()).unwrap();
assert_eq!(added, vec!["instance-track".to_string()]);
assert_eq!(
std::fs::read_to_string(
project
.path()
.join("configs/behaviors/@mecha10/instance-track/config.json")
)
.unwrap(),
r#"{"name": "instance-track"}"#
);
}
#[test]
fn sync_framework_behaviors_reports_nothing_to_do_when_already_up_to_date() {
let assets_root = stage_bundled_behaviors(&[("idle-wander", r#"{"name": "idle-wander"}"#)]);
let framework_behaviors_dir = assets_root.path().join(FRAMEWORK_BEHAVIORS_ASSETS_DIR);
let project = tempdir().unwrap();
std::fs::create_dir_all(project.path().join("configs/behaviors/@mecha10/idle-wander")).unwrap();
std::fs::write(
project
.path()
.join("configs/behaviors/@mecha10/idle-wander/config.json"),
r#"{"name": "idle-wander"}"#,
)
.unwrap();
let added = sync_framework_behaviors(&framework_behaviors_dir, project.path()).unwrap();
assert!(added.is_empty(), "expected nothing to sync, got {:?}", added);
}
#[test]
fn sync_framework_behaviors_never_overwrites_an_existing_customized_behavior() {
let assets_root = stage_bundled_behaviors(&[("idle-wander", r#"{"name": "idle-wander-bundled"}"#)]);
let framework_behaviors_dir = assets_root.path().join(FRAMEWORK_BEHAVIORS_ASSETS_DIR);
let project = tempdir().unwrap();
std::fs::create_dir_all(project.path().join("configs/behaviors/@mecha10/idle-wander")).unwrap();
std::fs::write(
project
.path()
.join("configs/behaviors/@mecha10/idle-wander/config.json"),
r#"{"name": "idle-wander-customized"}"#,
)
.unwrap();
let added = sync_framework_behaviors(&framework_behaviors_dir, project.path()).unwrap();
assert!(added.is_empty());
assert_eq!(
std::fs::read_to_string(
project
.path()
.join("configs/behaviors/@mecha10/idle-wander/config.json")
)
.unwrap(),
r#"{"name": "idle-wander-customized"}"#,
"an existing @mecha10/ behavior must never be overwritten by sync"
);
}
#[test]
fn sync_framework_behaviors_leaves_project_tier_custom_behaviors_untouched() {
let assets_root = stage_bundled_behaviors(&[("instance-track", r#"{"name": "instance-track"}"#)]);
let framework_behaviors_dir = assets_root.path().join(FRAMEWORK_BEHAVIORS_ASSETS_DIR);
let project = tempdir().unwrap();
std::fs::create_dir_all(project.path().join("configs/behaviors/my-custom-patrol")).unwrap();
std::fs::write(
project.path().join("configs/behaviors/my-custom-patrol/config.json"),
r#"{"name": "my-custom-patrol"}"#,
)
.unwrap();
let added = sync_framework_behaviors(&framework_behaviors_dir, project.path()).unwrap();
assert_eq!(added, vec!["instance-track".to_string()]);
assert_eq!(
std::fs::read_to_string(project.path().join("configs/behaviors/my-custom-patrol/config.json")).unwrap(),
r#"{"name": "my-custom-patrol"}"#,
"project-tier custom behaviors must be left untouched by sync"
);
}
}