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