use crate::error::{Mecha10Error, Result};
use crate::topics::system::{HEALTH, LOG};
use schemars::JsonSchema;
use serde::{Deserialize, Serialize};
use serde_json::Value;
use std::collections::{BTreeSet, HashSet};
use std::path::{Path, PathBuf};
pub const RELAY_TOPICS_FIELD: &str = "relay_topics";
#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize, JsonSchema)]
pub struct RelayTopicsField {
#[serde(default)]
pub relay_topics: Option<Vec<String>>,
}
fn framework_relay_topics() -> [&'static str; 2] {
[HEALTH.path(), LOG.path()]
}
const NODES_MECHA10_DIR: &str = "configs/nodes/@mecha10";
pub(crate) const NODES_PROJECT_DIR: &str = "configs/nodes";
const WEBSOCKET_BRIDGE_NODE_NAME: &str = "websocket-bridge";
pub fn derive_relay_topics(project_root: &Path) -> Result<Vec<String>> {
let mut topics: BTreeSet<String> = framework_relay_topics().iter().map(|t| t.to_string()).collect();
let environment = std::env::var("MECHA10_ENV").unwrap_or_else(|_| "dev".to_string());
for config_path in find_node_config_files(&project_root.join(NODES_PROJECT_DIR)) {
let value = read_json(&config_path)?;
let resolved = resolve_environment_section(value, &environment);
let Some(relay_topics) = resolved.get(RELAY_TOPICS_FIELD).and_then(Value::as_array) else {
continue;
};
for topic in relay_topics.iter().filter_map(Value::as_str) {
topics.insert(topic.to_string());
}
}
Ok(topics.into_iter().collect())
}
pub(crate) fn find_node_config_files(dir: &Path) -> Vec<PathBuf> {
let mut found = Vec::new();
collect_config_files(dir, &mut found);
found
}
fn collect_config_files(dir: &Path, found: &mut Vec<PathBuf>) {
let Ok(entries) = std::fs::read_dir(dir) else {
return;
};
for entry in entries.filter_map(|entry| entry.ok()) {
let path = entry.path();
if path.is_dir() {
collect_config_files(&path, found);
} else if path.file_name().and_then(|n| n.to_str()) == Some("config.json") {
found.push(path);
}
}
}
pub(crate) fn resolve_environment_section(value: Value, environment: &str) -> Value {
let env_key = match environment {
"production" | "prod" => "production",
_ => "dev",
};
let Some(obj) = value.as_object() else {
return value;
};
if !obj.contains_key("dev") && !obj.contains_key("production") {
return value;
}
let fallback_key = if env_key == "dev" { "production" } else { "dev" };
obj.get(env_key)
.or_else(|| obj.get(fallback_key))
.cloned()
.unwrap_or(value)
}
pub(crate) fn read_json(path: &Path) -> Result<Value> {
let content = std::fs::read_to_string(path)
.map_err(|e| Mecha10Error::Configuration(format!("Failed to read {}: {}", path.display(), e)))?;
serde_json::from_str(&content)
.map_err(|e| Mecha10Error::Configuration(format!("Failed to parse JSON from {}: {}", path.display(), e)))
}
pub fn regenerate_websocket_bridge_relay_config(project_root: &Path) -> Result<()> {
let config_path = project_root
.join(NODES_MECHA10_DIR)
.join(WEBSOCKET_BRIDGE_NODE_NAME)
.join("config.json");
if !config_path.is_file() {
return Ok(());
}
let derived = derive_relay_topics(project_root)?;
let content = std::fs::read_to_string(&config_path)
.map_err(|e| Mecha10Error::Configuration(format!("Failed to read {}: {}", config_path.display(), e)))?;
let mut root: Value = serde_json::from_str(&content).map_err(|e| {
Mecha10Error::Configuration(format!("Failed to parse JSON from {}: {}", config_path.display(), e))
})?;
let root_obj = root.as_object_mut().ok_or_else(|| {
Mecha10Error::Configuration(format!(
"websocket-bridge config must be a JSON object: {}",
config_path.display()
))
})?;
let env_keys: Vec<&'static str> = ["dev", "production"]
.into_iter()
.filter(|key| root_obj.contains_key(*key))
.collect();
if env_keys.is_empty() {
union_subscribes_into(root_obj, &derived);
} else {
for key in env_keys {
if let Some(section) = root_obj.get_mut(key).and_then(Value::as_object_mut) {
union_subscribes_into(section, &derived);
}
}
}
let updated = serde_json::to_string_pretty(&root)
.map_err(|e| Mecha10Error::Configuration(format!("Failed to serialize {}: {}", config_path.display(), e)))?;
std::fs::write(&config_path, updated)
.map_err(|e| Mecha10Error::Configuration(format!("Failed to write {}: {}", config_path.display(), e)))?;
Ok(())
}
fn union_subscribes_into(section: &mut serde_json::Map<String, Value>, derived: &[String]) {
let topics_entry = section
.entry("topics")
.or_insert_with(|| Value::Object(serde_json::Map::new()));
let Some(topics_obj) = topics_entry.as_object_mut() else {
return;
};
let subscribes_entry = topics_obj
.entry("subscribes")
.or_insert_with(|| Value::Array(Vec::new()));
let Some(subscribes_arr) = subscribes_entry.as_array_mut() else {
return;
};
let mut seen: HashSet<String> = subscribes_arr
.iter()
.filter_map(Value::as_object)
.flat_map(|entry| entry.values())
.filter_map(Value::as_str)
.map(|s| s.to_string())
.collect();
for topic in derived {
if seen.insert(topic.clone()) {
let mut entry = serde_json::Map::new();
entry.insert(topic.clone(), Value::String(topic.clone()));
subscribes_arr.push(Value::Object(entry));
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use serial_test::serial;
use std::fs;
fn write_json(path: &Path, content: &str) {
fs::create_dir_all(path.parent().unwrap()).unwrap();
fs::write(path, content).unwrap();
}
fn nodes_dir(project_root: &Path) -> PathBuf {
project_root.join(NODES_MECHA10_DIR)
}
#[test]
fn framework_topics_always_present_even_with_no_nodes_dir() {
let project = tempfile::tempdir().unwrap();
let topics = derive_relay_topics(project.path()).unwrap();
assert!(topics.contains(&"/system/health".to_string()));
assert!(topics.contains(&"/system/log".to_string()));
}
#[test]
#[serial]
fn derives_from_single_config_json_with_dev_production_envelope() {
std::env::remove_var("MECHA10_ENV");
let project = tempfile::tempdir().unwrap();
write_json(
&nodes_dir(project.path()).join("llm-command").join("config.json"),
r#"{
"dev": { "relay_topics": ["/ai/command", "/ai/response"] },
"production": { "relay_topics": ["/ai/command", "/ai/response"] }
}"#,
);
let topics = derive_relay_topics(project.path()).unwrap();
assert!(topics.contains(&"/ai/command".to_string()));
assert!(topics.contains(&"/ai/response".to_string()));
}
#[test]
#[serial]
fn derives_from_split_dev_and_production_config_files() {
std::env::remove_var("MECHA10_ENV");
let project = tempfile::tempdir().unwrap();
write_json(
&nodes_dir(project.path()).join("imu").join("dev").join("config.json"),
r#"{ "relay_topics": ["/imu/data"] }"#,
);
write_json(
&nodes_dir(project.path())
.join("imu")
.join("production")
.join("config.json"),
r#"{ "relay_topics": ["/imu/data"] }"#,
);
let topics = derive_relay_topics(project.path()).unwrap();
assert!(topics.contains(&"/imu/data".to_string()));
}
#[test]
#[serial]
fn derives_from_dev_only_config_falling_back_when_production_requested() {
std::env::set_var("MECHA10_ENV", "production");
let project = tempfile::tempdir().unwrap();
write_json(
&nodes_dir(project.path())
.join("simulator")
.join("dev")
.join("config.json"),
r#"{ "relay_topics": ["/camera/rgb"] }"#,
);
let topics = derive_relay_topics(project.path()).unwrap();
std::env::remove_var("MECHA10_ENV");
assert!(topics.contains(&"/camera/rgb".to_string()));
}
#[test]
#[serial]
fn node_without_relay_topics_field_is_silently_skipped() {
std::env::remove_var("MECHA10_ENV");
let project = tempfile::tempdir().unwrap();
write_json(
&nodes_dir(project.path()).join("motor").join("dev").join("config.json"),
r#"{ "update_rate_hz": 50.0 }"#,
);
let topics = derive_relay_topics(project.path()).unwrap();
assert_eq!(topics.len(), 2);
assert!(topics.contains(&"/system/health".to_string()));
assert!(topics.contains(&"/system/log".to_string()));
}
#[test]
#[serial]
fn derives_from_a_project_authored_custom_node_with_no_mecha10_prefix() {
std::env::remove_var("MECHA10_ENV");
let project = tempfile::tempdir().unwrap();
write_json(
&project.path().join("configs/nodes/my-custom-node/config.json"),
r#"{ "relay_topics": ["/custom/status"] }"#,
);
let topics = derive_relay_topics(project.path()).unwrap();
assert!(topics.contains(&"/custom/status".to_string()));
}
#[test]
#[serial]
fn both_tiers_contribute_even_for_the_same_node_name() {
std::env::remove_var("MECHA10_ENV");
let project = tempfile::tempdir().unwrap();
write_json(
&nodes_dir(project.path()).join("camera").join("config.json"),
r#"{ "relay_topics": ["/camera/rgb"] }"#,
);
write_json(
&project.path().join("configs/nodes/camera/config.json"),
r#"{ "relay_topics": ["/camera/other"] }"#,
);
let topics = derive_relay_topics(project.path()).unwrap();
assert!(topics.contains(&"/camera/rgb".to_string()));
assert!(topics.contains(&"/camera/other".to_string()));
}
#[test]
#[serial]
fn dedups_topics_declared_by_multiple_nodes() {
std::env::remove_var("MECHA10_ENV");
let project = tempfile::tempdir().unwrap();
write_json(
&nodes_dir(project.path()).join("node-a").join("config.json"),
r#"{ "relay_topics": ["/shared/topic"] }"#,
);
write_json(
&nodes_dir(project.path()).join("node-b").join("config.json"),
r#"{ "relay_topics": ["/shared/topic"] }"#,
);
let topics = derive_relay_topics(project.path()).unwrap();
assert_eq!(topics.iter().filter(|t| *t == "/shared/topic").count(), 1);
}
#[test]
#[serial]
fn regenerate_unions_derived_topics_with_existing_entries_without_removing_any() {
std::env::remove_var("MECHA10_ENV");
let project = tempfile::tempdir().unwrap();
write_json(
&nodes_dir(project.path()).join("image-classifier").join("config.json"),
r#"{ "relay_topics": ["/vision/classification"] }"#,
);
let bridge_config = nodes_dir(project.path())
.join(WEBSOCKET_BRIDGE_NODE_NAME)
.join("config.json");
write_json(
&bridge_config,
r#"{
"dev": {
"relay": {"url": "wss://x", "robot_id": "r", "api_key": "k"},
"topics": {
"subscribes": [
{ "health": "/system/health" },
{ "legacyOnly": "/legacy/only/topic" }
]
}
},
"production": {
"relay": {"url": "wss://x", "robot_id": "r", "api_key": "k"},
"topics": {
"subscribes": [
{ "legacyOnly": "/legacy/only/topic" }
]
}
}
}"#,
);
regenerate_websocket_bridge_relay_config(project.path()).unwrap();
let updated: Value = serde_json::from_str(&fs::read_to_string(&bridge_config).unwrap()).unwrap();
for env_key in ["dev", "production"] {
let subscribes = updated[env_key]["topics"]["subscribes"].as_array().unwrap();
let values: HashSet<String> = subscribes
.iter()
.filter_map(|entry| entry.as_object())
.flat_map(|m| m.values())
.filter_map(Value::as_str)
.map(|s| s.to_string())
.collect();
assert!(values.contains("/legacy/only/topic"));
assert!(values.contains("/system/health"));
assert!(values.contains("/system/log"));
assert!(values.contains("/vision/classification"));
}
let dev_subscribes = updated["dev"]["topics"]["subscribes"].as_array().unwrap();
let health_entries: Vec<&Value> = dev_subscribes
.iter()
.filter(|entry| entry.get("health").and_then(Value::as_str) == Some("/system/health"))
.collect();
assert_eq!(health_entries.len(), 1);
}
#[test]
fn regenerate_is_a_no_op_when_websocket_bridge_has_no_config_file() {
let project = tempfile::tempdir().unwrap();
regenerate_websocket_bridge_relay_config(project.path()).unwrap();
}
}