Skip to main content

cu29_rendercfg/
config.rs

1//! This module defines the configuration of the copper runtime.
2//! The configuration is a directed graph where nodes are tasks and edges are connections between tasks.
3//! The configuration is serialized in the RON format.
4//! The configuration is used to generate the runtime code at compile time.
5#[cfg(not(feature = "std"))]
6extern crate alloc;
7
8use ConfigGraphs::{Missions, Simple};
9use core::any::type_name;
10use core::fmt;
11use core::fmt::Display;
12use cu29_traits::{CuError, CuResult};
13use cu29_value::Value as CuValue;
14use hashbrown::HashMap;
15pub use petgraph::Direction::Incoming;
16pub use petgraph::Direction::Outgoing;
17use petgraph::stable_graph::{EdgeIndex, NodeIndex, StableDiGraph};
18#[cfg(feature = "std")]
19use petgraph::visit::IntoEdgeReferences;
20use petgraph::visit::{Bfs, EdgeRef};
21use ron::extensions::Extensions;
22use ron::value::Value as RonValue;
23use ron::{Number, Options};
24use serde::de::DeserializeOwned;
25use serde::{Deserialize, Deserializer, Serialize, Serializer};
26
27#[cfg(not(feature = "std"))]
28use alloc::boxed::Box;
29#[cfg(not(feature = "std"))]
30use alloc::collections::BTreeMap;
31#[cfg(not(feature = "std"))]
32use alloc::vec;
33#[cfg(feature = "std")]
34use std::collections::BTreeMap;
35
36#[cfg(not(feature = "std"))]
37mod imp {
38    pub use alloc::borrow::ToOwned;
39    pub use alloc::format;
40    pub use alloc::string::String;
41    pub use alloc::string::ToString;
42    pub use alloc::vec::Vec;
43}
44
45#[cfg(feature = "std")]
46mod imp {
47    pub use html_escape::encode_text;
48    pub use std::fs::read_to_string;
49}
50
51use imp::*;
52
53/// NodeId is the unique identifier of a node in the configuration graph for petgraph
54/// and the code generation.
55pub type NodeId = u32;
56pub const DEFAULT_MISSION_ID: &str = "default";
57
58/// This is the configuration of a component (like a task config or a monitoring config):w
59/// It is a map of key-value pairs.
60/// It is given to the new method of the task implementation.
61#[derive(Serialize, Deserialize, Debug, Clone, Default)]
62pub struct ComponentConfig(pub HashMap<String, Value>);
63
64/// Mapping between resource binding names and bundle-scoped resource ids.
65#[allow(dead_code)]
66impl Display for ComponentConfig {
67    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
68        let mut first = true;
69        let ComponentConfig(config) = self;
70        write!(f, "{{")?;
71        for (key, value) in config.iter() {
72            if !first {
73                write!(f, ", ")?;
74            }
75            write!(f, "{key}: {value}")?;
76            first = false;
77        }
78        write!(f, "}}")
79    }
80}
81
82// forward map interface
83impl ComponentConfig {
84    #[allow(dead_code)]
85    pub fn new() -> Self {
86        ComponentConfig(HashMap::new())
87    }
88
89    #[allow(dead_code)]
90    pub fn get<T>(&self, key: &str) -> Result<Option<T>, ConfigError>
91    where
92        T: for<'a> TryFrom<&'a Value, Error = ConfigError>,
93    {
94        let ComponentConfig(config) = self;
95        match config.get(key) {
96            Some(value) => T::try_from(value).map(Some),
97            None => Ok(None),
98        }
99    }
100
101    #[allow(dead_code)]
102    /// Retrieve a structured config value by deserializing it with cu29-value.
103    ///
104    /// Example RON:
105    /// `{ "calibration": { "matrix": [[1.0, 0.0, 0.0], [0.0, 1.0, 0.0], [0.0, 0.0, 1.0]], "enabled": true } }`
106    ///
107    /// ```rust,ignore
108    /// #[derive(serde::Deserialize)]
109    /// struct CalibrationCfg {
110    ///     matrix: [[f32; 3]; 3],
111    ///     enabled: bool,
112    /// }
113    /// let cfg: CalibrationCfg = config.get_value("calibration")?.unwrap();
114    /// ```
115    pub fn get_value<T>(&self, key: &str) -> Result<Option<T>, ConfigError>
116    where
117        T: DeserializeOwned,
118    {
119        let ComponentConfig(config) = self;
120        let Some(value) = config.get(key) else {
121            return Ok(None);
122        };
123        let cu_value = ron_value_to_cu_value(&value.0).map_err(|err| err.with_key(key))?;
124        cu_value
125            .deserialize_into::<T>()
126            .map(Some)
127            .map_err(|err| ConfigError {
128                message: format!(
129                    "Config key '{key}' failed to deserialize as {}: {err}",
130                    type_name::<T>()
131                ),
132            })
133    }
134
135    #[allow(dead_code)]
136    pub fn deserialize_into<T>(&self) -> Result<T, ConfigError>
137    where
138        T: DeserializeOwned,
139    {
140        let mut map = BTreeMap::new();
141        for (key, value) in &self.0 {
142            let mapped_value = ron_value_to_cu_value(&value.0).map_err(|err| err.with_key(key))?;
143            map.insert(CuValue::String(key.clone()), mapped_value);
144        }
145
146        CuValue::Map(map)
147            .deserialize_into::<T>()
148            .map_err(|err| ConfigError {
149                message: format!(
150                    "Config failed to deserialize as {}: {err}",
151                    type_name::<T>()
152                ),
153            })
154    }
155
156    #[allow(dead_code)]
157    pub fn set<T: Into<Value>>(&mut self, key: &str, value: T) {
158        let ComponentConfig(config) = self;
159        config.insert(key.to_string(), value.into());
160    }
161
162    #[allow(dead_code)]
163    pub fn merge_from(&mut self, other: &ComponentConfig) {
164        let ComponentConfig(config) = self;
165        for (key, value) in &other.0 {
166            config.insert(key.clone(), value.clone());
167        }
168    }
169}
170
171fn ron_value_to_cu_value(value: &RonValue) -> Result<CuValue, ConfigError> {
172    match value {
173        RonValue::Bool(v) => Ok(CuValue::Bool(*v)),
174        RonValue::Char(v) => Ok(CuValue::Char(*v)),
175        RonValue::String(v) => Ok(CuValue::String(v.clone())),
176        RonValue::Bytes(v) => Ok(CuValue::Bytes(v.clone())),
177        RonValue::Unit => Ok(CuValue::Unit),
178        RonValue::Option(v) => {
179            let mapped = match v {
180                Some(inner) => Some(Box::new(ron_value_to_cu_value(inner)?)),
181                None => None,
182            };
183            Ok(CuValue::Option(mapped))
184        }
185        RonValue::Seq(seq) => {
186            let mut mapped = Vec::with_capacity(seq.len());
187            for item in seq {
188                mapped.push(ron_value_to_cu_value(item)?);
189            }
190            Ok(CuValue::Seq(mapped))
191        }
192        RonValue::Map(map) => {
193            let mut mapped = BTreeMap::new();
194            for (key, value) in map.iter() {
195                let mapped_key = ron_value_to_cu_value(key)?;
196                let mapped_value = ron_value_to_cu_value(value)?;
197                mapped.insert(mapped_key, mapped_value);
198            }
199            Ok(CuValue::Map(mapped))
200        }
201        RonValue::Number(num) => match num {
202            Number::I8(v) => Ok(CuValue::I8(*v)),
203            Number::I16(v) => Ok(CuValue::I16(*v)),
204            Number::I32(v) => Ok(CuValue::I32(*v)),
205            Number::I64(v) => Ok(CuValue::I64(*v)),
206            Number::U8(v) => Ok(CuValue::U8(*v)),
207            Number::U16(v) => Ok(CuValue::U16(*v)),
208            Number::U32(v) => Ok(CuValue::U32(*v)),
209            Number::U64(v) => Ok(CuValue::U64(*v)),
210            Number::F32(v) => Ok(CuValue::F32(v.0)),
211            Number::F64(v) => Ok(CuValue::F64(v.0)),
212            _ => Err(ConfigError {
213                message: "Unsupported RON number variant".to_string(),
214            }),
215        },
216    }
217}
218
219// The configuration Serialization format is as follows:
220// (
221//   tasks : [ (id: "toto", type: "zorglub::MyType", config: {...}),
222//             (id: "titi", type: "zorglub::MyType2", config: {...})]
223//   cnx : [ (src: "toto", dst: "titi", msg: "zorglub::MyMsgType"),...]
224// )
225
226/// Wrapper around the ron::Value to allow for custom serialization.
227#[derive(Serialize, Deserialize, Debug, Clone, PartialEq)]
228pub struct Value(RonValue);
229
230#[derive(Debug, Clone, PartialEq)]
231pub struct ConfigError {
232    message: String,
233}
234
235impl ConfigError {
236    fn type_mismatch(expected: &'static str, value: &Value) -> Self {
237        ConfigError {
238            message: format!("Expected {expected} but got {value:?}"),
239        }
240    }
241
242    fn with_key(self, key: &str) -> Self {
243        ConfigError {
244            message: format!("Config key '{key}': {}", self.message),
245        }
246    }
247}
248
249impl Display for ConfigError {
250    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
251        write!(f, "{}", self.message)
252    }
253}
254
255#[cfg(feature = "std")]
256impl std::error::Error for ConfigError {}
257
258#[cfg(not(feature = "std"))]
259impl core::error::Error for ConfigError {}
260
261impl From<ConfigError> for CuError {
262    fn from(err: ConfigError) -> Self {
263        CuError::from(err.to_string())
264    }
265}
266
267// Macro for implementing From<T> for Value where T is a numeric type
268macro_rules! impl_from_numeric_for_value {
269    ($($source:ty),* $(,)?) => {
270        $(impl From<$source> for Value {
271            fn from(value: $source) -> Self {
272                Value(RonValue::Number(value.into()))
273            }
274        })*
275    };
276}
277
278// Implement From for common numeric types
279impl_from_numeric_for_value!(i8, i16, i32, i64, u8, u16, u32, u64, f32, f64);
280
281impl TryFrom<&Value> for bool {
282    type Error = ConfigError;
283
284    fn try_from(value: &Value) -> Result<Self, Self::Error> {
285        if let Value(RonValue::Bool(v)) = value {
286            Ok(*v)
287        } else {
288            Err(ConfigError::type_mismatch("bool", value))
289        }
290    }
291}
292
293impl From<Value> for bool {
294    fn from(value: Value) -> Self {
295        if let Value(RonValue::Bool(v)) = value {
296            v
297        } else {
298            panic!("Expected a Boolean variant but got {value:?}")
299        }
300    }
301}
302macro_rules! impl_from_value_for_int {
303    ($($target:ty),* $(,)?) => {
304        $(
305            impl From<Value> for $target {
306                fn from(value: Value) -> Self {
307                    if let Value(RonValue::Number(num)) = value {
308                        match num {
309                            Number::I8(n) => n as $target,
310                            Number::I16(n) => n as $target,
311                            Number::I32(n) => n as $target,
312                            Number::I64(n) => n as $target,
313                            Number::U8(n) => n as $target,
314                            Number::U16(n) => n as $target,
315                            Number::U32(n) => n as $target,
316                            Number::U64(n) => n as $target,
317                            Number::F32(_) | Number::F64(_) => {
318                                panic!("Expected an integer Number variant but got {num:?}")
319                            }
320                            _ => {
321                                panic!("Expected an integer Number variant but got {num:?}")
322                            }
323                        }
324                    } else {
325                        panic!("Expected a Number variant but got {value:?}")
326                    }
327                }
328            }
329        )*
330    };
331}
332
333impl_from_value_for_int!(u8, i8, u16, i16, u32, i32, u64, i64);
334
335macro_rules! impl_try_from_value_for_int {
336    ($($target:ty),* $(,)?) => {
337        $(
338            impl TryFrom<&Value> for $target {
339                type Error = ConfigError;
340
341                fn try_from(value: &Value) -> Result<Self, Self::Error> {
342                    if let Value(RonValue::Number(num)) = value {
343                        match num {
344                            Number::I8(n) => Ok(*n as $target),
345                            Number::I16(n) => Ok(*n as $target),
346                            Number::I32(n) => Ok(*n as $target),
347                            Number::I64(n) => Ok(*n as $target),
348                            Number::U8(n) => Ok(*n as $target),
349                            Number::U16(n) => Ok(*n as $target),
350                            Number::U32(n) => Ok(*n as $target),
351                            Number::U64(n) => Ok(*n as $target),
352                            Number::F32(_) | Number::F64(_) => {
353                                Err(ConfigError::type_mismatch("integer", value))
354                            }
355                            _ => {
356                                Err(ConfigError::type_mismatch("integer", value))
357                            }
358                        }
359                    } else {
360                        Err(ConfigError::type_mismatch("integer", value))
361                    }
362                }
363            }
364        )*
365    };
366}
367
368impl_try_from_value_for_int!(u8, i8, u16, i16, u32, i32, u64, i64);
369
370impl TryFrom<&Value> for f64 {
371    type Error = ConfigError;
372
373    fn try_from(value: &Value) -> Result<Self, Self::Error> {
374        if let Value(RonValue::Number(num)) = value {
375            let number = match num {
376                Number::I8(n) => *n as f64,
377                Number::I16(n) => *n as f64,
378                Number::I32(n) => *n as f64,
379                Number::I64(n) => *n as f64,
380                Number::U8(n) => *n as f64,
381                Number::U16(n) => *n as f64,
382                Number::U32(n) => *n as f64,
383                Number::U64(n) => *n as f64,
384                Number::F32(n) => n.0 as f64,
385                Number::F64(n) => n.0,
386                _ => {
387                    return Err(ConfigError::type_mismatch("number", value));
388                }
389            };
390            Ok(number)
391        } else {
392            Err(ConfigError::type_mismatch("number", value))
393        }
394    }
395}
396
397impl From<Value> for f64 {
398    fn from(value: Value) -> Self {
399        if let Value(RonValue::Number(num)) = value {
400            num.into_f64()
401        } else {
402            panic!("Expected a Number variant but got {value:?}")
403        }
404    }
405}
406
407impl From<String> for Value {
408    fn from(value: String) -> Self {
409        Value(RonValue::String(value))
410    }
411}
412
413impl TryFrom<&Value> for String {
414    type Error = ConfigError;
415
416    fn try_from(value: &Value) -> Result<Self, Self::Error> {
417        if let Value(RonValue::String(s)) = value {
418            Ok(s.clone())
419        } else {
420            Err(ConfigError::type_mismatch("string", value))
421        }
422    }
423}
424
425impl From<Value> for String {
426    fn from(value: Value) -> Self {
427        if let Value(RonValue::String(s)) = value {
428            s
429        } else {
430            panic!("Expected a String variant")
431        }
432    }
433}
434
435impl Display for Value {
436    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
437        let Value(value) = self;
438        match value {
439            RonValue::Number(n) => {
440                let s = match n {
441                    Number::I8(n) => n.to_string(),
442                    Number::I16(n) => n.to_string(),
443                    Number::I32(n) => n.to_string(),
444                    Number::I64(n) => n.to_string(),
445                    Number::U8(n) => n.to_string(),
446                    Number::U16(n) => n.to_string(),
447                    Number::U32(n) => n.to_string(),
448                    Number::U64(n) => n.to_string(),
449                    Number::F32(n) => n.0.to_string(),
450                    Number::F64(n) => n.0.to_string(),
451                    _ => panic!("Expected a Number variant but got {value:?}"),
452                };
453                write!(f, "{s}")
454            }
455            RonValue::String(s) => write!(f, "{s}"),
456            RonValue::Bool(b) => write!(f, "{b}"),
457            RonValue::Map(m) => write!(f, "{m:?}"),
458            RonValue::Char(c) => write!(f, "{c:?}"),
459            RonValue::Unit => write!(f, "unit"),
460            RonValue::Option(o) => write!(f, "{o:?}"),
461            RonValue::Seq(s) => write!(f, "{s:?}"),
462            RonValue::Bytes(bytes) => write!(f, "{bytes:?}"),
463        }
464    }
465}
466
467/// Configuration for logging in the node.
468#[derive(Serialize, Deserialize, Debug, Clone)]
469pub struct NodeLogging {
470    #[serde(default = "default_as_true")]
471    enabled: bool,
472    #[serde(skip_serializing_if = "Option::is_none")]
473    codec: Option<String>,
474    #[serde(default, skip_serializing_if = "HashMap::is_empty")]
475    codecs: HashMap<String, String>,
476}
477
478impl NodeLogging {
479    #[allow(dead_code)]
480    pub fn enabled(&self) -> bool {
481        self.enabled
482    }
483
484    #[allow(dead_code)]
485    pub fn codec(&self) -> Option<&str> {
486        self.codec.as_deref()
487    }
488
489    #[allow(dead_code)]
490    pub fn codecs(&self) -> &HashMap<String, String> {
491        &self.codecs
492    }
493
494    #[allow(dead_code)]
495    pub fn codec_for_msg_type(&self, msg_type: &str) -> Option<&str> {
496        self.codecs
497            .get(msg_type)
498            .map(String::as_str)
499            .or(self.codec.as_deref())
500    }
501}
502
503impl Default for NodeLogging {
504    fn default() -> Self {
505        Self {
506            enabled: true,
507            codec: None,
508            codecs: HashMap::new(),
509        }
510    }
511}
512
513/// Distinguishes regular tasks from bridge nodes so downstream stages can apply
514/// bridge-specific instantiation rules.
515#[derive(Default, Debug, Copy, Clone, PartialEq, Eq)]
516pub enum Flavor {
517    #[default]
518    Task,
519    Bridge,
520}
521
522/// A node in the configuration graph.
523/// A node represents a Task in the system Graph.
524#[derive(Serialize, Deserialize, Debug, Clone)]
525pub struct Node {
526    /// Unique node identifier.
527    id: String,
528
529    /// Task rust struct underlying type, e.g. "mymodule::Sensor", etc.
530    #[serde(rename = "type", skip_serializing_if = "Option::is_none")]
531    type_: Option<String>,
532
533    /// Config passed to the task.
534    #[serde(skip_serializing_if = "Option::is_none")]
535    config: Option<ComponentConfig>,
536
537    /// Resources requested by the task.
538    #[serde(skip_serializing_if = "Option::is_none")]
539    resources: Option<HashMap<String, String>>,
540
541    /// Missions for which this task is run.
542    missions: Option<Vec<String>>,
543
544    /// Run this task in the background:
545    /// ie. Will be set to run on a background thread and until it is finished `CuTask::process` will return None.
546    #[serde(skip_serializing_if = "Option::is_none")]
547    background: Option<bool>,
548
549    /// Option to include/exclude stubbing for simulation.
550    /// By default, sources and sinks are replaces (stubbed) by the runtime to avoid trying to compile hardware specific code for sensing or actuation.
551    /// In some cases, for example a sink or source used as a middleware bridge, you might want to run the real code even in simulation.
552    /// This option allows to control this behavior.
553    /// Note: Normal tasks will be run in sim and this parameter ignored.
554    #[serde(skip_serializing_if = "Option::is_none")]
555    run_in_sim: Option<bool>,
556
557    /// Config passed to the task.
558    #[serde(skip_serializing_if = "Option::is_none")]
559    logging: Option<NodeLogging>,
560
561    /// Node role in the runtime graph (normal task or bridge endpoint).
562    #[serde(skip, default)]
563    flavor: Flavor,
564    /// Message types that are intentionally not connected (NC) in configuration.
565    #[serde(skip, default)]
566    nc_outputs: Vec<String>,
567    /// Original config connection order for each NC output message type.
568    #[serde(skip, default)]
569    nc_output_orders: Vec<usize>,
570}
571
572impl Node {
573    #[allow(dead_code)]
574    pub fn new(id: &str, ptype: &str) -> Self {
575        Node {
576            id: id.to_string(),
577            type_: Some(ptype.to_string()),
578            config: None,
579            resources: None,
580            missions: None,
581            background: None,
582            run_in_sim: None,
583            logging: None,
584            flavor: Flavor::Task,
585            nc_outputs: Vec::new(),
586            nc_output_orders: Vec::new(),
587        }
588    }
589
590    #[allow(dead_code)]
591    pub fn new_with_flavor(id: &str, ptype: &str, flavor: Flavor) -> Self {
592        let mut node = Self::new(id, ptype);
593        node.flavor = flavor;
594        node
595    }
596
597    #[allow(dead_code)]
598    pub fn get_id(&self) -> String {
599        self.id.clone()
600    }
601
602    #[allow(dead_code)]
603    pub fn get_type(&self) -> &str {
604        self.type_.as_ref().unwrap()
605    }
606
607    #[allow(dead_code)]
608    pub fn set_type(mut self, name: Option<String>) -> Self {
609        self.type_ = name;
610        self
611    }
612
613    #[allow(dead_code)]
614    pub fn set_resources<I>(&mut self, resources: Option<I>)
615    where
616        I: IntoIterator<Item = (String, String)>,
617    {
618        self.resources = resources.map(|iter| iter.into_iter().collect());
619    }
620
621    #[allow(dead_code)]
622    pub fn is_background(&self) -> bool {
623        self.background.unwrap_or(false)
624    }
625
626    #[allow(dead_code)]
627    pub fn get_instance_config(&self) -> Option<&ComponentConfig> {
628        self.config.as_ref()
629    }
630
631    #[allow(dead_code)]
632    pub fn get_resources(&self) -> Option<&HashMap<String, String>> {
633        self.resources.as_ref()
634    }
635
636    /// By default, assume a source or a sink is not run in sim.
637    /// Normal tasks will be run in sim and this parameter ignored.
638    #[allow(dead_code)]
639    pub fn is_run_in_sim(&self) -> bool {
640        self.run_in_sim.unwrap_or(false)
641    }
642
643    #[allow(dead_code)]
644    pub fn is_logging_enabled(&self) -> bool {
645        if let Some(logging) = &self.logging {
646            logging.enabled()
647        } else {
648            true
649        }
650    }
651
652    #[allow(dead_code)]
653    pub fn get_logging(&self) -> Option<&NodeLogging> {
654        self.logging.as_ref()
655    }
656
657    #[allow(dead_code)]
658    pub fn get_param<T>(&self, key: &str) -> Result<Option<T>, ConfigError>
659    where
660        T: for<'a> TryFrom<&'a Value, Error = ConfigError>,
661    {
662        let pc = match self.config.as_ref() {
663            Some(pc) => pc,
664            None => return Ok(None),
665        };
666        let ComponentConfig(pc) = pc;
667        match pc.get(key) {
668            Some(v) => T::try_from(v).map(Some),
669            None => Ok(None),
670        }
671    }
672
673    #[allow(dead_code)]
674    pub fn set_param<T: Into<Value>>(&mut self, key: &str, value: T) {
675        if self.config.is_none() {
676            self.config = Some(ComponentConfig(HashMap::new()));
677        }
678        let ComponentConfig(config) = self.config.as_mut().unwrap();
679        config.insert(key.to_string(), value.into());
680    }
681
682    /// Returns whether this node is treated as a normal task or as a bridge.
683    #[allow(dead_code)]
684    pub fn get_flavor(&self) -> Flavor {
685        self.flavor
686    }
687
688    /// Overrides the node flavor; primarily used when injecting bridge nodes.
689    #[allow(dead_code)]
690    pub fn set_flavor(&mut self, flavor: Flavor) {
691        self.flavor = flavor;
692    }
693
694    /// Registers an intentionally unconnected output message type for this node.
695    #[allow(dead_code)]
696    pub fn add_nc_output(&mut self, msg_type: &str, order: usize) {
697        if let Some(pos) = self
698            .nc_outputs
699            .iter()
700            .position(|existing| existing == msg_type)
701        {
702            if order < self.nc_output_orders[pos] {
703                self.nc_output_orders[pos] = order;
704            }
705            return;
706        }
707        self.nc_outputs.push(msg_type.to_string());
708        self.nc_output_orders.push(order);
709    }
710
711    /// Returns message types intentionally marked as not connected.
712    #[allow(dead_code)]
713    pub fn nc_outputs(&self) -> &[String] {
714        &self.nc_outputs
715    }
716
717    /// Returns NC outputs paired with original config order.
718    #[allow(dead_code)]
719    pub fn nc_outputs_with_order(&self) -> impl Iterator<Item = (&String, usize)> {
720        self.nc_outputs
721            .iter()
722            .zip(self.nc_output_orders.iter().copied())
723    }
724}
725
726/// Directional mapping for bridge channels.
727#[derive(Serialize, Deserialize, Debug, Clone)]
728pub enum BridgeChannelConfigRepresentation {
729    /// Channel that receives data from the bridge into the graph.
730    Rx {
731        id: String,
732        /// Optional transport/topic identifier specific to the bridge backend.
733        #[serde(skip_serializing_if = "Option::is_none")]
734        route: Option<String>,
735        /// Optional per-channel configuration forwarded to the bridge implementation.
736        #[serde(skip_serializing_if = "Option::is_none")]
737        config: Option<ComponentConfig>,
738    },
739    /// Channel that transmits data from the graph into the bridge.
740    Tx {
741        id: String,
742        /// Optional transport/topic identifier specific to the bridge backend.
743        #[serde(skip_serializing_if = "Option::is_none")]
744        route: Option<String>,
745        /// Optional per-channel configuration forwarded to the bridge implementation.
746        #[serde(skip_serializing_if = "Option::is_none")]
747        config: Option<ComponentConfig>,
748    },
749}
750
751impl BridgeChannelConfigRepresentation {
752    /// Stable logical identifier to reference this channel in connections.
753    #[allow(dead_code)]
754    pub fn id(&self) -> &str {
755        match self {
756            BridgeChannelConfigRepresentation::Rx { id, .. }
757            | BridgeChannelConfigRepresentation::Tx { id, .. } => id,
758        }
759    }
760
761    /// Bridge-specific transport path (topic, route, path...) describing this channel.
762    #[allow(dead_code)]
763    pub fn route(&self) -> Option<&str> {
764        match self {
765            BridgeChannelConfigRepresentation::Rx { route, .. }
766            | BridgeChannelConfigRepresentation::Tx { route, .. } => route.as_deref(),
767        }
768    }
769}
770
771enum EndpointRole {
772    Source,
773    Destination,
774}
775
776fn validate_bridge_channel(
777    bridge: &BridgeConfig,
778    channel_id: &str,
779    role: EndpointRole,
780) -> Result<(), String> {
781    let channel = bridge
782        .channels
783        .iter()
784        .find(|ch| ch.id() == channel_id)
785        .ok_or_else(|| {
786            format!(
787                "Bridge '{}' does not declare a channel named '{}'",
788                bridge.id, channel_id
789            )
790        })?;
791
792    match (role, channel) {
793        (EndpointRole::Source, BridgeChannelConfigRepresentation::Rx { .. }) => Ok(()),
794        (EndpointRole::Destination, BridgeChannelConfigRepresentation::Tx { .. }) => Ok(()),
795        (EndpointRole::Source, BridgeChannelConfigRepresentation::Tx { .. }) => Err(format!(
796            "Bridge '{}' channel '{}' is Tx and cannot act as a source",
797            bridge.id, channel_id
798        )),
799        (EndpointRole::Destination, BridgeChannelConfigRepresentation::Rx { .. }) => Err(format!(
800            "Bridge '{}' channel '{}' is Rx and cannot act as a destination",
801            bridge.id, channel_id
802        )),
803    }
804}
805
806/// Declarative definition of a resource bundle.
807#[derive(Serialize, Deserialize, Debug, Clone)]
808pub struct ResourceBundleConfig {
809    pub id: String,
810    #[serde(rename = "provider")]
811    pub provider: String,
812    #[serde(skip_serializing_if = "Option::is_none")]
813    pub config: Option<ComponentConfig>,
814    #[serde(skip_serializing_if = "Option::is_none")]
815    pub missions: Option<Vec<String>>,
816}
817
818/// Declarative definition of a bridge component with a list of channels.
819#[derive(Serialize, Deserialize, Debug, Clone)]
820pub struct BridgeConfig {
821    pub id: String,
822    #[serde(rename = "type")]
823    pub type_: String,
824    #[serde(skip_serializing_if = "Option::is_none")]
825    pub config: Option<ComponentConfig>,
826    #[serde(skip_serializing_if = "Option::is_none")]
827    pub resources: Option<HashMap<String, String>>,
828    #[serde(skip_serializing_if = "Option::is_none")]
829    pub missions: Option<Vec<String>>,
830    /// Whether this bridge should run as the real implementation in simulation mode.
831    ///
832    /// Default is `true` to preserve historical behavior where bridges were always
833    /// instantiated in sim mode.
834    #[serde(skip_serializing_if = "Option::is_none")]
835    pub run_in_sim: Option<bool>,
836    /// List of logical endpoints exposed by this bridge.
837    pub channels: Vec<BridgeChannelConfigRepresentation>,
838}
839
840impl BridgeConfig {
841    /// By default, bridges run as real implementations in sim mode for backward compatibility.
842    #[allow(dead_code)]
843    pub fn is_run_in_sim(&self) -> bool {
844        self.run_in_sim.unwrap_or(true)
845    }
846
847    fn to_node(&self) -> Node {
848        let mut node = Node::new_with_flavor(&self.id, &self.type_, Flavor::Bridge);
849        node.config = self.config.clone();
850        node.resources = self.resources.clone();
851        node.missions = self.missions.clone();
852        node
853    }
854}
855
856fn insert_bridge_node(graph: &mut CuGraph, bridge: &BridgeConfig) -> Result<(), String> {
857    if graph.get_node_id_by_name(bridge.id.as_str()).is_some() {
858        return Err(format!(
859            "Bridge '{}' reuses an existing node id. Bridge ids must be unique.",
860            bridge.id
861        ));
862    }
863    graph
864        .add_node(bridge.to_node())
865        .map(|_| ())
866        .map_err(|e| e.to_string())
867}
868
869/// Serialized representation of a connection used for the RON config.
870#[derive(Serialize, Deserialize, Debug, Clone)]
871struct SerializedCnx {
872    src: String,
873    dst: String,
874    msg: String,
875    missions: Option<Vec<String>>,
876}
877
878/// Special destination endpoint used to mark an output as intentionally not connected.
879pub const NC_ENDPOINT: &str = "__nc__";
880
881/// This represents a connection between 2 tasks (nodes) in the configuration graph.
882#[derive(Debug, Clone)]
883pub struct Cnx {
884    /// Source node id.
885    pub src: String,
886    /// Destination node id.
887    pub dst: String,
888    /// Message type exchanged between src and dst.
889    pub msg: String,
890    /// Restrict this connection for this list of missions.
891    pub missions: Option<Vec<String>>,
892    /// Optional channel id when the source endpoint is a bridge.
893    pub src_channel: Option<String>,
894    /// Optional channel id when the destination endpoint is a bridge.
895    pub dst_channel: Option<String>,
896    /// Original serialized connection index used to preserve output ordering.
897    pub order: usize,
898}
899
900impl From<&Cnx> for SerializedCnx {
901    fn from(cnx: &Cnx) -> Self {
902        SerializedCnx {
903            src: format_endpoint(&cnx.src, cnx.src_channel.as_deref()),
904            dst: format_endpoint(&cnx.dst, cnx.dst_channel.as_deref()),
905            msg: cnx.msg.clone(),
906            missions: cnx.missions.clone(),
907        }
908    }
909}
910
911fn format_endpoint(node: &str, channel: Option<&str>) -> String {
912    match channel {
913        Some(ch) => format!("{node}/{ch}"),
914        None => node.to_string(),
915    }
916}
917
918fn parse_endpoint(
919    endpoint: &str,
920    role: EndpointRole,
921    bridges: &HashMap<&str, &BridgeConfig>,
922) -> Result<(String, Option<String>), String> {
923    if let Some((node, channel)) = endpoint.split_once('/') {
924        if let Some(bridge) = bridges.get(node) {
925            validate_bridge_channel(bridge, channel, role)?;
926            return Ok((node.to_string(), Some(channel.to_string())));
927        } else {
928            return Err(format!(
929                "Endpoint '{endpoint}' references an unknown bridge '{node}'"
930            ));
931        }
932    }
933
934    if let Some(bridge) = bridges.get(endpoint) {
935        return Err(format!(
936            "Bridge '{}' connections must reference a channel using '{}/<channel>'",
937            bridge.id, bridge.id
938        ));
939    }
940
941    Ok((endpoint.to_string(), None))
942}
943
944fn build_bridge_lookup(bridges: Option<&Vec<BridgeConfig>>) -> HashMap<&str, &BridgeConfig> {
945    let mut map = HashMap::new();
946    if let Some(bridges) = bridges {
947        for bridge in bridges {
948            map.insert(bridge.id.as_str(), bridge);
949        }
950    }
951    map
952}
953
954fn mission_applies(missions: &Option<Vec<String>>, mission_id: &str) -> bool {
955    missions
956        .as_ref()
957        .map(|mission_list| mission_list.iter().any(|m| m == mission_id))
958        .unwrap_or(true)
959}
960
961fn merge_connection_missions(existing: &mut Option<Vec<String>>, incoming: &Option<Vec<String>>) {
962    if incoming.is_none() {
963        *existing = None;
964        return;
965    }
966    if existing.is_none() {
967        return;
968    }
969
970    if let (Some(existing_missions), Some(incoming_missions)) =
971        (existing.as_mut(), incoming.as_ref())
972    {
973        for mission in incoming_missions {
974            if !existing_missions
975                .iter()
976                .any(|existing_mission| existing_mission == mission)
977            {
978                existing_missions.push(mission.clone());
979            }
980        }
981        existing_missions.sort();
982        existing_missions.dedup();
983    }
984}
985
986fn register_nc_output<E>(
987    graph: &mut CuGraph,
988    src_endpoint: &str,
989    msg_type: &str,
990    order: usize,
991    bridge_lookup: &HashMap<&str, &BridgeConfig>,
992) -> Result<(), E>
993where
994    E: From<String>,
995{
996    let (src_name, src_channel) =
997        parse_endpoint(src_endpoint, EndpointRole::Source, bridge_lookup).map_err(E::from)?;
998    if src_channel.is_some() {
999        return Err(E::from(format!(
1000            "NC destination '{}' does not support bridge channels in source endpoint '{}'",
1001            NC_ENDPOINT, src_endpoint
1002        )));
1003    }
1004
1005    let src = graph
1006        .get_node_id_by_name(src_name.as_str())
1007        .ok_or_else(|| E::from(format!("Source node not found: {src_endpoint}")))?;
1008    let src_node = graph
1009        .get_node_mut(src)
1010        .ok_or_else(|| E::from(format!("Source node id {src} not found for NC output")))?;
1011    if src_node.get_flavor() != Flavor::Task {
1012        return Err(E::from(format!(
1013            "NC destination '{}' is only supported for task outputs (source '{}')",
1014            NC_ENDPOINT, src_endpoint
1015        )));
1016    }
1017    src_node.add_nc_output(msg_type, order);
1018    Ok(())
1019}
1020
1021/// A simple wrapper enum for `petgraph::Direction`,
1022/// designed to be converted *into* it via the `From` trait.
1023#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1024pub enum CuDirection {
1025    Outgoing,
1026    Incoming,
1027}
1028
1029impl From<CuDirection> for petgraph::Direction {
1030    fn from(dir: CuDirection) -> Self {
1031        match dir {
1032            CuDirection::Outgoing => petgraph::Direction::Outgoing,
1033            CuDirection::Incoming => petgraph::Direction::Incoming,
1034        }
1035    }
1036}
1037
1038#[derive(Default, Debug, Clone)]
1039pub struct CuGraph(pub StableDiGraph<Node, Cnx, NodeId>);
1040
1041impl CuGraph {
1042    #[allow(dead_code)]
1043    pub fn get_all_nodes(&self) -> Vec<(NodeId, &Node)> {
1044        self.0
1045            .node_indices()
1046            .map(|index| (index.index() as u32, &self.0[index]))
1047            .collect()
1048    }
1049
1050    #[allow(dead_code)]
1051    pub fn get_neighbor_ids(&self, node_id: NodeId, dir: CuDirection) -> Vec<NodeId> {
1052        self.0
1053            .neighbors_directed(node_id.into(), dir.into())
1054            .map(|petgraph_index| petgraph_index.index() as NodeId)
1055            .collect()
1056    }
1057
1058    #[allow(dead_code)]
1059    pub fn node_ids(&self) -> Vec<NodeId> {
1060        self.0
1061            .node_indices()
1062            .map(|index| index.index() as NodeId)
1063            .collect()
1064    }
1065
1066    #[allow(dead_code)]
1067    pub fn edge_id_between(&self, source: NodeId, target: NodeId) -> Option<usize> {
1068        self.0
1069            .find_edge(source.into(), target.into())
1070            .map(|edge| edge.index())
1071    }
1072
1073    #[allow(dead_code)]
1074    pub fn edge(&self, edge_id: usize) -> Option<&Cnx> {
1075        self.0.edge_weight(EdgeIndex::new(edge_id))
1076    }
1077
1078    #[allow(dead_code)]
1079    pub fn edges(&self) -> impl Iterator<Item = &Cnx> {
1080        self.0
1081            .edge_indices()
1082            .filter_map(|edge| self.0.edge_weight(edge))
1083    }
1084
1085    #[allow(dead_code)]
1086    pub fn bfs_nodes(&self, start: NodeId) -> Vec<NodeId> {
1087        let mut visitor = Bfs::new(&self.0, start.into());
1088        let mut nodes = Vec::new();
1089        while let Some(node) = visitor.next(&self.0) {
1090            nodes.push(node.index() as NodeId);
1091        }
1092        nodes
1093    }
1094
1095    #[allow(dead_code)]
1096    pub fn incoming_neighbor_count(&self, node_id: NodeId) -> usize {
1097        self.0.neighbors_directed(node_id.into(), Incoming).count()
1098    }
1099
1100    #[allow(dead_code)]
1101    pub fn outgoing_neighbor_count(&self, node_id: NodeId) -> usize {
1102        self.0.neighbors_directed(node_id.into(), Outgoing).count()
1103    }
1104
1105    pub fn node_indices(&self) -> Vec<petgraph::stable_graph::NodeIndex> {
1106        self.0.node_indices().collect()
1107    }
1108
1109    pub fn add_node(&mut self, node: Node) -> CuResult<NodeId> {
1110        Ok(self.0.add_node(node).index() as NodeId)
1111    }
1112
1113    #[allow(dead_code)]
1114    pub fn connection_exists(&self, source: NodeId, target: NodeId) -> bool {
1115        self.0.find_edge(source.into(), target.into()).is_some()
1116    }
1117
1118    pub fn connect_ext(
1119        &mut self,
1120        source: NodeId,
1121        target: NodeId,
1122        msg_type: &str,
1123        missions: Option<Vec<String>>,
1124        src_channel: Option<String>,
1125        dst_channel: Option<String>,
1126    ) -> CuResult<()> {
1127        self.connect_ext_with_order(
1128            source,
1129            target,
1130            msg_type,
1131            missions,
1132            src_channel,
1133            dst_channel,
1134            usize::MAX,
1135        )
1136    }
1137
1138    #[allow(clippy::too_many_arguments)]
1139    pub fn connect_ext_with_order(
1140        &mut self,
1141        source: NodeId,
1142        target: NodeId,
1143        msg_type: &str,
1144        missions: Option<Vec<String>>,
1145        src_channel: Option<String>,
1146        dst_channel: Option<String>,
1147        order: usize,
1148    ) -> CuResult<()> {
1149        let (src_id, dst_id) = (
1150            self.0
1151                .node_weight(source.into())
1152                .ok_or("Source node not found")?
1153                .id
1154                .clone(),
1155            self.0
1156                .node_weight(target.into())
1157                .ok_or("Target node not found")?
1158                .id
1159                .clone(),
1160        );
1161
1162        let _ = self.0.add_edge(
1163            petgraph::stable_graph::NodeIndex::from(source),
1164            petgraph::stable_graph::NodeIndex::from(target),
1165            Cnx {
1166                src: src_id,
1167                dst: dst_id,
1168                msg: msg_type.to_string(),
1169                missions,
1170                src_channel,
1171                dst_channel,
1172                order,
1173            },
1174        );
1175        Ok(())
1176    }
1177    /// Get the node with the given id.
1178    /// If mission_id is provided, get the node from that mission's graph.
1179    /// Otherwise get the node from the simple graph.
1180    #[allow(dead_code)]
1181    pub fn get_node(&self, node_id: NodeId) -> Option<&Node> {
1182        self.0.node_weight(node_id.into())
1183    }
1184
1185    #[allow(dead_code)]
1186    pub fn get_node_weight(&self, index: NodeId) -> Option<&Node> {
1187        self.0.node_weight(index.into())
1188    }
1189
1190    #[allow(dead_code)]
1191    pub fn get_node_mut(&mut self, node_id: NodeId) -> Option<&mut Node> {
1192        self.0.node_weight_mut(node_id.into())
1193    }
1194
1195    pub fn get_node_id_by_name(&self, name: &str) -> Option<NodeId> {
1196        self.0
1197            .node_indices()
1198            .into_iter()
1199            .find(|idx| self.0[*idx].get_id() == name)
1200            .map(|i| i.index() as NodeId)
1201    }
1202
1203    #[allow(dead_code)]
1204    pub fn get_edge_weight(&self, index: usize) -> Option<Cnx> {
1205        self.0.edge_weight(EdgeIndex::new(index)).cloned()
1206    }
1207
1208    #[allow(dead_code)]
1209    pub fn get_node_output_msg_type(&self, node_id: &str) -> Option<String> {
1210        self.0.node_indices().find_map(|node_index| {
1211            if let Some(node) = self.0.node_weight(node_index) {
1212                if node.id != node_id {
1213                    return None;
1214                }
1215                let edges: Vec<_> = self
1216                    .0
1217                    .edges_directed(node_index, Outgoing)
1218                    .map(|edge| edge.id().index())
1219                    .collect();
1220                if edges.is_empty() {
1221                    return None;
1222                }
1223                let cnx = self
1224                    .0
1225                    .edge_weight(EdgeIndex::new(edges[0]))
1226                    .expect("Found an cnx id but could not retrieve it back");
1227                return Some(cnx.msg.clone());
1228            }
1229            None
1230        })
1231    }
1232
1233    #[allow(dead_code)]
1234    pub fn get_node_input_msg_type(&self, node_id: &str) -> Option<String> {
1235        self.get_node_input_msg_types(node_id)
1236            .and_then(|mut v| v.pop())
1237    }
1238
1239    pub fn get_node_input_msg_types(&self, node_id: &str) -> Option<Vec<String>> {
1240        self.0.node_indices().find_map(|node_index| {
1241            if let Some(node) = self.0.node_weight(node_index) {
1242                if node.id != node_id {
1243                    return None;
1244                }
1245                let edges: Vec<_> = self
1246                    .0
1247                    .edges_directed(node_index, Incoming)
1248                    .map(|edge| edge.id().index())
1249                    .collect();
1250                if edges.is_empty() {
1251                    return None;
1252                }
1253                let msgs = edges
1254                    .into_iter()
1255                    .map(|edge_id| {
1256                        let cnx = self
1257                            .0
1258                            .edge_weight(EdgeIndex::new(edge_id))
1259                            .expect("Found an cnx id but could not retrieve it back");
1260                        cnx.msg.clone()
1261                    })
1262                    .collect();
1263                return Some(msgs);
1264            }
1265            None
1266        })
1267    }
1268
1269    #[allow(dead_code)]
1270    pub fn get_connection_msg_type(&self, source: NodeId, target: NodeId) -> Option<&str> {
1271        self.0
1272            .find_edge(source.into(), target.into())
1273            .map(|edge_index| self.0[edge_index].msg.as_str())
1274    }
1275
1276    /// Get the list of edges that are connected to the given node as a source.
1277    fn get_edges_by_direction(
1278        &self,
1279        node_id: NodeId,
1280        direction: petgraph::Direction,
1281    ) -> CuResult<Vec<usize>> {
1282        Ok(self
1283            .0
1284            .edges_directed(node_id.into(), direction)
1285            .map(|edge| edge.id().index())
1286            .collect())
1287    }
1288
1289    pub fn get_src_edges(&self, node_id: NodeId) -> CuResult<Vec<usize>> {
1290        self.get_edges_by_direction(node_id, Outgoing)
1291    }
1292
1293    /// Get the list of edges that are connected to the given node as a destination.
1294    pub fn get_dst_edges(&self, node_id: NodeId) -> CuResult<Vec<usize>> {
1295        self.get_edges_by_direction(node_id, Incoming)
1296    }
1297
1298    #[allow(dead_code)]
1299    pub fn node_count(&self) -> usize {
1300        self.0.node_count()
1301    }
1302
1303    #[allow(dead_code)]
1304    pub fn edge_count(&self) -> usize {
1305        self.0.edge_count()
1306    }
1307
1308    /// Adds an edge between two nodes/tasks in the configuration graph.
1309    /// msg_type is the type of message exchanged between the two nodes/tasks.
1310    #[allow(dead_code)]
1311    pub fn connect(&mut self, source: NodeId, target: NodeId, msg_type: &str) -> CuResult<()> {
1312        self.connect_ext(source, target, msg_type, None, None, None)
1313    }
1314}
1315
1316impl core::ops::Index<NodeIndex> for CuGraph {
1317    type Output = Node;
1318
1319    fn index(&self, index: NodeIndex) -> &Self::Output {
1320        &self.0[index]
1321    }
1322}
1323
1324#[derive(Debug, Clone)]
1325pub enum ConfigGraphs {
1326    Simple(CuGraph),
1327    Missions(HashMap<String, CuGraph>),
1328}
1329
1330impl ConfigGraphs {
1331    /// Returns a consistent hashmap of mission names to Graphs whatever the shape of the config is.
1332    /// Note: if there is only one anonymous mission it will be called "default"
1333    #[allow(dead_code)]
1334    pub fn get_all_missions_graphs(&self) -> HashMap<String, CuGraph> {
1335        match self {
1336            Simple(graph) => HashMap::from([(DEFAULT_MISSION_ID.to_string(), graph.clone())]),
1337            Missions(graphs) => graphs.clone(),
1338        }
1339    }
1340
1341    #[allow(dead_code)]
1342    pub fn get_default_mission_graph(&self) -> CuResult<&CuGraph> {
1343        match self {
1344            Simple(graph) => Ok(graph),
1345            Missions(graphs) => {
1346                if graphs.len() == 1 {
1347                    Ok(graphs.values().next().unwrap())
1348                } else {
1349                    Err("Cannot get default mission graph from mission config".into())
1350                }
1351            }
1352        }
1353    }
1354
1355    #[allow(dead_code)]
1356    pub fn get_graph(&self, mission_id: Option<&str>) -> CuResult<&CuGraph> {
1357        match self {
1358            Simple(graph) => match mission_id {
1359                None | Some(DEFAULT_MISSION_ID) => Ok(graph),
1360                Some(_) => Err("Cannot get mission graph from simple config".into()),
1361            },
1362            Missions(graphs) => {
1363                let id = mission_id
1364                    .ok_or_else(|| "Mission ID required for mission configs".to_string())?;
1365                graphs
1366                    .get(id)
1367                    .ok_or_else(|| format!("Mission {id} not found").into())
1368            }
1369        }
1370    }
1371
1372    #[allow(dead_code)]
1373    pub fn get_graph_mut(&mut self, mission_id: Option<&str>) -> CuResult<&mut CuGraph> {
1374        match self {
1375            Simple(graph) => match mission_id {
1376                None => Ok(graph),
1377                Some(_) => Err("Cannot get mission graph from simple config".into()),
1378            },
1379            Missions(graphs) => {
1380                let id = mission_id
1381                    .ok_or_else(|| "Mission ID required for mission configs".to_string())?;
1382                graphs
1383                    .get_mut(id)
1384                    .ok_or_else(|| format!("Mission {id} not found").into())
1385            }
1386        }
1387    }
1388
1389    pub fn add_mission(&mut self, mission_id: &str) -> CuResult<&mut CuGraph> {
1390        match self {
1391            Simple(_) => Err("Cannot add mission to simple config".into()),
1392            Missions(graphs) => match graphs.entry(mission_id.to_string()) {
1393                hashbrown::hash_map::Entry::Occupied(_) => {
1394                    Err(format!("Mission {mission_id} already exists").into())
1395                }
1396                hashbrown::hash_map::Entry::Vacant(entry) => Ok(entry.insert(CuGraph::default())),
1397            },
1398        }
1399    }
1400}
1401
1402/// CuConfig is the programmatic representation of the configuration graph.
1403/// It is a directed graph where nodes are tasks and edges are connections between tasks.
1404///
1405/// The core of CuConfig is its `graphs` field which can be either a simple graph
1406/// or a collection of mission-specific graphs. The graph structure is based on petgraph.
1407#[derive(Debug, Clone)]
1408pub struct CuConfig {
1409    /// Monitoring configuration list.
1410    pub monitors: Vec<MonitorConfig>,
1411    /// Optional logging configuration
1412    pub logging: Option<LoggingConfig>,
1413    /// Optional runtime configuration
1414    pub runtime: Option<RuntimeConfig>,
1415    /// Declarative resource bundle definitions
1416    pub resources: Vec<ResourceBundleConfig>,
1417    /// Declarative bridge definitions that are yet to be expanded into the graph
1418    pub bridges: Vec<BridgeConfig>,
1419    /// Graph structure - either a single graph or multiple mission-specific graphs
1420    pub graphs: ConfigGraphs,
1421}
1422
1423impl CuConfig {
1424    #[cfg(feature = "std")]
1425    fn ensure_threadpool_bundle(&mut self) {
1426        if !self.has_background_tasks() {
1427            return;
1428        }
1429        if self
1430            .resources
1431            .iter()
1432            .any(|bundle| bundle.id == "threadpool")
1433        {
1434            return;
1435        }
1436
1437        let mut config = ComponentConfig::default();
1438        config.set("threads", 2u64);
1439        self.resources.push(ResourceBundleConfig {
1440            id: "threadpool".to_string(),
1441            provider: "cu29::resource::ThreadPoolBundle".to_string(),
1442            config: Some(config),
1443            missions: None,
1444        });
1445    }
1446
1447    #[cfg(feature = "std")]
1448    fn has_background_tasks(&self) -> bool {
1449        match &self.graphs {
1450            ConfigGraphs::Simple(graph) => graph
1451                .get_all_nodes()
1452                .iter()
1453                .any(|(_, node)| node.is_background()),
1454            ConfigGraphs::Missions(graphs) => graphs.values().any(|graph| {
1455                graph
1456                    .get_all_nodes()
1457                    .iter()
1458                    .any(|(_, node)| node.is_background())
1459            }),
1460        }
1461    }
1462}
1463
1464#[derive(Serialize, Deserialize, Default, Debug, Clone)]
1465pub struct MonitorConfig {
1466    #[serde(rename = "type")]
1467    type_: String,
1468    #[serde(skip_serializing_if = "Option::is_none")]
1469    config: Option<ComponentConfig>,
1470}
1471
1472impl MonitorConfig {
1473    #[allow(dead_code)]
1474    pub fn get_type(&self) -> &str {
1475        &self.type_
1476    }
1477
1478    #[allow(dead_code)]
1479    pub fn get_config(&self) -> Option<&ComponentConfig> {
1480        self.config.as_ref()
1481    }
1482}
1483
1484fn default_as_true() -> bool {
1485    true
1486}
1487
1488pub const DEFAULT_KEYFRAME_INTERVAL: u32 = 100;
1489
1490fn default_keyframe_interval() -> Option<u32> {
1491    Some(DEFAULT_KEYFRAME_INTERVAL)
1492}
1493
1494#[derive(Serialize, Deserialize, Debug, Clone)]
1495pub struct LoggingConfig {
1496    /// Enable task logging to the log file.
1497    #[serde(default = "default_as_true", skip_serializing_if = "Clone::clone")]
1498    pub enable_task_logging: bool,
1499
1500    /// Number of preallocated CopperLists available to the runtime.
1501    ///
1502    /// This is consumed by proc-macro codegen and must match the value compiled into the
1503    /// application binary.
1504    #[serde(skip_serializing_if = "Option::is_none")]
1505    pub copperlist_count: Option<usize>,
1506
1507    /// Size of each slab in the log file. (it is the size of the memory mapped file at a time)
1508    #[serde(skip_serializing_if = "Option::is_none")]
1509    pub slab_size_mib: Option<u64>,
1510
1511    /// Pre-allocated size for each section in the log file.
1512    #[serde(skip_serializing_if = "Option::is_none")]
1513    pub section_size_mib: Option<u64>,
1514
1515    /// Interval in copperlists between two "keyframes" in the log file i.e. freezing tasks.
1516    #[serde(
1517        default = "default_keyframe_interval",
1518        skip_serializing_if = "Option::is_none"
1519    )]
1520    pub keyframe_interval: Option<u32>,
1521
1522    /// Named log codec specs reusable across task output bindings.
1523    #[serde(default, skip_serializing_if = "Vec::is_empty")]
1524    pub codecs: Vec<LoggingCodecSpec>,
1525}
1526
1527impl Default for LoggingConfig {
1528    fn default() -> Self {
1529        Self {
1530            enable_task_logging: true,
1531            copperlist_count: None,
1532            slab_size_mib: None,
1533            section_size_mib: None,
1534            keyframe_interval: default_keyframe_interval(),
1535            codecs: Vec::new(),
1536        }
1537    }
1538}
1539
1540#[derive(Serialize, Deserialize, Debug, Clone)]
1541pub struct LoggingCodecSpec {
1542    pub id: String,
1543    #[serde(rename = "type")]
1544    pub type_: String,
1545    #[serde(skip_serializing_if = "Option::is_none")]
1546    pub config: Option<ComponentConfig>,
1547}
1548
1549#[derive(Serialize, Deserialize, Default, Debug, Clone)]
1550pub struct RuntimeConfig {
1551    /// Set a CopperList execution rate target in Hz
1552    /// It will act as a rate limiter: if the execution is slower than this rate,
1553    /// it will continue to execute at "best effort".
1554    ///
1555    /// The main usecase is to not waste cycles when the system doesn't need an unbounded execution rate.
1556    #[serde(skip_serializing_if = "Option::is_none")]
1557    pub rate_target_hz: Option<u64>,
1558}
1559
1560/// Maximum representable Copper runtime rate target in whole Hertz.
1561///
1562/// Copper stores runtime periods in integer nanoseconds, so anything above 1 GHz
1563/// would round down to a zero-duration period.
1564pub const MAX_RATE_TARGET_HZ: u64 = 1_000_000_000;
1565
1566/// Missions are used to generate alternative DAGs within the same configuration.
1567#[derive(Serialize, Deserialize, Debug, Clone)]
1568pub struct MissionsConfig {
1569    pub id: String,
1570}
1571
1572/// Includes are used to include other configuration files.
1573#[derive(Serialize, Deserialize, Debug, Clone)]
1574pub struct IncludesConfig {
1575    pub path: String,
1576    pub params: HashMap<String, Value>,
1577    pub missions: Option<Vec<String>>,
1578}
1579
1580/// One subsystem participating in a multi-Copper deployment.
1581#[cfg(feature = "std")]
1582#[allow(dead_code)]
1583#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq)]
1584pub struct MultiCopperSubsystemConfig {
1585    pub id: String,
1586    pub config: String,
1587}
1588
1589/// One explicit interconnect between two subsystem bridge channels.
1590#[cfg(feature = "std")]
1591#[allow(dead_code)]
1592#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq)]
1593pub struct MultiCopperInterconnectConfig {
1594    pub from: String,
1595    pub to: String,
1596    pub msg: String,
1597}
1598
1599/// One path-based config overlay applied to a parsed local Copper config.
1600#[cfg(feature = "std")]
1601#[allow(dead_code)]
1602#[derive(Serialize, Deserialize, Debug, Clone)]
1603pub struct InstanceConfigSetOperation {
1604    pub path: String,
1605    pub value: ComponentConfig,
1606}
1607
1608/// Typed endpoint reference used by validated multi-Copper interconnects.
1609#[cfg(feature = "std")]
1610#[allow(dead_code)]
1611#[derive(Debug, Clone, PartialEq, Eq, Hash)]
1612pub struct MultiCopperEndpoint {
1613    pub subsystem_id: String,
1614    pub bridge_id: String,
1615    pub channel_id: String,
1616}
1617
1618#[cfg(feature = "std")]
1619impl Display for MultiCopperEndpoint {
1620    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
1621        write!(
1622            f,
1623            "{}/{}/{}",
1624            self.subsystem_id, self.bridge_id, self.channel_id
1625        )
1626    }
1627}
1628
1629/// Validated subsystem entry with its compiler-assigned numeric subsystem code and parsed local Copper config.
1630#[cfg(feature = "std")]
1631#[allow(dead_code)]
1632#[derive(Debug, Clone)]
1633pub struct MultiCopperSubsystem {
1634    pub id: String,
1635    pub subsystem_code: u16,
1636    pub config_path: String,
1637    pub config: CuConfig,
1638}
1639
1640/// Validated explicit interconnect between two subsystem endpoints.
1641#[cfg(feature = "std")]
1642#[allow(dead_code)]
1643#[derive(Debug, Clone, PartialEq, Eq)]
1644pub struct MultiCopperInterconnect {
1645    pub from: MultiCopperEndpoint,
1646    pub to: MultiCopperEndpoint,
1647    pub msg: String,
1648    pub bridge_type: String,
1649}
1650
1651/// Strict umbrella configuration describing multiple Copper subsystems and their explicit links.
1652#[cfg(feature = "std")]
1653#[allow(dead_code)]
1654#[derive(Debug, Clone)]
1655pub struct MultiCopperConfig {
1656    pub subsystems: Vec<MultiCopperSubsystem>,
1657    pub interconnects: Vec<MultiCopperInterconnect>,
1658    pub instance_overrides_root: Option<String>,
1659}
1660
1661#[cfg(feature = "std")]
1662impl MultiCopperConfig {
1663    #[allow(dead_code)]
1664    pub fn subsystem(&self, id: &str) -> Option<&MultiCopperSubsystem> {
1665        self.subsystems.iter().find(|subsystem| subsystem.id == id)
1666    }
1667
1668    #[allow(dead_code)]
1669    pub fn resolve_subsystem_config_for_instance(
1670        &self,
1671        subsystem_id: &str,
1672        instance_id: u32,
1673    ) -> CuResult<CuConfig> {
1674        let subsystem = self.subsystem(subsystem_id).ok_or_else(|| {
1675            CuError::from(format!(
1676                "Multi-Copper config does not define subsystem '{}'.",
1677                subsystem_id
1678            ))
1679        })?;
1680        let mut config = subsystem.config.clone();
1681
1682        let Some(root) = &self.instance_overrides_root else {
1683            return Ok(config);
1684        };
1685
1686        let override_path = std::path::Path::new(root)
1687            .join(instance_id.to_string())
1688            .join(format!("{subsystem_id}.ron"));
1689        if !override_path.exists() {
1690            return Ok(config);
1691        }
1692
1693        apply_instance_overrides_from_file(&mut config, &override_path)?;
1694        Ok(config)
1695    }
1696}
1697
1698#[cfg(feature = "std")]
1699#[allow(dead_code)]
1700#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq)]
1701struct MultiCopperConfigRepresentation {
1702    subsystems: Vec<MultiCopperSubsystemConfig>,
1703    interconnects: Vec<MultiCopperInterconnectConfig>,
1704    instance_overrides_root: Option<String>,
1705}
1706
1707#[cfg(feature = "std")]
1708#[derive(Serialize, Deserialize, Debug, Clone, Default)]
1709struct InstanceConfigOverridesRepresentation {
1710    #[serde(default)]
1711    set: Vec<InstanceConfigSetOperation>,
1712}
1713
1714#[cfg(feature = "std")]
1715#[allow(dead_code)]
1716#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1717enum MultiCopperChannelDirection {
1718    Rx,
1719    Tx,
1720}
1721
1722#[cfg(feature = "std")]
1723#[allow(dead_code)]
1724#[derive(Debug, Clone)]
1725struct MultiCopperChannelContract {
1726    bridge_type: String,
1727    direction: MultiCopperChannelDirection,
1728    msg: Option<String>,
1729}
1730
1731#[cfg(feature = "std")]
1732#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1733enum InstanceConfigTargetKind {
1734    Task,
1735    Resource,
1736    Bridge,
1737}
1738
1739/// This is the main Copper configuration representation.
1740#[derive(Serialize, Deserialize, Default)]
1741struct CuConfigRepresentation {
1742    tasks: Option<Vec<Node>>,
1743    resources: Option<Vec<ResourceBundleConfig>>,
1744    bridges: Option<Vec<BridgeConfig>>,
1745    cnx: Option<Vec<SerializedCnx>>,
1746    #[serde(
1747        default,
1748        alias = "monitor",
1749        deserialize_with = "deserialize_monitor_configs"
1750    )]
1751    monitors: Option<Vec<MonitorConfig>>,
1752    logging: Option<LoggingConfig>,
1753    runtime: Option<RuntimeConfig>,
1754    missions: Option<Vec<MissionsConfig>>,
1755    includes: Option<Vec<IncludesConfig>>,
1756}
1757
1758#[derive(Deserialize)]
1759#[serde(untagged)]
1760enum OneOrManyMonitorConfig {
1761    One(MonitorConfig),
1762    Many(Vec<MonitorConfig>),
1763}
1764
1765fn deserialize_monitor_configs<'de, D>(
1766    deserializer: D,
1767) -> Result<Option<Vec<MonitorConfig>>, D::Error>
1768where
1769    D: Deserializer<'de>,
1770{
1771    let parsed = Option::<OneOrManyMonitorConfig>::deserialize(deserializer)?;
1772    Ok(parsed.map(|value| match value {
1773        OneOrManyMonitorConfig::One(single) => vec![single],
1774        OneOrManyMonitorConfig::Many(many) => many,
1775    }))
1776}
1777
1778/// Shared implementation for deserializing a CuConfigRepresentation into a CuConfig
1779fn deserialize_config_representation<E>(
1780    representation: &CuConfigRepresentation,
1781) -> Result<CuConfig, E>
1782where
1783    E: From<String>,
1784{
1785    let mut cuconfig = CuConfig::default();
1786    let bridge_lookup = build_bridge_lookup(representation.bridges.as_ref());
1787
1788    if let Some(mission_configs) = &representation.missions {
1789        // This is the multi-mission case
1790        let mut missions = Missions(HashMap::new());
1791
1792        for mission_config in mission_configs {
1793            let mission_id = mission_config.id.as_str();
1794            let graph = missions
1795                .add_mission(mission_id)
1796                .map_err(|e| E::from(e.to_string()))?;
1797
1798            if let Some(tasks) = &representation.tasks {
1799                for task in tasks {
1800                    if let Some(task_missions) = &task.missions {
1801                        // if there is a filter by mission on the task, only add the task to the mission if it matches the filter.
1802                        if task_missions.contains(&mission_id.to_owned()) {
1803                            graph
1804                                .add_node(task.clone())
1805                                .map_err(|e| E::from(e.to_string()))?;
1806                        }
1807                    } else {
1808                        // if there is no filter by mission on the task, add the task to the mission.
1809                        graph
1810                            .add_node(task.clone())
1811                            .map_err(|e| E::from(e.to_string()))?;
1812                    }
1813                }
1814            }
1815
1816            if let Some(bridges) = &representation.bridges {
1817                for bridge in bridges {
1818                    if mission_applies(&bridge.missions, mission_id) {
1819                        insert_bridge_node(graph, bridge).map_err(E::from)?;
1820                    }
1821                }
1822            }
1823
1824            if let Some(cnx) = &representation.cnx {
1825                for (connection_order, c) in cnx.iter().enumerate() {
1826                    if let Some(cnx_missions) = &c.missions {
1827                        // if there is a filter by mission on the connection, only add the connection to the mission if it matches the filter.
1828                        if cnx_missions.contains(&mission_id.to_owned()) {
1829                            if c.dst == NC_ENDPOINT {
1830                                register_nc_output::<E>(
1831                                    graph,
1832                                    &c.src,
1833                                    &c.msg,
1834                                    connection_order,
1835                                    &bridge_lookup,
1836                                )?;
1837                                continue;
1838                            }
1839                            let (src_name, src_channel) =
1840                                parse_endpoint(&c.src, EndpointRole::Source, &bridge_lookup)
1841                                    .map_err(E::from)?;
1842                            let (dst_name, dst_channel) =
1843                                parse_endpoint(&c.dst, EndpointRole::Destination, &bridge_lookup)
1844                                    .map_err(E::from)?;
1845                            let src =
1846                                graph
1847                                    .get_node_id_by_name(src_name.as_str())
1848                                    .ok_or_else(|| {
1849                                        E::from(format!("Source node not found: {}", c.src))
1850                                    })?;
1851                            let dst =
1852                                graph
1853                                    .get_node_id_by_name(dst_name.as_str())
1854                                    .ok_or_else(|| {
1855                                        E::from(format!("Destination node not found: {}", c.dst))
1856                                    })?;
1857                            graph
1858                                .connect_ext_with_order(
1859                                    src,
1860                                    dst,
1861                                    &c.msg,
1862                                    Some(cnx_missions.clone()),
1863                                    src_channel,
1864                                    dst_channel,
1865                                    connection_order,
1866                                )
1867                                .map_err(|e| E::from(e.to_string()))?;
1868                        }
1869                    } else {
1870                        // if there is no filter by mission on the connection, add the connection to the mission.
1871                        if c.dst == NC_ENDPOINT {
1872                            register_nc_output::<E>(
1873                                graph,
1874                                &c.src,
1875                                &c.msg,
1876                                connection_order,
1877                                &bridge_lookup,
1878                            )?;
1879                            continue;
1880                        }
1881                        let (src_name, src_channel) =
1882                            parse_endpoint(&c.src, EndpointRole::Source, &bridge_lookup)
1883                                .map_err(E::from)?;
1884                        let (dst_name, dst_channel) =
1885                            parse_endpoint(&c.dst, EndpointRole::Destination, &bridge_lookup)
1886                                .map_err(E::from)?;
1887                        let src = graph
1888                            .get_node_id_by_name(src_name.as_str())
1889                            .ok_or_else(|| E::from(format!("Source node not found: {}", c.src)))?;
1890                        let dst =
1891                            graph
1892                                .get_node_id_by_name(dst_name.as_str())
1893                                .ok_or_else(|| {
1894                                    E::from(format!("Destination node not found: {}", c.dst))
1895                                })?;
1896                        graph
1897                            .connect_ext_with_order(
1898                                src,
1899                                dst,
1900                                &c.msg,
1901                                None,
1902                                src_channel,
1903                                dst_channel,
1904                                connection_order,
1905                            )
1906                            .map_err(|e| E::from(e.to_string()))?;
1907                    }
1908                }
1909            }
1910        }
1911        cuconfig.graphs = missions;
1912    } else {
1913        // this is the simple case
1914        let mut graph = CuGraph::default();
1915
1916        if let Some(tasks) = &representation.tasks {
1917            for task in tasks {
1918                graph
1919                    .add_node(task.clone())
1920                    .map_err(|e| E::from(e.to_string()))?;
1921            }
1922        }
1923
1924        if let Some(bridges) = &representation.bridges {
1925            for bridge in bridges {
1926                insert_bridge_node(&mut graph, bridge).map_err(E::from)?;
1927            }
1928        }
1929
1930        if let Some(cnx) = &representation.cnx {
1931            for (connection_order, c) in cnx.iter().enumerate() {
1932                if c.dst == NC_ENDPOINT {
1933                    register_nc_output::<E>(
1934                        &mut graph,
1935                        &c.src,
1936                        &c.msg,
1937                        connection_order,
1938                        &bridge_lookup,
1939                    )?;
1940                    continue;
1941                }
1942                let (src_name, src_channel) =
1943                    parse_endpoint(&c.src, EndpointRole::Source, &bridge_lookup)
1944                        .map_err(E::from)?;
1945                let (dst_name, dst_channel) =
1946                    parse_endpoint(&c.dst, EndpointRole::Destination, &bridge_lookup)
1947                        .map_err(E::from)?;
1948                let src = graph
1949                    .get_node_id_by_name(src_name.as_str())
1950                    .ok_or_else(|| E::from(format!("Source node not found: {}", c.src)))?;
1951                let dst = graph
1952                    .get_node_id_by_name(dst_name.as_str())
1953                    .ok_or_else(|| E::from(format!("Destination node not found: {}", c.dst)))?;
1954                graph
1955                    .connect_ext_with_order(
1956                        src,
1957                        dst,
1958                        &c.msg,
1959                        None,
1960                        src_channel,
1961                        dst_channel,
1962                        connection_order,
1963                    )
1964                    .map_err(|e| E::from(e.to_string()))?;
1965            }
1966        }
1967        cuconfig.graphs = Simple(graph);
1968    }
1969
1970    cuconfig.monitors = representation.monitors.clone().unwrap_or_default();
1971    cuconfig.logging = representation.logging.clone();
1972    cuconfig.runtime = representation.runtime.clone();
1973    cuconfig.resources = representation.resources.clone().unwrap_or_default();
1974    cuconfig.bridges = representation.bridges.clone().unwrap_or_default();
1975
1976    Ok(cuconfig)
1977}
1978
1979impl<'de> Deserialize<'de> for CuConfig {
1980    /// This is a custom serialization to make this implementation independent of petgraph.
1981    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
1982    where
1983        D: Deserializer<'de>,
1984    {
1985        let representation =
1986            CuConfigRepresentation::deserialize(deserializer).map_err(serde::de::Error::custom)?;
1987
1988        // Convert String errors to D::Error using serde::de::Error::custom
1989        match deserialize_config_representation::<String>(&representation) {
1990            Ok(config) => Ok(config),
1991            Err(e) => Err(serde::de::Error::custom(e)),
1992        }
1993    }
1994}
1995
1996impl Serialize for CuConfig {
1997    /// This is a custom serialization to make this implementation independent of petgraph.
1998    fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
1999    where
2000        S: Serializer,
2001    {
2002        let bridges = if self.bridges.is_empty() {
2003            None
2004        } else {
2005            Some(self.bridges.clone())
2006        };
2007        let resources = if self.resources.is_empty() {
2008            None
2009        } else {
2010            Some(self.resources.clone())
2011        };
2012        let monitors = (!self.monitors.is_empty()).then_some(self.monitors.clone());
2013        match &self.graphs {
2014            Simple(graph) => {
2015                let tasks: Vec<Node> = graph
2016                    .0
2017                    .node_indices()
2018                    .map(|idx| graph.0[idx].clone())
2019                    .filter(|node| node.get_flavor() == Flavor::Task)
2020                    .collect();
2021
2022                let mut ordered_cnx: Vec<(usize, SerializedCnx)> = graph
2023                    .0
2024                    .edge_indices()
2025                    .map(|edge_idx| {
2026                        let edge = &graph.0[edge_idx];
2027                        let order = if edge.order == usize::MAX {
2028                            edge_idx.index()
2029                        } else {
2030                            edge.order
2031                        };
2032                        (order, SerializedCnx::from(edge))
2033                    })
2034                    .collect();
2035                for node_idx in graph.0.node_indices() {
2036                    let node = &graph.0[node_idx];
2037                    if node.get_flavor() != Flavor::Task {
2038                        continue;
2039                    }
2040                    for (msg, order) in node.nc_outputs_with_order() {
2041                        ordered_cnx.push((
2042                            order,
2043                            SerializedCnx {
2044                                src: node.get_id(),
2045                                dst: NC_ENDPOINT.to_string(),
2046                                msg: msg.clone(),
2047                                missions: None,
2048                            },
2049                        ));
2050                    }
2051                }
2052                ordered_cnx.sort_by(|(order_a, cnx_a), (order_b, cnx_b)| {
2053                    order_a
2054                        .cmp(order_b)
2055                        .then_with(|| cnx_a.src.cmp(&cnx_b.src))
2056                        .then_with(|| cnx_a.dst.cmp(&cnx_b.dst))
2057                        .then_with(|| cnx_a.msg.cmp(&cnx_b.msg))
2058                });
2059                let cnx: Vec<SerializedCnx> = ordered_cnx
2060                    .into_iter()
2061                    .map(|(_, serialized)| serialized)
2062                    .collect();
2063
2064                CuConfigRepresentation {
2065                    tasks: Some(tasks),
2066                    bridges: bridges.clone(),
2067                    cnx: Some(cnx),
2068                    monitors: monitors.clone(),
2069                    logging: self.logging.clone(),
2070                    runtime: self.runtime.clone(),
2071                    resources: resources.clone(),
2072                    missions: None,
2073                    includes: None,
2074                }
2075                .serialize(serializer)
2076            }
2077            Missions(graphs) => {
2078                let missions = graphs
2079                    .keys()
2080                    .map(|id| MissionsConfig { id: id.clone() })
2081                    .collect();
2082
2083                // Collect all unique tasks across missions
2084                let mut tasks = Vec::new();
2085                let mut ordered_cnx: Vec<(usize, SerializedCnx)> = Vec::new();
2086
2087                for (mission_id, graph) in graphs {
2088                    // Add all nodes from this mission
2089                    for node_idx in graph.node_indices() {
2090                        let node = &graph[node_idx];
2091                        if node.get_flavor() == Flavor::Task
2092                            && !tasks.iter().any(|n: &Node| n.id == node.id)
2093                        {
2094                            tasks.push(node.clone());
2095                        }
2096                    }
2097
2098                    // Add all edges from this mission
2099                    for edge_idx in graph.0.edge_indices() {
2100                        let edge = &graph.0[edge_idx];
2101                        let order = if edge.order == usize::MAX {
2102                            edge_idx.index()
2103                        } else {
2104                            edge.order
2105                        };
2106                        let serialized = SerializedCnx::from(edge);
2107                        if let Some((existing_order, existing_serialized)) =
2108                            ordered_cnx.iter_mut().find(|(_, c)| {
2109                                c.src == serialized.src
2110                                    && c.dst == serialized.dst
2111                                    && c.msg == serialized.msg
2112                            })
2113                        {
2114                            if order < *existing_order {
2115                                *existing_order = order;
2116                            }
2117                            merge_connection_missions(
2118                                &mut existing_serialized.missions,
2119                                &serialized.missions,
2120                            );
2121                        } else {
2122                            ordered_cnx.push((order, serialized));
2123                        }
2124                    }
2125                    for node_idx in graph.0.node_indices() {
2126                        let node = &graph.0[node_idx];
2127                        if node.get_flavor() != Flavor::Task {
2128                            continue;
2129                        }
2130                        for (msg, order) in node.nc_outputs_with_order() {
2131                            let serialized = SerializedCnx {
2132                                src: node.get_id(),
2133                                dst: NC_ENDPOINT.to_string(),
2134                                msg: msg.clone(),
2135                                missions: Some(vec![mission_id.clone()]),
2136                            };
2137                            if let Some((existing_order, existing_serialized)) =
2138                                ordered_cnx.iter_mut().find(|(_, c)| {
2139                                    c.src == serialized.src
2140                                        && c.dst == serialized.dst
2141                                        && c.msg == serialized.msg
2142                                })
2143                            {
2144                                if order < *existing_order {
2145                                    *existing_order = order;
2146                                }
2147                                merge_connection_missions(
2148                                    &mut existing_serialized.missions,
2149                                    &serialized.missions,
2150                                );
2151                            } else {
2152                                ordered_cnx.push((order, serialized));
2153                            }
2154                        }
2155                    }
2156                }
2157                ordered_cnx.sort_by(|(order_a, cnx_a), (order_b, cnx_b)| {
2158                    order_a
2159                        .cmp(order_b)
2160                        .then_with(|| cnx_a.src.cmp(&cnx_b.src))
2161                        .then_with(|| cnx_a.dst.cmp(&cnx_b.dst))
2162                        .then_with(|| cnx_a.msg.cmp(&cnx_b.msg))
2163                });
2164                let cnx: Vec<SerializedCnx> = ordered_cnx
2165                    .into_iter()
2166                    .map(|(_, serialized)| serialized)
2167                    .collect();
2168
2169                CuConfigRepresentation {
2170                    tasks: Some(tasks),
2171                    resources: resources.clone(),
2172                    bridges,
2173                    cnx: Some(cnx),
2174                    monitors,
2175                    logging: self.logging.clone(),
2176                    runtime: self.runtime.clone(),
2177                    missions: Some(missions),
2178                    includes: None,
2179                }
2180                .serialize(serializer)
2181            }
2182        }
2183    }
2184}
2185
2186impl Default for CuConfig {
2187    fn default() -> Self {
2188        CuConfig {
2189            graphs: Simple(CuGraph(StableDiGraph::new())),
2190            monitors: Vec::new(),
2191            logging: None,
2192            runtime: None,
2193            resources: Vec::new(),
2194            bridges: Vec::new(),
2195        }
2196    }
2197}
2198
2199/// The implementation has a lot of convenience methods to manipulate
2200/// the configuration to give some flexibility into programmatically creating the configuration.
2201impl CuConfig {
2202    #[allow(dead_code)]
2203    pub fn new_simple_type() -> Self {
2204        Self::default()
2205    }
2206
2207    #[allow(dead_code)]
2208    pub fn new_mission_type() -> Self {
2209        CuConfig {
2210            graphs: Missions(HashMap::new()),
2211            monitors: Vec::new(),
2212            logging: None,
2213            runtime: None,
2214            resources: Vec::new(),
2215            bridges: Vec::new(),
2216        }
2217    }
2218
2219    fn get_options() -> Options {
2220        Options::default()
2221            .with_default_extension(Extensions::IMPLICIT_SOME)
2222            .with_default_extension(Extensions::UNWRAP_NEWTYPES)
2223            .with_default_extension(Extensions::UNWRAP_VARIANT_NEWTYPES)
2224    }
2225
2226    #[allow(dead_code)]
2227    pub fn serialize_ron(&self) -> CuResult<String> {
2228        let ron = Self::get_options();
2229        let pretty = ron::ser::PrettyConfig::default();
2230        ron.to_string_pretty(&self, pretty)
2231            .map_err(|e| CuError::from(format!("Error serializing configuration: {e}")))
2232    }
2233
2234    #[allow(dead_code)]
2235    pub fn deserialize_ron(ron: &str) -> CuResult<Self> {
2236        let representation = Self::get_options().from_str(ron).map_err(|e| {
2237            CuError::from(format!(
2238                "Syntax Error in config: {} at position {}",
2239                e.code, e.span
2240            ))
2241        })?;
2242        Self::deserialize_impl(representation)
2243            .map_err(|e| CuError::from(format!("Error deserializing configuration: {e}")))
2244    }
2245
2246    fn deserialize_impl(representation: CuConfigRepresentation) -> Result<Self, String> {
2247        deserialize_config_representation(&representation)
2248    }
2249
2250    /// Render the configuration graph in the dot format.
2251    #[cfg(feature = "std")]
2252    #[allow(dead_code)]
2253    pub fn render(
2254        &self,
2255        output: &mut dyn std::io::Write,
2256        mission_id: Option<&str>,
2257    ) -> CuResult<()> {
2258        writeln!(output, "digraph G {{")
2259            .map_err(|e| CuError::new_with_cause("Failed to write render output", e))?;
2260        writeln!(output, "    graph [rankdir=LR, nodesep=0.8, ranksep=1.2];")
2261            .map_err(|e| CuError::new_with_cause("Failed to write render output", e))?;
2262        writeln!(output, "    node [shape=plain, fontname=\"Noto Sans\"];")
2263            .map_err(|e| CuError::new_with_cause("Failed to write render output", e))?;
2264        writeln!(output, "    edge [fontname=\"Noto Sans\"];")
2265            .map_err(|e| CuError::new_with_cause("Failed to write render output", e))?;
2266
2267        let sections = match (&self.graphs, mission_id) {
2268            (Simple(graph), _) => vec![RenderSection { label: None, graph }],
2269            (Missions(graphs), Some(id)) => {
2270                let graph = graphs
2271                    .get(id)
2272                    .ok_or_else(|| CuError::from(format!("Mission {id} not found")))?;
2273                vec![RenderSection {
2274                    label: Some(id.to_string()),
2275                    graph,
2276                }]
2277            }
2278            (Missions(graphs), None) => {
2279                let mut missions: Vec<_> = graphs.iter().collect();
2280                missions.sort_by(|a, b| a.0.cmp(b.0));
2281                missions
2282                    .into_iter()
2283                    .map(|(label, graph)| RenderSection {
2284                        label: Some(label.clone()),
2285                        graph,
2286                    })
2287                    .collect()
2288            }
2289        };
2290
2291        for section in sections {
2292            self.render_section(output, section.graph, section.label.as_deref())?;
2293        }
2294
2295        writeln!(output, "}}")
2296            .map_err(|e| CuError::new_with_cause("Failed to write render output", e))?;
2297        Ok(())
2298    }
2299
2300    #[allow(dead_code)]
2301    pub fn get_all_instances_configs(
2302        &self,
2303        mission_id: Option<&str>,
2304    ) -> Vec<Option<&ComponentConfig>> {
2305        let graph = self.graphs.get_graph(mission_id).unwrap();
2306        graph
2307            .get_all_nodes()
2308            .iter()
2309            .map(|(_, node)| node.get_instance_config())
2310            .collect()
2311    }
2312
2313    #[allow(dead_code)]
2314    pub fn get_graph(&self, mission_id: Option<&str>) -> CuResult<&CuGraph> {
2315        self.graphs.get_graph(mission_id)
2316    }
2317
2318    #[allow(dead_code)]
2319    pub fn get_graph_mut(&mut self, mission_id: Option<&str>) -> CuResult<&mut CuGraph> {
2320        self.graphs.get_graph_mut(mission_id)
2321    }
2322
2323    #[allow(dead_code)]
2324    pub fn get_monitor_config(&self) -> Option<&MonitorConfig> {
2325        self.monitors.first()
2326    }
2327
2328    #[allow(dead_code)]
2329    pub fn get_monitor_configs(&self) -> &[MonitorConfig] {
2330        &self.monitors
2331    }
2332
2333    #[allow(dead_code)]
2334    pub fn get_runtime_config(&self) -> Option<&RuntimeConfig> {
2335        self.runtime.as_ref()
2336    }
2337
2338    #[allow(dead_code)]
2339    pub fn find_task_node(&self, mission_id: Option<&str>, task_id: &str) -> Option<&Node> {
2340        self.get_graph(mission_id)
2341            .ok()?
2342            .get_all_nodes()
2343            .into_iter()
2344            .find_map(|(_, node)| {
2345                (node.get_flavor() == Flavor::Task && node.id == task_id).then_some(node)
2346            })
2347    }
2348
2349    #[allow(dead_code)]
2350    pub fn find_logging_codec_spec(&self, codec_id: &str) -> Option<&LoggingCodecSpec> {
2351        self.logging
2352            .as_ref()?
2353            .codecs
2354            .iter()
2355            .find(|spec| spec.id == codec_id)
2356    }
2357
2358    /// Validate the logging configuration to ensure section pre-allocation sizes do not exceed slab sizes.
2359    /// This method is wrapper around [LoggingConfig::validate]
2360    pub fn validate_logging_config(&self) -> CuResult<()> {
2361        if let Some(logging) = &self.logging {
2362            return logging.validate();
2363        }
2364        Ok(())
2365    }
2366
2367    /// Validate the runtime configuration.
2368    pub fn validate_runtime_config(&self) -> CuResult<()> {
2369        if let Some(runtime) = &self.runtime {
2370            return runtime.validate();
2371        }
2372        Ok(())
2373    }
2374}
2375
2376#[cfg(feature = "std")]
2377#[derive(Default)]
2378pub(crate) struct PortLookup {
2379    pub inputs: HashMap<String, String>,
2380    pub outputs: HashMap<String, String>,
2381    pub default_input: Option<String>,
2382    pub default_output: Option<String>,
2383}
2384
2385#[cfg(feature = "std")]
2386#[derive(Clone)]
2387pub(crate) struct RenderNode {
2388    pub id: String,
2389    pub type_name: String,
2390    pub flavor: Flavor,
2391    pub inputs: Vec<String>,
2392    pub outputs: Vec<String>,
2393}
2394
2395#[cfg(feature = "std")]
2396#[derive(Clone)]
2397pub(crate) struct RenderConnection {
2398    pub src: String,
2399    pub src_port: Option<String>,
2400    #[allow(dead_code)]
2401    pub src_channel: Option<String>,
2402    pub dst: String,
2403    pub dst_port: Option<String>,
2404    #[allow(dead_code)]
2405    pub dst_channel: Option<String>,
2406    pub msg: String,
2407}
2408
2409#[cfg(feature = "std")]
2410pub(crate) struct RenderTopology {
2411    pub nodes: Vec<RenderNode>,
2412    pub connections: Vec<RenderConnection>,
2413}
2414
2415#[cfg(feature = "std")]
2416impl RenderTopology {
2417    pub fn sort_connections(&mut self) {
2418        self.connections.sort_by(|a, b| {
2419            a.src
2420                .cmp(&b.src)
2421                .then(a.dst.cmp(&b.dst))
2422                .then(a.msg.cmp(&b.msg))
2423        });
2424    }
2425}
2426
2427#[cfg(feature = "std")]
2428#[allow(dead_code)]
2429struct RenderSection<'a> {
2430    label: Option<String>,
2431    graph: &'a CuGraph,
2432}
2433
2434#[cfg(feature = "std")]
2435impl CuConfig {
2436    #[allow(dead_code)]
2437    fn render_section(
2438        &self,
2439        output: &mut dyn std::io::Write,
2440        graph: &CuGraph,
2441        label: Option<&str>,
2442    ) -> CuResult<()> {
2443        use std::fmt::Write as FmtWrite;
2444
2445        let mut topology = build_render_topology(graph, &self.bridges);
2446        topology.nodes.sort_by(|a, b| a.id.cmp(&b.id));
2447        topology.sort_connections();
2448
2449        let cluster_id = label.map(|lbl| format!("cluster_{}", sanitize_identifier(lbl)));
2450        if let Some(ref cluster_id) = cluster_id {
2451            writeln!(output, "    subgraph \"{cluster_id}\" {{")
2452                .map_err(|e| CuError::new_with_cause("Failed to write render output", e))?;
2453            writeln!(
2454                output,
2455                "        label=<<B>Mission: {}</B>>;",
2456                encode_text(label.unwrap())
2457            )
2458            .map_err(|e| CuError::new_with_cause("Failed to write render output", e))?;
2459            writeln!(
2460                output,
2461                "        labelloc=t; labeljust=l; color=\"#bbbbbb\"; style=\"rounded\"; margin=20;"
2462            )
2463            .map_err(|e| CuError::new_with_cause("Failed to write render output", e))?;
2464        }
2465        let indent = if cluster_id.is_some() {
2466            "        "
2467        } else {
2468            "    "
2469        };
2470        let node_prefix = label
2471            .map(|lbl| format!("{}__", sanitize_identifier(lbl)))
2472            .unwrap_or_default();
2473
2474        let mut port_lookup: HashMap<String, PortLookup> = HashMap::new();
2475        let mut id_lookup: HashMap<String, String> = HashMap::new();
2476
2477        for node in &topology.nodes {
2478            let node_idx = graph
2479                .get_node_id_by_name(node.id.as_str())
2480                .ok_or_else(|| CuError::from(format!("Node '{}' missing from graph", node.id)))?;
2481            let node_weight = graph
2482                .get_node(node_idx)
2483                .ok_or_else(|| CuError::from(format!("Node '{}' missing weight", node.id)))?;
2484
2485            let is_src = graph.get_dst_edges(node_idx).unwrap_or_default().is_empty();
2486            let is_sink = graph.get_src_edges(node_idx).unwrap_or_default().is_empty();
2487
2488            let fillcolor = match node.flavor {
2489                Flavor::Bridge => "#faedcd",
2490                Flavor::Task if is_src => "#ddefc7",
2491                Flavor::Task if is_sink => "#cce0ff",
2492                _ => "#f2f2f2",
2493            };
2494
2495            let port_base = format!("{}{}", node_prefix, sanitize_identifier(&node.id));
2496            let (inputs_table, input_map, default_input) =
2497                build_port_table("Inputs", &node.inputs, &port_base, "in");
2498            let (outputs_table, output_map, default_output) =
2499                build_port_table("Outputs", &node.outputs, &port_base, "out");
2500            let config_html = node_weight.config.as_ref().and_then(build_config_table);
2501
2502            let mut label_html = String::new();
2503            write!(
2504                label_html,
2505                "<TABLE BORDER=\"0\" CELLBORDER=\"1\" CELLSPACING=\"0\" CELLPADDING=\"6\" COLOR=\"gray\" BGCOLOR=\"white\">"
2506            )
2507            .unwrap();
2508            write!(
2509                label_html,
2510                "<TR><TD COLSPAN=\"2\" ALIGN=\"LEFT\" BGCOLOR=\"{fillcolor}\"><FONT POINT-SIZE=\"12\"><B>{}</B></FONT><BR/><FONT COLOR=\"dimgray\">[{}]</FONT></TD></TR>",
2511                encode_text(&node.id),
2512                encode_text(&node.type_name)
2513            )
2514            .unwrap();
2515            write!(
2516                label_html,
2517                "<TR><TD ALIGN=\"LEFT\" VALIGN=\"TOP\">{inputs_table}</TD><TD ALIGN=\"LEFT\" VALIGN=\"TOP\">{outputs_table}</TD></TR>"
2518            )
2519            .unwrap();
2520
2521            if let Some(config_html) = config_html {
2522                write!(
2523                    label_html,
2524                    "<TR><TD COLSPAN=\"2\" ALIGN=\"LEFT\">{config_html}</TD></TR>"
2525                )
2526                .unwrap();
2527            }
2528
2529            label_html.push_str("</TABLE>");
2530
2531            let identifier_raw = if node_prefix.is_empty() {
2532                node.id.clone()
2533            } else {
2534                format!("{node_prefix}{}", node.id)
2535            };
2536            let identifier = escape_dot_id(&identifier_raw);
2537            writeln!(output, "{indent}\"{identifier}\" [label=<{label_html}>];")
2538                .map_err(|e| CuError::new_with_cause("Failed to write render output", e))?;
2539
2540            id_lookup.insert(node.id.clone(), identifier);
2541            port_lookup.insert(
2542                node.id.clone(),
2543                PortLookup {
2544                    inputs: input_map,
2545                    outputs: output_map,
2546                    default_input,
2547                    default_output,
2548                },
2549            );
2550        }
2551
2552        for cnx in &topology.connections {
2553            let src_id = id_lookup
2554                .get(&cnx.src)
2555                .ok_or_else(|| CuError::from(format!("Unknown node '{}'", cnx.src)))?;
2556            let dst_id = id_lookup
2557                .get(&cnx.dst)
2558                .ok_or_else(|| CuError::from(format!("Unknown node '{}'", cnx.dst)))?;
2559            let src_suffix = port_lookup
2560                .get(&cnx.src)
2561                .and_then(|lookup| lookup.resolve_output(cnx.src_port.as_deref()))
2562                .map(|port| format!(":\"{port}\":e"))
2563                .unwrap_or_default();
2564            let dst_suffix = port_lookup
2565                .get(&cnx.dst)
2566                .and_then(|lookup| lookup.resolve_input(cnx.dst_port.as_deref()))
2567                .map(|port| format!(":\"{port}\":w"))
2568                .unwrap_or_default();
2569            let msg = encode_text(&cnx.msg);
2570            writeln!(
2571                output,
2572                "{indent}\"{src_id}\"{src_suffix} -> \"{dst_id}\"{dst_suffix} [label=< <B><FONT COLOR=\"gray\">{msg}</FONT></B> >];"
2573            )
2574            .map_err(|e| CuError::new_with_cause("Failed to write render output", e))?;
2575        }
2576
2577        if cluster_id.is_some() {
2578            writeln!(output, "    }}")
2579                .map_err(|e| CuError::new_with_cause("Failed to write render output", e))?;
2580        }
2581
2582        Ok(())
2583    }
2584}
2585
2586#[cfg(feature = "std")]
2587pub(crate) fn build_render_topology(graph: &CuGraph, bridges: &[BridgeConfig]) -> RenderTopology {
2588    let mut bridge_lookup = HashMap::new();
2589    for bridge in bridges {
2590        bridge_lookup.insert(bridge.id.as_str(), bridge);
2591    }
2592
2593    let mut nodes: Vec<RenderNode> = Vec::new();
2594    let mut node_lookup: HashMap<String, usize> = HashMap::new();
2595    for (_, node) in graph.get_all_nodes() {
2596        let node_id = node.get_id();
2597        let mut inputs = Vec::new();
2598        let mut outputs = Vec::new();
2599        if node.get_flavor() == Flavor::Bridge
2600            && let Some(bridge) = bridge_lookup.get(node_id.as_str())
2601        {
2602            for channel in &bridge.channels {
2603                match channel {
2604                    // Rx brings data from the bridge into the graph, so treat it as an output.
2605                    BridgeChannelConfigRepresentation::Rx { id, .. } => outputs.push(id.clone()),
2606                    // Tx consumes data from the graph heading into the bridge, so show it on the input side.
2607                    BridgeChannelConfigRepresentation::Tx { id, .. } => inputs.push(id.clone()),
2608                }
2609            }
2610        }
2611
2612        node_lookup.insert(node_id.clone(), nodes.len());
2613        nodes.push(RenderNode {
2614            id: node_id,
2615            type_name: node.get_type().to_string(),
2616            flavor: node.get_flavor(),
2617            inputs,
2618            outputs,
2619        });
2620    }
2621
2622    let mut output_port_lookup: Vec<HashMap<String, String>> = vec![HashMap::new(); nodes.len()];
2623    let mut output_edges: Vec<_> = graph.0.edge_references().collect();
2624    output_edges.sort_by_key(|edge| edge.id().index());
2625    for edge in output_edges {
2626        let cnx = edge.weight();
2627        if let Some(&idx) = node_lookup.get(&cnx.src)
2628            && nodes[idx].flavor == Flavor::Task
2629            && cnx.src_channel.is_none()
2630        {
2631            let port_map = &mut output_port_lookup[idx];
2632            if !port_map.contains_key(&cnx.msg) {
2633                let label = format!("out{}: {}", port_map.len(), cnx.msg);
2634                port_map.insert(cnx.msg.clone(), label.clone());
2635                nodes[idx].outputs.push(label);
2636            }
2637        }
2638    }
2639
2640    let mut auto_input_counts = vec![0usize; nodes.len()];
2641    for edge in graph.0.edge_references() {
2642        let cnx = edge.weight();
2643        if let Some(&idx) = node_lookup.get(&cnx.dst)
2644            && nodes[idx].flavor == Flavor::Task
2645            && cnx.dst_channel.is_none()
2646        {
2647            auto_input_counts[idx] += 1;
2648        }
2649    }
2650
2651    let mut next_auto_input = vec![0usize; nodes.len()];
2652    let mut connections = Vec::new();
2653    for edge in graph.0.edge_references() {
2654        let cnx = edge.weight();
2655        let mut src_port = cnx.src_channel.clone();
2656        let mut dst_port = cnx.dst_channel.clone();
2657
2658        if let Some(&idx) = node_lookup.get(&cnx.src) {
2659            let node = &mut nodes[idx];
2660            if node.flavor == Flavor::Task && src_port.is_none() {
2661                src_port = output_port_lookup[idx].get(&cnx.msg).cloned();
2662            }
2663        }
2664        if let Some(&idx) = node_lookup.get(&cnx.dst) {
2665            let node = &mut nodes[idx];
2666            if node.flavor == Flavor::Task && dst_port.is_none() {
2667                let count = auto_input_counts[idx];
2668                let next = if count <= 1 {
2669                    "in".to_string()
2670                } else {
2671                    let next = format!("in.{}", next_auto_input[idx]);
2672                    next_auto_input[idx] += 1;
2673                    next
2674                };
2675                node.inputs.push(next.clone());
2676                dst_port = Some(next);
2677            }
2678        }
2679
2680        connections.push(RenderConnection {
2681            src: cnx.src.clone(),
2682            src_port,
2683            src_channel: cnx.src_channel.clone(),
2684            dst: cnx.dst.clone(),
2685            dst_port,
2686            dst_channel: cnx.dst_channel.clone(),
2687            msg: cnx.msg.clone(),
2688        });
2689    }
2690
2691    RenderTopology { nodes, connections }
2692}
2693
2694#[cfg(feature = "std")]
2695impl PortLookup {
2696    pub fn resolve_input(&self, name: Option<&str>) -> Option<&str> {
2697        if let Some(name) = name
2698            && let Some(port) = self.inputs.get(name)
2699        {
2700            return Some(port.as_str());
2701        }
2702        self.default_input.as_deref()
2703    }
2704
2705    pub fn resolve_output(&self, name: Option<&str>) -> Option<&str> {
2706        if let Some(name) = name
2707            && let Some(port) = self.outputs.get(name)
2708        {
2709            return Some(port.as_str());
2710        }
2711        self.default_output.as_deref()
2712    }
2713}
2714
2715#[cfg(feature = "std")]
2716#[allow(dead_code)]
2717fn build_port_table(
2718    title: &str,
2719    names: &[String],
2720    base_id: &str,
2721    prefix: &str,
2722) -> (String, HashMap<String, String>, Option<String>) {
2723    use std::fmt::Write as FmtWrite;
2724
2725    let mut html = String::new();
2726    write!(
2727        html,
2728        "<TABLE BORDER=\"0\" CELLBORDER=\"0\" CELLSPACING=\"0\" CELLPADDING=\"1\">"
2729    )
2730    .unwrap();
2731    write!(
2732        html,
2733        "<TR><TD ALIGN=\"LEFT\"><FONT COLOR=\"dimgray\">{}</FONT></TD></TR>",
2734        encode_text(title)
2735    )
2736    .unwrap();
2737
2738    let mut lookup = HashMap::new();
2739    let mut default_port = None;
2740
2741    if names.is_empty() {
2742        html.push_str("<TR><TD ALIGN=\"LEFT\"><FONT COLOR=\"lightgray\">&mdash;</FONT></TD></TR>");
2743    } else {
2744        for (idx, name) in names.iter().enumerate() {
2745            let port_id = format!("{base_id}_{prefix}_{idx}");
2746            write!(
2747                html,
2748                "<TR><TD PORT=\"{port_id}\" ALIGN=\"LEFT\">{}</TD></TR>",
2749                encode_text(name)
2750            )
2751            .unwrap();
2752            lookup.insert(name.clone(), port_id.clone());
2753            if idx == 0 {
2754                default_port = Some(port_id);
2755            }
2756        }
2757    }
2758
2759    html.push_str("</TABLE>");
2760    (html, lookup, default_port)
2761}
2762
2763#[cfg(feature = "std")]
2764#[allow(dead_code)]
2765fn build_config_table(config: &ComponentConfig) -> Option<String> {
2766    use std::fmt::Write as FmtWrite;
2767
2768    if config.0.is_empty() {
2769        return None;
2770    }
2771
2772    let mut entries: Vec<_> = config.0.iter().collect();
2773    entries.sort_by(|a, b| a.0.cmp(b.0));
2774
2775    let mut html = String::new();
2776    html.push_str("<TABLE BORDER=\"0\" CELLBORDER=\"0\" CELLSPACING=\"0\" CELLPADDING=\"1\">");
2777    for (key, value) in entries {
2778        let value_txt = format!("{value}");
2779        write!(
2780            html,
2781            "<TR><TD ALIGN=\"LEFT\"><FONT COLOR=\"dimgray\">{}</FONT> = {}</TD></TR>",
2782            encode_text(key),
2783            encode_text(&value_txt)
2784        )
2785        .unwrap();
2786    }
2787    html.push_str("</TABLE>");
2788    Some(html)
2789}
2790
2791#[cfg(feature = "std")]
2792#[allow(dead_code)]
2793fn sanitize_identifier(value: &str) -> String {
2794    value
2795        .chars()
2796        .map(|c| if c.is_ascii_alphanumeric() { c } else { '_' })
2797        .collect()
2798}
2799
2800#[cfg(feature = "std")]
2801#[allow(dead_code)]
2802fn escape_dot_id(value: &str) -> String {
2803    let mut escaped = String::with_capacity(value.len());
2804    for ch in value.chars() {
2805        match ch {
2806            '"' => escaped.push_str("\\\""),
2807            '\\' => escaped.push_str("\\\\"),
2808            _ => escaped.push(ch),
2809        }
2810    }
2811    escaped
2812}
2813
2814impl LoggingConfig {
2815    /// Validate the logging configuration to ensure section pre-allocation sizes do not exceed slab sizes.
2816    pub fn validate(&self) -> CuResult<()> {
2817        if let Some(copperlist_count) = self.copperlist_count
2818            && copperlist_count == 0
2819        {
2820            return Err(CuError::from(
2821                "CopperList count cannot be zero. Set logging.copperlist_count to at least 1.",
2822            ));
2823        }
2824
2825        if let Some(section_size_mib) = self.section_size_mib
2826            && let Some(slab_size_mib) = self.slab_size_mib
2827            && section_size_mib > slab_size_mib
2828        {
2829            return Err(CuError::from(format!(
2830                "Section size ({section_size_mib} MiB) cannot be larger than slab size ({slab_size_mib} MiB). Adjust the parameters accordingly."
2831            )));
2832        }
2833
2834        let mut codec_ids = HashMap::new();
2835        for codec in &self.codecs {
2836            if codec_ids.insert(codec.id.as_str(), ()).is_some() {
2837                return Err(CuError::from(format!(
2838                    "Duplicate logging codec id '{}'. Codec ids must be unique.",
2839                    codec.id
2840                )));
2841            }
2842        }
2843
2844        Ok(())
2845    }
2846}
2847
2848impl RuntimeConfig {
2849    /// Validate runtime loop-rate settings.
2850    pub fn validate(&self) -> CuResult<()> {
2851        if let Some(rate_target_hz) = self.rate_target_hz {
2852            if rate_target_hz == 0 {
2853                return Err(CuError::from(
2854                    "Runtime rate target cannot be zero. Set runtime.rate_target_hz to at least 1.",
2855                ));
2856            }
2857
2858            if rate_target_hz > MAX_RATE_TARGET_HZ {
2859                return Err(CuError::from(format!(
2860                    "Runtime rate target ({rate_target_hz} Hz) exceeds the supported maximum of {MAX_RATE_TARGET_HZ} Hz."
2861                )));
2862            }
2863        }
2864
2865        Ok(())
2866    }
2867}
2868
2869#[allow(dead_code)] // dead in no-std
2870fn substitute_parameters(content: &str, params: &HashMap<String, Value>) -> String {
2871    let mut result = content.to_string();
2872
2873    for (key, value) in params {
2874        let pattern = format!("{{{{{key}}}}}");
2875        result = result.replace(&pattern, &value.to_string());
2876    }
2877
2878    result
2879}
2880
2881/// Returns a merged CuConfigRepresentation.
2882#[cfg(feature = "std")]
2883fn process_includes(
2884    file_path: &str,
2885    base_representation: CuConfigRepresentation,
2886    processed_files: &mut Vec<String>,
2887) -> CuResult<CuConfigRepresentation> {
2888    // Note: Circular dependency detection removed
2889    processed_files.push(file_path.to_string());
2890
2891    let mut result = base_representation;
2892
2893    if let Some(includes) = result.includes.take() {
2894        for include in includes {
2895            let include_path = if include.path.starts_with('/') {
2896                include.path.clone()
2897            } else {
2898                let current_dir = std::path::Path::new(file_path).parent();
2899
2900                match current_dir.map(|path| path.to_string_lossy().to_string()) {
2901                    Some(current_dir) if !current_dir.is_empty() => {
2902                        format!("{}/{}", current_dir, include.path)
2903                    }
2904                    _ => include.path,
2905                }
2906            };
2907
2908            let include_content = read_to_string(&include_path).map_err(|e| {
2909                CuError::from(format!("Failed to read include file: {include_path}"))
2910                    .add_cause(e.to_string().as_str())
2911            })?;
2912
2913            let processed_content = substitute_parameters(&include_content, &include.params);
2914
2915            let mut included_representation: CuConfigRepresentation = match Options::default()
2916                .with_default_extension(Extensions::IMPLICIT_SOME)
2917                .with_default_extension(Extensions::UNWRAP_NEWTYPES)
2918                .with_default_extension(Extensions::UNWRAP_VARIANT_NEWTYPES)
2919                .from_str(&processed_content)
2920            {
2921                Ok(rep) => rep,
2922                Err(e) => {
2923                    return Err(CuError::from(format!(
2924                        "Failed to parse include file: {} - Error: {} at position {}",
2925                        include_path, e.code, e.span
2926                    )));
2927                }
2928            };
2929
2930            included_representation =
2931                process_includes(&include_path, included_representation, processed_files)?;
2932
2933            if let Some(included_tasks) = included_representation.tasks {
2934                if result.tasks.is_none() {
2935                    result.tasks = Some(included_tasks);
2936                } else {
2937                    let mut tasks = result.tasks.take().unwrap();
2938                    for included_task in included_tasks {
2939                        if !tasks.iter().any(|t| t.id == included_task.id) {
2940                            tasks.push(included_task);
2941                        }
2942                    }
2943                    result.tasks = Some(tasks);
2944                }
2945            }
2946
2947            if let Some(included_bridges) = included_representation.bridges {
2948                if result.bridges.is_none() {
2949                    result.bridges = Some(included_bridges);
2950                } else {
2951                    let mut bridges = result.bridges.take().unwrap();
2952                    for included_bridge in included_bridges {
2953                        if !bridges.iter().any(|b| b.id == included_bridge.id) {
2954                            bridges.push(included_bridge);
2955                        }
2956                    }
2957                    result.bridges = Some(bridges);
2958                }
2959            }
2960
2961            if let Some(included_resources) = included_representation.resources {
2962                if result.resources.is_none() {
2963                    result.resources = Some(included_resources);
2964                } else {
2965                    let mut resources = result.resources.take().unwrap();
2966                    for included_resource in included_resources {
2967                        if !resources.iter().any(|r| r.id == included_resource.id) {
2968                            resources.push(included_resource);
2969                        }
2970                    }
2971                    result.resources = Some(resources);
2972                }
2973            }
2974
2975            if let Some(included_cnx) = included_representation.cnx {
2976                if result.cnx.is_none() {
2977                    result.cnx = Some(included_cnx);
2978                } else {
2979                    let mut cnx = result.cnx.take().unwrap();
2980                    for included_c in included_cnx {
2981                        if !cnx
2982                            .iter()
2983                            .any(|c| c.src == included_c.src && c.dst == included_c.dst)
2984                        {
2985                            cnx.push(included_c);
2986                        }
2987                    }
2988                    result.cnx = Some(cnx);
2989                }
2990            }
2991
2992            if let Some(included_monitors) = included_representation.monitors {
2993                if result.monitors.is_none() {
2994                    result.monitors = Some(included_monitors);
2995                } else {
2996                    let mut monitors = result.monitors.take().unwrap();
2997                    for included_monitor in included_monitors {
2998                        if !monitors.iter().any(|m| m.type_ == included_monitor.type_) {
2999                            monitors.push(included_monitor);
3000                        }
3001                    }
3002                    result.monitors = Some(monitors);
3003                }
3004            }
3005
3006            if result.logging.is_none() {
3007                result.logging = included_representation.logging;
3008            }
3009
3010            if result.runtime.is_none() {
3011                result.runtime = included_representation.runtime;
3012            }
3013
3014            if let Some(included_missions) = included_representation.missions {
3015                if result.missions.is_none() {
3016                    result.missions = Some(included_missions);
3017                } else {
3018                    let mut missions = result.missions.take().unwrap();
3019                    for included_mission in included_missions {
3020                        if !missions.iter().any(|m| m.id == included_mission.id) {
3021                            missions.push(included_mission);
3022                        }
3023                    }
3024                    result.missions = Some(missions);
3025                }
3026            }
3027        }
3028    }
3029
3030    Ok(result)
3031}
3032
3033#[cfg(feature = "std")]
3034fn parse_instance_config_overrides_string(
3035    content: &str,
3036) -> CuResult<InstanceConfigOverridesRepresentation> {
3037    Options::default()
3038        .with_default_extension(Extensions::IMPLICIT_SOME)
3039        .with_default_extension(Extensions::UNWRAP_NEWTYPES)
3040        .with_default_extension(Extensions::UNWRAP_VARIANT_NEWTYPES)
3041        .from_str(content)
3042        .map_err(|e| {
3043            CuError::from(format!(
3044                "Failed to parse instance override file: Error: {} at position {}",
3045                e.code, e.span
3046            ))
3047        })
3048}
3049
3050#[cfg(feature = "std")]
3051fn merge_component_config(target: &mut Option<ComponentConfig>, value: &ComponentConfig) {
3052    if let Some(existing) = target {
3053        existing.merge_from(value);
3054    } else {
3055        *target = Some(value.clone());
3056    }
3057}
3058
3059#[cfg(feature = "std")]
3060fn apply_task_config_override_to_graph(
3061    graph: &mut CuGraph,
3062    task_id: &str,
3063    value: &ComponentConfig,
3064) -> usize {
3065    let mut matches = 0usize;
3066    let node_indices: Vec<_> = graph.0.node_indices().collect();
3067    for node_index in node_indices {
3068        let node = &mut graph.0[node_index];
3069        if node.get_flavor() == Flavor::Task && node.id == task_id {
3070            merge_component_config(&mut node.config, value);
3071            matches += 1;
3072        }
3073    }
3074    matches
3075}
3076
3077#[cfg(feature = "std")]
3078fn apply_bridge_node_config_override_to_graph(
3079    graph: &mut CuGraph,
3080    bridge_id: &str,
3081    value: &ComponentConfig,
3082) {
3083    let node_indices: Vec<_> = graph.0.node_indices().collect();
3084    for node_index in node_indices {
3085        let node = &mut graph.0[node_index];
3086        if node.get_flavor() == Flavor::Bridge && node.id == bridge_id {
3087            merge_component_config(&mut node.config, value);
3088        }
3089    }
3090}
3091
3092#[cfg(feature = "std")]
3093fn parse_instance_override_target(path: &str) -> CuResult<(InstanceConfigTargetKind, String)> {
3094    let mut parts = path.split('/');
3095    let scope = parts.next().unwrap_or_default();
3096    let id = parts.next().unwrap_or_default();
3097    let leaf = parts.next().unwrap_or_default();
3098
3099    if scope.is_empty() || id.is_empty() || leaf.is_empty() || parts.next().is_some() {
3100        return Err(CuError::from(format!(
3101            "Invalid instance override path '{}'. Expected 'tasks/<id>/config', 'resources/<id>/config', or 'bridges/<id>/config'.",
3102            path
3103        )));
3104    }
3105
3106    if leaf != "config" {
3107        return Err(CuError::from(format!(
3108            "Invalid instance override path '{}'. Only the '/config' leaf is supported.",
3109            path
3110        )));
3111    }
3112
3113    let kind = match scope {
3114        "tasks" => InstanceConfigTargetKind::Task,
3115        "resources" => InstanceConfigTargetKind::Resource,
3116        "bridges" => InstanceConfigTargetKind::Bridge,
3117        _ => {
3118            return Err(CuError::from(format!(
3119                "Invalid instance override path '{}'. Supported roots are 'tasks', 'resources', and 'bridges'.",
3120                path
3121            )));
3122        }
3123    };
3124
3125    Ok((kind, id.to_string()))
3126}
3127
3128#[cfg(feature = "std")]
3129fn apply_instance_config_set_operation(
3130    config: &mut CuConfig,
3131    operation: &InstanceConfigSetOperation,
3132) -> CuResult<()> {
3133    let (target_kind, target_id) = parse_instance_override_target(&operation.path)?;
3134
3135    match target_kind {
3136        InstanceConfigTargetKind::Task => {
3137            let matches = match &mut config.graphs {
3138                ConfigGraphs::Simple(graph) => {
3139                    apply_task_config_override_to_graph(graph, &target_id, &operation.value)
3140                }
3141                ConfigGraphs::Missions(graphs) => graphs
3142                    .values_mut()
3143                    .map(|graph| {
3144                        apply_task_config_override_to_graph(graph, &target_id, &operation.value)
3145                    })
3146                    .sum(),
3147            };
3148
3149            if matches == 0 {
3150                return Err(CuError::from(format!(
3151                    "Instance override path '{}' targets unknown task '{}'.",
3152                    operation.path, target_id
3153                )));
3154            }
3155        }
3156        InstanceConfigTargetKind::Resource => {
3157            let mut matches = 0usize;
3158            for resource in &mut config.resources {
3159                if resource.id == target_id {
3160                    merge_component_config(&mut resource.config, &operation.value);
3161                    matches += 1;
3162                }
3163            }
3164            if matches == 0 {
3165                return Err(CuError::from(format!(
3166                    "Instance override path '{}' targets unknown resource '{}'.",
3167                    operation.path, target_id
3168                )));
3169            }
3170        }
3171        InstanceConfigTargetKind::Bridge => {
3172            let mut matches = 0usize;
3173            for bridge in &mut config.bridges {
3174                if bridge.id == target_id {
3175                    merge_component_config(&mut bridge.config, &operation.value);
3176                    matches += 1;
3177                }
3178            }
3179            if matches == 0 {
3180                return Err(CuError::from(format!(
3181                    "Instance override path '{}' targets unknown bridge '{}'.",
3182                    operation.path, target_id
3183                )));
3184            }
3185
3186            match &mut config.graphs {
3187                ConfigGraphs::Simple(graph) => {
3188                    apply_bridge_node_config_override_to_graph(graph, &target_id, &operation.value);
3189                }
3190                ConfigGraphs::Missions(graphs) => {
3191                    for graph in graphs.values_mut() {
3192                        apply_bridge_node_config_override_to_graph(
3193                            graph,
3194                            &target_id,
3195                            &operation.value,
3196                        );
3197                    }
3198                }
3199            }
3200        }
3201    }
3202
3203    Ok(())
3204}
3205
3206#[cfg(feature = "std")]
3207fn apply_instance_overrides(
3208    config: &mut CuConfig,
3209    overrides: &InstanceConfigOverridesRepresentation,
3210) -> CuResult<()> {
3211    for operation in &overrides.set {
3212        apply_instance_config_set_operation(config, operation)?;
3213    }
3214    Ok(())
3215}
3216
3217#[cfg(feature = "std")]
3218fn apply_instance_overrides_from_file(
3219    config: &mut CuConfig,
3220    override_path: &std::path::Path,
3221) -> CuResult<()> {
3222    let override_content = read_to_string(override_path).map_err(|e| {
3223        CuError::from(format!(
3224            "Failed to read instance override file '{}'",
3225            override_path.display()
3226        ))
3227        .add_cause(e.to_string().as_str())
3228    })?;
3229    let overrides = parse_instance_config_overrides_string(&override_content).map_err(|e| {
3230        CuError::from(format!(
3231            "Failed to parse instance override file '{}': {e}",
3232            override_path.display()
3233        ))
3234    })?;
3235    apply_instance_overrides(config, &overrides)
3236}
3237
3238#[cfg(feature = "std")]
3239#[allow(dead_code)]
3240fn parse_multi_config_string(content: &str) -> CuResult<MultiCopperConfigRepresentation> {
3241    Options::default()
3242        .with_default_extension(Extensions::IMPLICIT_SOME)
3243        .with_default_extension(Extensions::UNWRAP_NEWTYPES)
3244        .with_default_extension(Extensions::UNWRAP_VARIANT_NEWTYPES)
3245        .from_str(content)
3246        .map_err(|e| {
3247            CuError::from(format!(
3248                "Failed to parse multi-Copper configuration: Error: {} at position {}",
3249                e.code, e.span
3250            ))
3251        })
3252}
3253
3254#[cfg(feature = "std")]
3255#[allow(dead_code)]
3256fn resolve_relative_config_path(base_path: Option<&str>, referenced_path: &str) -> String {
3257    if referenced_path.starts_with('/') || base_path.is_none() {
3258        return referenced_path.to_string();
3259    }
3260
3261    let current_dir = std::path::Path::new(base_path.expect("checked above"))
3262        .parent()
3263        .unwrap_or_else(|| std::path::Path::new(""))
3264        .to_path_buf();
3265    current_dir
3266        .join(referenced_path)
3267        .to_string_lossy()
3268        .to_string()
3269}
3270
3271#[cfg(feature = "std")]
3272#[allow(dead_code)]
3273fn parse_multi_endpoint(endpoint: &str) -> CuResult<MultiCopperEndpoint> {
3274    let mut parts = endpoint.split('/');
3275    let subsystem_id = parts.next().unwrap_or_default();
3276    let bridge_id = parts.next().unwrap_or_default();
3277    let channel_id = parts.next().unwrap_or_default();
3278
3279    if subsystem_id.is_empty()
3280        || bridge_id.is_empty()
3281        || channel_id.is_empty()
3282        || parts.next().is_some()
3283    {
3284        return Err(CuError::from(format!(
3285            "Invalid multi-Copper endpoint '{endpoint}'. Expected 'subsystem/bridge/channel'."
3286        )));
3287    }
3288
3289    Ok(MultiCopperEndpoint {
3290        subsystem_id: subsystem_id.to_string(),
3291        bridge_id: bridge_id.to_string(),
3292        channel_id: channel_id.to_string(),
3293    })
3294}
3295
3296#[cfg(feature = "std")]
3297#[allow(dead_code)]
3298fn multi_channel_key(bridge_id: &str, channel_id: &str) -> String {
3299    format!("{bridge_id}/{channel_id}")
3300}
3301
3302#[cfg(feature = "std")]
3303#[allow(dead_code)]
3304fn register_multi_channel_msg(
3305    contracts: &mut HashMap<String, MultiCopperChannelContract>,
3306    bridge_id: &str,
3307    channel_id: &str,
3308    expected_direction: MultiCopperChannelDirection,
3309    msg: &str,
3310) -> CuResult<()> {
3311    let key = multi_channel_key(bridge_id, channel_id);
3312    let contract = contracts.get_mut(&key).ok_or_else(|| {
3313        CuError::from(format!(
3314            "Bridge channel '{bridge_id}/{channel_id}' is referenced by the graph but not declared in the bridge config."
3315        ))
3316    })?;
3317
3318    if contract.direction != expected_direction {
3319        let expected = match expected_direction {
3320            MultiCopperChannelDirection::Rx => "Rx",
3321            MultiCopperChannelDirection::Tx => "Tx",
3322        };
3323        return Err(CuError::from(format!(
3324            "Bridge channel '{bridge_id}/{channel_id}' is used as {expected} in the graph but declared with the opposite direction."
3325        )));
3326    }
3327
3328    match &contract.msg {
3329        Some(existing) if existing != msg => Err(CuError::from(format!(
3330            "Bridge channel '{bridge_id}/{channel_id}' carries inconsistent message types '{existing}' and '{msg}'."
3331        ))),
3332        Some(_) => Ok(()),
3333        None => {
3334            contract.msg = Some(msg.to_string());
3335            Ok(())
3336        }
3337    }
3338}
3339
3340#[cfg(feature = "std")]
3341#[allow(dead_code)]
3342fn build_multi_bridge_channel_contracts(
3343    config: &CuConfig,
3344) -> CuResult<HashMap<String, MultiCopperChannelContract>> {
3345    let graph = config.graphs.get_default_mission_graph().map_err(|e| {
3346        CuError::from(format!(
3347            "Multi-Copper subsystem configs currently require exactly one local graph: {e}"
3348        ))
3349    })?;
3350
3351    let mut contracts = HashMap::new();
3352    for bridge in &config.bridges {
3353        for channel in &bridge.channels {
3354            let (channel_id, direction) = match channel {
3355                BridgeChannelConfigRepresentation::Rx { id, .. } => {
3356                    (id.as_str(), MultiCopperChannelDirection::Rx)
3357                }
3358                BridgeChannelConfigRepresentation::Tx { id, .. } => {
3359                    (id.as_str(), MultiCopperChannelDirection::Tx)
3360                }
3361            };
3362
3363            let key = multi_channel_key(&bridge.id, channel_id);
3364            if contracts.contains_key(&key) {
3365                return Err(CuError::from(format!(
3366                    "Duplicate bridge channel declaration for '{key}'."
3367                )));
3368            }
3369
3370            contracts.insert(
3371                key,
3372                MultiCopperChannelContract {
3373                    bridge_type: bridge.type_.clone(),
3374                    direction,
3375                    msg: None,
3376                },
3377            );
3378        }
3379    }
3380
3381    for edge in graph.edges() {
3382        if let Some(channel_id) = &edge.src_channel {
3383            register_multi_channel_msg(
3384                &mut contracts,
3385                &edge.src,
3386                channel_id,
3387                MultiCopperChannelDirection::Rx,
3388                &edge.msg,
3389            )?;
3390        }
3391        if let Some(channel_id) = &edge.dst_channel {
3392            register_multi_channel_msg(
3393                &mut contracts,
3394                &edge.dst,
3395                channel_id,
3396                MultiCopperChannelDirection::Tx,
3397                &edge.msg,
3398            )?;
3399        }
3400    }
3401
3402    Ok(contracts)
3403}
3404
3405#[cfg(feature = "std")]
3406#[allow(dead_code)]
3407fn validate_multi_config_representation(
3408    representation: MultiCopperConfigRepresentation,
3409    file_path: Option<&str>,
3410) -> CuResult<MultiCopperConfig> {
3411    if representation
3412        .instance_overrides_root
3413        .as_ref()
3414        .is_some_and(|root| root.trim().is_empty())
3415    {
3416        return Err(CuError::from(
3417            "Multi-Copper instance_overrides_root must not be empty.",
3418        ));
3419    }
3420
3421    if representation.subsystems.is_empty() {
3422        return Err(CuError::from(
3423            "Multi-Copper config must declare at least one subsystem.",
3424        ));
3425    }
3426    if representation.subsystems.len() > usize::from(u16::MAX) + 1 {
3427        return Err(CuError::from(
3428            "Multi-Copper config supports at most 65536 distinct subsystem ids.",
3429        ));
3430    }
3431
3432    let mut seen_subsystems = std::collections::HashSet::new();
3433    for subsystem in &representation.subsystems {
3434        if subsystem.id.trim().is_empty() {
3435            return Err(CuError::from(
3436                "Multi-Copper subsystem ids must not be empty.",
3437            ));
3438        }
3439        if !seen_subsystems.insert(subsystem.id.clone()) {
3440            return Err(CuError::from(format!(
3441                "Duplicate multi-Copper subsystem id '{}'.",
3442                subsystem.id
3443            )));
3444        }
3445    }
3446
3447    let mut sorted_ids: Vec<_> = representation
3448        .subsystems
3449        .iter()
3450        .map(|subsystem| subsystem.id.clone())
3451        .collect();
3452    sorted_ids.sort();
3453    let subsystem_code_map: HashMap<_, _> = sorted_ids
3454        .into_iter()
3455        .enumerate()
3456        .map(|(idx, id)| {
3457            (
3458                id,
3459                u16::try_from(idx).expect("subsystem count was validated against u16 range"),
3460            )
3461        })
3462        .collect();
3463
3464    let mut subsystem_contracts: HashMap<String, HashMap<String, MultiCopperChannelContract>> =
3465        HashMap::new();
3466    let mut subsystems = Vec::with_capacity(representation.subsystems.len());
3467
3468    for subsystem in representation.subsystems {
3469        let resolved_config_path = resolve_relative_config_path(file_path, &subsystem.config);
3470        let config = read_configuration(&resolved_config_path).map_err(|e| {
3471            CuError::from(format!(
3472                "Failed to read subsystem '{}' from '{}': {e}",
3473                subsystem.id, resolved_config_path
3474            ))
3475        })?;
3476        let contracts = build_multi_bridge_channel_contracts(&config).map_err(|e| {
3477            CuError::from(format!(
3478                "Invalid subsystem '{}' for multi-Copper validation: {e}",
3479                subsystem.id
3480            ))
3481        })?;
3482        subsystem_contracts.insert(subsystem.id.clone(), contracts);
3483        subsystems.push(MultiCopperSubsystem {
3484            subsystem_code: *subsystem_code_map
3485                .get(&subsystem.id)
3486                .expect("subsystem code map must contain every subsystem"),
3487            id: subsystem.id,
3488            config_path: resolved_config_path,
3489            config,
3490        });
3491    }
3492
3493    let mut interconnects = Vec::with_capacity(representation.interconnects.len());
3494    for interconnect in representation.interconnects {
3495        let from = parse_multi_endpoint(&interconnect.from).map_err(|e| {
3496            CuError::from(format!(
3497                "Invalid multi-Copper interconnect source '{}': {e}",
3498                interconnect.from
3499            ))
3500        })?;
3501        let to = parse_multi_endpoint(&interconnect.to).map_err(|e| {
3502            CuError::from(format!(
3503                "Invalid multi-Copper interconnect destination '{}': {e}",
3504                interconnect.to
3505            ))
3506        })?;
3507
3508        let from_contracts = subsystem_contracts.get(&from.subsystem_id).ok_or_else(|| {
3509            CuError::from(format!(
3510                "Interconnect source '{}' references unknown subsystem '{}'.",
3511                from, from.subsystem_id
3512            ))
3513        })?;
3514        let to_contracts = subsystem_contracts.get(&to.subsystem_id).ok_or_else(|| {
3515            CuError::from(format!(
3516                "Interconnect destination '{}' references unknown subsystem '{}'.",
3517                to, to.subsystem_id
3518            ))
3519        })?;
3520
3521        let from_contract = from_contracts
3522            .get(&multi_channel_key(&from.bridge_id, &from.channel_id))
3523            .ok_or_else(|| {
3524                CuError::from(format!(
3525                    "Interconnect source '{}' references unknown bridge channel.",
3526                    from
3527                ))
3528            })?;
3529        let to_contract = to_contracts
3530            .get(&multi_channel_key(&to.bridge_id, &to.channel_id))
3531            .ok_or_else(|| {
3532                CuError::from(format!(
3533                    "Interconnect destination '{}' references unknown bridge channel.",
3534                    to
3535                ))
3536            })?;
3537
3538        if from_contract.direction != MultiCopperChannelDirection::Tx {
3539            return Err(CuError::from(format!(
3540                "Interconnect source '{}' must reference a Tx bridge channel.",
3541                from
3542            )));
3543        }
3544        if to_contract.direction != MultiCopperChannelDirection::Rx {
3545            return Err(CuError::from(format!(
3546                "Interconnect destination '{}' must reference an Rx bridge channel.",
3547                to
3548            )));
3549        }
3550
3551        if from_contract.bridge_type != to_contract.bridge_type {
3552            return Err(CuError::from(format!(
3553                "Interconnect '{}' -> '{}' mixes incompatible bridge types '{}' and '{}'.",
3554                from, to, from_contract.bridge_type, to_contract.bridge_type
3555            )));
3556        }
3557
3558        let from_msg = from_contract.msg.as_ref().ok_or_else(|| {
3559            CuError::from(format!(
3560                "Interconnect source '{}' is not wired inside subsystem '{}', so its message type cannot be inferred.",
3561                from, from.subsystem_id
3562            ))
3563        })?;
3564        let to_msg = to_contract.msg.as_ref().ok_or_else(|| {
3565            CuError::from(format!(
3566                "Interconnect destination '{}' is not wired inside subsystem '{}', so its message type cannot be inferred.",
3567                to, to.subsystem_id
3568            ))
3569        })?;
3570
3571        if from_msg != to_msg {
3572            return Err(CuError::from(format!(
3573                "Interconnect '{}' -> '{}' connects incompatible message types '{}' and '{}'.",
3574                from, to, from_msg, to_msg
3575            )));
3576        }
3577        if interconnect.msg != *from_msg {
3578            return Err(CuError::from(format!(
3579                "Interconnect '{}' -> '{}' declares message type '{}' but subsystem graphs require '{}'.",
3580                from, to, interconnect.msg, from_msg
3581            )));
3582        }
3583
3584        interconnects.push(MultiCopperInterconnect {
3585            from,
3586            to,
3587            msg: interconnect.msg,
3588            bridge_type: from_contract.bridge_type.clone(),
3589        });
3590    }
3591
3592    let instance_overrides_root = representation
3593        .instance_overrides_root
3594        .as_ref()
3595        .map(|root| resolve_relative_config_path(file_path, root));
3596
3597    Ok(MultiCopperConfig {
3598        subsystems,
3599        interconnects,
3600        instance_overrides_root,
3601    })
3602}
3603
3604/// Read a copper configuration from a file.
3605#[cfg(feature = "std")]
3606pub fn read_configuration(config_filename: &str) -> CuResult<CuConfig> {
3607    let config_content = read_to_string(config_filename).map_err(|e| {
3608        CuError::from(format!(
3609            "Failed to read configuration file: {:?}",
3610            &config_filename
3611        ))
3612        .add_cause(e.to_string().as_str())
3613    })?;
3614    read_configuration_str(config_content, Some(config_filename))
3615}
3616
3617/// Read a copper configuration from a String.
3618/// Parse a RON string into a CuConfigRepresentation, using the standard options.
3619/// Returns an error if the parsing fails.
3620fn parse_config_string(content: &str) -> CuResult<CuConfigRepresentation> {
3621    Options::default()
3622        .with_default_extension(Extensions::IMPLICIT_SOME)
3623        .with_default_extension(Extensions::UNWRAP_NEWTYPES)
3624        .with_default_extension(Extensions::UNWRAP_VARIANT_NEWTYPES)
3625        .from_str(content)
3626        .map_err(|e| {
3627            CuError::from(format!(
3628                "Failed to parse configuration: Error: {} at position {}",
3629                e.code, e.span
3630            ))
3631        })
3632}
3633
3634/// Convert a CuConfigRepresentation to a CuConfig.
3635/// Uses the deserialize_impl method and validates the logging configuration.
3636fn config_representation_to_config(representation: CuConfigRepresentation) -> CuResult<CuConfig> {
3637    #[allow(unused_mut)]
3638    let mut cuconfig = CuConfig::deserialize_impl(representation)
3639        .map_err(|e| CuError::from(format!("Error deserializing configuration: {e}")))?;
3640
3641    #[cfg(feature = "std")]
3642    cuconfig.ensure_threadpool_bundle();
3643
3644    cuconfig.validate_logging_config()?;
3645    cuconfig.validate_runtime_config()?;
3646
3647    Ok(cuconfig)
3648}
3649
3650#[allow(unused_variables)]
3651pub fn read_configuration_str(
3652    config_content: String,
3653    file_path: Option<&str>,
3654) -> CuResult<CuConfig> {
3655    // Parse the configuration string
3656    let representation = parse_config_string(&config_content)?;
3657
3658    // Process includes and generate a merged configuration if a file path is provided
3659    // includes are only available with std.
3660    #[cfg(feature = "std")]
3661    let representation = if let Some(path) = file_path {
3662        process_includes(path, representation, &mut Vec::new())?
3663    } else {
3664        representation
3665    };
3666
3667    // Convert the representation to a CuConfig and validate
3668    config_representation_to_config(representation)
3669}
3670
3671/// Read a strict multi-Copper umbrella configuration from a file.
3672#[cfg(feature = "std")]
3673#[allow(dead_code)]
3674pub fn read_multi_configuration(config_filename: &str) -> CuResult<MultiCopperConfig> {
3675    let config_content = read_to_string(config_filename).map_err(|e| {
3676        CuError::from(format!(
3677            "Failed to read multi-Copper configuration file: {:?}",
3678            &config_filename
3679        ))
3680        .add_cause(e.to_string().as_str())
3681    })?;
3682    read_multi_configuration_str(config_content, Some(config_filename))
3683}
3684
3685/// Read a strict multi-Copper umbrella configuration from a string.
3686#[cfg(feature = "std")]
3687#[allow(dead_code)]
3688pub fn read_multi_configuration_str(
3689    config_content: String,
3690    file_path: Option<&str>,
3691) -> CuResult<MultiCopperConfig> {
3692    let representation = parse_multi_config_string(&config_content)?;
3693    validate_multi_config_representation(representation, file_path)
3694}
3695
3696// tests
3697#[cfg(test)]
3698mod tests {
3699    use super::*;
3700    #[cfg(not(feature = "std"))]
3701    use alloc::vec;
3702    use serde::Deserialize;
3703    #[cfg(feature = "std")]
3704    use std::path::{Path, PathBuf};
3705
3706    #[test]
3707    fn test_plain_serialize() {
3708        let mut config = CuConfig::default();
3709        let graph = config.get_graph_mut(None).unwrap();
3710        let n1 = graph
3711            .add_node(Node::new("test1", "package::Plugin1"))
3712            .unwrap();
3713        let n2 = graph
3714            .add_node(Node::new("test2", "package::Plugin2"))
3715            .unwrap();
3716        graph.connect(n1, n2, "msgpkg::MsgType").unwrap();
3717        let serialized = config.serialize_ron().unwrap();
3718        let deserialized = CuConfig::deserialize_ron(&serialized).unwrap();
3719        let graph = config.graphs.get_graph(None).unwrap();
3720        let deserialized_graph = deserialized.graphs.get_graph(None).unwrap();
3721        assert_eq!(graph.node_count(), deserialized_graph.node_count());
3722        assert_eq!(graph.edge_count(), deserialized_graph.edge_count());
3723    }
3724
3725    #[test]
3726    fn test_serialize_with_params() {
3727        let mut config = CuConfig::default();
3728        let graph = config.get_graph_mut(None).unwrap();
3729        let mut camera = Node::new("copper-camera", "camerapkg::Camera");
3730        camera.set_param::<Value>("resolution-height", 1080.into());
3731        graph.add_node(camera).unwrap();
3732        let serialized = config.serialize_ron().unwrap();
3733        let config = CuConfig::deserialize_ron(&serialized).unwrap();
3734        let deserialized = config.get_graph(None).unwrap();
3735        let resolution = deserialized
3736            .get_node(0)
3737            .unwrap()
3738            .get_param::<i32>("resolution-height")
3739            .expect("resolution-height lookup failed");
3740        assert_eq!(resolution, Some(1080));
3741    }
3742
3743    #[derive(Debug, Deserialize, PartialEq)]
3744    struct InnerSettings {
3745        threshold: u32,
3746        flags: Option<bool>,
3747    }
3748
3749    #[derive(Debug, Deserialize, PartialEq)]
3750    struct SettingsConfig {
3751        gain: f32,
3752        matrix: [[f32; 3]; 3],
3753        inner: InnerSettings,
3754        tags: Vec<String>,
3755    }
3756
3757    #[test]
3758    fn test_component_config_get_value_structured() {
3759        let txt = r#"
3760            (
3761                tasks: [
3762                    (
3763                        id: "task",
3764                        type: "pkg::Task",
3765                        config: {
3766                            "settings": {
3767                                "gain": 1.5,
3768                                "matrix": [
3769                                    [1.0, 0.0, 0.0],
3770                                    [0.0, 1.0, 0.0],
3771                                    [0.0, 0.0, 1.0],
3772                                ],
3773                                "inner": { "threshold": 42, "flags": Some(true) },
3774                                "tags": ["alpha", "beta"],
3775                            },
3776                        },
3777                    ),
3778                ],
3779                cnx: [],
3780            )
3781        "#;
3782        let config = CuConfig::deserialize_ron(txt).unwrap();
3783        let graph = config.graphs.get_graph(None).unwrap();
3784        let node = graph.get_node(0).unwrap();
3785        let component = node.get_instance_config().expect("missing config");
3786        let settings = component
3787            .get_value::<SettingsConfig>("settings")
3788            .expect("settings lookup failed")
3789            .expect("missing settings");
3790        let expected = SettingsConfig {
3791            gain: 1.5,
3792            matrix: [[1.0, 0.0, 0.0], [0.0, 1.0, 0.0], [0.0, 0.0, 1.0]],
3793            inner: InnerSettings {
3794                threshold: 42,
3795                flags: Some(true),
3796            },
3797            tags: vec!["alpha".to_string(), "beta".to_string()],
3798        };
3799        assert_eq!(settings, expected);
3800    }
3801
3802    #[test]
3803    fn test_component_config_get_value_scalar_compatibility() {
3804        let txt = r#"
3805            (
3806                tasks: [
3807                    (id: "task", type: "pkg::Task", config: { "scalar": 7 }),
3808                ],
3809                cnx: [],
3810            )
3811        "#;
3812        let config = CuConfig::deserialize_ron(txt).unwrap();
3813        let graph = config.graphs.get_graph(None).unwrap();
3814        let node = graph.get_node(0).unwrap();
3815        let component = node.get_instance_config().expect("missing config");
3816        let scalar = component
3817            .get::<u32>("scalar")
3818            .expect("scalar lookup failed");
3819        assert_eq!(scalar, Some(7));
3820    }
3821
3822    #[test]
3823    fn test_component_config_get_value_mixed_usage() {
3824        let txt = r#"
3825            (
3826                tasks: [
3827                    (
3828                        id: "task",
3829                        type: "pkg::Task",
3830                        config: {
3831                            "scalar": 12,
3832                            "settings": {
3833                                "gain": 2.5,
3834                                "matrix": [
3835                                    [1.0, 2.0, 3.0],
3836                                    [4.0, 5.0, 6.0],
3837                                    [7.0, 8.0, 9.0],
3838                                ],
3839                                "inner": { "threshold": 7, "flags": None },
3840                                "tags": ["gamma"],
3841                            },
3842                        },
3843                    ),
3844                ],
3845                cnx: [],
3846            )
3847        "#;
3848        let config = CuConfig::deserialize_ron(txt).unwrap();
3849        let graph = config.graphs.get_graph(None).unwrap();
3850        let node = graph.get_node(0).unwrap();
3851        let component = node.get_instance_config().expect("missing config");
3852        let scalar = component
3853            .get::<u32>("scalar")
3854            .expect("scalar lookup failed");
3855        let settings = component
3856            .get_value::<SettingsConfig>("settings")
3857            .expect("settings lookup failed");
3858        assert_eq!(scalar, Some(12));
3859        assert!(settings.is_some());
3860    }
3861
3862    #[test]
3863    fn test_component_config_get_value_error_includes_key() {
3864        let txt = r#"
3865            (
3866                tasks: [
3867                    (
3868                        id: "task",
3869                        type: "pkg::Task",
3870                        config: { "settings": { "gain": 1.0 } },
3871                    ),
3872                ],
3873                cnx: [],
3874            )
3875        "#;
3876        let config = CuConfig::deserialize_ron(txt).unwrap();
3877        let graph = config.graphs.get_graph(None).unwrap();
3878        let node = graph.get_node(0).unwrap();
3879        let component = node.get_instance_config().expect("missing config");
3880        let err = component
3881            .get_value::<u32>("settings")
3882            .expect_err("expected type mismatch");
3883        assert!(err.to_string().contains("settings"));
3884    }
3885
3886    #[test]
3887    fn test_deserialization_error() {
3888        // Task needs to be an array, but provided tuple wrongfully
3889        let txt = r#"( tasks: (), cnx: [], monitors: [(type: "ExampleMonitor", )] ) "#;
3890        let err = CuConfig::deserialize_ron(txt).expect_err("expected deserialization error");
3891        assert!(
3892            err.to_string()
3893                .contains("Syntax Error in config: Expected opening `[` at position 1:9-1:10")
3894        );
3895    }
3896    #[test]
3897    fn test_missions() {
3898        let txt = r#"( missions: [ (id: "data_collection"), (id: "autonomous")])"#;
3899        let config = CuConfig::deserialize_ron(txt).unwrap();
3900        let graph = config.graphs.get_graph(Some("data_collection")).unwrap();
3901        assert!(graph.node_count() == 0);
3902        let graph = config.graphs.get_graph(Some("autonomous")).unwrap();
3903        assert!(graph.node_count() == 0);
3904    }
3905
3906    #[test]
3907    fn test_monitor_plural_syntax() {
3908        let txt = r#"( tasks: [], cnx: [], monitors: [(type: "ExampleMonitor", )] ) "#;
3909        let config = CuConfig::deserialize_ron(txt).unwrap();
3910        assert_eq!(config.get_monitor_config().unwrap().type_, "ExampleMonitor");
3911
3912        let txt = r#"( tasks: [], cnx: [], monitors: [(type: "ExampleMonitor", config: { "toto": 4, } )] ) "#;
3913        let config = CuConfig::deserialize_ron(txt).unwrap();
3914        assert_eq!(
3915            config
3916                .get_monitor_config()
3917                .unwrap()
3918                .config
3919                .as_ref()
3920                .unwrap()
3921                .0["toto"]
3922                .0,
3923            4u8.into()
3924        );
3925    }
3926
3927    #[test]
3928    fn test_monitor_singular_syntax() {
3929        let txt = r#"( tasks: [], cnx: [], monitor: (type: "ExampleMonitor", config: { "toto": 4, } ) ) "#;
3930        let config = CuConfig::deserialize_ron(txt).unwrap();
3931        assert_eq!(config.get_monitor_configs().len(), 1);
3932        assert_eq!(config.get_monitor_config().unwrap().type_, "ExampleMonitor");
3933        assert_eq!(
3934            config
3935                .get_monitor_config()
3936                .unwrap()
3937                .config
3938                .as_ref()
3939                .unwrap()
3940                .0["toto"]
3941                .0,
3942            4u8.into()
3943        );
3944    }
3945
3946    #[test]
3947    #[cfg(feature = "std")]
3948    fn test_render_topology_multi_input_ports() {
3949        let mut config = CuConfig::default();
3950        let graph = config.get_graph_mut(None).unwrap();
3951        let src1 = graph.add_node(Node::new("src1", "tasks::Source1")).unwrap();
3952        let src2 = graph.add_node(Node::new("src2", "tasks::Source2")).unwrap();
3953        let dst = graph.add_node(Node::new("dst", "tasks::Dst")).unwrap();
3954        graph.connect(src1, dst, "msg::A").unwrap();
3955        graph.connect(src2, dst, "msg::B").unwrap();
3956
3957        let topology = build_render_topology(graph, &[]);
3958        let dst_node = topology
3959            .nodes
3960            .iter()
3961            .find(|node| node.id == "dst")
3962            .expect("missing dst node");
3963        assert_eq!(dst_node.inputs.len(), 2);
3964
3965        let mut dst_ports: Vec<_> = topology
3966            .connections
3967            .iter()
3968            .filter(|cnx| cnx.dst == "dst")
3969            .map(|cnx| cnx.dst_port.as_deref().expect("missing dst port"))
3970            .collect();
3971        dst_ports.sort();
3972        assert_eq!(dst_ports, vec!["in.0", "in.1"]);
3973    }
3974
3975    #[test]
3976    fn test_logging_parameters() {
3977        // Test with `enable_task_logging: false`
3978        let txt = r#"( tasks: [], cnx: [], logging: ( slab_size_mib: 1024, section_size_mib: 100, enable_task_logging: false ),) "#;
3979
3980        let config = CuConfig::deserialize_ron(txt).unwrap();
3981        assert!(config.logging.is_some());
3982        let logging_config = config.logging.unwrap();
3983        assert_eq!(logging_config.slab_size_mib.unwrap(), 1024);
3984        assert_eq!(logging_config.section_size_mib.unwrap(), 100);
3985        assert!(!logging_config.enable_task_logging);
3986
3987        // Test with `enable_task_logging` not provided
3988        let txt =
3989            r#"( tasks: [], cnx: [], logging: ( slab_size_mib: 1024, section_size_mib: 100, ),) "#;
3990        let config = CuConfig::deserialize_ron(txt).unwrap();
3991        assert!(config.logging.is_some());
3992        let logging_config = config.logging.unwrap();
3993        assert_eq!(logging_config.slab_size_mib.unwrap(), 1024);
3994        assert_eq!(logging_config.section_size_mib.unwrap(), 100);
3995        assert!(logging_config.enable_task_logging);
3996    }
3997
3998    #[test]
3999    fn test_bridge_parsing() {
4000        let txt = r#"
4001        (
4002            tasks: [
4003                (id: "dst", type: "tasks::Destination"),
4004                (id: "src", type: "tasks::Source"),
4005            ],
4006            bridges: [
4007                (
4008                    id: "radio",
4009                    type: "tasks::SerialBridge",
4010                    config: { "path": "/dev/ttyACM0", "baud": 921600 },
4011                    channels: [
4012                        Rx ( id: "status", route: "sys/status" ),
4013                        Tx ( id: "motor", route: "motor/cmd" ),
4014                    ],
4015                ),
4016            ],
4017            cnx: [
4018                (src: "radio/status", dst: "dst", msg: "mymsgs::Status"),
4019                (src: "src", dst: "radio/motor", msg: "mymsgs::MotorCmd"),
4020            ],
4021        )
4022        "#;
4023
4024        let config = CuConfig::deserialize_ron(txt).unwrap();
4025        assert_eq!(config.bridges.len(), 1);
4026        let bridge = &config.bridges[0];
4027        assert_eq!(bridge.id, "radio");
4028        assert_eq!(bridge.channels.len(), 2);
4029        match &bridge.channels[0] {
4030            BridgeChannelConfigRepresentation::Rx { id, route, .. } => {
4031                assert_eq!(id, "status");
4032                assert_eq!(route.as_deref(), Some("sys/status"));
4033            }
4034            _ => panic!("expected Rx channel"),
4035        }
4036        match &bridge.channels[1] {
4037            BridgeChannelConfigRepresentation::Tx { id, route, .. } => {
4038                assert_eq!(id, "motor");
4039                assert_eq!(route.as_deref(), Some("motor/cmd"));
4040            }
4041            _ => panic!("expected Tx channel"),
4042        }
4043        let graph = config.graphs.get_graph(None).unwrap();
4044        let bridge_id = graph
4045            .get_node_id_by_name("radio")
4046            .expect("bridge node missing");
4047        let bridge_node = graph.get_node(bridge_id).unwrap();
4048        assert_eq!(bridge_node.get_flavor(), Flavor::Bridge);
4049
4050        // Edges should retain channel metadata.
4051        let mut edges = Vec::new();
4052        for edge_idx in graph.0.edge_indices() {
4053            edges.push(graph.0[edge_idx].clone());
4054        }
4055        assert_eq!(edges.len(), 2);
4056        let status_edge = edges
4057            .iter()
4058            .find(|e| e.dst == "dst")
4059            .expect("status edge missing");
4060        assert_eq!(status_edge.src_channel.as_deref(), Some("status"));
4061        assert!(status_edge.dst_channel.is_none());
4062        let motor_edge = edges
4063            .iter()
4064            .find(|e| e.dst_channel.is_some())
4065            .expect("motor edge missing");
4066        assert_eq!(motor_edge.dst_channel.as_deref(), Some("motor"));
4067    }
4068
4069    #[test]
4070    fn test_bridge_roundtrip() {
4071        let mut config = CuConfig::default();
4072        let mut bridge_config = ComponentConfig::default();
4073        bridge_config.set("port", "/dev/ttyACM0".to_string());
4074        config.bridges.push(BridgeConfig {
4075            id: "radio".to_string(),
4076            type_: "tasks::SerialBridge".to_string(),
4077            config: Some(bridge_config),
4078            resources: None,
4079            missions: None,
4080            run_in_sim: None,
4081            channels: vec![
4082                BridgeChannelConfigRepresentation::Rx {
4083                    id: "status".to_string(),
4084                    route: Some("sys/status".to_string()),
4085                    config: None,
4086                },
4087                BridgeChannelConfigRepresentation::Tx {
4088                    id: "motor".to_string(),
4089                    route: Some("motor/cmd".to_string()),
4090                    config: None,
4091                },
4092            ],
4093        });
4094
4095        let serialized = config.serialize_ron().unwrap();
4096        assert!(
4097            serialized.contains("bridges"),
4098            "bridges section missing from serialized config"
4099        );
4100        let deserialized = CuConfig::deserialize_ron(&serialized).unwrap();
4101        assert_eq!(deserialized.bridges.len(), 1);
4102        let bridge = &deserialized.bridges[0];
4103        assert!(bridge.is_run_in_sim());
4104        assert_eq!(bridge.channels.len(), 2);
4105        assert!(matches!(
4106            bridge.channels[0],
4107            BridgeChannelConfigRepresentation::Rx { .. }
4108        ));
4109        assert!(matches!(
4110            bridge.channels[1],
4111            BridgeChannelConfigRepresentation::Tx { .. }
4112        ));
4113    }
4114
4115    #[test]
4116    fn test_resource_parsing() {
4117        let txt = r#"
4118        (
4119            resources: [
4120                (
4121                    id: "fc",
4122                    provider: "copper_board_px4::Px4Bundle",
4123                    config: { "baud": 921600 },
4124                    missions: ["m1"],
4125                ),
4126                (
4127                    id: "misc",
4128                    provider: "cu29_runtime::StdClockBundle",
4129                ),
4130            ],
4131        )
4132        "#;
4133
4134        let config = CuConfig::deserialize_ron(txt).unwrap();
4135        assert_eq!(config.resources.len(), 2);
4136        let fc = &config.resources[0];
4137        assert_eq!(fc.id, "fc");
4138        assert_eq!(fc.provider, "copper_board_px4::Px4Bundle");
4139        assert_eq!(fc.missions.as_deref(), Some(&["m1".to_string()][..]));
4140        let baud: u32 = fc
4141            .config
4142            .as_ref()
4143            .expect("missing config")
4144            .get::<u32>("baud")
4145            .expect("baud lookup failed")
4146            .expect("missing baud");
4147        assert_eq!(baud, 921_600);
4148        let misc = &config.resources[1];
4149        assert_eq!(misc.id, "misc");
4150        assert_eq!(misc.provider, "cu29_runtime::StdClockBundle");
4151        assert!(misc.config.is_none());
4152    }
4153
4154    #[test]
4155    fn test_resource_roundtrip() {
4156        let mut config = CuConfig::default();
4157        let mut bundle_cfg = ComponentConfig::default();
4158        bundle_cfg.set("path", "/dev/ttyACM0".to_string());
4159        config.resources.push(ResourceBundleConfig {
4160            id: "fc".to_string(),
4161            provider: "copper_board_px4::Px4Bundle".to_string(),
4162            config: Some(bundle_cfg),
4163            missions: Some(vec!["m1".to_string()]),
4164        });
4165
4166        let serialized = config.serialize_ron().unwrap();
4167        let deserialized = CuConfig::deserialize_ron(&serialized).unwrap();
4168        assert_eq!(deserialized.resources.len(), 1);
4169        let res = &deserialized.resources[0];
4170        assert_eq!(res.id, "fc");
4171        assert_eq!(res.provider, "copper_board_px4::Px4Bundle");
4172        assert_eq!(res.missions.as_deref(), Some(&["m1".to_string()][..]));
4173        let path: String = res
4174            .config
4175            .as_ref()
4176            .expect("missing config")
4177            .get::<String>("path")
4178            .expect("path lookup failed")
4179            .expect("missing path");
4180        assert_eq!(path, "/dev/ttyACM0");
4181    }
4182
4183    #[test]
4184    fn test_bridge_channel_config() {
4185        let txt = r#"
4186        (
4187            tasks: [],
4188            bridges: [
4189                (
4190                    id: "radio",
4191                    type: "tasks::SerialBridge",
4192                    channels: [
4193                        Rx ( id: "status", route: "sys/status", config: { "filter": "fast" } ),
4194                        Tx ( id: "imu", route: "telemetry/imu", config: { "rate": 100 } ),
4195                    ],
4196                ),
4197            ],
4198            cnx: [],
4199        )
4200        "#;
4201
4202        let config = CuConfig::deserialize_ron(txt).unwrap();
4203        let bridge = &config.bridges[0];
4204        match &bridge.channels[0] {
4205            BridgeChannelConfigRepresentation::Rx {
4206                config: Some(cfg), ..
4207            } => {
4208                let val = cfg
4209                    .get::<String>("filter")
4210                    .expect("filter lookup failed")
4211                    .expect("filter missing");
4212                assert_eq!(val, "fast");
4213            }
4214            _ => panic!("expected Rx channel with config"),
4215        }
4216        match &bridge.channels[1] {
4217            BridgeChannelConfigRepresentation::Tx {
4218                config: Some(cfg), ..
4219            } => {
4220                let rate = cfg
4221                    .get::<i32>("rate")
4222                    .expect("rate lookup failed")
4223                    .expect("rate missing");
4224                assert_eq!(rate, 100);
4225            }
4226            _ => panic!("expected Tx channel with config"),
4227        }
4228    }
4229
4230    #[test]
4231    fn test_task_resources_roundtrip() {
4232        let txt = r#"
4233        (
4234            tasks: [
4235                (
4236                    id: "imu",
4237                    type: "tasks::ImuDriver",
4238                    resources: { "bus": "fc.spi_1", "irq": "fc.gpio_imu" },
4239                ),
4240            ],
4241            cnx: [],
4242        )
4243        "#;
4244
4245        let config = CuConfig::deserialize_ron(txt).unwrap();
4246        let graph = config.graphs.get_graph(None).unwrap();
4247        let node = graph.get_node(0).expect("missing task node");
4248        let resources = node.get_resources().expect("missing resources map");
4249        assert_eq!(resources.get("bus").map(String::as_str), Some("fc.spi_1"));
4250        assert_eq!(
4251            resources.get("irq").map(String::as_str),
4252            Some("fc.gpio_imu")
4253        );
4254
4255        let serialized = config.serialize_ron().unwrap();
4256        let deserialized = CuConfig::deserialize_ron(&serialized).unwrap();
4257        let graph = deserialized.graphs.get_graph(None).unwrap();
4258        let node = graph.get_node(0).expect("missing task node");
4259        let resources = node
4260            .get_resources()
4261            .expect("missing resources map after roundtrip");
4262        assert_eq!(resources.get("bus").map(String::as_str), Some("fc.spi_1"));
4263        assert_eq!(
4264            resources.get("irq").map(String::as_str),
4265            Some("fc.gpio_imu")
4266        );
4267    }
4268
4269    #[test]
4270    fn test_bridge_resources_preserved() {
4271        let mut config = CuConfig::default();
4272        config.resources.push(ResourceBundleConfig {
4273            id: "fc".to_string(),
4274            provider: "board::Bundle".to_string(),
4275            config: None,
4276            missions: None,
4277        });
4278        let bridge_resources = HashMap::from([("serial".to_string(), "fc.serial0".to_string())]);
4279        config.bridges.push(BridgeConfig {
4280            id: "radio".to_string(),
4281            type_: "tasks::SerialBridge".to_string(),
4282            config: None,
4283            resources: Some(bridge_resources),
4284            missions: None,
4285            run_in_sim: None,
4286            channels: vec![BridgeChannelConfigRepresentation::Tx {
4287                id: "uplink".to_string(),
4288                route: None,
4289                config: None,
4290            }],
4291        });
4292
4293        let serialized = config.serialize_ron().unwrap();
4294        let deserialized = CuConfig::deserialize_ron(&serialized).unwrap();
4295        let graph = deserialized.graphs.get_graph(None).expect("missing graph");
4296        let bridge_id = graph
4297            .get_node_id_by_name("radio")
4298            .expect("bridge node missing");
4299        let node = graph.get_node(bridge_id).expect("missing bridge node");
4300        let resources = node
4301            .get_resources()
4302            .expect("bridge resources were not preserved");
4303        assert_eq!(
4304            resources.get("serial").map(String::as_str),
4305            Some("fc.serial0")
4306        );
4307    }
4308
4309    #[test]
4310    fn test_demo_config_parses() {
4311        let txt = r#"(
4312    resources: [
4313        (
4314            id: "fc",
4315            provider: "crate::resources::RadioBundle",
4316        ),
4317    ],
4318    tasks: [
4319        (id: "thr", type: "tasks::ThrottleControl"),
4320        (id: "tele0", type: "tasks::TelemetrySink0"),
4321        (id: "tele1", type: "tasks::TelemetrySink1"),
4322        (id: "tele2", type: "tasks::TelemetrySink2"),
4323        (id: "tele3", type: "tasks::TelemetrySink3"),
4324    ],
4325    bridges: [
4326        (  id: "crsf",
4327           type: "cu_crsf::CrsfBridge<SerialResource, SerialPortError>",
4328           resources: { "serial": "fc.serial" },
4329           channels: [
4330                Rx ( id: "rc_rx" ),  // receiving RC Channels
4331                Tx ( id: "lq_tx" ),  // Sending LineQuality back
4332            ],
4333        ),
4334        (
4335            id: "bdshot",
4336            type: "cu_bdshot::RpBdshotBridge",
4337            channels: [
4338                Tx ( id: "esc0_tx" ),
4339                Tx ( id: "esc1_tx" ),
4340                Tx ( id: "esc2_tx" ),
4341                Tx ( id: "esc3_tx" ),
4342                Rx ( id: "esc0_rx" ),
4343                Rx ( id: "esc1_rx" ),
4344                Rx ( id: "esc2_rx" ),
4345                Rx ( id: "esc3_rx" ),
4346            ],
4347        ),
4348    ],
4349    cnx: [
4350        (src: "crsf/rc_rx", dst: "thr", msg: "cu_crsf::messages::RcChannelsPayload"),
4351        (src: "thr", dst: "bdshot/esc0_tx", msg: "cu_bdshot::EscCommand"),
4352        (src: "thr", dst: "bdshot/esc1_tx", msg: "cu_bdshot::EscCommand"),
4353        (src: "thr", dst: "bdshot/esc2_tx", msg: "cu_bdshot::EscCommand"),
4354        (src: "thr", dst: "bdshot/esc3_tx", msg: "cu_bdshot::EscCommand"),
4355        (src: "bdshot/esc0_rx", dst: "tele0", msg: "cu_bdshot::EscTelemetry"),
4356        (src: "bdshot/esc1_rx", dst: "tele1", msg: "cu_bdshot::EscTelemetry"),
4357        (src: "bdshot/esc2_rx", dst: "tele2", msg: "cu_bdshot::EscTelemetry"),
4358        (src: "bdshot/esc3_rx", dst: "tele3", msg: "cu_bdshot::EscTelemetry"),
4359    ],
4360)"#;
4361        let config = CuConfig::deserialize_ron(txt).unwrap();
4362        assert_eq!(config.resources.len(), 1);
4363        assert_eq!(config.bridges.len(), 2);
4364    }
4365
4366    #[test]
4367    fn test_bridge_tx_cannot_be_source() {
4368        let txt = r#"
4369        (
4370            tasks: [
4371                (id: "dst", type: "tasks::Destination"),
4372            ],
4373            bridges: [
4374                (
4375                    id: "radio",
4376                    type: "tasks::SerialBridge",
4377                    channels: [
4378                        Tx ( id: "motor", route: "motor/cmd" ),
4379                    ],
4380                ),
4381            ],
4382            cnx: [
4383                (src: "radio/motor", dst: "dst", msg: "mymsgs::MotorCmd"),
4384            ],
4385        )
4386        "#;
4387
4388        let err = CuConfig::deserialize_ron(txt).expect_err("expected bridge source error");
4389        assert!(
4390            err.to_string()
4391                .contains("channel 'motor' is Tx and cannot act as a source")
4392        );
4393    }
4394
4395    #[test]
4396    fn test_bridge_rx_cannot_be_destination() {
4397        let txt = r#"
4398        (
4399            tasks: [
4400                (id: "src", type: "tasks::Source"),
4401            ],
4402            bridges: [
4403                (
4404                    id: "radio",
4405                    type: "tasks::SerialBridge",
4406                    channels: [
4407                        Rx ( id: "status", route: "sys/status" ),
4408                    ],
4409                ),
4410            ],
4411            cnx: [
4412                (src: "src", dst: "radio/status", msg: "mymsgs::Status"),
4413            ],
4414        )
4415        "#;
4416
4417        let err = CuConfig::deserialize_ron(txt).expect_err("expected bridge destination error");
4418        assert!(
4419            err.to_string()
4420                .contains("channel 'status' is Rx and cannot act as a destination")
4421        );
4422    }
4423
4424    #[test]
4425    fn test_validate_logging_config() {
4426        // Test with valid logging configuration
4427        let txt =
4428            r#"( tasks: [], cnx: [], logging: ( slab_size_mib: 1024, section_size_mib: 100 ) )"#;
4429        let config = CuConfig::deserialize_ron(txt).unwrap();
4430        assert!(config.validate_logging_config().is_ok());
4431
4432        // Test with invalid logging configuration
4433        let txt =
4434            r#"( tasks: [], cnx: [], logging: ( slab_size_mib: 100, section_size_mib: 1024 ) )"#;
4435        let config = CuConfig::deserialize_ron(txt).unwrap();
4436        assert!(config.validate_logging_config().is_err());
4437    }
4438
4439    // this test makes sure the edge id is suitable to be used to sort the inputs of a task
4440    #[test]
4441    fn test_deserialization_edge_id_assignment() {
4442        // note here that the src1 task is added before src2 in the tasks array,
4443        // however, src1 connection is added AFTER src2 in the cnx array
4444        let txt = r#"(
4445            tasks: [(id: "src1", type: "a"), (id: "src2", type: "b"), (id: "sink", type: "c")],
4446            cnx: [(src: "src2", dst: "sink", msg: "msg1"), (src: "src1", dst: "sink", msg: "msg2")]
4447        )"#;
4448        let config = CuConfig::deserialize_ron(txt).unwrap();
4449        let graph = config.graphs.get_graph(None).unwrap();
4450        assert!(config.validate_logging_config().is_ok());
4451
4452        // the node id depends on the order in which the tasks are added
4453        let src1_id = 0;
4454        assert_eq!(graph.get_node(src1_id).unwrap().id, "src1");
4455        let src2_id = 1;
4456        assert_eq!(graph.get_node(src2_id).unwrap().id, "src2");
4457
4458        // the edge id depends on the order the connection is created
4459        // the src2 was added second in the tasks, but the connection was added first
4460        let src1_edge_id = *graph.get_src_edges(src1_id).unwrap().first().unwrap();
4461        assert_eq!(src1_edge_id, 1);
4462        let src2_edge_id = *graph.get_src_edges(src2_id).unwrap().first().unwrap();
4463        assert_eq!(src2_edge_id, 0);
4464    }
4465
4466    #[test]
4467    fn test_simple_missions() {
4468        // A simple config that selection a source depending on the mission it is in.
4469        let txt = r#"(
4470                    missions: [ (id: "m1"),
4471                                (id: "m2"),
4472                                ],
4473                    tasks: [(id: "src1", type: "a", missions: ["m1"]),
4474                            (id: "src2", type: "b", missions: ["m2"]),
4475                            (id: "sink", type: "c")],
4476
4477                    cnx: [
4478                            (src: "src1", dst: "sink", msg: "u32", missions: ["m1"]),
4479                            (src: "src2", dst: "sink", msg: "u32", missions: ["m2"]),
4480                         ],
4481              )
4482              "#;
4483
4484        let config = CuConfig::deserialize_ron(txt).unwrap();
4485        let m1_graph = config.graphs.get_graph(Some("m1")).unwrap();
4486        assert_eq!(m1_graph.edge_count(), 1);
4487        assert_eq!(m1_graph.node_count(), 2);
4488        let index = 0;
4489        let cnx = m1_graph.get_edge_weight(index).unwrap();
4490
4491        assert_eq!(cnx.src, "src1");
4492        assert_eq!(cnx.dst, "sink");
4493        assert_eq!(cnx.msg, "u32");
4494        assert_eq!(cnx.missions, Some(vec!["m1".to_string()]));
4495
4496        let m2_graph = config.graphs.get_graph(Some("m2")).unwrap();
4497        assert_eq!(m2_graph.edge_count(), 1);
4498        assert_eq!(m2_graph.node_count(), 2);
4499        let index = 0;
4500        let cnx = m2_graph.get_edge_weight(index).unwrap();
4501        assert_eq!(cnx.src, "src2");
4502        assert_eq!(cnx.dst, "sink");
4503        assert_eq!(cnx.msg, "u32");
4504        assert_eq!(cnx.missions, Some(vec!["m2".to_string()]));
4505    }
4506    #[test]
4507    fn test_mission_serde() {
4508        // A simple config that selection a source depending on the mission it is in.
4509        let txt = r#"(
4510                    missions: [ (id: "m1"),
4511                                (id: "m2"),
4512                                ],
4513                    tasks: [(id: "src1", type: "a", missions: ["m1"]),
4514                            (id: "src2", type: "b", missions: ["m2"]),
4515                            (id: "sink", type: "c")],
4516
4517                    cnx: [
4518                            (src: "src1", dst: "sink", msg: "u32", missions: ["m1"]),
4519                            (src: "src2", dst: "sink", msg: "u32", missions: ["m2"]),
4520                         ],
4521              )
4522              "#;
4523
4524        let config = CuConfig::deserialize_ron(txt).unwrap();
4525        let serialized = config.serialize_ron().unwrap();
4526        let deserialized = CuConfig::deserialize_ron(&serialized).unwrap();
4527        let m1_graph = deserialized.graphs.get_graph(Some("m1")).unwrap();
4528        assert_eq!(m1_graph.edge_count(), 1);
4529        assert_eq!(m1_graph.node_count(), 2);
4530        let index = 0;
4531        let cnx = m1_graph.get_edge_weight(index).unwrap();
4532        assert_eq!(cnx.src, "src1");
4533        assert_eq!(cnx.dst, "sink");
4534        assert_eq!(cnx.msg, "u32");
4535        assert_eq!(cnx.missions, Some(vec!["m1".to_string()]));
4536    }
4537
4538    #[test]
4539    fn test_mission_scoped_nc_connection_survives_serialize_roundtrip() {
4540        let txt = r#"(
4541            missions: [(id: "m1"), (id: "m2")],
4542            tasks: [
4543                (id: "src_m1", type: "a", missions: ["m1"]),
4544                (id: "src_m2", type: "b", missions: ["m2"]),
4545            ],
4546            cnx: [
4547                (src: "src_m1", dst: "__nc__", msg: "msg::A", missions: ["m1"]),
4548                (src: "src_m2", dst: "__nc__", msg: "msg::B", missions: ["m2"]),
4549            ]
4550        )"#;
4551
4552        let config = CuConfig::deserialize_ron(txt).unwrap();
4553        let serialized = config.serialize_ron().unwrap();
4554        let deserialized = CuConfig::deserialize_ron(&serialized).unwrap();
4555
4556        let m1_graph = deserialized.graphs.get_graph(Some("m1")).unwrap();
4557        let src_m1_id = m1_graph.get_node_id_by_name("src_m1").unwrap();
4558        let src_m1 = m1_graph.get_node(src_m1_id).unwrap();
4559        assert_eq!(src_m1.nc_outputs(), &["msg::A".to_string()]);
4560
4561        let m2_graph = deserialized.graphs.get_graph(Some("m2")).unwrap();
4562        let src_m2_id = m2_graph.get_node_id_by_name("src_m2").unwrap();
4563        let src_m2 = m2_graph.get_node(src_m2_id).unwrap();
4564        assert_eq!(src_m2.nc_outputs(), &["msg::B".to_string()]);
4565    }
4566
4567    #[test]
4568    fn test_keyframe_interval() {
4569        // note here that the src1 task is added before src2 in the tasks array,
4570        // however, src1 connection is added AFTER src2 in the cnx array
4571        let txt = r#"(
4572            tasks: [(id: "src1", type: "a"), (id: "src2", type: "b"), (id: "sink", type: "c")],
4573            cnx: [(src: "src2", dst: "sink", msg: "msg1"), (src: "src1", dst: "sink", msg: "msg2")],
4574            logging: ( keyframe_interval: 314 )
4575        )"#;
4576        let config = CuConfig::deserialize_ron(txt).unwrap();
4577        let logging_config = config.logging.unwrap();
4578        assert_eq!(logging_config.keyframe_interval.unwrap(), 314);
4579    }
4580
4581    #[test]
4582    fn test_default_keyframe_interval() {
4583        // note here that the src1 task is added before src2 in the tasks array,
4584        // however, src1 connection is added AFTER src2 in the cnx array
4585        let txt = r#"(
4586            tasks: [(id: "src1", type: "a"), (id: "src2", type: "b"), (id: "sink", type: "c")],
4587            cnx: [(src: "src2", dst: "sink", msg: "msg1"), (src: "src1", dst: "sink", msg: "msg2")],
4588            logging: ( slab_size_mib: 200, section_size_mib: 1024, )
4589        )"#;
4590        let config = CuConfig::deserialize_ron(txt).unwrap();
4591        let logging_config = config.logging.unwrap();
4592        assert_eq!(logging_config.keyframe_interval.unwrap(), 100);
4593    }
4594
4595    #[test]
4596    fn test_runtime_rate_target_rejects_zero() {
4597        let txt = r#"(
4598            tasks: [(id: "src", type: "a"), (id: "sink", type: "b")],
4599            cnx: [(src: "src", dst: "sink", msg: "msg::A")],
4600            runtime: (rate_target_hz: 0)
4601        )"#;
4602
4603        let err =
4604            read_configuration_str(txt.to_string(), None).expect_err("runtime config should fail");
4605        assert!(
4606            err.to_string()
4607                .contains("Runtime rate target cannot be zero"),
4608            "unexpected error: {err}"
4609        );
4610    }
4611
4612    #[test]
4613    fn test_runtime_rate_target_rejects_above_nanosecond_resolution() {
4614        let txt = format!(
4615            r#"(
4616                tasks: [(id: "src", type: "a"), (id: "sink", type: "b")],
4617                cnx: [(src: "src", dst: "sink", msg: "msg::A")],
4618                runtime: (rate_target_hz: {})
4619            )"#,
4620            MAX_RATE_TARGET_HZ + 1
4621        );
4622
4623        let err = read_configuration_str(txt, None).expect_err("runtime config should fail");
4624        assert!(
4625            err.to_string().contains("exceeds the supported maximum"),
4626            "unexpected error: {err}"
4627        );
4628    }
4629
4630    #[test]
4631    fn test_nc_connection_marks_source_output_without_creating_edge() {
4632        let txt = r#"(
4633            tasks: [(id: "src", type: "a"), (id: "sink", type: "b")],
4634            cnx: [
4635                (src: "src", dst: "sink", msg: "msg::A"),
4636                (src: "src", dst: "__nc__", msg: "msg::B"),
4637            ]
4638        )"#;
4639        let config = CuConfig::deserialize_ron(txt).unwrap();
4640        let graph = config.get_graph(None).unwrap();
4641        let src_id = graph.get_node_id_by_name("src").unwrap();
4642        let src_node = graph.get_node(src_id).unwrap();
4643
4644        assert_eq!(graph.edge_count(), 1);
4645        assert_eq!(src_node.nc_outputs(), &["msg::B".to_string()]);
4646    }
4647
4648    #[test]
4649    fn test_nc_connection_survives_serialize_roundtrip() {
4650        let txt = r#"(
4651            tasks: [(id: "src", type: "a"), (id: "sink", type: "b")],
4652            cnx: [
4653                (src: "src", dst: "sink", msg: "msg::A"),
4654                (src: "src", dst: "__nc__", msg: "msg::B"),
4655            ]
4656        )"#;
4657        let config = CuConfig::deserialize_ron(txt).unwrap();
4658        let serialized = config.serialize_ron().unwrap();
4659        let deserialized = CuConfig::deserialize_ron(&serialized).unwrap();
4660        let graph = deserialized.get_graph(None).unwrap();
4661        let src_id = graph.get_node_id_by_name("src").unwrap();
4662        let src_node = graph.get_node(src_id).unwrap();
4663
4664        assert_eq!(graph.edge_count(), 1);
4665        assert_eq!(src_node.nc_outputs(), &["msg::B".to_string()]);
4666    }
4667
4668    #[test]
4669    fn test_nc_connection_preserves_original_connection_order() {
4670        let txt = r#"(
4671            tasks: [(id: "src", type: "a"), (id: "sink", type: "b")],
4672            cnx: [
4673                (src: "src", dst: "__nc__", msg: "msg::A"),
4674                (src: "src", dst: "sink", msg: "msg::B"),
4675            ]
4676        )"#;
4677        let config = CuConfig::deserialize_ron(txt).unwrap();
4678        let graph = config.get_graph(None).unwrap();
4679        let src_id = graph.get_node_id_by_name("src").unwrap();
4680        let src_node = graph.get_node(src_id).unwrap();
4681        let edge_id = graph.get_src_edges(src_id).unwrap()[0];
4682        let edge = graph.edge(edge_id).unwrap();
4683
4684        assert_eq!(edge.msg, "msg::B");
4685        assert_eq!(edge.order, 1);
4686        assert_eq!(
4687            src_node
4688                .nc_outputs_with_order()
4689                .map(|(msg, order)| (msg.as_str(), order))
4690                .collect::<Vec<_>>(),
4691            vec![("msg::A", 0)]
4692        );
4693    }
4694
4695    #[cfg(feature = "std")]
4696    fn multi_config_test_dir(name: &str) -> PathBuf {
4697        let unique = std::time::SystemTime::now()
4698            .duration_since(std::time::UNIX_EPOCH)
4699            .expect("system time before unix epoch")
4700            .as_nanos();
4701        let dir = std::env::temp_dir().join(format!("cu29_multi_config_{name}_{unique}"));
4702        std::fs::create_dir_all(&dir).expect("create temp test dir");
4703        dir
4704    }
4705
4706    #[cfg(feature = "std")]
4707    fn write_multi_config_file(dir: &Path, name: &str, contents: &str) -> PathBuf {
4708        let path = dir.join(name);
4709        std::fs::write(&path, contents).expect("write temp config file");
4710        path
4711    }
4712
4713    #[cfg(feature = "std")]
4714    fn alpha_subsystem_config() -> &'static str {
4715        r#"(
4716            tasks: [
4717                (id: "src", type: "demo::Src"),
4718                (id: "sink", type: "demo::Sink"),
4719            ],
4720            bridges: [
4721                (
4722                    id: "zenoh",
4723                    type: "demo::ZenohBridge",
4724                    channels: [
4725                        Tx(id: "ping"),
4726                        Rx(id: "pong"),
4727                    ],
4728                ),
4729            ],
4730            cnx: [
4731                (src: "src", dst: "zenoh/ping", msg: "demo::Ping"),
4732                (src: "zenoh/pong", dst: "sink", msg: "demo::Pong"),
4733            ],
4734        )"#
4735    }
4736
4737    #[cfg(feature = "std")]
4738    fn beta_subsystem_config() -> &'static str {
4739        r#"(
4740            tasks: [
4741                (id: "responder", type: "demo::Responder"),
4742            ],
4743            bridges: [
4744                (
4745                    id: "zenoh",
4746                    type: "demo::ZenohBridge",
4747                    channels: [
4748                        Rx(id: "ping"),
4749                        Tx(id: "pong"),
4750                    ],
4751                ),
4752            ],
4753            cnx: [
4754                (src: "zenoh/ping", dst: "responder", msg: "demo::Ping"),
4755                (src: "responder", dst: "zenoh/pong", msg: "demo::Pong"),
4756            ],
4757        )"#
4758    }
4759
4760    #[cfg(feature = "std")]
4761    fn instance_override_subsystem_config() -> &'static str {
4762        r#"(
4763            tasks: [
4764                (
4765                    id: "imu",
4766                    type: "demo::ImuTask",
4767                    config: {
4768                        "sample_hz": 200,
4769                    },
4770                ),
4771            ],
4772            resources: [
4773                (
4774                    id: "board",
4775                    provider: "demo::BoardBundle",
4776                    config: {
4777                        "bus": "i2c-1",
4778                    },
4779                ),
4780            ],
4781            bridges: [
4782                (
4783                    id: "radio",
4784                    type: "demo::RadioBridge",
4785                    config: {
4786                        "mtu": 32,
4787                    },
4788                    channels: [
4789                        Tx(id: "tx"),
4790                        Rx(id: "rx"),
4791                    ],
4792                ),
4793            ],
4794            cnx: [
4795                (src: "imu", dst: "radio/tx", msg: "demo::Packet"),
4796                (src: "radio/rx", dst: "imu", msg: "demo::Packet"),
4797            ],
4798        )"#
4799    }
4800
4801    #[cfg(feature = "std")]
4802    #[test]
4803    fn test_read_multi_configuration_assigns_stable_subsystem_codes() {
4804        let dir = multi_config_test_dir("stable_ids");
4805        write_multi_config_file(&dir, "alpha.ron", alpha_subsystem_config());
4806        write_multi_config_file(&dir, "beta.ron", beta_subsystem_config());
4807        let network_path = write_multi_config_file(
4808            &dir,
4809            "network.ron",
4810            r#"(
4811                subsystems: [
4812                    (id: "beta", config: "beta.ron"),
4813                    (id: "alpha", config: "alpha.ron"),
4814                ],
4815                interconnects: [
4816                    (from: "alpha/zenoh/ping", to: "beta/zenoh/ping", msg: "demo::Ping"),
4817                    (from: "beta/zenoh/pong", to: "alpha/zenoh/pong", msg: "demo::Pong"),
4818                ],
4819            )"#,
4820        );
4821
4822        let config =
4823            read_multi_configuration(network_path.to_str().expect("network path utf8")).unwrap();
4824
4825        let alpha = config.subsystem("alpha").expect("alpha subsystem missing");
4826        let beta = config.subsystem("beta").expect("beta subsystem missing");
4827        assert_eq!(alpha.subsystem_code, 0);
4828        assert_eq!(beta.subsystem_code, 1);
4829        assert_eq!(config.interconnects.len(), 2);
4830        assert_eq!(config.interconnects[0].bridge_type, "demo::ZenohBridge");
4831    }
4832
4833    #[cfg(feature = "std")]
4834    #[test]
4835    fn test_read_multi_configuration_rejects_wrong_direction() {
4836        let dir = multi_config_test_dir("wrong_direction");
4837        write_multi_config_file(&dir, "alpha.ron", alpha_subsystem_config());
4838        write_multi_config_file(&dir, "beta.ron", beta_subsystem_config());
4839        let network_path = write_multi_config_file(
4840            &dir,
4841            "network.ron",
4842            r#"(
4843                subsystems: [
4844                    (id: "alpha", config: "alpha.ron"),
4845                    (id: "beta", config: "beta.ron"),
4846                ],
4847                interconnects: [
4848                    (from: "alpha/zenoh/pong", to: "beta/zenoh/ping", msg: "demo::Pong"),
4849                ],
4850            )"#,
4851        );
4852
4853        let err = read_multi_configuration(network_path.to_str().expect("network path utf8"))
4854            .expect_err("direction mismatch should fail");
4855
4856        assert!(
4857            err.to_string()
4858                .contains("must reference a Tx bridge channel"),
4859            "unexpected error: {err}"
4860        );
4861    }
4862
4863    #[cfg(feature = "std")]
4864    #[test]
4865    fn test_read_multi_configuration_rejects_declared_message_mismatch() {
4866        let dir = multi_config_test_dir("msg_mismatch");
4867        write_multi_config_file(&dir, "alpha.ron", alpha_subsystem_config());
4868        write_multi_config_file(&dir, "beta.ron", beta_subsystem_config());
4869        let network_path = write_multi_config_file(
4870            &dir,
4871            "network.ron",
4872            r#"(
4873                subsystems: [
4874                    (id: "alpha", config: "alpha.ron"),
4875                    (id: "beta", config: "beta.ron"),
4876                ],
4877                interconnects: [
4878                    (from: "alpha/zenoh/ping", to: "beta/zenoh/ping", msg: "demo::Wrong"),
4879                ],
4880            )"#,
4881        );
4882
4883        let err = read_multi_configuration(network_path.to_str().expect("network path utf8"))
4884            .expect_err("message mismatch should fail");
4885
4886        assert!(
4887            err.to_string()
4888                .contains("declares message type 'demo::Wrong'"),
4889            "unexpected error: {err}"
4890        );
4891    }
4892
4893    #[cfg(feature = "std")]
4894    #[test]
4895    fn test_read_multi_configuration_resolves_instance_override_root() {
4896        let dir = multi_config_test_dir("instance_root");
4897        write_multi_config_file(&dir, "robot.ron", instance_override_subsystem_config());
4898        let network_path = write_multi_config_file(
4899            &dir,
4900            "multi_copper.ron",
4901            r#"(
4902                subsystems: [
4903                    (id: "robot", config: "robot.ron"),
4904                ],
4905                interconnects: [],
4906                instance_overrides_root: "instances",
4907            )"#,
4908        );
4909
4910        let config =
4911            read_multi_configuration(network_path.to_str().expect("network path utf8")).unwrap();
4912
4913        assert_eq!(
4914            config.instance_overrides_root.as_deref().map(Path::new),
4915            Some(dir.join("instances").as_path())
4916        );
4917    }
4918
4919    #[cfg(feature = "std")]
4920    #[test]
4921    fn test_resolve_subsystem_config_for_instance_applies_overrides() {
4922        let dir = multi_config_test_dir("instance_apply");
4923        write_multi_config_file(&dir, "robot.ron", instance_override_subsystem_config());
4924        let instances_dir = dir.join("instances").join("17");
4925        std::fs::create_dir_all(&instances_dir).expect("create instance dir");
4926        write_multi_config_file(
4927            &instances_dir,
4928            "robot.ron",
4929            r#"(
4930                set: [
4931                    (
4932                        path: "tasks/imu/config",
4933                        value: {
4934                            "gyro_bias": [0.1, -0.2, 0.3],
4935                        },
4936                    ),
4937                    (
4938                        path: "resources/board/config",
4939                        value: {
4940                            "bus": "robot17-imu",
4941                        },
4942                    ),
4943                    (
4944                        path: "bridges/radio/config",
4945                        value: {
4946                            "mtu": 64,
4947                        },
4948                    ),
4949                ],
4950            )"#,
4951        );
4952        let network_path = write_multi_config_file(
4953            &dir,
4954            "multi_copper.ron",
4955            r#"(
4956                subsystems: [
4957                    (id: "robot", config: "robot.ron"),
4958                ],
4959                interconnects: [],
4960                instance_overrides_root: "instances",
4961            )"#,
4962        );
4963
4964        let multi =
4965            read_multi_configuration(network_path.to_str().expect("network path utf8")).unwrap();
4966        let effective = multi
4967            .resolve_subsystem_config_for_instance("robot", 17)
4968            .expect("effective config");
4969
4970        let graph = effective.get_graph(None).expect("graph");
4971        let imu_id = graph.get_node_id_by_name("imu").expect("imu node");
4972        let imu = graph.get_node(imu_id).expect("imu weight");
4973        let imu_cfg = imu.get_instance_config().expect("imu config");
4974        assert_eq!(imu_cfg.get::<u64>("sample_hz").unwrap(), Some(200));
4975        let gyro_bias: Vec<f64> = imu_cfg
4976            .get_value("gyro_bias")
4977            .expect("gyro_bias deserialize")
4978            .expect("gyro_bias value");
4979        assert_eq!(gyro_bias, vec![0.1, -0.2, 0.3]);
4980
4981        let board = effective
4982            .resources
4983            .iter()
4984            .find(|resource| resource.id == "board")
4985            .expect("board resource");
4986        assert_eq!(
4987            board.config.as_ref().unwrap().get::<String>("bus").unwrap(),
4988            Some("robot17-imu".to_string())
4989        );
4990
4991        let radio = effective
4992            .bridges
4993            .iter()
4994            .find(|bridge| bridge.id == "radio")
4995            .expect("radio bridge");
4996        assert_eq!(
4997            radio.config.as_ref().unwrap().get::<u64>("mtu").unwrap(),
4998            Some(64)
4999        );
5000
5001        let radio_id = graph.get_node_id_by_name("radio").expect("radio node");
5002        let radio_node = graph.get_node(radio_id).expect("radio weight");
5003        assert_eq!(
5004            radio_node
5005                .get_instance_config()
5006                .unwrap()
5007                .get::<u64>("mtu")
5008                .unwrap(),
5009            Some(64)
5010        );
5011    }
5012
5013    #[cfg(feature = "std")]
5014    #[test]
5015    fn test_resolve_subsystem_config_for_instance_rejects_unknown_path() {
5016        let dir = multi_config_test_dir("instance_unknown");
5017        write_multi_config_file(&dir, "robot.ron", instance_override_subsystem_config());
5018        let instances_dir = dir.join("instances").join("17");
5019        std::fs::create_dir_all(&instances_dir).expect("create instance dir");
5020        write_multi_config_file(
5021            &instances_dir,
5022            "robot.ron",
5023            r#"(
5024                set: [
5025                    (
5026                        path: "tasks/missing/config",
5027                        value: {
5028                            "gyro_bias": [1.0, 2.0, 3.0],
5029                        },
5030                    ),
5031                ],
5032            )"#,
5033        );
5034        let network_path = write_multi_config_file(
5035            &dir,
5036            "multi_copper.ron",
5037            r#"(
5038                subsystems: [
5039                    (id: "robot", config: "robot.ron"),
5040                ],
5041                interconnects: [],
5042                instance_overrides_root: "instances",
5043            )"#,
5044        );
5045
5046        let multi =
5047            read_multi_configuration(network_path.to_str().expect("network path utf8")).unwrap();
5048        let err = multi
5049            .resolve_subsystem_config_for_instance("robot", 17)
5050            .expect_err("unknown task override should fail");
5051
5052        assert!(
5053            err.to_string().contains("targets unknown task 'missing'"),
5054            "unexpected error: {err}"
5055        );
5056    }
5057}