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