1use crate::error::{Mecha10Error, Result};
72use crate::topics::system::{HEALTH, LOG};
73use schemars::JsonSchema;
74use serde::{Deserialize, Serialize};
75use serde_json::Value;
76use std::collections::{BTreeSet, HashSet};
77use std::path::{Path, PathBuf};
78
79pub const RELAY_TOPICS_FIELD: &str = "relay_topics";
80
81#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize, JsonSchema)]
82#[schemars(description = "Declares which topics this node publishes should be relayed to the dashboard.")]
83pub struct RelayTopicsField {
84 #[serde(default)]
85 #[schemars(example = Some(vec!["/vision/classification".to_string()]))]
86 pub relay_topics: Option<Vec<String>>,
87}
88
89fn framework_relay_topics() -> [&'static str; 2] {
100 [HEALTH.path(), LOG.path()]
101}
102
103const NODES_MECHA10_DIR: &str = "configs/nodes/@mecha10";
104
105pub(crate) const NODES_PROJECT_DIR: &str = "configs/nodes";
106
107const WEBSOCKET_BRIDGE_NODE_NAME: &str = "websocket-bridge";
108
109pub fn derive_relay_topics(project_root: &Path) -> Result<Vec<String>> {
130 let mut topics: BTreeSet<String> = framework_relay_topics().iter().map(|t| t.to_string()).collect();
131
132 let environment = std::env::var("MECHA10_ENV").unwrap_or_else(|_| "dev".to_string());
133
134 for config_path in find_node_config_files(&project_root.join(NODES_PROJECT_DIR)) {
135 let value = read_json(&config_path)?;
136 let resolved = resolve_environment_section(value, &environment, &config_path)?;
137
138 if let Some(relay_topics) = resolved.get(RELAY_TOPICS_FIELD).and_then(Value::as_array) {
139 for topic in relay_topics.iter().filter_map(Value::as_str) {
140 topics.insert(topic.to_string());
141 }
142 }
143
144 if let Some(obj) = resolved.as_object() {
145 for nested in obj.values().filter_map(Value::as_object) {
146 if let Some(relay_topics) = nested.get(RELAY_TOPICS_FIELD).and_then(Value::as_array) {
147 for topic in relay_topics.iter().filter_map(Value::as_str) {
148 topics.insert(topic.to_string());
149 }
150 }
151 }
152 }
153 }
154
155 Ok(topics.into_iter().collect())
156}
157
158pub(crate) fn find_node_config_files(dir: &Path) -> Vec<PathBuf> {
159 let mut found = Vec::new();
160 collect_config_files(dir, &mut found);
161 found
162}
163
164fn collect_config_files(dir: &Path, found: &mut Vec<PathBuf>) {
165 let Ok(entries) = std::fs::read_dir(dir) else {
166 return;
167 };
168
169 for entry in entries.filter_map(|entry| entry.ok()) {
170 let path = entry.path();
171 if path.is_dir() {
172 collect_config_files(&path, found);
173 } else if path.file_name().and_then(|n| n.to_str()) == Some("config.json") {
174 found.push(path);
175 }
176 }
177}
178
179pub(crate) fn resolve_environment_section(value: Value, environment: &str, config_path: &Path) -> Result<Value> {
187 let env_key = match environment {
188 "production" | "prod" => "production",
189 _ => "dev",
190 };
191
192 let Some(obj) = value.as_object() else {
193 return Ok(value);
194 };
195
196 if !obj.contains_key("dev") && !obj.contains_key("production") {
197 return Ok(value);
198 }
199
200 obj.get(env_key).cloned().ok_or_else(|| {
201 Mecha10Error::Configuration(format!(
202 "Config is missing its '{}' environment section (MECHA10_ENV='{}'): {}",
203 env_key,
204 environment,
205 config_path.display()
206 ))
207 })
208}
209
210pub(crate) fn read_json(path: &Path) -> Result<Value> {
211 let content = std::fs::read_to_string(path)
212 .map_err(|e| Mecha10Error::Configuration(format!("Failed to read {}: {}", path.display(), e)))?;
213 serde_json::from_str(&content)
214 .map_err(|e| Mecha10Error::Configuration(format!("Failed to parse JSON from {}: {}", path.display(), e)))
215}
216
217pub fn regenerate_websocket_bridge_relay_config(project_root: &Path) -> Result<()> {
230 let config_path = project_root
231 .join(NODES_MECHA10_DIR)
232 .join(WEBSOCKET_BRIDGE_NODE_NAME)
233 .join("config.json");
234
235 if !config_path.is_file() {
236 return Ok(());
237 }
238
239 let derived = derive_relay_topics(project_root)?;
240
241 let content = std::fs::read_to_string(&config_path)
242 .map_err(|e| Mecha10Error::Configuration(format!("Failed to read {}: {}", config_path.display(), e)))?;
243 let mut root: Value = serde_json::from_str(&content).map_err(|e| {
244 Mecha10Error::Configuration(format!("Failed to parse JSON from {}: {}", config_path.display(), e))
245 })?;
246
247 let root_obj = root.as_object_mut().ok_or_else(|| {
248 Mecha10Error::Configuration(format!(
249 "websocket-bridge config must be a JSON object: {}",
250 config_path.display()
251 ))
252 })?;
253
254 let env_keys: Vec<&'static str> = ["dev", "production"]
255 .into_iter()
256 .filter(|key| root_obj.contains_key(*key))
257 .collect();
258
259 if env_keys.is_empty() {
260 union_relay_topics_into(root_obj, &derived);
261 } else {
262 for key in env_keys {
263 if let Some(section) = root_obj.get_mut(key).and_then(Value::as_object_mut) {
264 union_relay_topics_into(section, &derived);
265 }
266 }
267 }
268
269 let updated = serde_json::to_string_pretty(&root)
270 .map_err(|e| Mecha10Error::Configuration(format!("Failed to serialize {}: {}", config_path.display(), e)))?;
271 std::fs::write(&config_path, updated)
272 .map_err(|e| Mecha10Error::Configuration(format!("Failed to write {}: {}", config_path.display(), e)))?;
273
274 Ok(())
275}
276
277fn union_relay_topics_into(section: &mut serde_json::Map<String, Value>, derived: &[String]) {
283 let relay_topics_entry = section
284 .entry(RELAY_TOPICS_FIELD)
285 .or_insert_with(|| Value::Array(Vec::new()));
286 let Some(relay_topics_arr) = relay_topics_entry.as_array_mut() else {
287 return;
288 };
289
290 let mut seen: HashSet<String> = relay_topics_arr
291 .iter()
292 .filter_map(Value::as_str)
293 .map(|s| s.to_string())
294 .collect();
295
296 for topic in derived {
297 if seen.insert(topic.clone()) {
298 relay_topics_arr.push(Value::String(topic.clone()));
299 }
300 }
301}
302
303#[cfg(test)]
304mod tests {
305 use super::*;
306 use serial_test::serial;
307 use std::fs;
308
309 fn write_json(path: &Path, content: &str) {
310 fs::create_dir_all(path.parent().unwrap()).unwrap();
311 fs::write(path, content).unwrap();
312 }
313
314 fn nodes_dir(project_root: &Path) -> PathBuf {
315 project_root.join(NODES_MECHA10_DIR)
316 }
317
318 #[test]
319 fn framework_topics_always_present_even_with_no_nodes_dir() {
320 let project = tempfile::tempdir().unwrap();
321
322 let topics = derive_relay_topics(project.path()).unwrap();
323
324 assert!(topics.contains(&"/system/health".to_string()));
325 assert!(topics.contains(&"/system/log".to_string()));
326 }
327
328 #[test]
329 #[serial]
330 fn derives_from_single_config_json_with_dev_production_envelope() {
331 std::env::remove_var("MECHA10_ENV");
332
333 let project = tempfile::tempdir().unwrap();
334 write_json(
335 &nodes_dir(project.path()).join("llm-command").join("config.json"),
336 r#"{
337 "dev": { "relay_topics": ["/ai/command", "/ai/response"] },
338 "production": { "relay_topics": ["/ai/command", "/ai/response"] }
339 }"#,
340 );
341
342 let topics = derive_relay_topics(project.path()).unwrap();
343
344 assert!(topics.contains(&"/ai/command".to_string()));
345 assert!(topics.contains(&"/ai/response".to_string()));
346 }
347
348 #[test]
349 #[serial]
350 fn derives_from_split_dev_and_production_config_files() {
351 std::env::remove_var("MECHA10_ENV");
352
353 let project = tempfile::tempdir().unwrap();
354 write_json(
355 &nodes_dir(project.path()).join("imu").join("dev").join("config.json"),
356 r#"{ "relay_topics": ["/imu/data"] }"#,
357 );
358 write_json(
359 &nodes_dir(project.path())
360 .join("imu")
361 .join("production")
362 .join("config.json"),
363 r#"{ "relay_topics": ["/imu/data"] }"#,
364 );
365
366 let topics = derive_relay_topics(project.path()).unwrap();
367
368 assert!(topics.contains(&"/imu/data".to_string()));
369 }
370
371 #[test]
378 #[serial]
379 fn derives_from_dev_only_split_file_regardless_of_requested_environment() {
380 std::env::set_var("MECHA10_ENV", "production");
381
382 let project = tempfile::tempdir().unwrap();
383 write_json(
384 &nodes_dir(project.path())
385 .join("simulator")
386 .join("dev")
387 .join("config.json"),
388 r#"{ "relay_topics": ["/camera/rgb"] }"#,
389 );
390
391 let topics = derive_relay_topics(project.path()).unwrap();
392 std::env::remove_var("MECHA10_ENV");
393
394 assert!(topics.contains(&"/camera/rgb".to_string()));
395 }
396
397 #[test]
401 #[serial]
402 fn derives_from_envelope_config_errors_when_requested_environment_section_is_missing() {
403 std::env::set_var("MECHA10_ENV", "production");
404
405 let project = tempfile::tempdir().unwrap();
406 write_json(
407 &nodes_dir(project.path()).join("simulator").join("config.json"),
408 r#"{ "dev": { "relay_topics": ["/camera/rgb"] } }"#,
409 );
410
411 let err = derive_relay_topics(project.path()).unwrap_err();
412 std::env::remove_var("MECHA10_ENV");
413
414 assert!(err.to_string().contains("'production'"));
415 }
416
417 #[test]
418 #[serial]
419 fn node_without_relay_topics_field_is_silently_skipped() {
420 std::env::remove_var("MECHA10_ENV");
421
422 let project = tempfile::tempdir().unwrap();
423 write_json(
424 &nodes_dir(project.path()).join("motor").join("dev").join("config.json"),
425 r#"{ "update_rate_hz": 50.0 }"#,
426 );
427
428 let topics = derive_relay_topics(project.path()).unwrap();
429
430 assert_eq!(topics.len(), 2);
431 assert!(topics.contains(&"/system/health".to_string()));
432 assert!(topics.contains(&"/system/log".to_string()));
433 }
434
435 #[test]
440 #[serial]
441 fn derives_from_a_project_authored_custom_node_with_no_mecha10_prefix() {
442 std::env::remove_var("MECHA10_ENV");
443
444 let project = tempfile::tempdir().unwrap();
445 write_json(
446 &project.path().join("configs/nodes/my-custom-node/config.json"),
447 r#"{ "relay_topics": ["/custom/status"] }"#,
448 );
449
450 let topics = derive_relay_topics(project.path()).unwrap();
451
452 assert!(topics.contains(&"/custom/status".to_string()));
453 }
454
455 #[test]
460 #[serial]
461 fn both_tiers_contribute_even_for_the_same_node_name() {
462 std::env::remove_var("MECHA10_ENV");
463
464 let project = tempfile::tempdir().unwrap();
465 write_json(
466 &nodes_dir(project.path()).join("camera").join("config.json"),
467 r#"{ "relay_topics": ["/camera/rgb"] }"#,
468 );
469 write_json(
470 &project.path().join("configs/nodes/camera/config.json"),
471 r#"{ "relay_topics": ["/camera/other"] }"#,
472 );
473
474 let topics = derive_relay_topics(project.path()).unwrap();
475
476 assert!(topics.contains(&"/camera/rgb".to_string()));
477 assert!(topics.contains(&"/camera/other".to_string()));
478 }
479
480 #[test]
481 #[serial]
482 fn dedups_topics_declared_by_multiple_nodes() {
483 std::env::remove_var("MECHA10_ENV");
484
485 let project = tempfile::tempdir().unwrap();
486 write_json(
487 &nodes_dir(project.path()).join("node-a").join("config.json"),
488 r#"{ "relay_topics": ["/shared/topic"] }"#,
489 );
490 write_json(
491 &nodes_dir(project.path()).join("node-b").join("config.json"),
492 r#"{ "relay_topics": ["/shared/topic"] }"#,
493 );
494
495 let topics = derive_relay_topics(project.path()).unwrap();
496
497 assert_eq!(topics.iter().filter(|t| *t == "/shared/topic").count(), 1);
498 }
499
500 #[test]
501 #[serial]
502 fn regenerate_unions_derived_topics_with_existing_entries_without_removing_any() {
503 std::env::remove_var("MECHA10_ENV");
504
505 let project = tempfile::tempdir().unwrap();
506
507 write_json(
508 &nodes_dir(project.path()).join("image-classifier").join("config.json"),
509 r#"{ "relay_topics": ["/vision/classification"] }"#,
510 );
511
512 let bridge_config = nodes_dir(project.path())
513 .join(WEBSOCKET_BRIDGE_NODE_NAME)
514 .join("config.json");
515 write_json(
516 &bridge_config,
517 r#"{
518 "dev": {
519 "relay": {"url": "wss://x", "robot_id": "r", "api_key": "k"},
520 "relay_topics": ["/system/health", "/legacy/only/topic"]
521 },
522 "production": {
523 "relay": {"url": "wss://x", "robot_id": "r", "api_key": "k"},
524 "relay_topics": ["/legacy/only/topic"]
525 }
526 }"#,
527 );
528
529 regenerate_websocket_bridge_relay_config(project.path()).unwrap();
530
531 let updated: Value = serde_json::from_str(&fs::read_to_string(&bridge_config).unwrap()).unwrap();
532
533 for env_key in ["dev", "production"] {
534 let relay_topics = updated[env_key]["relay_topics"].as_array().unwrap();
535 let values: HashSet<String> = relay_topics
536 .iter()
537 .filter_map(Value::as_str)
538 .map(|s| s.to_string())
539 .collect();
540
541 assert!(values.contains("/legacy/only/topic"));
542 assert!(values.contains("/system/health"));
543 assert!(values.contains("/system/log"));
544 assert!(values.contains("/vision/classification"));
545 }
546
547 let dev_relay_topics = updated["dev"]["relay_topics"].as_array().unwrap();
548 let health_entries = dev_relay_topics
549 .iter()
550 .filter(|t| t.as_str() == Some("/system/health"))
551 .count();
552 assert_eq!(health_entries, 1);
553 }
554
555 #[test]
556 fn regenerate_is_a_no_op_when_websocket_bridge_has_no_config_file() {
557 let project = tempfile::tempdir().unwrap();
558
559 regenerate_websocket_bridge_relay_config(project.path()).unwrap();
560 }
561
562 #[test]
567 #[serial]
568 fn derives_nested_relay_topics_from_an_instance_keyed_multi_instance_node() {
569 std::env::remove_var("MECHA10_ENV");
570
571 let project = tempfile::tempdir().unwrap();
572 write_json(
573 &nodes_dir(project.path()).join("camera").join("config.json"),
574 r#"{
575 "dev": {
576 "real": { "relay_topics": ["/camera/rgb/real"] },
577 "sim": { "relay_topics": ["/camera/rgb/sim"] }
578 },
579 "production": {
580 "real": { "relay_topics": ["/camera/rgb/real"] },
581 "sim": { "relay_topics": ["/camera/rgb/sim"] }
582 }
583 }"#,
584 );
585
586 let topics = derive_relay_topics(project.path()).unwrap();
587
588 assert!(topics.contains(&"/camera/rgb/real".to_string()));
589 assert!(topics.contains(&"/camera/rgb/sim".to_string()));
590 }
591}