1#[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
53pub type NodeId = u32;
56pub const DEFAULT_MISSION_ID: &str = "default";
57
58#[derive(Serialize, Deserialize, Debug, Clone, Default)]
62pub struct ComponentConfig(pub HashMap<String, Value>);
63
64#[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
82impl 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 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#[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
267macro_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
278impl_from_numeric_for_value!(i8, i16, i32, i64, u8, u16, u32, u64, f32, f64);
280
281impl TryFrom<&Value> for bool {
282 type Error = ConfigError;
283
284 fn try_from(value: &Value) -> Result<Self, Self::Error> {
285 if let Value(RonValue::Bool(v)) = value {
286 Ok(*v)
287 } else {
288 Err(ConfigError::type_mismatch("bool", value))
289 }
290 }
291}
292
293impl From<Value> for bool {
294 fn from(value: Value) -> Self {
295 if let Value(RonValue::Bool(v)) = value {
296 v
297 } else {
298 panic!("Expected a Boolean variant but got {value:?}")
299 }
300 }
301}
302macro_rules! impl_from_value_for_int {
303 ($($target:ty),* $(,)?) => {
304 $(
305 impl From<Value> for $target {
306 fn from(value: Value) -> Self {
307 if let Value(RonValue::Number(num)) = value {
308 match num {
309 Number::I8(n) => n as $target,
310 Number::I16(n) => n as $target,
311 Number::I32(n) => n as $target,
312 Number::I64(n) => n as $target,
313 Number::U8(n) => n as $target,
314 Number::U16(n) => n as $target,
315 Number::U32(n) => n as $target,
316 Number::U64(n) => n as $target,
317 Number::F32(_) | Number::F64(_) => {
318 panic!("Expected an integer Number variant but got {num:?}")
319 }
320 _ => {
321 panic!("Expected an integer Number variant but got {num:?}")
322 }
323 }
324 } else {
325 panic!("Expected a Number variant but got {value:?}")
326 }
327 }
328 }
329 )*
330 };
331}
332
333impl_from_value_for_int!(u8, i8, u16, i16, u32, i32, u64, i64);
334
335macro_rules! impl_try_from_value_for_int {
336 ($($target:ty),* $(,)?) => {
337 $(
338 impl TryFrom<&Value> for $target {
339 type Error = ConfigError;
340
341 fn try_from(value: &Value) -> Result<Self, Self::Error> {
342 if let Value(RonValue::Number(num)) = value {
343 match num {
344 Number::I8(n) => Ok(*n as $target),
345 Number::I16(n) => Ok(*n as $target),
346 Number::I32(n) => Ok(*n as $target),
347 Number::I64(n) => Ok(*n as $target),
348 Number::U8(n) => Ok(*n as $target),
349 Number::U16(n) => Ok(*n as $target),
350 Number::U32(n) => Ok(*n as $target),
351 Number::U64(n) => Ok(*n as $target),
352 Number::F32(_) | Number::F64(_) => {
353 Err(ConfigError::type_mismatch("integer", value))
354 }
355 _ => {
356 Err(ConfigError::type_mismatch("integer", value))
357 }
358 }
359 } else {
360 Err(ConfigError::type_mismatch("integer", value))
361 }
362 }
363 }
364 )*
365 };
366}
367
368impl_try_from_value_for_int!(u8, i8, u16, i16, u32, i32, u64, i64);
369
370impl TryFrom<&Value> for f64 {
371 type Error = ConfigError;
372
373 fn try_from(value: &Value) -> Result<Self, Self::Error> {
374 if let Value(RonValue::Number(num)) = value {
375 let number = match num {
376 Number::I8(n) => *n as f64,
377 Number::I16(n) => *n as f64,
378 Number::I32(n) => *n as f64,
379 Number::I64(n) => *n as f64,
380 Number::U8(n) => *n as f64,
381 Number::U16(n) => *n as f64,
382 Number::U32(n) => *n as f64,
383 Number::U64(n) => *n as f64,
384 Number::F32(n) => n.0 as f64,
385 Number::F64(n) => n.0,
386 _ => {
387 return Err(ConfigError::type_mismatch("number", value));
388 }
389 };
390 Ok(number)
391 } else {
392 Err(ConfigError::type_mismatch("number", value))
393 }
394 }
395}
396
397impl From<Value> for f64 {
398 fn from(value: Value) -> Self {
399 if let Value(RonValue::Number(num)) = value {
400 num.into_f64()
401 } else {
402 panic!("Expected a Number variant but got {value:?}")
403 }
404 }
405}
406
407impl 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#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq, Default)]
515#[repr(u8)]
516pub enum HandleContent {
517 #[serde(rename = "all", alias = "All")]
519 #[default]
520 All = 0,
521 #[serde(rename = "touched_only", alias = "TouchedOnly")]
523 TouchedOnly = 1,
524 #[serde(rename = "none", alias = "None")]
526 None = 2,
527}
528
529impl HandleContent {
530 #[allow(dead_code)] 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#[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 #[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 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#[derive(Default, Debug, Copy, Clone, PartialEq, Eq)]
606pub enum Flavor {
607 #[default]
608 Task,
609 Bridge,
610}
611
612#[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
637pub const DEFAULT_BACKGROUND_POOL: &str = "background";
639
640#[allow(dead_code)] pub const RT_POOL: &str = "rt";
644
645#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq)]
651#[serde(untagged)]
652pub enum BackgroundConfig {
653 Flag(bool),
655 Pool { pool: String },
657}
658
659#[derive(Serialize, Deserialize, Debug, Clone)]
662pub struct Node {
663 id: String,
665
666 #[serde(rename = "type", skip_serializing_if = "Option::is_none")]
668 type_: Option<String>,
669
670 #[serde(skip_serializing_if = "Option::is_none")]
673 kind: Option<TaskKind>,
674
675 #[serde(skip_serializing_if = "Option::is_none")]
677 config: Option<ComponentConfig>,
678
679 #[serde(skip_serializing_if = "Option::is_none")]
681 resources: Option<HashMap<String, String>>,
682
683 missions: Option<Vec<String>>,
685
686 #[serde(skip_serializing_if = "Option::is_none")]
693 background: Option<BackgroundConfig>,
694
695 #[serde(skip_serializing_if = "Option::is_none")]
701 run_in_sim: Option<bool>,
702
703 #[serde(skip_serializing_if = "Option::is_none")]
705 logging: Option<NodeLogging>,
706
707 #[serde(skip, default)]
709 flavor: Flavor,
710 #[serde(skip, default)]
712 nc_outputs: Vec<String>,
713 #[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 #[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 #[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 #[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 #[allow(dead_code)]
866 pub fn get_flavor(&self) -> Flavor {
867 self.flavor
868 }
869
870 #[allow(dead_code)]
872 pub fn set_flavor(&mut self, flavor: Flavor) {
873 self.flavor = flavor;
874 }
875
876 #[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 #[allow(dead_code)]
895 pub fn nc_outputs(&self) -> &[String] {
896 &self.nc_outputs
897 }
898
899 #[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#[derive(Serialize, Deserialize, Debug, Clone)]
910pub enum BridgeChannelConfigRepresentation {
911 Rx {
913 id: String,
914 #[serde(skip_serializing_if = "Option::is_none")]
916 route: Option<String>,
917 #[serde(skip_serializing_if = "Option::is_none")]
919 config: Option<ComponentConfig>,
920 },
921 Tx {
923 id: String,
924 #[serde(skip_serializing_if = "Option::is_none")]
926 route: Option<String>,
927 #[serde(skip_serializing_if = "Option::is_none")]
929 config: Option<ComponentConfig>,
930 },
931}
932
933impl BridgeChannelConfigRepresentation {
934 #[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 #[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#[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#[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 #[serde(skip_serializing_if = "Option::is_none")]
1017 pub run_in_sim: Option<bool>,
1018 pub channels: Vec<BridgeChannelConfigRepresentation>,
1020}
1021
1022impl BridgeConfig {
1023 #[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#[derive(Serialize, Deserialize, Debug, Clone)]
1053struct SerializedCnx {
1054 src: String,
1055 dst: String,
1056 msg: String,
1057 missions: Option<Vec<String>>,
1058}
1059
1060pub const NC_ENDPOINT: &str = "__nc__";
1062
1063#[derive(Debug, Clone)]
1065pub struct Cnx {
1066 pub src: String,
1068 pub dst: String,
1070 pub msg: String,
1072 pub missions: Option<Vec<String>>,
1074 pub src_channel: Option<String>,
1076 pub dst_channel: Option<String>,
1078 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#[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 #[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 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 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 #[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 #[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#[derive(Debug, Clone)]
1705pub struct CuConfig {
1706 pub monitors: Vec<MonitorConfig>,
1708 pub logging: Option<LoggingConfig>,
1710 pub runtime: Option<RuntimeConfig>,
1712 pub resources: Vec<ResourceBundleConfig>,
1714 pub bridges: Vec<BridgeConfig>,
1716 pub graphs: ConfigGraphs,
1718}
1719
1720impl CuConfig {
1721 #[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 #[serde(default = "default_as_true", skip_serializing_if = "Clone::clone")]
1801 pub enable_task_logging: bool,
1802
1803 #[serde(skip_serializing_if = "Option::is_none")]
1808 pub copperlist_count: Option<usize>,
1809
1810 #[serde(skip_serializing_if = "Option::is_none")]
1812 pub slab_size_mib: Option<u64>,
1813
1814 #[serde(skip_serializing_if = "Option::is_none")]
1816 pub section_size_mib: Option<u64>,
1817
1818 #[serde(
1820 default = "default_keyframe_interval",
1821 skip_serializing_if = "Option::is_none"
1822 )]
1823 pub keyframe_interval: Option<u32>,
1824
1825 #[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 #[serde(skip_serializing_if = "Option::is_none")]
1860 pub rate_target_hz: Option<u64>,
1861
1862 #[serde(default, skip_serializing_if = "Vec::is_empty")]
1869 pub thread_pools: Vec<ThreadPoolConfig>,
1870}
1871
1872pub const MIN_RT_PRIORITY: u8 = 1;
1874pub const MAX_RT_PRIORITY: u8 = 99;
1876pub const MIN_NICE: i8 = -20;
1878pub const MAX_NICE: i8 = 19;
1880
1881#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq, Default)]
1887pub enum SchedulingPolicy {
1888 #[default]
1893 Fair,
1894 Nice(i8),
1901 Fifo { priority: u8 },
1909 RoundRobin { priority: u8 },
1916}
1917
1918#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq, Default)]
1921pub enum OnError {
1922 #[default]
1925 Warn,
1926 Strict,
1929}
1930
1931#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq)]
1933pub struct ThreadPoolConfig {
1934 pub id: String,
1937 pub threads: usize,
1939 #[serde(default, skip_serializing_if = "Option::is_none")]
1943 pub affinity: Option<Vec<usize>>,
1944 #[serde(default)]
1946 pub policy: SchedulingPolicy,
1947 #[serde(default)]
1949 pub on_error: OnError,
1950}
1951
1952fn 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
2015pub const MAX_RATE_TARGET_HZ: u64 = 1_000_000_000;
2020
2021#[derive(Serialize, Deserialize, Debug, Clone)]
2023pub struct MissionsConfig {
2024 pub id: String,
2025}
2026
2027#[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#[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#[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#[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#[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#[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#[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#[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#[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
2233fn 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 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 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 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 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 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 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 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 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 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 let mut tasks = Vec::new();
2542 let mut ordered_cnx: Vec<(usize, SerializedCnx)> = Vec::new();
2543
2544 for (mission_id, graph) in graphs {
2545 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 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
2656impl 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 #[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 pub fn validate_logging_config(&self) -> CuResult<()> {
2818 if let Some(logging) = &self.logging {
2819 return logging.validate();
2820 }
2821 Ok(())
2822 }
2823
2824 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 BridgeChannelConfigRepresentation::Rx { id, .. } => outputs.push(id.clone()),
3062 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\">—</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 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 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)] fn 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#[cfg(feature = "std")]
3348fn process_includes(
3349 file_path: &str,
3350 base_representation: CuConfigRepresentation,
3351 processed_files: &mut Vec<String>,
3352) -> CuResult<CuConfigRepresentation> {
3353 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#[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
4088fn 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
4105fn 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 let representation = parse_config_string(&config_content)?;
4128
4129 #[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 config_representation_to_config(representation)
4140}
4141
4142#[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#[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#[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 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 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 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 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 let noop = config.find_task_node(None, "noop").unwrap();
4488 assert_eq!(noop.handle_content_policy(), HandleContent::All);
4489
4490 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 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 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 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 #[test]
4965 fn test_deserialization_edge_id_assignment() {
4966 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 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 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 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 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 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 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 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 assert!(!config.resources.iter().any(|b| b.id == "threadpool"));
5835 }
5836}