Skip to main content

cu29_runtime/
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;
56
57/// This is the configuration of a component (like a task config or a monitoring config):w
58/// It is a map of key-value pairs.
59/// It is given to the new method of the task implementation.
60#[derive(Serialize, Deserialize, Debug, Clone, Default)]
61pub struct ComponentConfig(pub HashMap<String, Value>);
62
63/// Mapping between resource binding names and bundle-scoped resource ids.
64#[allow(dead_code)]
65impl Display for ComponentConfig {
66    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
67        let mut first = true;
68        let ComponentConfig(config) = self;
69        write!(f, "{{")?;
70        for (key, value) in config.iter() {
71            if !first {
72                write!(f, ", ")?;
73            }
74            write!(f, "{key}: {value}")?;
75            first = false;
76        }
77        write!(f, "}}")
78    }
79}
80
81// forward map interface
82impl ComponentConfig {
83    #[allow(dead_code)]
84    pub fn new() -> Self {
85        ComponentConfig(HashMap::new())
86    }
87
88    #[allow(dead_code)]
89    pub fn get<T>(&self, key: &str) -> Result<Option<T>, ConfigError>
90    where
91        T: for<'a> TryFrom<&'a Value, Error = ConfigError>,
92    {
93        let ComponentConfig(config) = self;
94        match config.get(key) {
95            Some(value) => T::try_from(value).map(Some),
96            None => Ok(None),
97        }
98    }
99
100    #[allow(dead_code)]
101    /// Retrieve a structured config value by deserializing it with cu29-value.
102    ///
103    /// Example RON:
104    /// `{ "calibration": { "matrix": [[1.0, 0.0, 0.0], [0.0, 1.0, 0.0], [0.0, 0.0, 1.0]], "enabled": true } }`
105    ///
106    /// ```rust,ignore
107    /// #[derive(serde::Deserialize)]
108    /// struct CalibrationCfg {
109    ///     matrix: [[f32; 3]; 3],
110    ///     enabled: bool,
111    /// }
112    /// let cfg: CalibrationCfg = config.get_value("calibration")?.unwrap();
113    /// ```
114    pub fn get_value<T>(&self, key: &str) -> Result<Option<T>, ConfigError>
115    where
116        T: DeserializeOwned,
117    {
118        let ComponentConfig(config) = self;
119        let Some(value) = config.get(key) else {
120            return Ok(None);
121        };
122        let cu_value = ron_value_to_cu_value(&value.0).map_err(|err| err.with_key(key))?;
123        cu_value
124            .deserialize_into::<T>()
125            .map(Some)
126            .map_err(|err| ConfigError {
127                message: format!(
128                    "Config key '{key}' failed to deserialize as {}: {err}",
129                    type_name::<T>()
130                ),
131            })
132    }
133
134    #[allow(dead_code)]
135    pub fn set<T: Into<Value>>(&mut self, key: &str, value: T) {
136        let ComponentConfig(config) = self;
137        config.insert(key.to_string(), value.into());
138    }
139}
140
141fn ron_value_to_cu_value(value: &RonValue) -> Result<CuValue, ConfigError> {
142    match value {
143        RonValue::Bool(v) => Ok(CuValue::Bool(*v)),
144        RonValue::Char(v) => Ok(CuValue::Char(*v)),
145        RonValue::String(v) => Ok(CuValue::String(v.clone())),
146        RonValue::Bytes(v) => Ok(CuValue::Bytes(v.clone())),
147        RonValue::Unit => Ok(CuValue::Unit),
148        RonValue::Option(v) => {
149            let mapped = match v {
150                Some(inner) => Some(Box::new(ron_value_to_cu_value(inner)?)),
151                None => None,
152            };
153            Ok(CuValue::Option(mapped))
154        }
155        RonValue::Seq(seq) => {
156            let mut mapped = Vec::with_capacity(seq.len());
157            for item in seq {
158                mapped.push(ron_value_to_cu_value(item)?);
159            }
160            Ok(CuValue::Seq(mapped))
161        }
162        RonValue::Map(map) => {
163            let mut mapped = BTreeMap::new();
164            for (key, value) in map.iter() {
165                let mapped_key = ron_value_to_cu_value(key)?;
166                let mapped_value = ron_value_to_cu_value(value)?;
167                mapped.insert(mapped_key, mapped_value);
168            }
169            Ok(CuValue::Map(mapped))
170        }
171        RonValue::Number(num) => match num {
172            Number::I8(v) => Ok(CuValue::I8(*v)),
173            Number::I16(v) => Ok(CuValue::I16(*v)),
174            Number::I32(v) => Ok(CuValue::I32(*v)),
175            Number::I64(v) => Ok(CuValue::I64(*v)),
176            Number::U8(v) => Ok(CuValue::U8(*v)),
177            Number::U16(v) => Ok(CuValue::U16(*v)),
178            Number::U32(v) => Ok(CuValue::U32(*v)),
179            Number::U64(v) => Ok(CuValue::U64(*v)),
180            Number::F32(v) => Ok(CuValue::F32(v.0)),
181            Number::F64(v) => Ok(CuValue::F64(v.0)),
182            Number::__NonExhaustive(_) => Err(ConfigError {
183                message: "Unsupported RON number variant".to_string(),
184            }),
185        },
186    }
187}
188
189// The configuration Serialization format is as follows:
190// (
191//   tasks : [ (id: "toto", type: "zorglub::MyType", config: {...}),
192//             (id: "titi", type: "zorglub::MyType2", config: {...})]
193//   cnx : [ (src: "toto", dst: "titi", msg: "zorglub::MyMsgType"),...]
194// )
195
196/// Wrapper around the ron::Value to allow for custom serialization.
197#[derive(Serialize, Deserialize, Debug, Clone, PartialEq)]
198pub struct Value(RonValue);
199
200#[derive(Debug, Clone, PartialEq)]
201pub struct ConfigError {
202    message: String,
203}
204
205impl ConfigError {
206    fn type_mismatch(expected: &'static str, value: &Value) -> Self {
207        ConfigError {
208            message: format!("Expected {expected} but got {value:?}"),
209        }
210    }
211
212    fn with_key(self, key: &str) -> Self {
213        ConfigError {
214            message: format!("Config key '{key}': {}", self.message),
215        }
216    }
217}
218
219impl Display for ConfigError {
220    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
221        write!(f, "{}", self.message)
222    }
223}
224
225#[cfg(feature = "std")]
226impl std::error::Error for ConfigError {}
227
228#[cfg(not(feature = "std"))]
229impl core::error::Error for ConfigError {}
230
231impl From<ConfigError> for CuError {
232    fn from(err: ConfigError) -> Self {
233        CuError::from(err.to_string())
234    }
235}
236
237// Macro for implementing From<T> for Value where T is a numeric type
238macro_rules! impl_from_numeric_for_value {
239    ($($source:ty),* $(,)?) => {
240        $(impl From<$source> for Value {
241            fn from(value: $source) -> Self {
242                Value(RonValue::Number(value.into()))
243            }
244        })*
245    };
246}
247
248// Implement From for common numeric types
249impl_from_numeric_for_value!(i8, i16, i32, i64, u8, u16, u32, u64, f32, f64);
250
251impl TryFrom<&Value> for bool {
252    type Error = ConfigError;
253
254    fn try_from(value: &Value) -> Result<Self, Self::Error> {
255        if let Value(RonValue::Bool(v)) = value {
256            Ok(*v)
257        } else {
258            Err(ConfigError::type_mismatch("bool", value))
259        }
260    }
261}
262
263impl From<Value> for bool {
264    fn from(value: Value) -> Self {
265        if let Value(RonValue::Bool(v)) = value {
266            v
267        } else {
268            panic!("Expected a Boolean variant but got {value:?}")
269        }
270    }
271}
272macro_rules! impl_from_value_for_int {
273    ($($target:ty),* $(,)?) => {
274        $(
275            impl From<Value> for $target {
276                fn from(value: Value) -> Self {
277                    if let Value(RonValue::Number(num)) = value {
278                        match num {
279                            Number::I8(n) => n as $target,
280                            Number::I16(n) => n as $target,
281                            Number::I32(n) => n as $target,
282                            Number::I64(n) => n as $target,
283                            Number::U8(n) => n as $target,
284                            Number::U16(n) => n as $target,
285                            Number::U32(n) => n as $target,
286                            Number::U64(n) => n as $target,
287                            Number::F32(_) | Number::F64(_) | Number::__NonExhaustive(_) => {
288                                panic!("Expected an integer Number variant but got {num:?}")
289                            }
290                        }
291                    } else {
292                        panic!("Expected a Number variant but got {value:?}")
293                    }
294                }
295            }
296        )*
297    };
298}
299
300impl_from_value_for_int!(u8, i8, u16, i16, u32, i32, u64, i64);
301
302macro_rules! impl_try_from_value_for_int {
303    ($($target:ty),* $(,)?) => {
304        $(
305            impl TryFrom<&Value> for $target {
306                type Error = ConfigError;
307
308                fn try_from(value: &Value) -> Result<Self, Self::Error> {
309                    if let Value(RonValue::Number(num)) = value {
310                        match num {
311                            Number::I8(n) => Ok(*n as $target),
312                            Number::I16(n) => Ok(*n as $target),
313                            Number::I32(n) => Ok(*n as $target),
314                            Number::I64(n) => Ok(*n as $target),
315                            Number::U8(n) => Ok(*n as $target),
316                            Number::U16(n) => Ok(*n as $target),
317                            Number::U32(n) => Ok(*n as $target),
318                            Number::U64(n) => Ok(*n as $target),
319                            Number::F32(_) | Number::F64(_) | Number::__NonExhaustive(_) => {
320                                Err(ConfigError::type_mismatch("integer", value))
321                            }
322                        }
323                    } else {
324                        Err(ConfigError::type_mismatch("integer", value))
325                    }
326                }
327            }
328        )*
329    };
330}
331
332impl_try_from_value_for_int!(u8, i8, u16, i16, u32, i32, u64, i64);
333
334impl TryFrom<&Value> for f64 {
335    type Error = ConfigError;
336
337    fn try_from(value: &Value) -> Result<Self, Self::Error> {
338        if let Value(RonValue::Number(num)) = value {
339            let number = match num {
340                Number::I8(n) => *n as f64,
341                Number::I16(n) => *n as f64,
342                Number::I32(n) => *n as f64,
343                Number::I64(n) => *n as f64,
344                Number::U8(n) => *n as f64,
345                Number::U16(n) => *n as f64,
346                Number::U32(n) => *n as f64,
347                Number::U64(n) => *n as f64,
348                Number::F32(n) => n.0 as f64,
349                Number::F64(n) => n.0,
350                Number::__NonExhaustive(_) => {
351                    return Err(ConfigError::type_mismatch("number", value));
352                }
353            };
354            Ok(number)
355        } else {
356            Err(ConfigError::type_mismatch("number", value))
357        }
358    }
359}
360
361impl From<Value> for f64 {
362    fn from(value: Value) -> Self {
363        if let Value(RonValue::Number(num)) = value {
364            num.into_f64()
365        } else {
366            panic!("Expected a Number variant but got {value:?}")
367        }
368    }
369}
370
371impl From<String> for Value {
372    fn from(value: String) -> Self {
373        Value(RonValue::String(value))
374    }
375}
376
377impl TryFrom<&Value> for String {
378    type Error = ConfigError;
379
380    fn try_from(value: &Value) -> Result<Self, Self::Error> {
381        if let Value(RonValue::String(s)) = value {
382            Ok(s.clone())
383        } else {
384            Err(ConfigError::type_mismatch("string", value))
385        }
386    }
387}
388
389impl From<Value> for String {
390    fn from(value: Value) -> Self {
391        if let Value(RonValue::String(s)) = value {
392            s
393        } else {
394            panic!("Expected a String variant")
395        }
396    }
397}
398
399impl Display for Value {
400    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
401        let Value(value) = self;
402        match value {
403            RonValue::Number(n) => {
404                let s = match n {
405                    Number::I8(n) => n.to_string(),
406                    Number::I16(n) => n.to_string(),
407                    Number::I32(n) => n.to_string(),
408                    Number::I64(n) => n.to_string(),
409                    Number::U8(n) => n.to_string(),
410                    Number::U16(n) => n.to_string(),
411                    Number::U32(n) => n.to_string(),
412                    Number::U64(n) => n.to_string(),
413                    Number::F32(n) => n.0.to_string(),
414                    Number::F64(n) => n.0.to_string(),
415                    _ => panic!("Expected a Number variant but got {value:?}"),
416                };
417                write!(f, "{s}")
418            }
419            RonValue::String(s) => write!(f, "{s}"),
420            RonValue::Bool(b) => write!(f, "{b}"),
421            RonValue::Map(m) => write!(f, "{m:?}"),
422            RonValue::Char(c) => write!(f, "{c:?}"),
423            RonValue::Unit => write!(f, "unit"),
424            RonValue::Option(o) => write!(f, "{o:?}"),
425            RonValue::Seq(s) => write!(f, "{s:?}"),
426            RonValue::Bytes(bytes) => write!(f, "{bytes:?}"),
427        }
428    }
429}
430
431/// Configuration for logging in the node.
432#[derive(Serialize, Deserialize, Debug, Clone)]
433pub struct NodeLogging {
434    enabled: bool,
435}
436
437/// Distinguishes regular tasks from bridge nodes so downstream stages can apply
438/// bridge-specific instantiation rules.
439#[derive(Default, Debug, Copy, Clone, PartialEq, Eq)]
440pub enum Flavor {
441    #[default]
442    Task,
443    Bridge,
444}
445
446/// A node in the configuration graph.
447/// A node represents a Task in the system Graph.
448#[derive(Serialize, Deserialize, Debug, Clone)]
449pub struct Node {
450    /// Unique node identifier.
451    id: String,
452
453    /// Task rust struct underlying type, e.g. "mymodule::Sensor", etc.
454    #[serde(rename = "type", skip_serializing_if = "Option::is_none")]
455    type_: Option<String>,
456
457    /// Config passed to the task.
458    #[serde(skip_serializing_if = "Option::is_none")]
459    config: Option<ComponentConfig>,
460
461    /// Resources requested by the task.
462    #[serde(skip_serializing_if = "Option::is_none")]
463    resources: Option<HashMap<String, String>>,
464
465    /// Missions for which this task is run.
466    missions: Option<Vec<String>>,
467
468    /// Run this task in the background:
469    /// ie. Will be set to run on a background thread and until it is finished `CuTask::process` will return None.
470    #[serde(skip_serializing_if = "Option::is_none")]
471    background: Option<bool>,
472
473    /// Option to include/exclude stubbing for simulation.
474    /// By default, sources and sinks are replaces (stubbed) by the runtime to avoid trying to compile hardware specific code for sensing or actuation.
475    /// 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.
476    /// This option allows to control this behavior.
477    /// Note: Normal tasks will be run in sim and this parameter ignored.
478    #[serde(skip_serializing_if = "Option::is_none")]
479    run_in_sim: Option<bool>,
480
481    /// Config passed to the task.
482    #[serde(skip_serializing_if = "Option::is_none")]
483    logging: Option<NodeLogging>,
484
485    /// Node role in the runtime graph (normal task or bridge endpoint).
486    #[serde(skip, default)]
487    flavor: Flavor,
488}
489
490impl Node {
491    #[allow(dead_code)]
492    pub fn new(id: &str, ptype: &str) -> Self {
493        Node {
494            id: id.to_string(),
495            type_: Some(ptype.to_string()),
496            config: None,
497            resources: None,
498            missions: None,
499            background: None,
500            run_in_sim: None,
501            logging: None,
502            flavor: Flavor::Task,
503        }
504    }
505
506    #[allow(dead_code)]
507    pub fn new_with_flavor(id: &str, ptype: &str, flavor: Flavor) -> Self {
508        let mut node = Self::new(id, ptype);
509        node.flavor = flavor;
510        node
511    }
512
513    #[allow(dead_code)]
514    pub fn get_id(&self) -> String {
515        self.id.clone()
516    }
517
518    #[allow(dead_code)]
519    pub fn get_type(&self) -> &str {
520        self.type_.as_ref().unwrap()
521    }
522
523    #[allow(dead_code)]
524    pub fn set_type(mut self, name: Option<String>) -> Self {
525        self.type_ = name;
526        self
527    }
528
529    #[allow(dead_code)]
530    pub fn set_resources<I>(&mut self, resources: Option<I>)
531    where
532        I: IntoIterator<Item = (String, String)>,
533    {
534        self.resources = resources.map(|iter| iter.into_iter().collect());
535    }
536
537    #[allow(dead_code)]
538    pub fn is_background(&self) -> bool {
539        self.background.unwrap_or(false)
540    }
541
542    #[allow(dead_code)]
543    pub fn get_instance_config(&self) -> Option<&ComponentConfig> {
544        self.config.as_ref()
545    }
546
547    #[allow(dead_code)]
548    pub fn get_resources(&self) -> Option<&HashMap<String, String>> {
549        self.resources.as_ref()
550    }
551
552    /// By default, assume a source or a sink is not run in sim.
553    /// Normal tasks will be run in sim and this parameter ignored.
554    #[allow(dead_code)]
555    pub fn is_run_in_sim(&self) -> bool {
556        self.run_in_sim.unwrap_or(false)
557    }
558
559    #[allow(dead_code)]
560    pub fn is_logging_enabled(&self) -> bool {
561        if let Some(logging) = &self.logging {
562            logging.enabled
563        } else {
564            true
565        }
566    }
567
568    #[allow(dead_code)]
569    pub fn get_param<T>(&self, key: &str) -> Result<Option<T>, ConfigError>
570    where
571        T: for<'a> TryFrom<&'a Value, Error = ConfigError>,
572    {
573        let pc = match self.config.as_ref() {
574            Some(pc) => pc,
575            None => return Ok(None),
576        };
577        let ComponentConfig(pc) = pc;
578        match pc.get(key) {
579            Some(v) => T::try_from(v).map(Some),
580            None => Ok(None),
581        }
582    }
583
584    #[allow(dead_code)]
585    pub fn set_param<T: Into<Value>>(&mut self, key: &str, value: T) {
586        if self.config.is_none() {
587            self.config = Some(ComponentConfig(HashMap::new()));
588        }
589        let ComponentConfig(config) = self.config.as_mut().unwrap();
590        config.insert(key.to_string(), value.into());
591    }
592
593    /// Returns whether this node is treated as a normal task or as a bridge.
594    #[allow(dead_code)]
595    pub fn get_flavor(&self) -> Flavor {
596        self.flavor
597    }
598
599    /// Overrides the node flavor; primarily used when injecting bridge nodes.
600    #[allow(dead_code)]
601    pub fn set_flavor(&mut self, flavor: Flavor) {
602        self.flavor = flavor;
603    }
604}
605
606/// Directional mapping for bridge channels.
607#[derive(Serialize, Deserialize, Debug, Clone)]
608pub enum BridgeChannelConfigRepresentation {
609    /// Channel that receives data from the bridge into the graph.
610    Rx {
611        id: String,
612        /// Optional transport/topic identifier specific to the bridge backend.
613        #[serde(skip_serializing_if = "Option::is_none")]
614        route: Option<String>,
615        /// Optional per-channel configuration forwarded to the bridge implementation.
616        #[serde(skip_serializing_if = "Option::is_none")]
617        config: Option<ComponentConfig>,
618    },
619    /// Channel that transmits data from the graph into the bridge.
620    Tx {
621        id: String,
622        /// Optional transport/topic identifier specific to the bridge backend.
623        #[serde(skip_serializing_if = "Option::is_none")]
624        route: Option<String>,
625        /// Optional per-channel configuration forwarded to the bridge implementation.
626        #[serde(skip_serializing_if = "Option::is_none")]
627        config: Option<ComponentConfig>,
628    },
629}
630
631impl BridgeChannelConfigRepresentation {
632    /// Stable logical identifier to reference this channel in connections.
633    #[allow(dead_code)]
634    pub fn id(&self) -> &str {
635        match self {
636            BridgeChannelConfigRepresentation::Rx { id, .. }
637            | BridgeChannelConfigRepresentation::Tx { id, .. } => id,
638        }
639    }
640
641    /// Bridge-specific transport path (topic, route, path...) describing this channel.
642    #[allow(dead_code)]
643    pub fn route(&self) -> Option<&str> {
644        match self {
645            BridgeChannelConfigRepresentation::Rx { route, .. }
646            | BridgeChannelConfigRepresentation::Tx { route, .. } => route.as_deref(),
647        }
648    }
649}
650
651enum EndpointRole {
652    Source,
653    Destination,
654}
655
656fn validate_bridge_channel(
657    bridge: &BridgeConfig,
658    channel_id: &str,
659    role: EndpointRole,
660) -> Result<(), String> {
661    let channel = bridge
662        .channels
663        .iter()
664        .find(|ch| ch.id() == channel_id)
665        .ok_or_else(|| {
666            format!(
667                "Bridge '{}' does not declare a channel named '{}'",
668                bridge.id, channel_id
669            )
670        })?;
671
672    match (role, channel) {
673        (EndpointRole::Source, BridgeChannelConfigRepresentation::Rx { .. }) => Ok(()),
674        (EndpointRole::Destination, BridgeChannelConfigRepresentation::Tx { .. }) => Ok(()),
675        (EndpointRole::Source, BridgeChannelConfigRepresentation::Tx { .. }) => Err(format!(
676            "Bridge '{}' channel '{}' is Tx and cannot act as a source",
677            bridge.id, channel_id
678        )),
679        (EndpointRole::Destination, BridgeChannelConfigRepresentation::Rx { .. }) => Err(format!(
680            "Bridge '{}' channel '{}' is Rx and cannot act as a destination",
681            bridge.id, channel_id
682        )),
683    }
684}
685
686/// Declarative definition of a resource bundle.
687#[derive(Serialize, Deserialize, Debug, Clone)]
688pub struct ResourceBundleConfig {
689    pub id: String,
690    #[serde(rename = "provider")]
691    pub provider: String,
692    #[serde(skip_serializing_if = "Option::is_none")]
693    pub config: Option<ComponentConfig>,
694    #[serde(skip_serializing_if = "Option::is_none")]
695    pub missions: Option<Vec<String>>,
696}
697
698/// Declarative definition of a bridge component with a list of channels.
699#[derive(Serialize, Deserialize, Debug, Clone)]
700pub struct BridgeConfig {
701    pub id: String,
702    #[serde(rename = "type")]
703    pub type_: String,
704    #[serde(skip_serializing_if = "Option::is_none")]
705    pub config: Option<ComponentConfig>,
706    #[serde(skip_serializing_if = "Option::is_none")]
707    pub resources: Option<HashMap<String, String>>,
708    #[serde(skip_serializing_if = "Option::is_none")]
709    pub missions: Option<Vec<String>>,
710    /// List of logical endpoints exposed by this bridge.
711    pub channels: Vec<BridgeChannelConfigRepresentation>,
712}
713
714impl BridgeConfig {
715    fn to_node(&self) -> Node {
716        let mut node = Node::new_with_flavor(&self.id, &self.type_, Flavor::Bridge);
717        node.config = self.config.clone();
718        node.resources = self.resources.clone();
719        node.missions = self.missions.clone();
720        node
721    }
722}
723
724fn insert_bridge_node(graph: &mut CuGraph, bridge: &BridgeConfig) -> Result<(), String> {
725    if graph.get_node_id_by_name(bridge.id.as_str()).is_some() {
726        return Err(format!(
727            "Bridge '{}' reuses an existing node id. Bridge ids must be unique.",
728            bridge.id
729        ));
730    }
731    graph
732        .add_node(bridge.to_node())
733        .map(|_| ())
734        .map_err(|e| e.to_string())
735}
736
737/// Serialized representation of a connection used for the RON config.
738#[derive(Serialize, Deserialize, Debug, Clone)]
739struct SerializedCnx {
740    src: String,
741    dst: String,
742    msg: String,
743    missions: Option<Vec<String>>,
744}
745
746/// This represents a connection between 2 tasks (nodes) in the configuration graph.
747#[derive(Debug, Clone)]
748pub struct Cnx {
749    /// Source node id.
750    pub src: String,
751    /// Destination node id.
752    pub dst: String,
753    /// Message type exchanged between src and dst.
754    pub msg: String,
755    /// Restrict this connection for this list of missions.
756    pub missions: Option<Vec<String>>,
757    /// Optional channel id when the source endpoint is a bridge.
758    pub src_channel: Option<String>,
759    /// Optional channel id when the destination endpoint is a bridge.
760    pub dst_channel: Option<String>,
761}
762
763impl From<&Cnx> for SerializedCnx {
764    fn from(cnx: &Cnx) -> Self {
765        SerializedCnx {
766            src: format_endpoint(&cnx.src, cnx.src_channel.as_deref()),
767            dst: format_endpoint(&cnx.dst, cnx.dst_channel.as_deref()),
768            msg: cnx.msg.clone(),
769            missions: cnx.missions.clone(),
770        }
771    }
772}
773
774fn format_endpoint(node: &str, channel: Option<&str>) -> String {
775    match channel {
776        Some(ch) => format!("{node}/{ch}"),
777        None => node.to_string(),
778    }
779}
780
781fn parse_endpoint(
782    endpoint: &str,
783    role: EndpointRole,
784    bridges: &HashMap<&str, &BridgeConfig>,
785) -> Result<(String, Option<String>), String> {
786    if let Some((node, channel)) = endpoint.split_once('/') {
787        if let Some(bridge) = bridges.get(node) {
788            validate_bridge_channel(bridge, channel, role)?;
789            return Ok((node.to_string(), Some(channel.to_string())));
790        } else {
791            return Err(format!(
792                "Endpoint '{endpoint}' references an unknown bridge '{node}'"
793            ));
794        }
795    }
796
797    if let Some(bridge) = bridges.get(endpoint) {
798        return Err(format!(
799            "Bridge '{}' connections must reference a channel using '{}/<channel>'",
800            bridge.id, bridge.id
801        ));
802    }
803
804    Ok((endpoint.to_string(), None))
805}
806
807fn build_bridge_lookup(bridges: Option<&Vec<BridgeConfig>>) -> HashMap<&str, &BridgeConfig> {
808    let mut map = HashMap::new();
809    if let Some(bridges) = bridges {
810        for bridge in bridges {
811            map.insert(bridge.id.as_str(), bridge);
812        }
813    }
814    map
815}
816
817fn mission_applies(missions: &Option<Vec<String>>, mission_id: &str) -> bool {
818    missions
819        .as_ref()
820        .map(|mission_list| mission_list.iter().any(|m| m == mission_id))
821        .unwrap_or(true)
822}
823
824/// A simple wrapper enum for `petgraph::Direction`,
825/// designed to be converted *into* it via the `From` trait.
826#[derive(Debug, Clone, Copy, PartialEq, Eq)]
827pub enum CuDirection {
828    Outgoing,
829    Incoming,
830}
831
832impl From<CuDirection> for petgraph::Direction {
833    fn from(dir: CuDirection) -> Self {
834        match dir {
835            CuDirection::Outgoing => petgraph::Direction::Outgoing,
836            CuDirection::Incoming => petgraph::Direction::Incoming,
837        }
838    }
839}
840
841#[derive(Default, Debug, Clone)]
842pub struct CuGraph(pub StableDiGraph<Node, Cnx, NodeId>);
843
844impl CuGraph {
845    #[allow(dead_code)]
846    pub fn get_all_nodes(&self) -> Vec<(NodeId, &Node)> {
847        self.0
848            .node_indices()
849            .map(|index| (index.index() as u32, &self.0[index]))
850            .collect()
851    }
852
853    #[allow(dead_code)]
854    pub fn get_neighbor_ids(&self, node_id: NodeId, dir: CuDirection) -> Vec<NodeId> {
855        self.0
856            .neighbors_directed(node_id.into(), dir.into())
857            .map(|petgraph_index| petgraph_index.index() as NodeId)
858            .collect()
859    }
860
861    #[allow(dead_code)]
862    pub fn node_ids(&self) -> Vec<NodeId> {
863        self.0
864            .node_indices()
865            .map(|index| index.index() as NodeId)
866            .collect()
867    }
868
869    #[allow(dead_code)]
870    pub fn edge_id_between(&self, source: NodeId, target: NodeId) -> Option<usize> {
871        self.0
872            .find_edge(source.into(), target.into())
873            .map(|edge| edge.index())
874    }
875
876    #[allow(dead_code)]
877    pub fn edge(&self, edge_id: usize) -> Option<&Cnx> {
878        self.0.edge_weight(EdgeIndex::new(edge_id))
879    }
880
881    #[allow(dead_code)]
882    pub fn edges(&self) -> impl Iterator<Item = &Cnx> {
883        self.0
884            .edge_indices()
885            .filter_map(|edge| self.0.edge_weight(edge))
886    }
887
888    #[allow(dead_code)]
889    pub fn bfs_nodes(&self, start: NodeId) -> Vec<NodeId> {
890        let mut visitor = Bfs::new(&self.0, start.into());
891        let mut nodes = Vec::new();
892        while let Some(node) = visitor.next(&self.0) {
893            nodes.push(node.index() as NodeId);
894        }
895        nodes
896    }
897
898    #[allow(dead_code)]
899    pub fn incoming_neighbor_count(&self, node_id: NodeId) -> usize {
900        self.0.neighbors_directed(node_id.into(), Incoming).count()
901    }
902
903    #[allow(dead_code)]
904    pub fn outgoing_neighbor_count(&self, node_id: NodeId) -> usize {
905        self.0.neighbors_directed(node_id.into(), Outgoing).count()
906    }
907
908    pub fn node_indices(&self) -> Vec<petgraph::stable_graph::NodeIndex> {
909        self.0.node_indices().collect()
910    }
911
912    pub fn add_node(&mut self, node: Node) -> CuResult<NodeId> {
913        Ok(self.0.add_node(node).index() as NodeId)
914    }
915
916    #[allow(dead_code)]
917    pub fn connection_exists(&self, source: NodeId, target: NodeId) -> bool {
918        self.0.find_edge(source.into(), target.into()).is_some()
919    }
920
921    pub fn connect_ext(
922        &mut self,
923        source: NodeId,
924        target: NodeId,
925        msg_type: &str,
926        missions: Option<Vec<String>>,
927        src_channel: Option<String>,
928        dst_channel: Option<String>,
929    ) -> CuResult<()> {
930        let (src_id, dst_id) = (
931            self.0
932                .node_weight(source.into())
933                .ok_or("Source node not found")?
934                .id
935                .clone(),
936            self.0
937                .node_weight(target.into())
938                .ok_or("Target node not found")?
939                .id
940                .clone(),
941        );
942
943        let _ = self.0.add_edge(
944            petgraph::stable_graph::NodeIndex::from(source),
945            petgraph::stable_graph::NodeIndex::from(target),
946            Cnx {
947                src: src_id,
948                dst: dst_id,
949                msg: msg_type.to_string(),
950                missions,
951                src_channel,
952                dst_channel,
953            },
954        );
955        Ok(())
956    }
957    /// Get the node with the given id.
958    /// If mission_id is provided, get the node from that mission's graph.
959    /// Otherwise get the node from the simple graph.
960    #[allow(dead_code)]
961    pub fn get_node(&self, node_id: NodeId) -> Option<&Node> {
962        self.0.node_weight(node_id.into())
963    }
964
965    #[allow(dead_code)]
966    pub fn get_node_weight(&self, index: NodeId) -> Option<&Node> {
967        self.0.node_weight(index.into())
968    }
969
970    #[allow(dead_code)]
971    pub fn get_node_mut(&mut self, node_id: NodeId) -> Option<&mut Node> {
972        self.0.node_weight_mut(node_id.into())
973    }
974
975    pub fn get_node_id_by_name(&self, name: &str) -> Option<NodeId> {
976        self.0
977            .node_indices()
978            .into_iter()
979            .find(|idx| self.0[*idx].get_id() == name)
980            .map(|i| i.index() as NodeId)
981    }
982
983    #[allow(dead_code)]
984    pub fn get_edge_weight(&self, index: usize) -> Option<Cnx> {
985        self.0.edge_weight(EdgeIndex::new(index)).cloned()
986    }
987
988    #[allow(dead_code)]
989    pub fn get_node_output_msg_type(&self, node_id: &str) -> Option<String> {
990        self.0.node_indices().find_map(|node_index| {
991            if let Some(node) = self.0.node_weight(node_index) {
992                if node.id != node_id {
993                    return None;
994                }
995                let edges: Vec<_> = self
996                    .0
997                    .edges_directed(node_index, Outgoing)
998                    .map(|edge| edge.id().index())
999                    .collect();
1000                if edges.is_empty() {
1001                    return None;
1002                }
1003                let cnx = self
1004                    .0
1005                    .edge_weight(EdgeIndex::new(edges[0]))
1006                    .expect("Found an cnx id but could not retrieve it back");
1007                return Some(cnx.msg.clone());
1008            }
1009            None
1010        })
1011    }
1012
1013    #[allow(dead_code)]
1014    pub fn get_node_input_msg_type(&self, node_id: &str) -> Option<String> {
1015        self.get_node_input_msg_types(node_id)
1016            .and_then(|mut v| v.pop())
1017    }
1018
1019    pub fn get_node_input_msg_types(&self, node_id: &str) -> Option<Vec<String>> {
1020        self.0.node_indices().find_map(|node_index| {
1021            if let Some(node) = self.0.node_weight(node_index) {
1022                if node.id != node_id {
1023                    return None;
1024                }
1025                let edges: Vec<_> = self
1026                    .0
1027                    .edges_directed(node_index, Incoming)
1028                    .map(|edge| edge.id().index())
1029                    .collect();
1030                if edges.is_empty() {
1031                    return None;
1032                }
1033                let msgs = edges
1034                    .into_iter()
1035                    .map(|edge_id| {
1036                        let cnx = self
1037                            .0
1038                            .edge_weight(EdgeIndex::new(edge_id))
1039                            .expect("Found an cnx id but could not retrieve it back");
1040                        cnx.msg.clone()
1041                    })
1042                    .collect();
1043                return Some(msgs);
1044            }
1045            None
1046        })
1047    }
1048
1049    #[allow(dead_code)]
1050    pub fn get_connection_msg_type(&self, source: NodeId, target: NodeId) -> Option<&str> {
1051        self.0
1052            .find_edge(source.into(), target.into())
1053            .map(|edge_index| self.0[edge_index].msg.as_str())
1054    }
1055
1056    /// Get the list of edges that are connected to the given node as a source.
1057    fn get_edges_by_direction(
1058        &self,
1059        node_id: NodeId,
1060        direction: petgraph::Direction,
1061    ) -> CuResult<Vec<usize>> {
1062        Ok(self
1063            .0
1064            .edges_directed(node_id.into(), direction)
1065            .map(|edge| edge.id().index())
1066            .collect())
1067    }
1068
1069    pub fn get_src_edges(&self, node_id: NodeId) -> CuResult<Vec<usize>> {
1070        self.get_edges_by_direction(node_id, Outgoing)
1071    }
1072
1073    /// Get the list of edges that are connected to the given node as a destination.
1074    pub fn get_dst_edges(&self, node_id: NodeId) -> CuResult<Vec<usize>> {
1075        self.get_edges_by_direction(node_id, Incoming)
1076    }
1077
1078    #[allow(dead_code)]
1079    pub fn node_count(&self) -> usize {
1080        self.0.node_count()
1081    }
1082
1083    #[allow(dead_code)]
1084    pub fn edge_count(&self) -> usize {
1085        self.0.edge_count()
1086    }
1087
1088    /// Adds an edge between two nodes/tasks in the configuration graph.
1089    /// msg_type is the type of message exchanged between the two nodes/tasks.
1090    #[allow(dead_code)]
1091    pub fn connect(&mut self, source: NodeId, target: NodeId, msg_type: &str) -> CuResult<()> {
1092        self.connect_ext(source, target, msg_type, None, None, None)
1093    }
1094}
1095
1096impl core::ops::Index<NodeIndex> for CuGraph {
1097    type Output = Node;
1098
1099    fn index(&self, index: NodeIndex) -> &Self::Output {
1100        &self.0[index]
1101    }
1102}
1103
1104#[derive(Debug, Clone)]
1105pub enum ConfigGraphs {
1106    Simple(CuGraph),
1107    Missions(HashMap<String, CuGraph>),
1108}
1109
1110impl ConfigGraphs {
1111    /// Returns a consistent hashmap of mission names to Graphs whatever the shape of the config is.
1112    /// Note: if there is only one anonymous mission it will be called "default"
1113    #[allow(dead_code)]
1114    pub fn get_all_missions_graphs(&self) -> HashMap<String, CuGraph> {
1115        match self {
1116            Simple(graph) => HashMap::from([("default".to_string(), graph.clone())]),
1117            Missions(graphs) => graphs.clone(),
1118        }
1119    }
1120
1121    #[allow(dead_code)]
1122    pub fn get_default_mission_graph(&self) -> CuResult<&CuGraph> {
1123        match self {
1124            Simple(graph) => Ok(graph),
1125            Missions(graphs) => {
1126                if graphs.len() == 1 {
1127                    Ok(graphs.values().next().unwrap())
1128                } else {
1129                    Err("Cannot get default mission graph from mission config".into())
1130                }
1131            }
1132        }
1133    }
1134
1135    #[allow(dead_code)]
1136    pub fn get_graph(&self, mission_id: Option<&str>) -> CuResult<&CuGraph> {
1137        match self {
1138            Simple(graph) => match mission_id {
1139                None | Some("default") => Ok(graph),
1140                Some(_) => Err("Cannot get mission graph from simple config".into()),
1141            },
1142            Missions(graphs) => {
1143                let id = mission_id
1144                    .ok_or_else(|| "Mission ID required for mission configs".to_string())?;
1145                graphs
1146                    .get(id)
1147                    .ok_or_else(|| format!("Mission {id} not found").into())
1148            }
1149        }
1150    }
1151
1152    #[allow(dead_code)]
1153    pub fn get_graph_mut(&mut self, mission_id: Option<&str>) -> CuResult<&mut CuGraph> {
1154        match self {
1155            Simple(graph) => match mission_id {
1156                None => Ok(graph),
1157                Some(_) => Err("Cannot get mission graph from simple config".into()),
1158            },
1159            Missions(graphs) => {
1160                let id = mission_id
1161                    .ok_or_else(|| "Mission ID required for mission configs".to_string())?;
1162                graphs
1163                    .get_mut(id)
1164                    .ok_or_else(|| format!("Mission {id} not found").into())
1165            }
1166        }
1167    }
1168
1169    pub fn add_mission(&mut self, mission_id: &str) -> CuResult<&mut CuGraph> {
1170        match self {
1171            Simple(_) => Err("Cannot add mission to simple config".into()),
1172            Missions(graphs) => match graphs.entry(mission_id.to_string()) {
1173                hashbrown::hash_map::Entry::Occupied(_) => {
1174                    Err(format!("Mission {mission_id} already exists").into())
1175                }
1176                hashbrown::hash_map::Entry::Vacant(entry) => Ok(entry.insert(CuGraph::default())),
1177            },
1178        }
1179    }
1180}
1181
1182/// CuConfig is the programmatic representation of the configuration graph.
1183/// It is a directed graph where nodes are tasks and edges are connections between tasks.
1184///
1185/// The core of CuConfig is its `graphs` field which can be either a simple graph
1186/// or a collection of mission-specific graphs. The graph structure is based on petgraph.
1187#[derive(Debug, Clone)]
1188pub struct CuConfig {
1189    /// Monitoring configuration list.
1190    pub monitors: Vec<MonitorConfig>,
1191    /// Optional logging configuration
1192    pub logging: Option<LoggingConfig>,
1193    /// Optional runtime configuration
1194    pub runtime: Option<RuntimeConfig>,
1195    /// Declarative resource bundle definitions
1196    pub resources: Vec<ResourceBundleConfig>,
1197    /// Declarative bridge definitions that are yet to be expanded into the graph
1198    pub bridges: Vec<BridgeConfig>,
1199    /// Graph structure - either a single graph or multiple mission-specific graphs
1200    pub graphs: ConfigGraphs,
1201}
1202
1203impl CuConfig {
1204    #[cfg(feature = "std")]
1205    fn ensure_threadpool_bundle(&mut self) {
1206        if !self.has_background_tasks() {
1207            return;
1208        }
1209        if self
1210            .resources
1211            .iter()
1212            .any(|bundle| bundle.id == "threadpool")
1213        {
1214            return;
1215        }
1216
1217        let mut config = ComponentConfig::default();
1218        config.set("threads", 2u64);
1219        self.resources.push(ResourceBundleConfig {
1220            id: "threadpool".to_string(),
1221            provider: "cu29::resource::ThreadPoolBundle".to_string(),
1222            config: Some(config),
1223            missions: None,
1224        });
1225    }
1226
1227    #[cfg(feature = "std")]
1228    fn has_background_tasks(&self) -> bool {
1229        match &self.graphs {
1230            ConfigGraphs::Simple(graph) => graph
1231                .get_all_nodes()
1232                .iter()
1233                .any(|(_, node)| node.is_background()),
1234            ConfigGraphs::Missions(graphs) => graphs.values().any(|graph| {
1235                graph
1236                    .get_all_nodes()
1237                    .iter()
1238                    .any(|(_, node)| node.is_background())
1239            }),
1240        }
1241    }
1242}
1243
1244#[derive(Serialize, Deserialize, Default, Debug, Clone)]
1245pub struct MonitorConfig {
1246    #[serde(rename = "type")]
1247    type_: String,
1248    #[serde(skip_serializing_if = "Option::is_none")]
1249    config: Option<ComponentConfig>,
1250}
1251
1252impl MonitorConfig {
1253    #[allow(dead_code)]
1254    pub fn get_type(&self) -> &str {
1255        &self.type_
1256    }
1257
1258    #[allow(dead_code)]
1259    pub fn get_config(&self) -> Option<&ComponentConfig> {
1260        self.config.as_ref()
1261    }
1262}
1263
1264fn default_as_true() -> bool {
1265    true
1266}
1267
1268pub const DEFAULT_KEYFRAME_INTERVAL: u32 = 100;
1269
1270fn default_keyframe_interval() -> Option<u32> {
1271    Some(DEFAULT_KEYFRAME_INTERVAL)
1272}
1273
1274#[derive(Serialize, Deserialize, Default, Debug, Clone)]
1275pub struct LoggingConfig {
1276    /// Enable task logging to the log file.
1277    #[serde(default = "default_as_true", skip_serializing_if = "Clone::clone")]
1278    pub enable_task_logging: bool,
1279
1280    /// Size of each slab in the log file. (it is the size of the memory mapped file at a time)
1281    #[serde(skip_serializing_if = "Option::is_none")]
1282    pub slab_size_mib: Option<u64>,
1283
1284    /// Pre-allocated size for each section in the log file.
1285    #[serde(skip_serializing_if = "Option::is_none")]
1286    pub section_size_mib: Option<u64>,
1287
1288    /// Interval in copperlists between two "keyframes" in the log file i.e. freezing tasks.
1289    #[serde(
1290        default = "default_keyframe_interval",
1291        skip_serializing_if = "Option::is_none"
1292    )]
1293    pub keyframe_interval: Option<u32>,
1294}
1295
1296#[derive(Serialize, Deserialize, Default, Debug, Clone)]
1297pub struct RuntimeConfig {
1298    /// Set a CopperList execution rate target in Hz
1299    /// It will act as a rate limiter: if the execution is slower than this rate,
1300    /// it will continue to execute at "best effort".
1301    ///
1302    /// The main usecase is to not waste cycles when the system doesn't need an unbounded execution rate.
1303    #[serde(skip_serializing_if = "Option::is_none")]
1304    pub rate_target_hz: Option<u64>,
1305}
1306
1307/// Missions are used to generate alternative DAGs within the same configuration.
1308#[derive(Serialize, Deserialize, Debug, Clone)]
1309pub struct MissionsConfig {
1310    pub id: String,
1311}
1312
1313/// Includes are used to include other configuration files.
1314#[derive(Serialize, Deserialize, Debug, Clone)]
1315pub struct IncludesConfig {
1316    pub path: String,
1317    pub params: HashMap<String, Value>,
1318    pub missions: Option<Vec<String>>,
1319}
1320
1321/// This is the main Copper configuration representation.
1322#[derive(Serialize, Deserialize, Default)]
1323struct CuConfigRepresentation {
1324    tasks: Option<Vec<Node>>,
1325    resources: Option<Vec<ResourceBundleConfig>>,
1326    bridges: Option<Vec<BridgeConfig>>,
1327    cnx: Option<Vec<SerializedCnx>>,
1328    #[serde(
1329        default,
1330        alias = "monitor",
1331        deserialize_with = "deserialize_monitor_configs"
1332    )]
1333    monitors: Option<Vec<MonitorConfig>>,
1334    logging: Option<LoggingConfig>,
1335    runtime: Option<RuntimeConfig>,
1336    missions: Option<Vec<MissionsConfig>>,
1337    includes: Option<Vec<IncludesConfig>>,
1338}
1339
1340#[derive(Deserialize)]
1341#[serde(untagged)]
1342enum OneOrManyMonitorConfig {
1343    One(MonitorConfig),
1344    Many(Vec<MonitorConfig>),
1345}
1346
1347fn deserialize_monitor_configs<'de, D>(
1348    deserializer: D,
1349) -> Result<Option<Vec<MonitorConfig>>, D::Error>
1350where
1351    D: Deserializer<'de>,
1352{
1353    let parsed = Option::<OneOrManyMonitorConfig>::deserialize(deserializer)?;
1354    Ok(parsed.map(|value| match value {
1355        OneOrManyMonitorConfig::One(single) => vec![single],
1356        OneOrManyMonitorConfig::Many(many) => many,
1357    }))
1358}
1359
1360/// Shared implementation for deserializing a CuConfigRepresentation into a CuConfig
1361fn deserialize_config_representation<E>(
1362    representation: &CuConfigRepresentation,
1363) -> Result<CuConfig, E>
1364where
1365    E: From<String>,
1366{
1367    let mut cuconfig = CuConfig::default();
1368    let bridge_lookup = build_bridge_lookup(representation.bridges.as_ref());
1369
1370    if let Some(mission_configs) = &representation.missions {
1371        // This is the multi-mission case
1372        let mut missions = Missions(HashMap::new());
1373
1374        for mission_config in mission_configs {
1375            let mission_id = mission_config.id.as_str();
1376            let graph = missions
1377                .add_mission(mission_id)
1378                .map_err(|e| E::from(e.to_string()))?;
1379
1380            if let Some(tasks) = &representation.tasks {
1381                for task in tasks {
1382                    if let Some(task_missions) = &task.missions {
1383                        // if there is a filter by mission on the task, only add the task to the mission if it matches the filter.
1384                        if task_missions.contains(&mission_id.to_owned()) {
1385                            graph
1386                                .add_node(task.clone())
1387                                .map_err(|e| E::from(e.to_string()))?;
1388                        }
1389                    } else {
1390                        // if there is no filter by mission on the task, add the task to the mission.
1391                        graph
1392                            .add_node(task.clone())
1393                            .map_err(|e| E::from(e.to_string()))?;
1394                    }
1395                }
1396            }
1397
1398            if let Some(bridges) = &representation.bridges {
1399                for bridge in bridges {
1400                    if mission_applies(&bridge.missions, mission_id) {
1401                        insert_bridge_node(graph, bridge).map_err(E::from)?;
1402                    }
1403                }
1404            }
1405
1406            if let Some(cnx) = &representation.cnx {
1407                for c in cnx {
1408                    if let Some(cnx_missions) = &c.missions {
1409                        // if there is a filter by mission on the connection, only add the connection to the mission if it matches the filter.
1410                        if cnx_missions.contains(&mission_id.to_owned()) {
1411                            let (src_name, src_channel) =
1412                                parse_endpoint(&c.src, EndpointRole::Source, &bridge_lookup)
1413                                    .map_err(E::from)?;
1414                            let (dst_name, dst_channel) =
1415                                parse_endpoint(&c.dst, EndpointRole::Destination, &bridge_lookup)
1416                                    .map_err(E::from)?;
1417                            let src =
1418                                graph
1419                                    .get_node_id_by_name(src_name.as_str())
1420                                    .ok_or_else(|| {
1421                                        E::from(format!("Source node not found: {}", c.src))
1422                                    })?;
1423                            let dst =
1424                                graph
1425                                    .get_node_id_by_name(dst_name.as_str())
1426                                    .ok_or_else(|| {
1427                                        E::from(format!("Destination node not found: {}", c.dst))
1428                                    })?;
1429                            graph
1430                                .connect_ext(
1431                                    src,
1432                                    dst,
1433                                    &c.msg,
1434                                    Some(cnx_missions.clone()),
1435                                    src_channel,
1436                                    dst_channel,
1437                                )
1438                                .map_err(|e| E::from(e.to_string()))?;
1439                        }
1440                    } else {
1441                        // if there is no filter by mission on the connection, add the connection to the mission.
1442                        let (src_name, src_channel) =
1443                            parse_endpoint(&c.src, EndpointRole::Source, &bridge_lookup)
1444                                .map_err(E::from)?;
1445                        let (dst_name, dst_channel) =
1446                            parse_endpoint(&c.dst, EndpointRole::Destination, &bridge_lookup)
1447                                .map_err(E::from)?;
1448                        let src = graph
1449                            .get_node_id_by_name(src_name.as_str())
1450                            .ok_or_else(|| E::from(format!("Source node not found: {}", c.src)))?;
1451                        let dst =
1452                            graph
1453                                .get_node_id_by_name(dst_name.as_str())
1454                                .ok_or_else(|| {
1455                                    E::from(format!("Destination node not found: {}", c.dst))
1456                                })?;
1457                        graph
1458                            .connect_ext(src, dst, &c.msg, None, src_channel, dst_channel)
1459                            .map_err(|e| E::from(e.to_string()))?;
1460                    }
1461                }
1462            }
1463        }
1464        cuconfig.graphs = missions;
1465    } else {
1466        // this is the simple case
1467        let mut graph = CuGraph::default();
1468
1469        if let Some(tasks) = &representation.tasks {
1470            for task in tasks {
1471                graph
1472                    .add_node(task.clone())
1473                    .map_err(|e| E::from(e.to_string()))?;
1474            }
1475        }
1476
1477        if let Some(bridges) = &representation.bridges {
1478            for bridge in bridges {
1479                insert_bridge_node(&mut graph, bridge).map_err(E::from)?;
1480            }
1481        }
1482
1483        if let Some(cnx) = &representation.cnx {
1484            for c in cnx {
1485                let (src_name, src_channel) =
1486                    parse_endpoint(&c.src, EndpointRole::Source, &bridge_lookup)
1487                        .map_err(E::from)?;
1488                let (dst_name, dst_channel) =
1489                    parse_endpoint(&c.dst, EndpointRole::Destination, &bridge_lookup)
1490                        .map_err(E::from)?;
1491                let src = graph
1492                    .get_node_id_by_name(src_name.as_str())
1493                    .ok_or_else(|| E::from(format!("Source node not found: {}", c.src)))?;
1494                let dst = graph
1495                    .get_node_id_by_name(dst_name.as_str())
1496                    .ok_or_else(|| E::from(format!("Destination node not found: {}", c.dst)))?;
1497                graph
1498                    .connect_ext(src, dst, &c.msg, None, src_channel, dst_channel)
1499                    .map_err(|e| E::from(e.to_string()))?;
1500            }
1501        }
1502        cuconfig.graphs = Simple(graph);
1503    }
1504
1505    cuconfig.monitors = representation.monitors.clone().unwrap_or_default();
1506    cuconfig.logging = representation.logging.clone();
1507    cuconfig.runtime = representation.runtime.clone();
1508    cuconfig.resources = representation.resources.clone().unwrap_or_default();
1509    cuconfig.bridges = representation.bridges.clone().unwrap_or_default();
1510
1511    Ok(cuconfig)
1512}
1513
1514impl<'de> Deserialize<'de> for CuConfig {
1515    /// This is a custom serialization to make this implementation independent of petgraph.
1516    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
1517    where
1518        D: Deserializer<'de>,
1519    {
1520        let representation =
1521            CuConfigRepresentation::deserialize(deserializer).map_err(serde::de::Error::custom)?;
1522
1523        // Convert String errors to D::Error using serde::de::Error::custom
1524        match deserialize_config_representation::<String>(&representation) {
1525            Ok(config) => Ok(config),
1526            Err(e) => Err(serde::de::Error::custom(e)),
1527        }
1528    }
1529}
1530
1531impl Serialize for CuConfig {
1532    /// This is a custom serialization to make this implementation independent of petgraph.
1533    fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
1534    where
1535        S: Serializer,
1536    {
1537        let bridges = if self.bridges.is_empty() {
1538            None
1539        } else {
1540            Some(self.bridges.clone())
1541        };
1542        let resources = if self.resources.is_empty() {
1543            None
1544        } else {
1545            Some(self.resources.clone())
1546        };
1547        let monitors = (!self.monitors.is_empty()).then_some(self.monitors.clone());
1548        match &self.graphs {
1549            Simple(graph) => {
1550                let tasks: Vec<Node> = graph
1551                    .0
1552                    .node_indices()
1553                    .map(|idx| graph.0[idx].clone())
1554                    .filter(|node| node.get_flavor() == Flavor::Task)
1555                    .collect();
1556
1557                let cnx: Vec<SerializedCnx> = graph
1558                    .0
1559                    .edge_indices()
1560                    .map(|edge| SerializedCnx::from(&graph.0[edge]))
1561                    .collect();
1562
1563                CuConfigRepresentation {
1564                    tasks: Some(tasks),
1565                    bridges: bridges.clone(),
1566                    cnx: Some(cnx),
1567                    monitors: monitors.clone(),
1568                    logging: self.logging.clone(),
1569                    runtime: self.runtime.clone(),
1570                    resources: resources.clone(),
1571                    missions: None,
1572                    includes: None,
1573                }
1574                .serialize(serializer)
1575            }
1576            Missions(graphs) => {
1577                let missions = graphs
1578                    .keys()
1579                    .map(|id| MissionsConfig { id: id.clone() })
1580                    .collect();
1581
1582                // Collect all unique tasks across missions
1583                let mut tasks = Vec::new();
1584                let mut cnx = Vec::new();
1585
1586                for graph in graphs.values() {
1587                    // Add all nodes from this mission
1588                    for node_idx in graph.node_indices() {
1589                        let node = &graph[node_idx];
1590                        if node.get_flavor() == Flavor::Task
1591                            && !tasks.iter().any(|n: &Node| n.id == node.id)
1592                        {
1593                            tasks.push(node.clone());
1594                        }
1595                    }
1596
1597                    // Add all edges from this mission
1598                    for edge_idx in graph.0.edge_indices() {
1599                        let edge = &graph.0[edge_idx];
1600                        let serialized = SerializedCnx::from(edge);
1601                        if !cnx.iter().any(|c: &SerializedCnx| {
1602                            c.src == serialized.src
1603                                && c.dst == serialized.dst
1604                                && c.msg == serialized.msg
1605                        }) {
1606                            cnx.push(serialized);
1607                        }
1608                    }
1609                }
1610
1611                CuConfigRepresentation {
1612                    tasks: Some(tasks),
1613                    resources: resources.clone(),
1614                    bridges,
1615                    cnx: Some(cnx),
1616                    monitors,
1617                    logging: self.logging.clone(),
1618                    runtime: self.runtime.clone(),
1619                    missions: Some(missions),
1620                    includes: None,
1621                }
1622                .serialize(serializer)
1623            }
1624        }
1625    }
1626}
1627
1628impl Default for CuConfig {
1629    fn default() -> Self {
1630        CuConfig {
1631            graphs: Simple(CuGraph(StableDiGraph::new())),
1632            monitors: Vec::new(),
1633            logging: None,
1634            runtime: None,
1635            resources: Vec::new(),
1636            bridges: Vec::new(),
1637        }
1638    }
1639}
1640
1641/// The implementation has a lot of convenience methods to manipulate
1642/// the configuration to give some flexibility into programmatically creating the configuration.
1643impl CuConfig {
1644    #[allow(dead_code)]
1645    pub fn new_simple_type() -> Self {
1646        Self::default()
1647    }
1648
1649    #[allow(dead_code)]
1650    pub fn new_mission_type() -> Self {
1651        CuConfig {
1652            graphs: Missions(HashMap::new()),
1653            monitors: Vec::new(),
1654            logging: None,
1655            runtime: None,
1656            resources: Vec::new(),
1657            bridges: Vec::new(),
1658        }
1659    }
1660
1661    fn get_options() -> Options {
1662        Options::default()
1663            .with_default_extension(Extensions::IMPLICIT_SOME)
1664            .with_default_extension(Extensions::UNWRAP_NEWTYPES)
1665            .with_default_extension(Extensions::UNWRAP_VARIANT_NEWTYPES)
1666    }
1667
1668    #[allow(dead_code)]
1669    pub fn serialize_ron(&self) -> CuResult<String> {
1670        let ron = Self::get_options();
1671        let pretty = ron::ser::PrettyConfig::default();
1672        ron.to_string_pretty(&self, pretty)
1673            .map_err(|e| CuError::from(format!("Error serializing configuration: {e}")))
1674    }
1675
1676    #[allow(dead_code)]
1677    pub fn deserialize_ron(ron: &str) -> CuResult<Self> {
1678        let representation = Self::get_options().from_str(ron).map_err(|e| {
1679            CuError::from(format!(
1680                "Syntax Error in config: {} at position {}",
1681                e.code, e.span
1682            ))
1683        })?;
1684        Self::deserialize_impl(representation)
1685            .map_err(|e| CuError::from(format!("Error deserializing configuration: {e}")))
1686    }
1687
1688    fn deserialize_impl(representation: CuConfigRepresentation) -> Result<Self, String> {
1689        deserialize_config_representation(&representation)
1690    }
1691
1692    /// Render the configuration graph in the dot format.
1693    #[cfg(feature = "std")]
1694    #[allow(dead_code)]
1695    pub fn render(
1696        &self,
1697        output: &mut dyn std::io::Write,
1698        mission_id: Option<&str>,
1699    ) -> CuResult<()> {
1700        writeln!(output, "digraph G {{")
1701            .map_err(|e| CuError::new_with_cause("Failed to write render output", e))?;
1702        writeln!(output, "    graph [rankdir=LR, nodesep=0.8, ranksep=1.2];")
1703            .map_err(|e| CuError::new_with_cause("Failed to write render output", e))?;
1704        writeln!(output, "    node [shape=plain, fontname=\"Noto Sans\"];")
1705            .map_err(|e| CuError::new_with_cause("Failed to write render output", e))?;
1706        writeln!(output, "    edge [fontname=\"Noto Sans\"];")
1707            .map_err(|e| CuError::new_with_cause("Failed to write render output", e))?;
1708
1709        let sections = match (&self.graphs, mission_id) {
1710            (Simple(graph), _) => vec![RenderSection { label: None, graph }],
1711            (Missions(graphs), Some(id)) => {
1712                let graph = graphs
1713                    .get(id)
1714                    .ok_or_else(|| CuError::from(format!("Mission {id} not found")))?;
1715                vec![RenderSection {
1716                    label: Some(id.to_string()),
1717                    graph,
1718                }]
1719            }
1720            (Missions(graphs), None) => {
1721                let mut missions: Vec<_> = graphs.iter().collect();
1722                missions.sort_by(|a, b| a.0.cmp(b.0));
1723                missions
1724                    .into_iter()
1725                    .map(|(label, graph)| RenderSection {
1726                        label: Some(label.clone()),
1727                        graph,
1728                    })
1729                    .collect()
1730            }
1731        };
1732
1733        for section in sections {
1734            self.render_section(output, section.graph, section.label.as_deref())?;
1735        }
1736
1737        writeln!(output, "}}")
1738            .map_err(|e| CuError::new_with_cause("Failed to write render output", e))?;
1739        Ok(())
1740    }
1741
1742    #[allow(dead_code)]
1743    pub fn get_all_instances_configs(
1744        &self,
1745        mission_id: Option<&str>,
1746    ) -> Vec<Option<&ComponentConfig>> {
1747        let graph = self.graphs.get_graph(mission_id).unwrap();
1748        graph
1749            .get_all_nodes()
1750            .iter()
1751            .map(|(_, node)| node.get_instance_config())
1752            .collect()
1753    }
1754
1755    #[allow(dead_code)]
1756    pub fn get_graph(&self, mission_id: Option<&str>) -> CuResult<&CuGraph> {
1757        self.graphs.get_graph(mission_id)
1758    }
1759
1760    #[allow(dead_code)]
1761    pub fn get_graph_mut(&mut self, mission_id: Option<&str>) -> CuResult<&mut CuGraph> {
1762        self.graphs.get_graph_mut(mission_id)
1763    }
1764
1765    #[allow(dead_code)]
1766    pub fn get_monitor_config(&self) -> Option<&MonitorConfig> {
1767        self.monitors.first()
1768    }
1769
1770    #[allow(dead_code)]
1771    pub fn get_monitor_configs(&self) -> &[MonitorConfig] {
1772        &self.monitors
1773    }
1774
1775    #[allow(dead_code)]
1776    pub fn get_runtime_config(&self) -> Option<&RuntimeConfig> {
1777        self.runtime.as_ref()
1778    }
1779
1780    /// Validate the logging configuration to ensure section pre-allocation sizes do not exceed slab sizes.
1781    /// This method is wrapper around [LoggingConfig::validate]
1782    pub fn validate_logging_config(&self) -> CuResult<()> {
1783        if let Some(logging) = &self.logging {
1784            return logging.validate();
1785        }
1786        Ok(())
1787    }
1788}
1789
1790#[cfg(feature = "std")]
1791#[derive(Default)]
1792pub(crate) struct PortLookup {
1793    pub inputs: HashMap<String, String>,
1794    pub outputs: HashMap<String, String>,
1795    pub default_input: Option<String>,
1796    pub default_output: Option<String>,
1797}
1798
1799#[cfg(feature = "std")]
1800#[derive(Clone)]
1801pub(crate) struct RenderNode {
1802    pub id: String,
1803    pub type_name: String,
1804    pub flavor: Flavor,
1805    pub inputs: Vec<String>,
1806    pub outputs: Vec<String>,
1807}
1808
1809#[cfg(feature = "std")]
1810#[derive(Clone)]
1811pub(crate) struct RenderConnection {
1812    pub src: String,
1813    pub src_port: Option<String>,
1814    #[allow(dead_code)]
1815    pub src_channel: Option<String>,
1816    pub dst: String,
1817    pub dst_port: Option<String>,
1818    #[allow(dead_code)]
1819    pub dst_channel: Option<String>,
1820    pub msg: String,
1821}
1822
1823#[cfg(feature = "std")]
1824pub(crate) struct RenderTopology {
1825    pub nodes: Vec<RenderNode>,
1826    pub connections: Vec<RenderConnection>,
1827}
1828
1829#[cfg(feature = "std")]
1830impl RenderTopology {
1831    pub fn sort_connections(&mut self) {
1832        self.connections.sort_by(|a, b| {
1833            a.src
1834                .cmp(&b.src)
1835                .then(a.dst.cmp(&b.dst))
1836                .then(a.msg.cmp(&b.msg))
1837        });
1838    }
1839}
1840
1841#[cfg(feature = "std")]
1842#[allow(dead_code)]
1843struct RenderSection<'a> {
1844    label: Option<String>,
1845    graph: &'a CuGraph,
1846}
1847
1848#[cfg(feature = "std")]
1849impl CuConfig {
1850    #[allow(dead_code)]
1851    fn render_section(
1852        &self,
1853        output: &mut dyn std::io::Write,
1854        graph: &CuGraph,
1855        label: Option<&str>,
1856    ) -> CuResult<()> {
1857        use std::fmt::Write as FmtWrite;
1858
1859        let mut topology = build_render_topology(graph, &self.bridges);
1860        topology.nodes.sort_by(|a, b| a.id.cmp(&b.id));
1861        topology.sort_connections();
1862
1863        let cluster_id = label.map(|lbl| format!("cluster_{}", sanitize_identifier(lbl)));
1864        if let Some(ref cluster_id) = cluster_id {
1865            writeln!(output, "    subgraph \"{cluster_id}\" {{")
1866                .map_err(|e| CuError::new_with_cause("Failed to write render output", e))?;
1867            writeln!(
1868                output,
1869                "        label=<<B>Mission: {}</B>>;",
1870                encode_text(label.unwrap())
1871            )
1872            .map_err(|e| CuError::new_with_cause("Failed to write render output", e))?;
1873            writeln!(
1874                output,
1875                "        labelloc=t; labeljust=l; color=\"#bbbbbb\"; style=\"rounded\"; margin=20;"
1876            )
1877            .map_err(|e| CuError::new_with_cause("Failed to write render output", e))?;
1878        }
1879        let indent = if cluster_id.is_some() {
1880            "        "
1881        } else {
1882            "    "
1883        };
1884        let node_prefix = label
1885            .map(|lbl| format!("{}__", sanitize_identifier(lbl)))
1886            .unwrap_or_default();
1887
1888        let mut port_lookup: HashMap<String, PortLookup> = HashMap::new();
1889        let mut id_lookup: HashMap<String, String> = HashMap::new();
1890
1891        for node in &topology.nodes {
1892            let node_idx = graph
1893                .get_node_id_by_name(node.id.as_str())
1894                .ok_or_else(|| CuError::from(format!("Node '{}' missing from graph", node.id)))?;
1895            let node_weight = graph
1896                .get_node(node_idx)
1897                .ok_or_else(|| CuError::from(format!("Node '{}' missing weight", node.id)))?;
1898
1899            let is_src = graph.get_dst_edges(node_idx).unwrap_or_default().is_empty();
1900            let is_sink = graph.get_src_edges(node_idx).unwrap_or_default().is_empty();
1901
1902            let fillcolor = match node.flavor {
1903                Flavor::Bridge => "#faedcd",
1904                Flavor::Task if is_src => "#ddefc7",
1905                Flavor::Task if is_sink => "#cce0ff",
1906                _ => "#f2f2f2",
1907            };
1908
1909            let port_base = format!("{}{}", node_prefix, sanitize_identifier(&node.id));
1910            let (inputs_table, input_map, default_input) =
1911                build_port_table("Inputs", &node.inputs, &port_base, "in");
1912            let (outputs_table, output_map, default_output) =
1913                build_port_table("Outputs", &node.outputs, &port_base, "out");
1914            let config_html = node_weight.config.as_ref().and_then(build_config_table);
1915
1916            let mut label_html = String::new();
1917            write!(
1918                label_html,
1919                "<TABLE BORDER=\"0\" CELLBORDER=\"1\" CELLSPACING=\"0\" CELLPADDING=\"6\" COLOR=\"gray\" BGCOLOR=\"white\">"
1920            )
1921            .unwrap();
1922            write!(
1923                label_html,
1924                "<TR><TD COLSPAN=\"2\" ALIGN=\"LEFT\" BGCOLOR=\"{fillcolor}\"><FONT POINT-SIZE=\"12\"><B>{}</B></FONT><BR/><FONT COLOR=\"dimgray\">[{}]</FONT></TD></TR>",
1925                encode_text(&node.id),
1926                encode_text(&node.type_name)
1927            )
1928            .unwrap();
1929            write!(
1930                label_html,
1931                "<TR><TD ALIGN=\"LEFT\" VALIGN=\"TOP\">{inputs_table}</TD><TD ALIGN=\"LEFT\" VALIGN=\"TOP\">{outputs_table}</TD></TR>"
1932            )
1933            .unwrap();
1934
1935            if let Some(config_html) = config_html {
1936                write!(
1937                    label_html,
1938                    "<TR><TD COLSPAN=\"2\" ALIGN=\"LEFT\">{config_html}</TD></TR>"
1939                )
1940                .unwrap();
1941            }
1942
1943            label_html.push_str("</TABLE>");
1944
1945            let identifier_raw = if node_prefix.is_empty() {
1946                node.id.clone()
1947            } else {
1948                format!("{node_prefix}{}", node.id)
1949            };
1950            let identifier = escape_dot_id(&identifier_raw);
1951            writeln!(output, "{indent}\"{identifier}\" [label=<{label_html}>];")
1952                .map_err(|e| CuError::new_with_cause("Failed to write render output", e))?;
1953
1954            id_lookup.insert(node.id.clone(), identifier);
1955            port_lookup.insert(
1956                node.id.clone(),
1957                PortLookup {
1958                    inputs: input_map,
1959                    outputs: output_map,
1960                    default_input,
1961                    default_output,
1962                },
1963            );
1964        }
1965
1966        for cnx in &topology.connections {
1967            let src_id = id_lookup
1968                .get(&cnx.src)
1969                .ok_or_else(|| CuError::from(format!("Unknown node '{}'", cnx.src)))?;
1970            let dst_id = id_lookup
1971                .get(&cnx.dst)
1972                .ok_or_else(|| CuError::from(format!("Unknown node '{}'", cnx.dst)))?;
1973            let src_suffix = port_lookup
1974                .get(&cnx.src)
1975                .and_then(|lookup| lookup.resolve_output(cnx.src_port.as_deref()))
1976                .map(|port| format!(":\"{port}\":e"))
1977                .unwrap_or_default();
1978            let dst_suffix = port_lookup
1979                .get(&cnx.dst)
1980                .and_then(|lookup| lookup.resolve_input(cnx.dst_port.as_deref()))
1981                .map(|port| format!(":\"{port}\":w"))
1982                .unwrap_or_default();
1983            let msg = encode_text(&cnx.msg);
1984            writeln!(
1985                output,
1986                "{indent}\"{src_id}\"{src_suffix} -> \"{dst_id}\"{dst_suffix} [label=< <B><FONT COLOR=\"gray\">{msg}</FONT></B> >];"
1987            )
1988            .map_err(|e| CuError::new_with_cause("Failed to write render output", e))?;
1989        }
1990
1991        if cluster_id.is_some() {
1992            writeln!(output, "    }}")
1993                .map_err(|e| CuError::new_with_cause("Failed to write render output", e))?;
1994        }
1995
1996        Ok(())
1997    }
1998}
1999
2000#[cfg(feature = "std")]
2001pub(crate) fn build_render_topology(graph: &CuGraph, bridges: &[BridgeConfig]) -> RenderTopology {
2002    let mut bridge_lookup = HashMap::new();
2003    for bridge in bridges {
2004        bridge_lookup.insert(bridge.id.as_str(), bridge);
2005    }
2006
2007    let mut nodes: Vec<RenderNode> = Vec::new();
2008    let mut node_lookup: HashMap<String, usize> = HashMap::new();
2009    for (_, node) in graph.get_all_nodes() {
2010        let node_id = node.get_id();
2011        let mut inputs = Vec::new();
2012        let mut outputs = Vec::new();
2013        if node.get_flavor() == Flavor::Bridge
2014            && let Some(bridge) = bridge_lookup.get(node_id.as_str())
2015        {
2016            for channel in &bridge.channels {
2017                match channel {
2018                    // Rx brings data from the bridge into the graph, so treat it as an output.
2019                    BridgeChannelConfigRepresentation::Rx { id, .. } => outputs.push(id.clone()),
2020                    // Tx consumes data from the graph heading into the bridge, so show it on the input side.
2021                    BridgeChannelConfigRepresentation::Tx { id, .. } => inputs.push(id.clone()),
2022                }
2023            }
2024        }
2025
2026        node_lookup.insert(node_id.clone(), nodes.len());
2027        nodes.push(RenderNode {
2028            id: node_id,
2029            type_name: node.get_type().to_string(),
2030            flavor: node.get_flavor(),
2031            inputs,
2032            outputs,
2033        });
2034    }
2035
2036    let mut output_port_lookup: Vec<HashMap<String, String>> = vec![HashMap::new(); nodes.len()];
2037    let mut output_edges: Vec<_> = graph.0.edge_references().collect();
2038    output_edges.sort_by_key(|edge| edge.id().index());
2039    for edge in output_edges {
2040        let cnx = edge.weight();
2041        if let Some(&idx) = node_lookup.get(&cnx.src)
2042            && nodes[idx].flavor == Flavor::Task
2043            && cnx.src_channel.is_none()
2044        {
2045            let port_map = &mut output_port_lookup[idx];
2046            if !port_map.contains_key(&cnx.msg) {
2047                let label = format!("out{}: {}", port_map.len(), cnx.msg);
2048                port_map.insert(cnx.msg.clone(), label.clone());
2049                nodes[idx].outputs.push(label);
2050            }
2051        }
2052    }
2053
2054    let mut auto_input_counts = vec![0usize; nodes.len()];
2055    for edge in graph.0.edge_references() {
2056        let cnx = edge.weight();
2057        if let Some(&idx) = node_lookup.get(&cnx.dst)
2058            && nodes[idx].flavor == Flavor::Task
2059            && cnx.dst_channel.is_none()
2060        {
2061            auto_input_counts[idx] += 1;
2062        }
2063    }
2064
2065    let mut next_auto_input = vec![0usize; nodes.len()];
2066    let mut connections = Vec::new();
2067    for edge in graph.0.edge_references() {
2068        let cnx = edge.weight();
2069        let mut src_port = cnx.src_channel.clone();
2070        let mut dst_port = cnx.dst_channel.clone();
2071
2072        if let Some(&idx) = node_lookup.get(&cnx.src) {
2073            let node = &mut nodes[idx];
2074            if node.flavor == Flavor::Task && src_port.is_none() {
2075                src_port = output_port_lookup[idx].get(&cnx.msg).cloned();
2076            }
2077        }
2078        if let Some(&idx) = node_lookup.get(&cnx.dst) {
2079            let node = &mut nodes[idx];
2080            if node.flavor == Flavor::Task && dst_port.is_none() {
2081                let count = auto_input_counts[idx];
2082                let next = if count <= 1 {
2083                    "in".to_string()
2084                } else {
2085                    let next = format!("in.{}", next_auto_input[idx]);
2086                    next_auto_input[idx] += 1;
2087                    next
2088                };
2089                node.inputs.push(next.clone());
2090                dst_port = Some(next);
2091            }
2092        }
2093
2094        connections.push(RenderConnection {
2095            src: cnx.src.clone(),
2096            src_port,
2097            src_channel: cnx.src_channel.clone(),
2098            dst: cnx.dst.clone(),
2099            dst_port,
2100            dst_channel: cnx.dst_channel.clone(),
2101            msg: cnx.msg.clone(),
2102        });
2103    }
2104
2105    RenderTopology { nodes, connections }
2106}
2107
2108#[cfg(feature = "std")]
2109impl PortLookup {
2110    pub fn resolve_input(&self, name: Option<&str>) -> Option<&str> {
2111        if let Some(name) = name
2112            && let Some(port) = self.inputs.get(name)
2113        {
2114            return Some(port.as_str());
2115        }
2116        self.default_input.as_deref()
2117    }
2118
2119    pub fn resolve_output(&self, name: Option<&str>) -> Option<&str> {
2120        if let Some(name) = name
2121            && let Some(port) = self.outputs.get(name)
2122        {
2123            return Some(port.as_str());
2124        }
2125        self.default_output.as_deref()
2126    }
2127}
2128
2129#[cfg(feature = "std")]
2130#[allow(dead_code)]
2131fn build_port_table(
2132    title: &str,
2133    names: &[String],
2134    base_id: &str,
2135    prefix: &str,
2136) -> (String, HashMap<String, String>, Option<String>) {
2137    use std::fmt::Write as FmtWrite;
2138
2139    let mut html = String::new();
2140    write!(
2141        html,
2142        "<TABLE BORDER=\"0\" CELLBORDER=\"0\" CELLSPACING=\"0\" CELLPADDING=\"1\">"
2143    )
2144    .unwrap();
2145    write!(
2146        html,
2147        "<TR><TD ALIGN=\"LEFT\"><FONT COLOR=\"dimgray\">{}</FONT></TD></TR>",
2148        encode_text(title)
2149    )
2150    .unwrap();
2151
2152    let mut lookup = HashMap::new();
2153    let mut default_port = None;
2154
2155    if names.is_empty() {
2156        html.push_str("<TR><TD ALIGN=\"LEFT\"><FONT COLOR=\"lightgray\">&mdash;</FONT></TD></TR>");
2157    } else {
2158        for (idx, name) in names.iter().enumerate() {
2159            let port_id = format!("{base_id}_{prefix}_{idx}");
2160            write!(
2161                html,
2162                "<TR><TD PORT=\"{port_id}\" ALIGN=\"LEFT\">{}</TD></TR>",
2163                encode_text(name)
2164            )
2165            .unwrap();
2166            lookup.insert(name.clone(), port_id.clone());
2167            if idx == 0 {
2168                default_port = Some(port_id);
2169            }
2170        }
2171    }
2172
2173    html.push_str("</TABLE>");
2174    (html, lookup, default_port)
2175}
2176
2177#[cfg(feature = "std")]
2178#[allow(dead_code)]
2179fn build_config_table(config: &ComponentConfig) -> Option<String> {
2180    use std::fmt::Write as FmtWrite;
2181
2182    if config.0.is_empty() {
2183        return None;
2184    }
2185
2186    let mut entries: Vec<_> = config.0.iter().collect();
2187    entries.sort_by(|a, b| a.0.cmp(b.0));
2188
2189    let mut html = String::new();
2190    html.push_str("<TABLE BORDER=\"0\" CELLBORDER=\"0\" CELLSPACING=\"0\" CELLPADDING=\"1\">");
2191    for (key, value) in entries {
2192        let value_txt = format!("{value}");
2193        write!(
2194            html,
2195            "<TR><TD ALIGN=\"LEFT\"><FONT COLOR=\"dimgray\">{}</FONT> = {}</TD></TR>",
2196            encode_text(key),
2197            encode_text(&value_txt)
2198        )
2199        .unwrap();
2200    }
2201    html.push_str("</TABLE>");
2202    Some(html)
2203}
2204
2205#[cfg(feature = "std")]
2206#[allow(dead_code)]
2207fn sanitize_identifier(value: &str) -> String {
2208    value
2209        .chars()
2210        .map(|c| if c.is_ascii_alphanumeric() { c } else { '_' })
2211        .collect()
2212}
2213
2214#[cfg(feature = "std")]
2215#[allow(dead_code)]
2216fn escape_dot_id(value: &str) -> String {
2217    let mut escaped = String::with_capacity(value.len());
2218    for ch in value.chars() {
2219        match ch {
2220            '"' => escaped.push_str("\\\""),
2221            '\\' => escaped.push_str("\\\\"),
2222            _ => escaped.push(ch),
2223        }
2224    }
2225    escaped
2226}
2227
2228impl LoggingConfig {
2229    /// Validate the logging configuration to ensure section pre-allocation sizes do not exceed slab sizes.
2230    pub fn validate(&self) -> CuResult<()> {
2231        if let Some(section_size_mib) = self.section_size_mib
2232            && let Some(slab_size_mib) = self.slab_size_mib
2233            && section_size_mib > slab_size_mib
2234        {
2235            return Err(CuError::from(format!(
2236                "Section size ({section_size_mib} MiB) cannot be larger than slab size ({slab_size_mib} MiB). Adjust the parameters accordingly."
2237            )));
2238        }
2239
2240        Ok(())
2241    }
2242}
2243
2244#[allow(dead_code)] // dead in no-std
2245fn substitute_parameters(content: &str, params: &HashMap<String, Value>) -> String {
2246    let mut result = content.to_string();
2247
2248    for (key, value) in params {
2249        let pattern = format!("{{{{{key}}}}}");
2250        result = result.replace(&pattern, &value.to_string());
2251    }
2252
2253    result
2254}
2255
2256/// Returns a merged CuConfigRepresentation.
2257#[cfg(feature = "std")]
2258fn process_includes(
2259    file_path: &str,
2260    base_representation: CuConfigRepresentation,
2261    processed_files: &mut Vec<String>,
2262) -> CuResult<CuConfigRepresentation> {
2263    // Note: Circular dependency detection removed
2264    processed_files.push(file_path.to_string());
2265
2266    let mut result = base_representation;
2267
2268    if let Some(includes) = result.includes.take() {
2269        for include in includes {
2270            let include_path = if include.path.starts_with('/') {
2271                include.path.clone()
2272            } else {
2273                let current_dir = std::path::Path::new(file_path)
2274                    .parent()
2275                    .unwrap_or_else(|| std::path::Path::new(""))
2276                    .to_string_lossy()
2277                    .to_string();
2278
2279                format!("{}/{}", current_dir, include.path)
2280            };
2281
2282            let include_content = read_to_string(&include_path).map_err(|e| {
2283                CuError::from(format!("Failed to read include file: {include_path}"))
2284                    .add_cause(e.to_string().as_str())
2285            })?;
2286
2287            let processed_content = substitute_parameters(&include_content, &include.params);
2288
2289            let mut included_representation: CuConfigRepresentation = match Options::default()
2290                .with_default_extension(Extensions::IMPLICIT_SOME)
2291                .with_default_extension(Extensions::UNWRAP_NEWTYPES)
2292                .with_default_extension(Extensions::UNWRAP_VARIANT_NEWTYPES)
2293                .from_str(&processed_content)
2294            {
2295                Ok(rep) => rep,
2296                Err(e) => {
2297                    return Err(CuError::from(format!(
2298                        "Failed to parse include file: {} - Error: {} at position {}",
2299                        include_path, e.code, e.span
2300                    )));
2301                }
2302            };
2303
2304            included_representation =
2305                process_includes(&include_path, included_representation, processed_files)?;
2306
2307            if let Some(included_tasks) = included_representation.tasks {
2308                if result.tasks.is_none() {
2309                    result.tasks = Some(included_tasks);
2310                } else {
2311                    let mut tasks = result.tasks.take().unwrap();
2312                    for included_task in included_tasks {
2313                        if !tasks.iter().any(|t| t.id == included_task.id) {
2314                            tasks.push(included_task);
2315                        }
2316                    }
2317                    result.tasks = Some(tasks);
2318                }
2319            }
2320
2321            if let Some(included_bridges) = included_representation.bridges {
2322                if result.bridges.is_none() {
2323                    result.bridges = Some(included_bridges);
2324                } else {
2325                    let mut bridges = result.bridges.take().unwrap();
2326                    for included_bridge in included_bridges {
2327                        if !bridges.iter().any(|b| b.id == included_bridge.id) {
2328                            bridges.push(included_bridge);
2329                        }
2330                    }
2331                    result.bridges = Some(bridges);
2332                }
2333            }
2334
2335            if let Some(included_resources) = included_representation.resources {
2336                if result.resources.is_none() {
2337                    result.resources = Some(included_resources);
2338                } else {
2339                    let mut resources = result.resources.take().unwrap();
2340                    for included_resource in included_resources {
2341                        if !resources.iter().any(|r| r.id == included_resource.id) {
2342                            resources.push(included_resource);
2343                        }
2344                    }
2345                    result.resources = Some(resources);
2346                }
2347            }
2348
2349            if let Some(included_cnx) = included_representation.cnx {
2350                if result.cnx.is_none() {
2351                    result.cnx = Some(included_cnx);
2352                } else {
2353                    let mut cnx = result.cnx.take().unwrap();
2354                    for included_c in included_cnx {
2355                        if !cnx
2356                            .iter()
2357                            .any(|c| c.src == included_c.src && c.dst == included_c.dst)
2358                        {
2359                            cnx.push(included_c);
2360                        }
2361                    }
2362                    result.cnx = Some(cnx);
2363                }
2364            }
2365
2366            if let Some(included_monitors) = included_representation.monitors {
2367                if result.monitors.is_none() {
2368                    result.monitors = Some(included_monitors);
2369                } else {
2370                    let mut monitors = result.monitors.take().unwrap();
2371                    for included_monitor in included_monitors {
2372                        if !monitors.iter().any(|m| m.type_ == included_monitor.type_) {
2373                            monitors.push(included_monitor);
2374                        }
2375                    }
2376                    result.monitors = Some(monitors);
2377                }
2378            }
2379
2380            if result.logging.is_none() {
2381                result.logging = included_representation.logging;
2382            }
2383
2384            if result.runtime.is_none() {
2385                result.runtime = included_representation.runtime;
2386            }
2387
2388            if let Some(included_missions) = included_representation.missions {
2389                if result.missions.is_none() {
2390                    result.missions = Some(included_missions);
2391                } else {
2392                    let mut missions = result.missions.take().unwrap();
2393                    for included_mission in included_missions {
2394                        if !missions.iter().any(|m| m.id == included_mission.id) {
2395                            missions.push(included_mission);
2396                        }
2397                    }
2398                    result.missions = Some(missions);
2399                }
2400            }
2401        }
2402    }
2403
2404    Ok(result)
2405}
2406
2407/// Read a copper configuration from a file.
2408#[cfg(feature = "std")]
2409pub fn read_configuration(config_filename: &str) -> CuResult<CuConfig> {
2410    let config_content = read_to_string(config_filename).map_err(|e| {
2411        CuError::from(format!(
2412            "Failed to read configuration file: {:?}",
2413            &config_filename
2414        ))
2415        .add_cause(e.to_string().as_str())
2416    })?;
2417    read_configuration_str(config_content, Some(config_filename))
2418}
2419
2420/// Read a copper configuration from a String.
2421/// Parse a RON string into a CuConfigRepresentation, using the standard options.
2422/// Returns an error if the parsing fails.
2423fn parse_config_string(content: &str) -> CuResult<CuConfigRepresentation> {
2424    Options::default()
2425        .with_default_extension(Extensions::IMPLICIT_SOME)
2426        .with_default_extension(Extensions::UNWRAP_NEWTYPES)
2427        .with_default_extension(Extensions::UNWRAP_VARIANT_NEWTYPES)
2428        .from_str(content)
2429        .map_err(|e| {
2430            CuError::from(format!(
2431                "Failed to parse configuration: Error: {} at position {}",
2432                e.code, e.span
2433            ))
2434        })
2435}
2436
2437/// Convert a CuConfigRepresentation to a CuConfig.
2438/// Uses the deserialize_impl method and validates the logging configuration.
2439fn config_representation_to_config(representation: CuConfigRepresentation) -> CuResult<CuConfig> {
2440    #[allow(unused_mut)]
2441    let mut cuconfig = CuConfig::deserialize_impl(representation)
2442        .map_err(|e| CuError::from(format!("Error deserializing configuration: {e}")))?;
2443
2444    #[cfg(feature = "std")]
2445    cuconfig.ensure_threadpool_bundle();
2446
2447    cuconfig.validate_logging_config()?;
2448
2449    Ok(cuconfig)
2450}
2451
2452#[allow(unused_variables)]
2453pub fn read_configuration_str(
2454    config_content: String,
2455    file_path: Option<&str>,
2456) -> CuResult<CuConfig> {
2457    // Parse the configuration string
2458    let representation = parse_config_string(&config_content)?;
2459
2460    // Process includes and generate a merged configuration if a file path is provided
2461    // includes are only available with std.
2462    #[cfg(feature = "std")]
2463    let representation = if let Some(path) = file_path {
2464        process_includes(path, representation, &mut Vec::new())?
2465    } else {
2466        representation
2467    };
2468
2469    // Convert the representation to a CuConfig and validate
2470    config_representation_to_config(representation)
2471}
2472
2473// tests
2474#[cfg(test)]
2475mod tests {
2476    use super::*;
2477    #[cfg(not(feature = "std"))]
2478    use alloc::vec;
2479    use serde::Deserialize;
2480
2481    #[test]
2482    fn test_plain_serialize() {
2483        let mut config = CuConfig::default();
2484        let graph = config.get_graph_mut(None).unwrap();
2485        let n1 = graph
2486            .add_node(Node::new("test1", "package::Plugin1"))
2487            .unwrap();
2488        let n2 = graph
2489            .add_node(Node::new("test2", "package::Plugin2"))
2490            .unwrap();
2491        graph.connect(n1, n2, "msgpkg::MsgType").unwrap();
2492        let serialized = config.serialize_ron().unwrap();
2493        let deserialized = CuConfig::deserialize_ron(&serialized).unwrap();
2494        let graph = config.graphs.get_graph(None).unwrap();
2495        let deserialized_graph = deserialized.graphs.get_graph(None).unwrap();
2496        assert_eq!(graph.node_count(), deserialized_graph.node_count());
2497        assert_eq!(graph.edge_count(), deserialized_graph.edge_count());
2498    }
2499
2500    #[test]
2501    fn test_serialize_with_params() {
2502        let mut config = CuConfig::default();
2503        let graph = config.get_graph_mut(None).unwrap();
2504        let mut camera = Node::new("copper-camera", "camerapkg::Camera");
2505        camera.set_param::<Value>("resolution-height", 1080.into());
2506        graph.add_node(camera).unwrap();
2507        let serialized = config.serialize_ron().unwrap();
2508        let config = CuConfig::deserialize_ron(&serialized).unwrap();
2509        let deserialized = config.get_graph(None).unwrap();
2510        let resolution = deserialized
2511            .get_node(0)
2512            .unwrap()
2513            .get_param::<i32>("resolution-height")
2514            .expect("resolution-height lookup failed");
2515        assert_eq!(resolution, Some(1080));
2516    }
2517
2518    #[derive(Debug, Deserialize, PartialEq)]
2519    struct InnerSettings {
2520        threshold: u32,
2521        flags: Option<bool>,
2522    }
2523
2524    #[derive(Debug, Deserialize, PartialEq)]
2525    struct SettingsConfig {
2526        gain: f32,
2527        matrix: [[f32; 3]; 3],
2528        inner: InnerSettings,
2529        tags: Vec<String>,
2530    }
2531
2532    #[test]
2533    fn test_component_config_get_value_structured() {
2534        let txt = r#"
2535            (
2536                tasks: [
2537                    (
2538                        id: "task",
2539                        type: "pkg::Task",
2540                        config: {
2541                            "settings": {
2542                                "gain": 1.5,
2543                                "matrix": [
2544                                    [1.0, 0.0, 0.0],
2545                                    [0.0, 1.0, 0.0],
2546                                    [0.0, 0.0, 1.0],
2547                                ],
2548                                "inner": { "threshold": 42, "flags": Some(true) },
2549                                "tags": ["alpha", "beta"],
2550                            },
2551                        },
2552                    ),
2553                ],
2554                cnx: [],
2555            )
2556        "#;
2557        let config = CuConfig::deserialize_ron(txt).unwrap();
2558        let graph = config.graphs.get_graph(None).unwrap();
2559        let node = graph.get_node(0).unwrap();
2560        let component = node.get_instance_config().expect("missing config");
2561        let settings = component
2562            .get_value::<SettingsConfig>("settings")
2563            .expect("settings lookup failed")
2564            .expect("missing settings");
2565        let expected = SettingsConfig {
2566            gain: 1.5,
2567            matrix: [[1.0, 0.0, 0.0], [0.0, 1.0, 0.0], [0.0, 0.0, 1.0]],
2568            inner: InnerSettings {
2569                threshold: 42,
2570                flags: Some(true),
2571            },
2572            tags: vec!["alpha".to_string(), "beta".to_string()],
2573        };
2574        assert_eq!(settings, expected);
2575    }
2576
2577    #[test]
2578    fn test_component_config_get_value_scalar_compatibility() {
2579        let txt = r#"
2580            (
2581                tasks: [
2582                    (id: "task", type: "pkg::Task", config: { "scalar": 7 }),
2583                ],
2584                cnx: [],
2585            )
2586        "#;
2587        let config = CuConfig::deserialize_ron(txt).unwrap();
2588        let graph = config.graphs.get_graph(None).unwrap();
2589        let node = graph.get_node(0).unwrap();
2590        let component = node.get_instance_config().expect("missing config");
2591        let scalar = component
2592            .get::<u32>("scalar")
2593            .expect("scalar lookup failed");
2594        assert_eq!(scalar, Some(7));
2595    }
2596
2597    #[test]
2598    fn test_component_config_get_value_mixed_usage() {
2599        let txt = r#"
2600            (
2601                tasks: [
2602                    (
2603                        id: "task",
2604                        type: "pkg::Task",
2605                        config: {
2606                            "scalar": 12,
2607                            "settings": {
2608                                "gain": 2.5,
2609                                "matrix": [
2610                                    [1.0, 2.0, 3.0],
2611                                    [4.0, 5.0, 6.0],
2612                                    [7.0, 8.0, 9.0],
2613                                ],
2614                                "inner": { "threshold": 7, "flags": None },
2615                                "tags": ["gamma"],
2616                            },
2617                        },
2618                    ),
2619                ],
2620                cnx: [],
2621            )
2622        "#;
2623        let config = CuConfig::deserialize_ron(txt).unwrap();
2624        let graph = config.graphs.get_graph(None).unwrap();
2625        let node = graph.get_node(0).unwrap();
2626        let component = node.get_instance_config().expect("missing config");
2627        let scalar = component
2628            .get::<u32>("scalar")
2629            .expect("scalar lookup failed");
2630        let settings = component
2631            .get_value::<SettingsConfig>("settings")
2632            .expect("settings lookup failed");
2633        assert_eq!(scalar, Some(12));
2634        assert!(settings.is_some());
2635    }
2636
2637    #[test]
2638    fn test_component_config_get_value_error_includes_key() {
2639        let txt = r#"
2640            (
2641                tasks: [
2642                    (
2643                        id: "task",
2644                        type: "pkg::Task",
2645                        config: { "settings": { "gain": 1.0 } },
2646                    ),
2647                ],
2648                cnx: [],
2649            )
2650        "#;
2651        let config = CuConfig::deserialize_ron(txt).unwrap();
2652        let graph = config.graphs.get_graph(None).unwrap();
2653        let node = graph.get_node(0).unwrap();
2654        let component = node.get_instance_config().expect("missing config");
2655        let err = component
2656            .get_value::<u32>("settings")
2657            .expect_err("expected type mismatch");
2658        assert!(err.to_string().contains("settings"));
2659    }
2660
2661    #[test]
2662    fn test_deserialization_error() {
2663        // Task needs to be an array, but provided tuple wrongfully
2664        let txt = r#"( tasks: (), cnx: [], monitors: [(type: "ExampleMonitor", )] ) "#;
2665        let err = CuConfig::deserialize_ron(txt).expect_err("expected deserialization error");
2666        assert!(
2667            err.to_string()
2668                .contains("Syntax Error in config: Expected opening `[` at position 1:9-1:10")
2669        );
2670    }
2671    #[test]
2672    fn test_missions() {
2673        let txt = r#"( missions: [ (id: "data_collection"), (id: "autonomous")])"#;
2674        let config = CuConfig::deserialize_ron(txt).unwrap();
2675        let graph = config.graphs.get_graph(Some("data_collection")).unwrap();
2676        assert!(graph.node_count() == 0);
2677        let graph = config.graphs.get_graph(Some("autonomous")).unwrap();
2678        assert!(graph.node_count() == 0);
2679    }
2680
2681    #[test]
2682    fn test_monitor_plural_syntax() {
2683        let txt = r#"( tasks: [], cnx: [], monitors: [(type: "ExampleMonitor", )] ) "#;
2684        let config = CuConfig::deserialize_ron(txt).unwrap();
2685        assert_eq!(config.get_monitor_config().unwrap().type_, "ExampleMonitor");
2686
2687        let txt = r#"( tasks: [], cnx: [], monitors: [(type: "ExampleMonitor", config: { "toto": 4, } )] ) "#;
2688        let config = CuConfig::deserialize_ron(txt).unwrap();
2689        assert_eq!(
2690            config
2691                .get_monitor_config()
2692                .unwrap()
2693                .config
2694                .as_ref()
2695                .unwrap()
2696                .0["toto"]
2697                .0,
2698            4u8.into()
2699        );
2700    }
2701
2702    #[test]
2703    fn test_monitor_singular_syntax() {
2704        let txt = r#"( tasks: [], cnx: [], monitor: (type: "ExampleMonitor", config: { "toto": 4, } ) ) "#;
2705        let config = CuConfig::deserialize_ron(txt).unwrap();
2706        assert_eq!(config.get_monitor_configs().len(), 1);
2707        assert_eq!(config.get_monitor_config().unwrap().type_, "ExampleMonitor");
2708        assert_eq!(
2709            config
2710                .get_monitor_config()
2711                .unwrap()
2712                .config
2713                .as_ref()
2714                .unwrap()
2715                .0["toto"]
2716                .0,
2717            4u8.into()
2718        );
2719    }
2720
2721    #[test]
2722    #[cfg(feature = "std")]
2723    fn test_render_topology_multi_input_ports() {
2724        let mut config = CuConfig::default();
2725        let graph = config.get_graph_mut(None).unwrap();
2726        let src1 = graph.add_node(Node::new("src1", "tasks::Source1")).unwrap();
2727        let src2 = graph.add_node(Node::new("src2", "tasks::Source2")).unwrap();
2728        let dst = graph.add_node(Node::new("dst", "tasks::Dst")).unwrap();
2729        graph.connect(src1, dst, "msg::A").unwrap();
2730        graph.connect(src2, dst, "msg::B").unwrap();
2731
2732        let topology = build_render_topology(graph, &[]);
2733        let dst_node = topology
2734            .nodes
2735            .iter()
2736            .find(|node| node.id == "dst")
2737            .expect("missing dst node");
2738        assert_eq!(dst_node.inputs.len(), 2);
2739
2740        let mut dst_ports: Vec<_> = topology
2741            .connections
2742            .iter()
2743            .filter(|cnx| cnx.dst == "dst")
2744            .map(|cnx| cnx.dst_port.as_deref().expect("missing dst port"))
2745            .collect();
2746        dst_ports.sort();
2747        assert_eq!(dst_ports, vec!["in.0", "in.1"]);
2748    }
2749
2750    #[test]
2751    fn test_logging_parameters() {
2752        // Test with `enable_task_logging: false`
2753        let txt = r#"( tasks: [], cnx: [], logging: ( slab_size_mib: 1024, section_size_mib: 100, enable_task_logging: false ),) "#;
2754
2755        let config = CuConfig::deserialize_ron(txt).unwrap();
2756        assert!(config.logging.is_some());
2757        let logging_config = config.logging.unwrap();
2758        assert_eq!(logging_config.slab_size_mib.unwrap(), 1024);
2759        assert_eq!(logging_config.section_size_mib.unwrap(), 100);
2760        assert!(!logging_config.enable_task_logging);
2761
2762        // Test with `enable_task_logging` not provided
2763        let txt =
2764            r#"( tasks: [], cnx: [], logging: ( slab_size_mib: 1024, section_size_mib: 100, ),) "#;
2765        let config = CuConfig::deserialize_ron(txt).unwrap();
2766        assert!(config.logging.is_some());
2767        let logging_config = config.logging.unwrap();
2768        assert_eq!(logging_config.slab_size_mib.unwrap(), 1024);
2769        assert_eq!(logging_config.section_size_mib.unwrap(), 100);
2770        assert!(logging_config.enable_task_logging);
2771    }
2772
2773    #[test]
2774    fn test_bridge_parsing() {
2775        let txt = r#"
2776        (
2777            tasks: [
2778                (id: "dst", type: "tasks::Destination"),
2779                (id: "src", type: "tasks::Source"),
2780            ],
2781            bridges: [
2782                (
2783                    id: "radio",
2784                    type: "tasks::SerialBridge",
2785                    config: { "path": "/dev/ttyACM0", "baud": 921600 },
2786                    channels: [
2787                        Rx ( id: "status", route: "sys/status" ),
2788                        Tx ( id: "motor", route: "motor/cmd" ),
2789                    ],
2790                ),
2791            ],
2792            cnx: [
2793                (src: "radio/status", dst: "dst", msg: "mymsgs::Status"),
2794                (src: "src", dst: "radio/motor", msg: "mymsgs::MotorCmd"),
2795            ],
2796        )
2797        "#;
2798
2799        let config = CuConfig::deserialize_ron(txt).unwrap();
2800        assert_eq!(config.bridges.len(), 1);
2801        let bridge = &config.bridges[0];
2802        assert_eq!(bridge.id, "radio");
2803        assert_eq!(bridge.channels.len(), 2);
2804        match &bridge.channels[0] {
2805            BridgeChannelConfigRepresentation::Rx { id, route, .. } => {
2806                assert_eq!(id, "status");
2807                assert_eq!(route.as_deref(), Some("sys/status"));
2808            }
2809            _ => panic!("expected Rx channel"),
2810        }
2811        match &bridge.channels[1] {
2812            BridgeChannelConfigRepresentation::Tx { id, route, .. } => {
2813                assert_eq!(id, "motor");
2814                assert_eq!(route.as_deref(), Some("motor/cmd"));
2815            }
2816            _ => panic!("expected Tx channel"),
2817        }
2818        let graph = config.graphs.get_graph(None).unwrap();
2819        let bridge_id = graph
2820            .get_node_id_by_name("radio")
2821            .expect("bridge node missing");
2822        let bridge_node = graph.get_node(bridge_id).unwrap();
2823        assert_eq!(bridge_node.get_flavor(), Flavor::Bridge);
2824
2825        // Edges should retain channel metadata.
2826        let mut edges = Vec::new();
2827        for edge_idx in graph.0.edge_indices() {
2828            edges.push(graph.0[edge_idx].clone());
2829        }
2830        assert_eq!(edges.len(), 2);
2831        let status_edge = edges
2832            .iter()
2833            .find(|e| e.dst == "dst")
2834            .expect("status edge missing");
2835        assert_eq!(status_edge.src_channel.as_deref(), Some("status"));
2836        assert!(status_edge.dst_channel.is_none());
2837        let motor_edge = edges
2838            .iter()
2839            .find(|e| e.dst_channel.is_some())
2840            .expect("motor edge missing");
2841        assert_eq!(motor_edge.dst_channel.as_deref(), Some("motor"));
2842    }
2843
2844    #[test]
2845    fn test_bridge_roundtrip() {
2846        let mut config = CuConfig::default();
2847        let mut bridge_config = ComponentConfig::default();
2848        bridge_config.set("port", "/dev/ttyACM0".to_string());
2849        config.bridges.push(BridgeConfig {
2850            id: "radio".to_string(),
2851            type_: "tasks::SerialBridge".to_string(),
2852            config: Some(bridge_config),
2853            resources: None,
2854            missions: None,
2855            channels: vec![
2856                BridgeChannelConfigRepresentation::Rx {
2857                    id: "status".to_string(),
2858                    route: Some("sys/status".to_string()),
2859                    config: None,
2860                },
2861                BridgeChannelConfigRepresentation::Tx {
2862                    id: "motor".to_string(),
2863                    route: Some("motor/cmd".to_string()),
2864                    config: None,
2865                },
2866            ],
2867        });
2868
2869        let serialized = config.serialize_ron().unwrap();
2870        assert!(
2871            serialized.contains("bridges"),
2872            "bridges section missing from serialized config"
2873        );
2874        let deserialized = CuConfig::deserialize_ron(&serialized).unwrap();
2875        assert_eq!(deserialized.bridges.len(), 1);
2876        let bridge = &deserialized.bridges[0];
2877        assert_eq!(bridge.channels.len(), 2);
2878        assert!(matches!(
2879            bridge.channels[0],
2880            BridgeChannelConfigRepresentation::Rx { .. }
2881        ));
2882        assert!(matches!(
2883            bridge.channels[1],
2884            BridgeChannelConfigRepresentation::Tx { .. }
2885        ));
2886    }
2887
2888    #[test]
2889    fn test_resource_parsing() {
2890        let txt = r#"
2891        (
2892            resources: [
2893                (
2894                    id: "fc",
2895                    provider: "copper_board_px4::Px4Bundle",
2896                    config: { "baud": 921600 },
2897                    missions: ["m1"],
2898                ),
2899                (
2900                    id: "misc",
2901                    provider: "cu29_runtime::StdClockBundle",
2902                ),
2903            ],
2904        )
2905        "#;
2906
2907        let config = CuConfig::deserialize_ron(txt).unwrap();
2908        assert_eq!(config.resources.len(), 2);
2909        let fc = &config.resources[0];
2910        assert_eq!(fc.id, "fc");
2911        assert_eq!(fc.provider, "copper_board_px4::Px4Bundle");
2912        assert_eq!(fc.missions.as_deref(), Some(&["m1".to_string()][..]));
2913        let baud: u32 = fc
2914            .config
2915            .as_ref()
2916            .expect("missing config")
2917            .get::<u32>("baud")
2918            .expect("baud lookup failed")
2919            .expect("missing baud");
2920        assert_eq!(baud, 921_600);
2921        let misc = &config.resources[1];
2922        assert_eq!(misc.id, "misc");
2923        assert_eq!(misc.provider, "cu29_runtime::StdClockBundle");
2924        assert!(misc.config.is_none());
2925    }
2926
2927    #[test]
2928    fn test_resource_roundtrip() {
2929        let mut config = CuConfig::default();
2930        let mut bundle_cfg = ComponentConfig::default();
2931        bundle_cfg.set("path", "/dev/ttyACM0".to_string());
2932        config.resources.push(ResourceBundleConfig {
2933            id: "fc".to_string(),
2934            provider: "copper_board_px4::Px4Bundle".to_string(),
2935            config: Some(bundle_cfg),
2936            missions: Some(vec!["m1".to_string()]),
2937        });
2938
2939        let serialized = config.serialize_ron().unwrap();
2940        let deserialized = CuConfig::deserialize_ron(&serialized).unwrap();
2941        assert_eq!(deserialized.resources.len(), 1);
2942        let res = &deserialized.resources[0];
2943        assert_eq!(res.id, "fc");
2944        assert_eq!(res.provider, "copper_board_px4::Px4Bundle");
2945        assert_eq!(res.missions.as_deref(), Some(&["m1".to_string()][..]));
2946        let path: String = res
2947            .config
2948            .as_ref()
2949            .expect("missing config")
2950            .get::<String>("path")
2951            .expect("path lookup failed")
2952            .expect("missing path");
2953        assert_eq!(path, "/dev/ttyACM0");
2954    }
2955
2956    #[test]
2957    fn test_bridge_channel_config() {
2958        let txt = r#"
2959        (
2960            tasks: [],
2961            bridges: [
2962                (
2963                    id: "radio",
2964                    type: "tasks::SerialBridge",
2965                    channels: [
2966                        Rx ( id: "status", route: "sys/status", config: { "filter": "fast" } ),
2967                        Tx ( id: "imu", route: "telemetry/imu", config: { "rate": 100 } ),
2968                    ],
2969                ),
2970            ],
2971            cnx: [],
2972        )
2973        "#;
2974
2975        let config = CuConfig::deserialize_ron(txt).unwrap();
2976        let bridge = &config.bridges[0];
2977        match &bridge.channels[0] {
2978            BridgeChannelConfigRepresentation::Rx {
2979                config: Some(cfg), ..
2980            } => {
2981                let val = cfg
2982                    .get::<String>("filter")
2983                    .expect("filter lookup failed")
2984                    .expect("filter missing");
2985                assert_eq!(val, "fast");
2986            }
2987            _ => panic!("expected Rx channel with config"),
2988        }
2989        match &bridge.channels[1] {
2990            BridgeChannelConfigRepresentation::Tx {
2991                config: Some(cfg), ..
2992            } => {
2993                let rate = cfg
2994                    .get::<i32>("rate")
2995                    .expect("rate lookup failed")
2996                    .expect("rate missing");
2997                assert_eq!(rate, 100);
2998            }
2999            _ => panic!("expected Tx channel with config"),
3000        }
3001    }
3002
3003    #[test]
3004    fn test_task_resources_roundtrip() {
3005        let txt = r#"
3006        (
3007            tasks: [
3008                (
3009                    id: "imu",
3010                    type: "tasks::ImuDriver",
3011                    resources: { "bus": "fc.spi_1", "irq": "fc.gpio_imu" },
3012                ),
3013            ],
3014            cnx: [],
3015        )
3016        "#;
3017
3018        let config = CuConfig::deserialize_ron(txt).unwrap();
3019        let graph = config.graphs.get_graph(None).unwrap();
3020        let node = graph.get_node(0).expect("missing task node");
3021        let resources = node.get_resources().expect("missing resources map");
3022        assert_eq!(resources.get("bus").map(String::as_str), Some("fc.spi_1"));
3023        assert_eq!(
3024            resources.get("irq").map(String::as_str),
3025            Some("fc.gpio_imu")
3026        );
3027
3028        let serialized = config.serialize_ron().unwrap();
3029        let deserialized = CuConfig::deserialize_ron(&serialized).unwrap();
3030        let graph = deserialized.graphs.get_graph(None).unwrap();
3031        let node = graph.get_node(0).expect("missing task node");
3032        let resources = node
3033            .get_resources()
3034            .expect("missing resources map after roundtrip");
3035        assert_eq!(resources.get("bus").map(String::as_str), Some("fc.spi_1"));
3036        assert_eq!(
3037            resources.get("irq").map(String::as_str),
3038            Some("fc.gpio_imu")
3039        );
3040    }
3041
3042    #[test]
3043    fn test_bridge_resources_preserved() {
3044        let mut config = CuConfig::default();
3045        config.resources.push(ResourceBundleConfig {
3046            id: "fc".to_string(),
3047            provider: "board::Bundle".to_string(),
3048            config: None,
3049            missions: None,
3050        });
3051        let bridge_resources = HashMap::from([("serial".to_string(), "fc.serial0".to_string())]);
3052        config.bridges.push(BridgeConfig {
3053            id: "radio".to_string(),
3054            type_: "tasks::SerialBridge".to_string(),
3055            config: None,
3056            resources: Some(bridge_resources),
3057            missions: None,
3058            channels: vec![BridgeChannelConfigRepresentation::Tx {
3059                id: "uplink".to_string(),
3060                route: None,
3061                config: None,
3062            }],
3063        });
3064
3065        let serialized = config.serialize_ron().unwrap();
3066        let deserialized = CuConfig::deserialize_ron(&serialized).unwrap();
3067        let graph = deserialized.graphs.get_graph(None).expect("missing graph");
3068        let bridge_id = graph
3069            .get_node_id_by_name("radio")
3070            .expect("bridge node missing");
3071        let node = graph.get_node(bridge_id).expect("missing bridge node");
3072        let resources = node
3073            .get_resources()
3074            .expect("bridge resources were not preserved");
3075        assert_eq!(
3076            resources.get("serial").map(String::as_str),
3077            Some("fc.serial0")
3078        );
3079    }
3080
3081    #[test]
3082    fn test_demo_config_parses() {
3083        let txt = r#"(
3084    resources: [
3085        (
3086            id: "fc",
3087            provider: "crate::resources::RadioBundle",
3088        ),
3089    ],
3090    tasks: [
3091        (id: "thr", type: "tasks::ThrottleControl"),
3092        (id: "tele0", type: "tasks::TelemetrySink0"),
3093        (id: "tele1", type: "tasks::TelemetrySink1"),
3094        (id: "tele2", type: "tasks::TelemetrySink2"),
3095        (id: "tele3", type: "tasks::TelemetrySink3"),
3096    ],
3097    bridges: [
3098        (  id: "crsf",
3099           type: "cu_crsf::CrsfBridge<SerialResource, SerialPortError>",
3100           resources: { "serial": "fc.serial" },
3101           channels: [
3102                Rx ( id: "rc_rx" ),  // receiving RC Channels
3103                Tx ( id: "lq_tx" ),  // Sending LineQuality back
3104            ],
3105        ),
3106        (
3107            id: "bdshot",
3108            type: "cu_bdshot::RpBdshotBridge",
3109            channels: [
3110                Tx ( id: "esc0_tx" ),
3111                Tx ( id: "esc1_tx" ),
3112                Tx ( id: "esc2_tx" ),
3113                Tx ( id: "esc3_tx" ),
3114                Rx ( id: "esc0_rx" ),
3115                Rx ( id: "esc1_rx" ),
3116                Rx ( id: "esc2_rx" ),
3117                Rx ( id: "esc3_rx" ),
3118            ],
3119        ),
3120    ],
3121    cnx: [
3122        (src: "crsf/rc_rx", dst: "thr", msg: "cu_crsf::messages::RcChannelsPayload"),
3123        (src: "thr", dst: "bdshot/esc0_tx", msg: "cu_bdshot::EscCommand"),
3124        (src: "thr", dst: "bdshot/esc1_tx", msg: "cu_bdshot::EscCommand"),
3125        (src: "thr", dst: "bdshot/esc2_tx", msg: "cu_bdshot::EscCommand"),
3126        (src: "thr", dst: "bdshot/esc3_tx", msg: "cu_bdshot::EscCommand"),
3127        (src: "bdshot/esc0_rx", dst: "tele0", msg: "cu_bdshot::EscTelemetry"),
3128        (src: "bdshot/esc1_rx", dst: "tele1", msg: "cu_bdshot::EscTelemetry"),
3129        (src: "bdshot/esc2_rx", dst: "tele2", msg: "cu_bdshot::EscTelemetry"),
3130        (src: "bdshot/esc3_rx", dst: "tele3", msg: "cu_bdshot::EscTelemetry"),
3131    ],
3132)"#;
3133        let config = CuConfig::deserialize_ron(txt).unwrap();
3134        assert_eq!(config.resources.len(), 1);
3135        assert_eq!(config.bridges.len(), 2);
3136    }
3137
3138    #[test]
3139    fn test_bridge_tx_cannot_be_source() {
3140        let txt = r#"
3141        (
3142            tasks: [
3143                (id: "dst", type: "tasks::Destination"),
3144            ],
3145            bridges: [
3146                (
3147                    id: "radio",
3148                    type: "tasks::SerialBridge",
3149                    channels: [
3150                        Tx ( id: "motor", route: "motor/cmd" ),
3151                    ],
3152                ),
3153            ],
3154            cnx: [
3155                (src: "radio/motor", dst: "dst", msg: "mymsgs::MotorCmd"),
3156            ],
3157        )
3158        "#;
3159
3160        let err = CuConfig::deserialize_ron(txt).expect_err("expected bridge source error");
3161        assert!(
3162            err.to_string()
3163                .contains("channel 'motor' is Tx and cannot act as a source")
3164        );
3165    }
3166
3167    #[test]
3168    fn test_bridge_rx_cannot_be_destination() {
3169        let txt = r#"
3170        (
3171            tasks: [
3172                (id: "src", type: "tasks::Source"),
3173            ],
3174            bridges: [
3175                (
3176                    id: "radio",
3177                    type: "tasks::SerialBridge",
3178                    channels: [
3179                        Rx ( id: "status", route: "sys/status" ),
3180                    ],
3181                ),
3182            ],
3183            cnx: [
3184                (src: "src", dst: "radio/status", msg: "mymsgs::Status"),
3185            ],
3186        )
3187        "#;
3188
3189        let err = CuConfig::deserialize_ron(txt).expect_err("expected bridge destination error");
3190        assert!(
3191            err.to_string()
3192                .contains("channel 'status' is Rx and cannot act as a destination")
3193        );
3194    }
3195
3196    #[test]
3197    fn test_validate_logging_config() {
3198        // Test with valid logging configuration
3199        let txt =
3200            r#"( tasks: [], cnx: [], logging: ( slab_size_mib: 1024, section_size_mib: 100 ) )"#;
3201        let config = CuConfig::deserialize_ron(txt).unwrap();
3202        assert!(config.validate_logging_config().is_ok());
3203
3204        // Test with invalid logging configuration
3205        let txt =
3206            r#"( tasks: [], cnx: [], logging: ( slab_size_mib: 100, section_size_mib: 1024 ) )"#;
3207        let config = CuConfig::deserialize_ron(txt).unwrap();
3208        assert!(config.validate_logging_config().is_err());
3209    }
3210
3211    // this test makes sure the edge id is suitable to be used to sort the inputs of a task
3212    #[test]
3213    fn test_deserialization_edge_id_assignment() {
3214        // note here that the src1 task is added before src2 in the tasks array,
3215        // however, src1 connection is added AFTER src2 in the cnx array
3216        let txt = r#"(
3217            tasks: [(id: "src1", type: "a"), (id: "src2", type: "b"), (id: "sink", type: "c")],
3218            cnx: [(src: "src2", dst: "sink", msg: "msg1"), (src: "src1", dst: "sink", msg: "msg2")]
3219        )"#;
3220        let config = CuConfig::deserialize_ron(txt).unwrap();
3221        let graph = config.graphs.get_graph(None).unwrap();
3222        assert!(config.validate_logging_config().is_ok());
3223
3224        // the node id depends on the order in which the tasks are added
3225        let src1_id = 0;
3226        assert_eq!(graph.get_node(src1_id).unwrap().id, "src1");
3227        let src2_id = 1;
3228        assert_eq!(graph.get_node(src2_id).unwrap().id, "src2");
3229
3230        // the edge id depends on the order the connection is created
3231        // the src2 was added second in the tasks, but the connection was added first
3232        let src1_edge_id = *graph.get_src_edges(src1_id).unwrap().first().unwrap();
3233        assert_eq!(src1_edge_id, 1);
3234        let src2_edge_id = *graph.get_src_edges(src2_id).unwrap().first().unwrap();
3235        assert_eq!(src2_edge_id, 0);
3236    }
3237
3238    #[test]
3239    fn test_simple_missions() {
3240        // A simple config that selection a source depending on the mission it is in.
3241        let txt = r#"(
3242                    missions: [ (id: "m1"),
3243                                (id: "m2"),
3244                                ],
3245                    tasks: [(id: "src1", type: "a", missions: ["m1"]),
3246                            (id: "src2", type: "b", missions: ["m2"]),
3247                            (id: "sink", type: "c")],
3248
3249                    cnx: [
3250                            (src: "src1", dst: "sink", msg: "u32", missions: ["m1"]),
3251                            (src: "src2", dst: "sink", msg: "u32", missions: ["m2"]),
3252                         ],
3253              )
3254              "#;
3255
3256        let config = CuConfig::deserialize_ron(txt).unwrap();
3257        let m1_graph = config.graphs.get_graph(Some("m1")).unwrap();
3258        assert_eq!(m1_graph.edge_count(), 1);
3259        assert_eq!(m1_graph.node_count(), 2);
3260        let index = 0;
3261        let cnx = m1_graph.get_edge_weight(index).unwrap();
3262
3263        assert_eq!(cnx.src, "src1");
3264        assert_eq!(cnx.dst, "sink");
3265        assert_eq!(cnx.msg, "u32");
3266        assert_eq!(cnx.missions, Some(vec!["m1".to_string()]));
3267
3268        let m2_graph = config.graphs.get_graph(Some("m2")).unwrap();
3269        assert_eq!(m2_graph.edge_count(), 1);
3270        assert_eq!(m2_graph.node_count(), 2);
3271        let index = 0;
3272        let cnx = m2_graph.get_edge_weight(index).unwrap();
3273        assert_eq!(cnx.src, "src2");
3274        assert_eq!(cnx.dst, "sink");
3275        assert_eq!(cnx.msg, "u32");
3276        assert_eq!(cnx.missions, Some(vec!["m2".to_string()]));
3277    }
3278    #[test]
3279    fn test_mission_serde() {
3280        // A simple config that selection a source depending on the mission it is in.
3281        let txt = r#"(
3282                    missions: [ (id: "m1"),
3283                                (id: "m2"),
3284                                ],
3285                    tasks: [(id: "src1", type: "a", missions: ["m1"]),
3286                            (id: "src2", type: "b", missions: ["m2"]),
3287                            (id: "sink", type: "c")],
3288
3289                    cnx: [
3290                            (src: "src1", dst: "sink", msg: "u32", missions: ["m1"]),
3291                            (src: "src2", dst: "sink", msg: "u32", missions: ["m2"]),
3292                         ],
3293              )
3294              "#;
3295
3296        let config = CuConfig::deserialize_ron(txt).unwrap();
3297        let serialized = config.serialize_ron().unwrap();
3298        let deserialized = CuConfig::deserialize_ron(&serialized).unwrap();
3299        let m1_graph = deserialized.graphs.get_graph(Some("m1")).unwrap();
3300        assert_eq!(m1_graph.edge_count(), 1);
3301        assert_eq!(m1_graph.node_count(), 2);
3302        let index = 0;
3303        let cnx = m1_graph.get_edge_weight(index).unwrap();
3304        assert_eq!(cnx.src, "src1");
3305        assert_eq!(cnx.dst, "sink");
3306        assert_eq!(cnx.msg, "u32");
3307        assert_eq!(cnx.missions, Some(vec!["m1".to_string()]));
3308    }
3309
3310    #[test]
3311    fn test_keyframe_interval() {
3312        // note here that the src1 task is added before src2 in the tasks array,
3313        // however, src1 connection is added AFTER src2 in the cnx array
3314        let txt = r#"(
3315            tasks: [(id: "src1", type: "a"), (id: "src2", type: "b"), (id: "sink", type: "c")],
3316            cnx: [(src: "src2", dst: "sink", msg: "msg1"), (src: "src1", dst: "sink", msg: "msg2")],
3317            logging: ( keyframe_interval: 314 )
3318        )"#;
3319        let config = CuConfig::deserialize_ron(txt).unwrap();
3320        let logging_config = config.logging.unwrap();
3321        assert_eq!(logging_config.keyframe_interval.unwrap(), 314);
3322    }
3323
3324    #[test]
3325    fn test_default_keyframe_interval() {
3326        // note here that the src1 task is added before src2 in the tasks array,
3327        // however, src1 connection is added AFTER src2 in the cnx array
3328        let txt = r#"(
3329            tasks: [(id: "src1", type: "a"), (id: "src2", type: "b"), (id: "sink", type: "c")],
3330            cnx: [(src: "src2", dst: "sink", msg: "msg1"), (src: "src1", dst: "sink", msg: "msg2")],
3331            logging: ( slab_size_mib: 200, section_size_mib: 1024, )
3332        )"#;
3333        let config = CuConfig::deserialize_ron(txt).unwrap();
3334        let logging_config = config.logging.unwrap();
3335        assert_eq!(logging_config.keyframe_interval.unwrap(), 100);
3336    }
3337}