Skip to main content

mecha10_core/
relay_topics.rs

1//! Relay topics auto-derivation for `websocket-bridge`.
2//!
3//! # Problem this replaces
4//!
5//! For a Redis topic published by some node to reach the dashboard, two independent things
6//! today have to happen: (1) the node publishes to some topic path, and (2) a human manually
7//! adds that exact same path to `websocket-bridge`'s own `configs/config.json`
8//! `topics.subscribes` allowlist. Nothing enforces these stay in sync - the two lists live in
9//! unrelated files, maintained by unrelated people, at unrelated times. This has already caused
10//! a real bug (a fix PR edited the wrong file entirely, unnoticed, because there was no
11//! structural link between "a node publishes X" and "the relay knows about X").
12//!
13//! # Design
14//!
15//! Nodes now optionally declare a `relay_topics` array in their own resolved config object
16//! (same level as any other node-specific config field, e.g. `model`, `topics`, ...):
17//!
18//! ```json
19//! { "relay_topics": ["/vision/classification"] }
20//! ```
21//!
22//! A node's own config struct picks this up by embedding [`RelayTopicsField`] with
23//! `#[serde(flatten)]` - one line, reusing a single canonical type, rather than each node
24//! hand-declaring its own `relay_topics: Option<Vec<String>>` field (the fragile pattern this
25//! replaced: easy to forget entirely, as `behavior-executor` once did, silently crashing that
26//! node at startup with "unknown field: relay_topics" the moment its `config.json` declared the
27//! key without a matching struct field). `#[serde(flatten)]` composes cleanly with
28//! `#[serde(deny_unknown_fields)]` on the outer struct - real typos in *other* fields still fail
29//! loudly, `relay_topics` just isn't one of them anymore.
30//!
31//! [`derive_relay_topics`] recursively walks every `config.json` found anywhere under a
32//! project's `configs/nodes/` tree - both the framework tier (`configs/nodes/@mecha10/{name}/`)
33//! and the project tier a custom node scaffolds into (`configs/nodes/{name}/`, no `@mecha10`
34//! prefix) - collects each one's `relay_topics` (a config that doesn't declare the field silently
35//! contributes nothing - this is how not-yet-migrated nodes are skipped, no error), and unions
36//! that with a small fixed set of framework-level topics that no single node owns
37//! (`/system/health`, `/system/log` - see [`framework_relay_topics`]).
38//!
39//! This is a plain recursive scan, not a precedence-aware resolution: if a node somehow has
40//! `config.json` files in more than one place (e.g. both tiers, or split `dev`/`production`
41//! files), every one of them is read and their `relay_topics` are unioned together, even ones
42//! that aren't the copy actually in effect for a given node/environment. That's deliberately
43//! unlike `Context::resolve_node_config`'s tier precedence - this module only ever produces an
44//! *allowlist*, so including an extra topic from an inactive config is harmless (nothing
45//! downstream is required to actually use it), whereas replicating full precedence-resolution
46//! logic here just to avoid that is not worth the complexity.
47//!
48//! [`regenerate_websocket_bridge_relay_config`] is the write half: it re-derives the topic list
49//! and merges it *additively* into `websocket-bridge`'s own scaffolded config file's top-level
50//! `relay_topics` array (the same flat shape every other node declares its own `relay_topics` in -
51//! see [`RelayTopicsField`]), unioning with whatever entries are already present rather than
52//! replacing them outright. This is deliberate - it guarantees regeneration can never regress an
53//! already-working topic, even before every node has been migrated to declare `relay_topics`
54//! (that migration is scoped as separate, parallel PRs per node/node-group).
55//!
56//! `websocket-bridge` previously carried its own hand-maintained `topics.subscribes` allowlist
57//! (a list of `{ friendlyName: topicPath }` entries) as a second, independently-edited place the
58//! same information lived - this duplicated `relay_topics` and was a frequent source of drift
59//! (an entry added to one list but not the other). `websocket-bridge`'s own `relay_topics` field
60//! (inherited from [`NodeConfigBase`](crate::node_config_base::NodeConfigBase) like every other
61//! node) is now the only place this is persisted.
62//!
63//! # Node config file layout
64//!
65//! A node's `config.json` isn't guaranteed to be in one single shape - it may be a single file
66//! with `"dev"`/`"production"` keys wrapping the actual config, or split into separate
67//! `dev/config.json` + `production/config.json` files. [`find_node_config_files`] finds every
68//! `config.json` regardless of shape or nesting depth; [`resolve_environment_section`] then picks
69//! the right section back out of whichever shape it turns out to be.
70
71use 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
89/// Framework-level topics no single node owns, so they can't live in any node's `relay_topics`
90/// field:
91///
92/// - `/system/health`, published by [`crate::health::HealthReporter`], wired into every node's
93///   shared runtime (`crate::node::run_node`).
94/// - `/system/log`, published by [`crate::log_publisher::attach`], also called from
95///   `run_node()` for every node.
96///
97/// Reuses the existing typed topic constants from [`crate::topics::system`] rather than
98/// duplicating the literal path strings.
99fn 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
109/// Derive the full relay allowlist (topic paths `websocket-bridge` should subscribe to) for the
110/// project rooted at `project_root`.
111///
112/// Returns the deduplicated union of:
113/// - The fixed framework-level topics (always present, see [`framework_relay_topics`]).
114/// - Every `relay_topics` field found in any `config.json` anywhere under `configs/nodes/`
115///   (see [`find_node_config_files`]), resolved for whichever environment (`dev`/`production`)
116///   is currently active (`MECHA10_ENV`, default `dev`).
117///
118/// A project with no `configs/nodes/` directory at all yields just the framework-level topics
119/// rather than an error - there's nothing to walk, which is a valid (if minimal) state.
120///
121/// A multi-instance node (LAB-4131 - see [`crate::context::node_config`]'s module docs) resolves
122/// its environment section to an instance-keyed object (e.g.
123/// `{"real": {"relay_topics": [...]}, "sim": {"relay_topics": [...]}}`) rather than a flat config
124/// with a top-level `relay_topics` key. To still pick those up, this also checks one level of
125/// nesting: every value of the resolved section that is itself a JSON object is inspected for its
126/// own top-level `relay_topics` array, and any found are unioned in alongside the top-level one.
127/// This is a harmless no-op for a flat config whose own field values happen to be objects (e.g.
128/// `topics`, `source`) since those don't have a `relay_topics` key themselves.
129pub 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
179/// If `value` is a JSON object with a `"dev"` or `"production"` key, return the section for
180/// `environment`. Otherwise, return `value` unchanged - it's already a flat, unwrapped config
181/// object.
182///
183/// Errors if `value` is an envelope (has a `"dev"` and/or `"production"` key) but is missing the
184/// section for the *requested* `environment` - this must fail loudly rather than silently run
185/// with the other environment's values (`LAB-4129`).
186pub(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
217/// Regenerate `websocket-bridge`'s own scaffolded config file
218/// (`<project_root>/configs/nodes/@mecha10/websocket-bridge/config.json`) so its own
219/// `relay_topics` array reflects [`derive_relay_topics`]'s current output.
220///
221/// **Additive, never destructive**: the derived topics are *unioned* into whatever
222/// `relay_topics` entries already exist (per environment section) rather than replacing the list
223/// outright. This guarantees regeneration can never regress an already-working topic, which
224/// matters because this lands independently of the separate, parallel PRs that migrate
225/// individual nodes onto declaring `relay_topics` - some nodes may not have been migrated yet
226/// when this runs, and their existing hand-authored entries must survive.
227///
228/// No-ops (not an error) if the project has no `websocket-bridge` config file scaffolded at all.
229pub 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
277/// Union `derived` topic paths into `section["relay_topics"]`, appending only the ones not
278/// already present in the existing array. Existing entries are left completely untouched.
279/// Silently leaves a malformed `relay_topics` field (present but not an array) alone rather than
280/// erroring - regeneration should never be the thing that breaks an otherwise-loadable config
281/// file.
282fn 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    /// Split `dev/config.json` + `production/config.json` files are flat (no `"dev"`/
372    /// `"production"` envelope key), so [`resolve_environment_section`] passes them through
373    /// unchanged regardless of which directory they live under - `find_node_config_files` finds
374    /// every `config.json` under a node's directory regardless of the currently active
375    /// environment, by design (see the module docs' "plain recursive scan" note). This is
376    /// unrelated to the dev/production envelope fallback `LAB-4129` removed below.
377    #[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    /// `LAB-4129`: a single-file envelope config (`{"dev": {...}, "production": {...}}`) missing
398    /// its *requested* environment key must fail to load, not silently fall back to whichever
399    /// other environment's section happens to exist.
400    #[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    /// LAB-2270 follow-up: a project-authored custom node (scaffolded at `configs/nodes/{name}/`,
436    /// no `@mecha10` prefix) must be able to reach the dashboard the same way a framework node
437    /// does, since its author can't edit anything under this framework's own `packages/nodes/`
438    /// source to register it any other way.
439    #[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    /// This is a plain recursive scan, not a precedence-aware resolution: if a node somehow has
456    /// `config.json` in both tiers (e.g. a leftover directory from before a node was migrated),
457    /// both contribute - unlike `Context::resolve_node_config`, which would only ever load one.
458    /// That's fine here since an extra allowlisted topic is harmless.
459    #[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    /// LAB-4131 follow-up: a multi-instance node's resolved environment section is
563    /// instance-keyed (e.g. `{"real": {...}, "sim": {...}}`) rather than a flat config with a
564    /// top-level `relay_topics` key, so `derive_relay_topics` must also check one level of
565    /// nesting for each instance's own `relay_topics`.
566    #[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}