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