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_configuration_content(config_filename)?;
4079 read_configuration_str(config_content, Some(config_filename))
4080}
4081
4082#[cfg(feature = "std")]
4083fn read_configuration_content(config_filename: &str) -> CuResult<String> {
4084 read_to_string(config_filename).map_err(|e| {
4085 CuError::from(format!(
4086 "Failed to read configuration file: {:?}",
4087 config_filename
4088 ))
4089 .add_cause(e.to_string().as_str())
4090 })
4091}
4092
4093fn parse_config_string(content: &str) -> CuResult<CuConfigRepresentation> {
4097 Options::default()
4098 .with_default_extension(Extensions::IMPLICIT_SOME)
4099 .with_default_extension(Extensions::UNWRAP_NEWTYPES)
4100 .with_default_extension(Extensions::UNWRAP_VARIANT_NEWTYPES)
4101 .from_str(content)
4102 .map_err(|e| {
4103 CuError::from(format!(
4104 "Failed to parse configuration: Error: {} at position {}",
4105 e.code, e.span
4106 ))
4107 })
4108}
4109
4110fn config_representation_to_config(representation: CuConfigRepresentation) -> CuResult<CuConfig> {
4113 #[allow(unused_mut)]
4114 let mut cuconfig = CuConfig::deserialize_impl(representation)
4115 .map_err(|e| CuError::from(format!("Error deserializing configuration: {e}")))?;
4116
4117 #[cfg(feature = "std")]
4118 cuconfig.ensure_default_background_pool();
4119
4120 cuconfig.validate_logging_config()?;
4121 cuconfig.validate_runtime_config()?;
4122
4123 Ok(cuconfig)
4124}
4125
4126#[allow(unused_variables)]
4127fn resolve_configuration_representation(
4128 config_content: &str,
4129 file_path: Option<&str>,
4130) -> CuResult<CuConfigRepresentation> {
4131 let representation = parse_config_string(config_content)?;
4133
4134 #[cfg(feature = "std")]
4137 let representation = if let Some(path) = file_path {
4138 process_includes(path, representation, &mut Vec::new())?
4139 } else {
4140 representation
4141 };
4142
4143 Ok(representation)
4144}
4145
4146#[cfg(feature = "std")]
4151#[doc(hidden)]
4152#[allow(dead_code)]
4153pub fn read_configuration_with_resolved_ron(config_filename: &str) -> CuResult<(CuConfig, String)> {
4154 let config_content = read_configuration_content(config_filename)?;
4155 let representation =
4156 resolve_configuration_representation(&config_content, Some(config_filename))?;
4157 let resolved_ron = CuConfig::get_options()
4158 .to_string_pretty(&representation, ron::ser::PrettyConfig::default())
4159 .map_err(|e| CuError::from(format!("Error serializing configuration: {e}")))?;
4160 let config = config_representation_to_config(representation)?;
4161 Ok((config, resolved_ron))
4162}
4163
4164pub fn read_configuration_str(
4165 config_content: String,
4166 file_path: Option<&str>,
4167) -> CuResult<CuConfig> {
4168 let representation = resolve_configuration_representation(&config_content, file_path)?;
4169
4170 config_representation_to_config(representation)
4172}
4173
4174#[cfg(feature = "std")]
4176#[allow(dead_code)]
4177pub fn read_multi_configuration(config_filename: &str) -> CuResult<MultiCopperConfig> {
4178 let config_content = read_to_string(config_filename).map_err(|e| {
4179 CuError::from(format!(
4180 "Failed to read multi-Copper configuration file: {:?}",
4181 config_filename
4182 ))
4183 .add_cause(e.to_string().as_str())
4184 })?;
4185 read_multi_configuration_str(config_content, Some(config_filename))
4186}
4187
4188#[cfg(feature = "std")]
4190#[allow(dead_code)]
4191pub fn read_multi_configuration_str(
4192 config_content: String,
4193 file_path: Option<&str>,
4194) -> CuResult<MultiCopperConfig> {
4195 let representation = parse_multi_config_string(&config_content)?;
4196 validate_multi_config_representation(representation, file_path)
4197}
4198
4199#[cfg(test)]
4201mod tests {
4202 use super::*;
4203 #[cfg(not(feature = "std"))]
4204 use alloc::vec;
4205 use serde::Deserialize;
4206 #[cfg(feature = "std")]
4207 use std::path::{Path, PathBuf};
4208
4209 #[test]
4210 fn test_plain_serialize() {
4211 let mut config = CuConfig::default();
4212 let graph = config.get_graph_mut(None).unwrap();
4213 let n1 = graph
4214 .add_node(Node::new("test1", "package::Plugin1"))
4215 .unwrap();
4216 let n2 = graph
4217 .add_node(Node::new("test2", "package::Plugin2"))
4218 .unwrap();
4219 graph.connect(n1, n2, "msgpkg::MsgType").unwrap();
4220 let serialized = config.serialize_ron().unwrap();
4221 let deserialized = CuConfig::deserialize_ron(&serialized).unwrap();
4222 let graph = config.graphs.get_graph(None).unwrap();
4223 let deserialized_graph = deserialized.graphs.get_graph(None).unwrap();
4224 assert_eq!(graph.node_count(), deserialized_graph.node_count());
4225 assert_eq!(graph.edge_count(), deserialized_graph.edge_count());
4226 }
4227
4228 #[test]
4229 fn test_serialize_with_params() {
4230 let mut config = CuConfig::default();
4231 let graph = config.get_graph_mut(None).unwrap();
4232 let mut camera = Node::new("copper-camera", "camerapkg::Camera");
4233 camera.set_param::<Value>("resolution-height", 1080.into());
4234 graph.add_node(camera).unwrap();
4235 let serialized = config.serialize_ron().unwrap();
4236 let config = CuConfig::deserialize_ron(&serialized).unwrap();
4237 let deserialized = config.get_graph(None).unwrap();
4238 let resolution = deserialized
4239 .get_node(0)
4240 .unwrap()
4241 .get_param::<i32>("resolution-height")
4242 .expect("resolution-height lookup failed");
4243 assert_eq!(resolution, Some(1080));
4244 }
4245
4246 #[derive(Debug, Deserialize, PartialEq)]
4247 struct InnerSettings {
4248 threshold: u32,
4249 flags: Option<bool>,
4250 }
4251
4252 #[derive(Debug, Deserialize, PartialEq)]
4253 struct SettingsConfig {
4254 gain: f32,
4255 matrix: [[f32; 3]; 3],
4256 inner: InnerSettings,
4257 tags: Vec<String>,
4258 }
4259
4260 #[test]
4261 fn test_component_config_get_value_structured() {
4262 let txt = r#"
4263 (
4264 tasks: [
4265 (
4266 id: "task",
4267 type: "pkg::Task",
4268 config: {
4269 "settings": {
4270 "gain": 1.5,
4271 "matrix": [
4272 [1.0, 0.0, 0.0],
4273 [0.0, 1.0, 0.0],
4274 [0.0, 0.0, 1.0],
4275 ],
4276 "inner": { "threshold": 42, "flags": Some(true) },
4277 "tags": ["alpha", "beta"],
4278 },
4279 },
4280 ),
4281 ],
4282 cnx: [],
4283 )
4284 "#;
4285 let config = CuConfig::deserialize_ron(txt).unwrap();
4286 let graph = config.graphs.get_graph(None).unwrap();
4287 let node = graph.get_node(0).unwrap();
4288 let component = node.get_instance_config().expect("missing config");
4289 let settings = component
4290 .get_value::<SettingsConfig>("settings")
4291 .expect("settings lookup failed")
4292 .expect("missing settings");
4293 let expected = SettingsConfig {
4294 gain: 1.5,
4295 matrix: [[1.0, 0.0, 0.0], [0.0, 1.0, 0.0], [0.0, 0.0, 1.0]],
4296 inner: InnerSettings {
4297 threshold: 42,
4298 flags: Some(true),
4299 },
4300 tags: vec!["alpha".to_string(), "beta".to_string()],
4301 };
4302 assert_eq!(settings, expected);
4303 }
4304
4305 #[test]
4306 fn test_component_config_get_value_scalar_compatibility() {
4307 let txt = r#"
4308 (
4309 tasks: [
4310 (id: "task", type: "pkg::Task", config: { "scalar": 7 }),
4311 ],
4312 cnx: [],
4313 )
4314 "#;
4315 let config = CuConfig::deserialize_ron(txt).unwrap();
4316 let graph = config.graphs.get_graph(None).unwrap();
4317 let node = graph.get_node(0).unwrap();
4318 let component = node.get_instance_config().expect("missing config");
4319 let scalar = component
4320 .get::<u32>("scalar")
4321 .expect("scalar lookup failed");
4322 assert_eq!(scalar, Some(7));
4323 }
4324
4325 #[test]
4326 fn test_component_config_get_value_mixed_usage() {
4327 let txt = r#"
4328 (
4329 tasks: [
4330 (
4331 id: "task",
4332 type: "pkg::Task",
4333 config: {
4334 "scalar": 12,
4335 "settings": {
4336 "gain": 2.5,
4337 "matrix": [
4338 [1.0, 2.0, 3.0],
4339 [4.0, 5.0, 6.0],
4340 [7.0, 8.0, 9.0],
4341 ],
4342 "inner": { "threshold": 7, "flags": None },
4343 "tags": ["gamma"],
4344 },
4345 },
4346 ),
4347 ],
4348 cnx: [],
4349 )
4350 "#;
4351 let config = CuConfig::deserialize_ron(txt).unwrap();
4352 let graph = config.graphs.get_graph(None).unwrap();
4353 let node = graph.get_node(0).unwrap();
4354 let component = node.get_instance_config().expect("missing config");
4355 let scalar = component
4356 .get::<u32>("scalar")
4357 .expect("scalar lookup failed");
4358 let settings = component
4359 .get_value::<SettingsConfig>("settings")
4360 .expect("settings lookup failed");
4361 assert_eq!(scalar, Some(12));
4362 assert!(settings.is_some());
4363 }
4364
4365 #[test]
4366 fn test_component_config_get_value_error_includes_key() {
4367 let txt = r#"
4368 (
4369 tasks: [
4370 (
4371 id: "task",
4372 type: "pkg::Task",
4373 config: { "settings": { "gain": 1.0 } },
4374 ),
4375 ],
4376 cnx: [],
4377 )
4378 "#;
4379 let config = CuConfig::deserialize_ron(txt).unwrap();
4380 let graph = config.graphs.get_graph(None).unwrap();
4381 let node = graph.get_node(0).unwrap();
4382 let component = node.get_instance_config().expect("missing config");
4383 let err = component
4384 .get_value::<u32>("settings")
4385 .expect_err("expected type mismatch");
4386 assert!(err.to_string().contains("settings"));
4387 }
4388
4389 #[test]
4390 fn test_deserialization_error() {
4391 let txt = r#"( tasks: (), cnx: [], monitors: [(type: "ExampleMonitor", )] ) "#;
4393 let err = CuConfig::deserialize_ron(txt).expect_err("expected deserialization error");
4394 assert!(
4395 err.to_string()
4396 .contains("Syntax Error in config: Expected opening `[` at position 1:9-1:10")
4397 );
4398 }
4399 #[test]
4400 fn test_missions() {
4401 let txt = r#"( missions: [ (id: "data_collection"), (id: "autonomous")])"#;
4402 let config = CuConfig::deserialize_ron(txt).unwrap();
4403 let graph = config.graphs.get_graph(Some("data_collection")).unwrap();
4404 assert!(graph.node_count() == 0);
4405 let graph = config.graphs.get_graph(Some("autonomous")).unwrap();
4406 assert!(graph.node_count() == 0);
4407 }
4408
4409 #[test]
4410 fn test_monitor_plural_syntax() {
4411 let txt = r#"( tasks: [], cnx: [], monitors: [(type: "ExampleMonitor", )] ) "#;
4412 let config = CuConfig::deserialize_ron(txt).unwrap();
4413 assert_eq!(config.get_monitor_config().unwrap().type_, "ExampleMonitor");
4414
4415 let txt = r#"( tasks: [], cnx: [], monitors: [(type: "ExampleMonitor", config: { "toto": 4, } )] ) "#;
4416 let config = CuConfig::deserialize_ron(txt).unwrap();
4417 assert_eq!(
4418 config
4419 .get_monitor_config()
4420 .unwrap()
4421 .config
4422 .as_ref()
4423 .unwrap()
4424 .0["toto"]
4425 .0,
4426 4u8.into()
4427 );
4428 }
4429
4430 #[test]
4431 fn test_monitor_singular_syntax() {
4432 let txt = r#"( tasks: [], cnx: [], monitor: (type: "ExampleMonitor", config: { "toto": 4, } ) ) "#;
4433 let config = CuConfig::deserialize_ron(txt).unwrap();
4434 assert_eq!(config.get_monitor_configs().len(), 1);
4435 assert_eq!(config.get_monitor_config().unwrap().type_, "ExampleMonitor");
4436 assert_eq!(
4437 config
4438 .get_monitor_config()
4439 .unwrap()
4440 .config
4441 .as_ref()
4442 .unwrap()
4443 .0["toto"]
4444 .0,
4445 4u8.into()
4446 );
4447 }
4448
4449 #[test]
4450 #[cfg(feature = "std")]
4451 fn test_render_topology_multi_input_ports() {
4452 let mut config = CuConfig::default();
4453 let graph = config.get_graph_mut(None).unwrap();
4454 let src1 = graph.add_node(Node::new("src1", "tasks::Source1")).unwrap();
4455 let src2 = graph.add_node(Node::new("src2", "tasks::Source2")).unwrap();
4456 let dst = graph.add_node(Node::new("dst", "tasks::Dst")).unwrap();
4457 graph.connect(src1, dst, "msg::A").unwrap();
4458 graph.connect(src2, dst, "msg::B").unwrap();
4459
4460 let topology = build_render_topology(graph, &[]);
4461 let dst_node = topology
4462 .nodes
4463 .iter()
4464 .find(|node| node.id == "dst")
4465 .expect("missing dst node");
4466 assert_eq!(dst_node.inputs.len(), 2);
4467
4468 let mut dst_ports: Vec<_> = topology
4469 .connections
4470 .iter()
4471 .filter(|cnx| cnx.dst == "dst")
4472 .map(|cnx| cnx.dst_port.as_deref().expect("missing dst port"))
4473 .collect();
4474 dst_ports.sort();
4475 assert_eq!(dst_ports, vec!["in.0", "in.1"]);
4476 }
4477
4478 #[test]
4479 fn test_logging_parameters() {
4480 let txt = r#"( tasks: [], cnx: [], logging: ( slab_size_mib: 1024, section_size_mib: 100, enable_task_logging: false ),) "#;
4482
4483 let config = CuConfig::deserialize_ron(txt).unwrap();
4484 assert!(config.logging.is_some());
4485 let logging_config = config.logging.unwrap();
4486 assert_eq!(logging_config.slab_size_mib.unwrap(), 1024);
4487 assert_eq!(logging_config.section_size_mib.unwrap(), 100);
4488 assert!(!logging_config.enable_task_logging);
4489
4490 let txt =
4492 r#"( tasks: [], cnx: [], logging: ( slab_size_mib: 1024, section_size_mib: 100, ),) "#;
4493 let config = CuConfig::deserialize_ron(txt).unwrap();
4494 assert!(config.logging.is_some());
4495 let logging_config = config.logging.unwrap();
4496 assert_eq!(logging_config.slab_size_mib.unwrap(), 1024);
4497 assert_eq!(logging_config.section_size_mib.unwrap(), 100);
4498 assert!(logging_config.enable_task_logging);
4499 }
4500
4501 #[test]
4502 fn test_node_logging_handle_content_round_trips() {
4503 let txt = r#"(
4505 tasks: [
4506 (id: "cam", type: "pkg::Cam", kind: source, logging: (handle_content: touched_only)),
4507 (id: "noop", type: "pkg::Noop", kind: sink),
4508 ],
4509 cnx: [
4510 (src: "cam", dst: "noop", msg: "pkg::Frame"),
4511 ],
4512 )"#;
4513
4514 let config = CuConfig::deserialize_ron(txt).unwrap();
4515 let cam = config.find_task_node(None, "cam").unwrap();
4516 assert_eq!(cam.handle_content_policy(), HandleContent::TouchedOnly);
4517
4518 let noop = config.find_task_node(None, "noop").unwrap();
4520 assert_eq!(noop.handle_content_policy(), HandleContent::All);
4521
4522 let reserialized = config.serialize_ron().unwrap();
4524 let reparsed = CuConfig::deserialize_ron(&reserialized).unwrap();
4525 let cam2 = reparsed.find_task_node(None, "cam").unwrap();
4526 assert_eq!(cam2.handle_content_policy(), HandleContent::TouchedOnly);
4527 }
4528
4529 #[test]
4530 fn test_node_logging_handle_content_all_variants_parse() {
4531 for (value, expected) in [
4532 ("all", HandleContent::All),
4533 ("touched_only", HandleContent::TouchedOnly),
4534 ("none", HandleContent::None),
4535 ] {
4536 let txt = format!(
4537 r#"(
4538 tasks: [(id: "s", type: "pkg::T", kind: source, logging: (handle_content: {value}))],
4539 cnx: [(src: "s", dst: "__nc__", msg: "pkg::M")],
4540 )"#
4541 );
4542 let config = CuConfig::deserialize_ron(&txt).unwrap();
4543 assert_eq!(
4544 config
4545 .find_task_node(None, "s")
4546 .unwrap()
4547 .handle_content_policy(),
4548 expected,
4549 "policy mismatch for `{value}`"
4550 );
4551 }
4552 }
4553
4554 #[test]
4555 fn test_bridge_parsing() {
4556 let txt = r#"
4557 (
4558 tasks: [
4559 (id: "dst", type: "tasks::Destination"),
4560 (id: "src", type: "tasks::Source"),
4561 ],
4562 bridges: [
4563 (
4564 id: "radio",
4565 type: "tasks::SerialBridge",
4566 config: { "path": "/dev/ttyACM0", "baud": 921600 },
4567 channels: [
4568 Rx ( id: "status", route: "sys/status" ),
4569 Tx ( id: "motor", route: "motor/cmd" ),
4570 ],
4571 ),
4572 ],
4573 cnx: [
4574 (src: "radio/status", dst: "dst", msg: "mymsgs::Status"),
4575 (src: "src", dst: "radio/motor", msg: "mymsgs::MotorCmd"),
4576 ],
4577 )
4578 "#;
4579
4580 let config = CuConfig::deserialize_ron(txt).unwrap();
4581 assert_eq!(config.bridges.len(), 1);
4582 let bridge = &config.bridges[0];
4583 assert_eq!(bridge.id, "radio");
4584 assert_eq!(bridge.channels.len(), 2);
4585 match &bridge.channels[0] {
4586 BridgeChannelConfigRepresentation::Rx { id, route, .. } => {
4587 assert_eq!(id, "status");
4588 assert_eq!(route.as_deref(), Some("sys/status"));
4589 }
4590 _ => panic!("expected Rx channel"),
4591 }
4592 match &bridge.channels[1] {
4593 BridgeChannelConfigRepresentation::Tx { id, route, .. } => {
4594 assert_eq!(id, "motor");
4595 assert_eq!(route.as_deref(), Some("motor/cmd"));
4596 }
4597 _ => panic!("expected Tx channel"),
4598 }
4599 let graph = config.graphs.get_graph(None).unwrap();
4600 let bridge_id = graph
4601 .get_node_id_by_name("radio")
4602 .expect("bridge node missing");
4603 let bridge_node = graph.get_node(bridge_id).unwrap();
4604 assert_eq!(bridge_node.get_flavor(), Flavor::Bridge);
4605
4606 let mut edges = Vec::new();
4608 for edge_idx in graph.0.edge_indices() {
4609 edges.push(graph.0[edge_idx].clone());
4610 }
4611 assert_eq!(edges.len(), 2);
4612 let status_edge = edges
4613 .iter()
4614 .find(|e| e.dst == "dst")
4615 .expect("status edge missing");
4616 assert_eq!(status_edge.src_channel.as_deref(), Some("status"));
4617 assert!(status_edge.dst_channel.is_none());
4618 let motor_edge = edges
4619 .iter()
4620 .find(|e| e.dst_channel.is_some())
4621 .expect("motor edge missing");
4622 assert_eq!(motor_edge.dst_channel.as_deref(), Some("motor"));
4623 }
4624
4625 #[test]
4626 fn test_bridge_roundtrip() {
4627 let mut config = CuConfig::default();
4628 let mut bridge_config = ComponentConfig::default();
4629 bridge_config.set("port", "/dev/ttyACM0".to_string());
4630 config.bridges.push(BridgeConfig {
4631 id: "radio".to_string(),
4632 type_: "tasks::SerialBridge".to_string(),
4633 config: Some(bridge_config),
4634 resources: None,
4635 missions: None,
4636 run_in_sim: None,
4637 channels: vec![
4638 BridgeChannelConfigRepresentation::Rx {
4639 id: "status".to_string(),
4640 route: Some("sys/status".to_string()),
4641 config: None,
4642 },
4643 BridgeChannelConfigRepresentation::Tx {
4644 id: "motor".to_string(),
4645 route: Some("motor/cmd".to_string()),
4646 config: None,
4647 },
4648 ],
4649 });
4650
4651 let serialized = config.serialize_ron().unwrap();
4652 assert!(
4653 serialized.contains("bridges"),
4654 "bridges section missing from serialized config"
4655 );
4656 let deserialized = CuConfig::deserialize_ron(&serialized).unwrap();
4657 assert_eq!(deserialized.bridges.len(), 1);
4658 let bridge = &deserialized.bridges[0];
4659 assert!(bridge.is_run_in_sim());
4660 assert_eq!(bridge.channels.len(), 2);
4661 assert!(matches!(
4662 bridge.channels[0],
4663 BridgeChannelConfigRepresentation::Rx { .. }
4664 ));
4665 assert!(matches!(
4666 bridge.channels[1],
4667 BridgeChannelConfigRepresentation::Tx { .. }
4668 ));
4669 }
4670
4671 #[test]
4672 fn test_resource_parsing() {
4673 let txt = r#"
4674 (
4675 resources: [
4676 (
4677 id: "fc",
4678 provider: "copper_board_px4::Px4Bundle",
4679 config: { "baud": 921600 },
4680 missions: ["m1"],
4681 ),
4682 (
4683 id: "misc",
4684 provider: "cu29_runtime::StdClockBundle",
4685 ),
4686 ],
4687 )
4688 "#;
4689
4690 let config = CuConfig::deserialize_ron(txt).unwrap();
4691 assert_eq!(config.resources.len(), 2);
4692 let fc = &config.resources[0];
4693 assert_eq!(fc.id, "fc");
4694 assert_eq!(fc.provider, "copper_board_px4::Px4Bundle");
4695 assert_eq!(fc.missions.as_deref(), Some(&["m1".to_string()][..]));
4696 let baud: u32 = fc
4697 .config
4698 .as_ref()
4699 .expect("missing config")
4700 .get::<u32>("baud")
4701 .expect("baud lookup failed")
4702 .expect("missing baud");
4703 assert_eq!(baud, 921_600);
4704 let misc = &config.resources[1];
4705 assert_eq!(misc.id, "misc");
4706 assert_eq!(misc.provider, "cu29_runtime::StdClockBundle");
4707 assert!(misc.config.is_none());
4708 }
4709
4710 #[test]
4711 fn test_resource_roundtrip() {
4712 let mut config = CuConfig::default();
4713 let mut bundle_cfg = ComponentConfig::default();
4714 bundle_cfg.set("path", "/dev/ttyACM0".to_string());
4715 config.resources.push(ResourceBundleConfig {
4716 id: "fc".to_string(),
4717 provider: "copper_board_px4::Px4Bundle".to_string(),
4718 config: Some(bundle_cfg),
4719 missions: Some(vec!["m1".to_string()]),
4720 });
4721
4722 let serialized = config.serialize_ron().unwrap();
4723 let deserialized = CuConfig::deserialize_ron(&serialized).unwrap();
4724 assert_eq!(deserialized.resources.len(), 1);
4725 let res = &deserialized.resources[0];
4726 assert_eq!(res.id, "fc");
4727 assert_eq!(res.provider, "copper_board_px4::Px4Bundle");
4728 assert_eq!(res.missions.as_deref(), Some(&["m1".to_string()][..]));
4729 let path: String = res
4730 .config
4731 .as_ref()
4732 .expect("missing config")
4733 .get::<String>("path")
4734 .expect("path lookup failed")
4735 .expect("missing path");
4736 assert_eq!(path, "/dev/ttyACM0");
4737 }
4738
4739 #[test]
4740 fn test_bridge_channel_config() {
4741 let txt = r#"
4742 (
4743 tasks: [],
4744 bridges: [
4745 (
4746 id: "radio",
4747 type: "tasks::SerialBridge",
4748 channels: [
4749 Rx ( id: "status", route: "sys/status", config: { "filter": "fast" } ),
4750 Tx ( id: "imu", route: "telemetry/imu", config: { "rate": 100 } ),
4751 ],
4752 ),
4753 ],
4754 cnx: [],
4755 )
4756 "#;
4757
4758 let config = CuConfig::deserialize_ron(txt).unwrap();
4759 let bridge = &config.bridges[0];
4760 match &bridge.channels[0] {
4761 BridgeChannelConfigRepresentation::Rx {
4762 config: Some(cfg), ..
4763 } => {
4764 let val = cfg
4765 .get::<String>("filter")
4766 .expect("filter lookup failed")
4767 .expect("filter missing");
4768 assert_eq!(val, "fast");
4769 }
4770 _ => panic!("expected Rx channel with config"),
4771 }
4772 match &bridge.channels[1] {
4773 BridgeChannelConfigRepresentation::Tx {
4774 config: Some(cfg), ..
4775 } => {
4776 let rate = cfg
4777 .get::<i32>("rate")
4778 .expect("rate lookup failed")
4779 .expect("rate missing");
4780 assert_eq!(rate, 100);
4781 }
4782 _ => panic!("expected Tx channel with config"),
4783 }
4784 }
4785
4786 #[test]
4787 fn test_task_resources_roundtrip() {
4788 let txt = r#"
4789 (
4790 tasks: [
4791 (
4792 id: "imu",
4793 type: "tasks::ImuDriver",
4794 resources: { "bus": "fc.spi_1", "irq": "fc.gpio_imu" },
4795 ),
4796 ],
4797 cnx: [],
4798 )
4799 "#;
4800
4801 let config = CuConfig::deserialize_ron(txt).unwrap();
4802 let graph = config.graphs.get_graph(None).unwrap();
4803 let node = graph.get_node(0).expect("missing task node");
4804 let resources = node.get_resources().expect("missing resources map");
4805 assert_eq!(resources.get("bus").map(String::as_str), Some("fc.spi_1"));
4806 assert_eq!(
4807 resources.get("irq").map(String::as_str),
4808 Some("fc.gpio_imu")
4809 );
4810
4811 let serialized = config.serialize_ron().unwrap();
4812 let deserialized = CuConfig::deserialize_ron(&serialized).unwrap();
4813 let graph = deserialized.graphs.get_graph(None).unwrap();
4814 let node = graph.get_node(0).expect("missing task node");
4815 let resources = node
4816 .get_resources()
4817 .expect("missing resources map after roundtrip");
4818 assert_eq!(resources.get("bus").map(String::as_str), Some("fc.spi_1"));
4819 assert_eq!(
4820 resources.get("irq").map(String::as_str),
4821 Some("fc.gpio_imu")
4822 );
4823 }
4824
4825 #[test]
4826 fn test_bridge_resources_preserved() {
4827 let mut config = CuConfig::default();
4828 config.resources.push(ResourceBundleConfig {
4829 id: "fc".to_string(),
4830 provider: "board::Bundle".to_string(),
4831 config: None,
4832 missions: None,
4833 });
4834 let bridge_resources = HashMap::from([("serial".to_string(), "fc.serial0".to_string())]);
4835 config.bridges.push(BridgeConfig {
4836 id: "radio".to_string(),
4837 type_: "tasks::SerialBridge".to_string(),
4838 config: None,
4839 resources: Some(bridge_resources),
4840 missions: None,
4841 run_in_sim: None,
4842 channels: vec![BridgeChannelConfigRepresentation::Tx {
4843 id: "uplink".to_string(),
4844 route: None,
4845 config: None,
4846 }],
4847 });
4848
4849 let serialized = config.serialize_ron().unwrap();
4850 let deserialized = CuConfig::deserialize_ron(&serialized).unwrap();
4851 let graph = deserialized.graphs.get_graph(None).expect("missing graph");
4852 let bridge_id = graph
4853 .get_node_id_by_name("radio")
4854 .expect("bridge node missing");
4855 let node = graph.get_node(bridge_id).expect("missing bridge node");
4856 let resources = node
4857 .get_resources()
4858 .expect("bridge resources were not preserved");
4859 assert_eq!(
4860 resources.get("serial").map(String::as_str),
4861 Some("fc.serial0")
4862 );
4863 }
4864
4865 #[test]
4866 fn test_demo_config_parses() {
4867 let txt = r#"(
4868 resources: [
4869 (
4870 id: "fc",
4871 provider: "crate::resources::RadioBundle",
4872 ),
4873 ],
4874 tasks: [
4875 (id: "thr", type: "tasks::ThrottleControl"),
4876 (id: "tele0", type: "tasks::TelemetrySink0"),
4877 (id: "tele1", type: "tasks::TelemetrySink1"),
4878 (id: "tele2", type: "tasks::TelemetrySink2"),
4879 (id: "tele3", type: "tasks::TelemetrySink3"),
4880 ],
4881 bridges: [
4882 ( id: "crsf",
4883 type: "cu_crsf::CrsfBridge<SerialResource, SerialPortError>",
4884 resources: { "serial": "fc.serial" },
4885 channels: [
4886 Rx ( id: "rc_rx" ), // receiving RC Channels
4887 Tx ( id: "lq_tx" ), // Sending LineQuality back
4888 ],
4889 ),
4890 (
4891 id: "bdshot",
4892 type: "cu_bdshot::RpBdshotBridge",
4893 channels: [
4894 Tx ( id: "esc0_tx" ),
4895 Tx ( id: "esc1_tx" ),
4896 Tx ( id: "esc2_tx" ),
4897 Tx ( id: "esc3_tx" ),
4898 Rx ( id: "esc0_rx" ),
4899 Rx ( id: "esc1_rx" ),
4900 Rx ( id: "esc2_rx" ),
4901 Rx ( id: "esc3_rx" ),
4902 ],
4903 ),
4904 ],
4905 cnx: [
4906 (src: "crsf/rc_rx", dst: "thr", msg: "cu_crsf::messages::RcChannelsPayload"),
4907 (src: "thr", dst: "bdshot/esc0_tx", msg: "cu_bdshot::EscCommand"),
4908 (src: "thr", dst: "bdshot/esc1_tx", msg: "cu_bdshot::EscCommand"),
4909 (src: "thr", dst: "bdshot/esc2_tx", msg: "cu_bdshot::EscCommand"),
4910 (src: "thr", dst: "bdshot/esc3_tx", msg: "cu_bdshot::EscCommand"),
4911 (src: "bdshot/esc0_rx", dst: "tele0", msg: "cu_bdshot::EscTelemetry"),
4912 (src: "bdshot/esc1_rx", dst: "tele1", msg: "cu_bdshot::EscTelemetry"),
4913 (src: "bdshot/esc2_rx", dst: "tele2", msg: "cu_bdshot::EscTelemetry"),
4914 (src: "bdshot/esc3_rx", dst: "tele3", msg: "cu_bdshot::EscTelemetry"),
4915 ],
4916)"#;
4917 let config = CuConfig::deserialize_ron(txt).unwrap();
4918 assert_eq!(config.resources.len(), 1);
4919 assert_eq!(config.bridges.len(), 2);
4920 }
4921
4922 #[test]
4923 fn test_bridge_tx_cannot_be_source() {
4924 let txt = r#"
4925 (
4926 tasks: [
4927 (id: "dst", type: "tasks::Destination"),
4928 ],
4929 bridges: [
4930 (
4931 id: "radio",
4932 type: "tasks::SerialBridge",
4933 channels: [
4934 Tx ( id: "motor", route: "motor/cmd" ),
4935 ],
4936 ),
4937 ],
4938 cnx: [
4939 (src: "radio/motor", dst: "dst", msg: "mymsgs::MotorCmd"),
4940 ],
4941 )
4942 "#;
4943
4944 let err = CuConfig::deserialize_ron(txt).expect_err("expected bridge source error");
4945 assert!(
4946 err.to_string()
4947 .contains("channel 'motor' is Tx and cannot act as a source")
4948 );
4949 }
4950
4951 #[test]
4952 fn test_bridge_rx_cannot_be_destination() {
4953 let txt = r#"
4954 (
4955 tasks: [
4956 (id: "src", type: "tasks::Source"),
4957 ],
4958 bridges: [
4959 (
4960 id: "radio",
4961 type: "tasks::SerialBridge",
4962 channels: [
4963 Rx ( id: "status", route: "sys/status" ),
4964 ],
4965 ),
4966 ],
4967 cnx: [
4968 (src: "src", dst: "radio/status", msg: "mymsgs::Status"),
4969 ],
4970 )
4971 "#;
4972
4973 let err = CuConfig::deserialize_ron(txt).expect_err("expected bridge destination error");
4974 assert!(
4975 err.to_string()
4976 .contains("channel 'status' is Rx and cannot act as a destination")
4977 );
4978 }
4979
4980 #[test]
4981 fn test_validate_logging_config() {
4982 let txt =
4984 r#"( tasks: [], cnx: [], logging: ( slab_size_mib: 1024, section_size_mib: 100 ) )"#;
4985 let config = CuConfig::deserialize_ron(txt).unwrap();
4986 assert!(config.validate_logging_config().is_ok());
4987
4988 let txt =
4990 r#"( tasks: [], cnx: [], logging: ( slab_size_mib: 100, section_size_mib: 1024 ) )"#;
4991 let config = CuConfig::deserialize_ron(txt).unwrap();
4992 assert!(config.validate_logging_config().is_err());
4993 }
4994
4995 #[test]
4997 fn test_deserialization_edge_id_assignment() {
4998 let txt = r#"(
5001 tasks: [(id: "src1", type: "a"), (id: "src2", type: "b"), (id: "sink", type: "c")],
5002 cnx: [(src: "src2", dst: "sink", msg: "msg1"), (src: "src1", dst: "sink", msg: "msg2")]
5003 )"#;
5004 let config = CuConfig::deserialize_ron(txt).unwrap();
5005 let graph = config.graphs.get_graph(None).unwrap();
5006 assert!(config.validate_logging_config().is_ok());
5007
5008 let src1_id = 0;
5010 assert_eq!(graph.get_node(src1_id).unwrap().id, "src1");
5011 let src2_id = 1;
5012 assert_eq!(graph.get_node(src2_id).unwrap().id, "src2");
5013
5014 let src1_edge_id = *graph.get_src_edges(src1_id).unwrap().first().unwrap();
5017 assert_eq!(src1_edge_id, 1);
5018 let src2_edge_id = *graph.get_src_edges(src2_id).unwrap().first().unwrap();
5019 assert_eq!(src2_edge_id, 0);
5020 }
5021
5022 #[test]
5023 fn test_simple_missions() {
5024 let txt = r#"(
5026 missions: [ (id: "m1"),
5027 (id: "m2"),
5028 ],
5029 tasks: [(id: "src1", type: "a", missions: ["m1"]),
5030 (id: "src2", type: "b", missions: ["m2"]),
5031 (id: "sink", type: "c")],
5032
5033 cnx: [
5034 (src: "src1", dst: "sink", msg: "u32", missions: ["m1"]),
5035 (src: "src2", dst: "sink", msg: "u32", missions: ["m2"]),
5036 ],
5037 )
5038 "#;
5039
5040 let config = CuConfig::deserialize_ron(txt).unwrap();
5041 let m1_graph = config.graphs.get_graph(Some("m1")).unwrap();
5042 assert_eq!(m1_graph.edge_count(), 1);
5043 assert_eq!(m1_graph.node_count(), 2);
5044 let index = 0;
5045 let cnx = m1_graph.get_edge_weight(index).unwrap();
5046
5047 assert_eq!(cnx.src, "src1");
5048 assert_eq!(cnx.dst, "sink");
5049 assert_eq!(cnx.msg, "u32");
5050 assert_eq!(cnx.missions, Some(vec!["m1".to_string()]));
5051
5052 let m2_graph = config.graphs.get_graph(Some("m2")).unwrap();
5053 assert_eq!(m2_graph.edge_count(), 1);
5054 assert_eq!(m2_graph.node_count(), 2);
5055 let index = 0;
5056 let cnx = m2_graph.get_edge_weight(index).unwrap();
5057 assert_eq!(cnx.src, "src2");
5058 assert_eq!(cnx.dst, "sink");
5059 assert_eq!(cnx.msg, "u32");
5060 assert_eq!(cnx.missions, Some(vec!["m2".to_string()]));
5061 }
5062 #[test]
5063 fn test_mission_serde() {
5064 let txt = r#"(
5066 missions: [ (id: "m1"),
5067 (id: "m2"),
5068 ],
5069 tasks: [(id: "src1", type: "a", missions: ["m1"]),
5070 (id: "src2", type: "b", missions: ["m2"]),
5071 (id: "sink", type: "c")],
5072
5073 cnx: [
5074 (src: "src1", dst: "sink", msg: "u32", missions: ["m1"]),
5075 (src: "src2", dst: "sink", msg: "u32", missions: ["m2"]),
5076 ],
5077 )
5078 "#;
5079
5080 let config = CuConfig::deserialize_ron(txt).unwrap();
5081 let serialized = config.serialize_ron().unwrap();
5082 let deserialized = CuConfig::deserialize_ron(&serialized).unwrap();
5083 let m1_graph = deserialized.graphs.get_graph(Some("m1")).unwrap();
5084 assert_eq!(m1_graph.edge_count(), 1);
5085 assert_eq!(m1_graph.node_count(), 2);
5086 let index = 0;
5087 let cnx = m1_graph.get_edge_weight(index).unwrap();
5088 assert_eq!(cnx.src, "src1");
5089 assert_eq!(cnx.dst, "sink");
5090 assert_eq!(cnx.msg, "u32");
5091 assert_eq!(cnx.missions, Some(vec!["m1".to_string()]));
5092 }
5093
5094 #[test]
5095 fn test_mission_scoped_nc_connection_survives_serialize_roundtrip() {
5096 let txt = r#"(
5097 missions: [(id: "m1"), (id: "m2")],
5098 tasks: [
5099 (id: "src_m1", type: "a", missions: ["m1"]),
5100 (id: "src_m2", type: "b", missions: ["m2"]),
5101 ],
5102 cnx: [
5103 (src: "src_m1", dst: "__nc__", msg: "msg::A", missions: ["m1"]),
5104 (src: "src_m2", dst: "__nc__", msg: "msg::B", missions: ["m2"]),
5105 ]
5106 )"#;
5107
5108 let config = CuConfig::deserialize_ron(txt).unwrap();
5109 let serialized = config.serialize_ron().unwrap();
5110 let deserialized = CuConfig::deserialize_ron(&serialized).unwrap();
5111
5112 let m1_graph = deserialized.graphs.get_graph(Some("m1")).unwrap();
5113 let src_m1_id = m1_graph.get_node_id_by_name("src_m1").unwrap();
5114 let src_m1 = m1_graph.get_node(src_m1_id).unwrap();
5115 assert_eq!(src_m1.nc_outputs(), &["msg::A".to_string()]);
5116
5117 let m2_graph = deserialized.graphs.get_graph(Some("m2")).unwrap();
5118 let src_m2_id = m2_graph.get_node_id_by_name("src_m2").unwrap();
5119 let src_m2 = m2_graph.get_node(src_m2_id).unwrap();
5120 assert_eq!(src_m2.nc_outputs(), &["msg::B".to_string()]);
5121 }
5122
5123 #[test]
5124 fn test_keyframe_interval() {
5125 let txt = r#"(
5128 tasks: [(id: "src1", type: "a"), (id: "src2", type: "b"), (id: "sink", type: "c")],
5129 cnx: [(src: "src2", dst: "sink", msg: "msg1"), (src: "src1", dst: "sink", msg: "msg2")],
5130 logging: ( keyframe_interval: 314 )
5131 )"#;
5132 let config = CuConfig::deserialize_ron(txt).unwrap();
5133 let logging_config = config.logging.unwrap();
5134 assert_eq!(logging_config.keyframe_interval.unwrap(), 314);
5135 }
5136
5137 #[test]
5138 fn test_default_keyframe_interval() {
5139 let txt = r#"(
5142 tasks: [(id: "src1", type: "a"), (id: "src2", type: "b"), (id: "sink", type: "c")],
5143 cnx: [(src: "src2", dst: "sink", msg: "msg1"), (src: "src1", dst: "sink", msg: "msg2")],
5144 logging: ( slab_size_mib: 200, section_size_mib: 1024, )
5145 )"#;
5146 let config = CuConfig::deserialize_ron(txt).unwrap();
5147 let logging_config = config.logging.unwrap();
5148 assert_eq!(logging_config.keyframe_interval.unwrap(), 100);
5149 }
5150
5151 #[test]
5152 fn test_task_kind_roundtrip_and_alias() {
5153 let txt = r#"(
5154 tasks: [
5155 (id: "src", type: "a", kind: source),
5156 (id: "regular", type: "b", kind: regular),
5157 (id: "sink", type: "c", kind: sink),
5158 ],
5159 cnx: [
5160 (src: "src", dst: "regular", msg: "msg::A"),
5161 (src: "regular", dst: "sink", msg: "msg::B"),
5162 ]
5163 )"#;
5164
5165 let config = CuConfig::deserialize_ron(txt).unwrap();
5166 let graph = config.get_graph(None).unwrap();
5167
5168 assert_eq!(
5169 graph
5170 .get_node(graph.get_node_id_by_name("src").unwrap())
5171 .unwrap()
5172 .get_declared_task_kind(),
5173 Some(TaskKind::Source)
5174 );
5175 assert_eq!(
5176 graph
5177 .get_node(graph.get_node_id_by_name("regular").unwrap())
5178 .unwrap()
5179 .get_declared_task_kind(),
5180 Some(TaskKind::Regular)
5181 );
5182 assert_eq!(
5183 graph
5184 .get_node(graph.get_node_id_by_name("sink").unwrap())
5185 .unwrap()
5186 .get_declared_task_kind(),
5187 Some(TaskKind::Sink)
5188 );
5189
5190 let serialized = config.serialize_ron().unwrap();
5191 assert!(serialized.contains("kind: source"));
5192 assert!(serialized.contains("kind: task"));
5193 assert!(serialized.contains("kind: sink"));
5194 }
5195
5196 #[test]
5197 fn test_resolve_task_kind_uses_nc_outputs_for_regular_tasks() {
5198 let txt = r#"(
5199 tasks: [
5200 (id: "src", type: "a"),
5201 (id: "regular", type: "b"),
5202 ],
5203 cnx: [
5204 (src: "src", dst: "regular", msg: "msg::A"),
5205 (src: "regular", dst: "__nc__", msg: "msg::B"),
5206 ]
5207 )"#;
5208
5209 let config = CuConfig::deserialize_ron(txt).unwrap();
5210 let graph = config.get_graph(None).unwrap();
5211 let regular_id = graph.get_node_id_by_name("regular").unwrap();
5212
5213 assert_eq!(
5214 resolve_task_kind_for_id(graph, regular_id).unwrap(),
5215 TaskKind::Regular
5216 );
5217 }
5218
5219 #[test]
5220 fn test_resolve_task_kind_rejects_isolated_task_without_kind() {
5221 let txt = r#"(
5222 tasks: [
5223 (id: "lonely", type: "a"),
5224 ],
5225 cnx: []
5226 )"#;
5227
5228 let config = CuConfig::deserialize_ron(txt).unwrap();
5229 let graph = config.get_graph(None).unwrap();
5230 let lonely_id = graph.get_node_id_by_name("lonely").unwrap();
5231
5232 let err = resolve_task_kind_for_id(graph, lonely_id).expect_err("expected task kind error");
5233 assert!(
5234 err.to_string()
5235 .contains("cannot infer whether it is a source, task, or sink"),
5236 "unexpected error: {err}"
5237 );
5238 }
5239
5240 #[test]
5241 fn test_resolve_explicit_source_kind_allows_missing_declared_outputs() {
5242 let txt = r#"(
5243 tasks: [
5244 (id: "src", type: "a", kind: source),
5245 ],
5246 cnx: []
5247 )"#;
5248
5249 let config = CuConfig::deserialize_ron(txt).unwrap();
5250 let graph = config.get_graph(None).unwrap();
5251 let src_id = graph.get_node_id_by_name("src").unwrap();
5252
5253 assert_eq!(
5254 resolve_task_kind_for_id(graph, src_id).unwrap(),
5255 TaskKind::Source
5256 );
5257 }
5258
5259 #[test]
5260 fn test_resolve_explicit_regular_kind_allows_missing_declared_outputs() {
5261 let txt = r#"(
5262 tasks: [
5263 (id: "src", type: "a"),
5264 (id: "regular", type: "b", kind: task),
5265 ],
5266 cnx: [
5267 (src: "src", dst: "regular", msg: "msg::A"),
5268 ]
5269 )"#;
5270
5271 let config = CuConfig::deserialize_ron(txt).unwrap();
5272 let graph = config.get_graph(None).unwrap();
5273 let regular_id = graph.get_node_id_by_name("regular").unwrap();
5274
5275 assert_eq!(
5276 resolve_task_kind_for_id(graph, regular_id).unwrap(),
5277 TaskKind::Regular
5278 );
5279 }
5280
5281 #[test]
5282 fn test_runtime_rate_target_rejects_zero() {
5283 let txt = r#"(
5284 tasks: [(id: "src", type: "a"), (id: "sink", type: "b")],
5285 cnx: [(src: "src", dst: "sink", msg: "msg::A")],
5286 runtime: (rate_target_hz: 0)
5287 )"#;
5288
5289 let err =
5290 read_configuration_str(txt.to_string(), None).expect_err("runtime config should fail");
5291 assert!(
5292 err.to_string()
5293 .contains("Runtime rate target cannot be zero"),
5294 "unexpected error: {err}"
5295 );
5296 }
5297
5298 #[test]
5299 fn test_runtime_rate_target_rejects_above_nanosecond_resolution() {
5300 let txt = format!(
5301 r#"(
5302 tasks: [(id: "src", type: "a"), (id: "sink", type: "b")],
5303 cnx: [(src: "src", dst: "sink", msg: "msg::A")],
5304 runtime: (rate_target_hz: {})
5305 )"#,
5306 MAX_RATE_TARGET_HZ + 1
5307 );
5308
5309 let err = read_configuration_str(txt, None).expect_err("runtime config should fail");
5310 assert!(
5311 err.to_string().contains("exceeds the supported maximum"),
5312 "unexpected error: {err}"
5313 );
5314 }
5315
5316 #[test]
5317 fn test_nc_connection_marks_source_output_without_creating_edge() {
5318 let txt = r#"(
5319 tasks: [(id: "src", type: "a"), (id: "sink", type: "b")],
5320 cnx: [
5321 (src: "src", dst: "sink", msg: "msg::A"),
5322 (src: "src", dst: "__nc__", msg: "msg::B"),
5323 ]
5324 )"#;
5325 let config = CuConfig::deserialize_ron(txt).unwrap();
5326 let graph = config.get_graph(None).unwrap();
5327 let src_id = graph.get_node_id_by_name("src").unwrap();
5328 let src_node = graph.get_node(src_id).unwrap();
5329
5330 assert_eq!(graph.edge_count(), 1);
5331 assert_eq!(src_node.nc_outputs(), &["msg::B".to_string()]);
5332 }
5333
5334 #[test]
5335 fn test_nc_connection_survives_serialize_roundtrip() {
5336 let txt = r#"(
5337 tasks: [(id: "src", type: "a"), (id: "sink", type: "b")],
5338 cnx: [
5339 (src: "src", dst: "sink", msg: "msg::A"),
5340 (src: "src", dst: "__nc__", msg: "msg::B"),
5341 ]
5342 )"#;
5343 let config = CuConfig::deserialize_ron(txt).unwrap();
5344 let serialized = config.serialize_ron().unwrap();
5345 let deserialized = CuConfig::deserialize_ron(&serialized).unwrap();
5346 let graph = deserialized.get_graph(None).unwrap();
5347 let src_id = graph.get_node_id_by_name("src").unwrap();
5348 let src_node = graph.get_node(src_id).unwrap();
5349
5350 assert_eq!(graph.edge_count(), 1);
5351 assert_eq!(src_node.nc_outputs(), &["msg::B".to_string()]);
5352 }
5353
5354 #[test]
5355 fn test_nc_connection_preserves_original_connection_order() {
5356 let txt = r#"(
5357 tasks: [(id: "src", type: "a"), (id: "sink", type: "b")],
5358 cnx: [
5359 (src: "src", dst: "__nc__", msg: "msg::A"),
5360 (src: "src", dst: "sink", msg: "msg::B"),
5361 ]
5362 )"#;
5363 let config = CuConfig::deserialize_ron(txt).unwrap();
5364 let graph = config.get_graph(None).unwrap();
5365 let src_id = graph.get_node_id_by_name("src").unwrap();
5366 let src_node = graph.get_node(src_id).unwrap();
5367 let edge_id = graph.get_src_edges(src_id).unwrap()[0];
5368 let edge = graph.edge(edge_id).unwrap();
5369
5370 assert_eq!(edge.msg, "msg::B");
5371 assert_eq!(edge.order, 1);
5372 assert_eq!(
5373 src_node
5374 .nc_outputs_with_order()
5375 .map(|(msg, order)| (msg.as_str(), order))
5376 .collect::<Vec<_>>(),
5377 vec![("msg::A", 0)]
5378 );
5379 }
5380
5381 #[cfg(feature = "std")]
5382 fn multi_config_test_dir(name: &str) -> PathBuf {
5383 let unique = std::time::SystemTime::now()
5384 .duration_since(std::time::UNIX_EPOCH)
5385 .expect("system time before unix epoch")
5386 .as_nanos();
5387 let dir = std::env::temp_dir().join(format!("cu29_multi_config_{name}_{unique}"));
5388 std::fs::create_dir_all(&dir).expect("create temp test dir");
5389 dir
5390 }
5391
5392 #[cfg(feature = "std")]
5393 fn write_multi_config_file(dir: &Path, name: &str, contents: &str) -> PathBuf {
5394 let path = dir.join(name);
5395 std::fs::write(&path, contents).expect("write temp config file");
5396 path
5397 }
5398
5399 #[cfg(feature = "std")]
5400 fn alpha_subsystem_config() -> &'static str {
5401 r#"(
5402 tasks: [
5403 (id: "src", type: "demo::Src"),
5404 (id: "sink", type: "demo::Sink"),
5405 ],
5406 bridges: [
5407 (
5408 id: "zenoh",
5409 type: "demo::ZenohBridge",
5410 channels: [
5411 Tx(id: "ping"),
5412 Rx(id: "pong"),
5413 ],
5414 ),
5415 ],
5416 cnx: [
5417 (src: "src", dst: "zenoh/ping", msg: "demo::Ping"),
5418 (src: "zenoh/pong", dst: "sink", msg: "demo::Pong"),
5419 ],
5420 )"#
5421 }
5422
5423 #[cfg(feature = "std")]
5424 fn beta_subsystem_config() -> &'static str {
5425 r#"(
5426 tasks: [
5427 (id: "responder", type: "demo::Responder"),
5428 ],
5429 bridges: [
5430 (
5431 id: "zenoh",
5432 type: "demo::ZenohBridge",
5433 channels: [
5434 Rx(id: "ping"),
5435 Tx(id: "pong"),
5436 ],
5437 ),
5438 ],
5439 cnx: [
5440 (src: "zenoh/ping", dst: "responder", msg: "demo::Ping"),
5441 (src: "responder", dst: "zenoh/pong", msg: "demo::Pong"),
5442 ],
5443 )"#
5444 }
5445
5446 #[cfg(feature = "std")]
5447 fn instance_override_subsystem_config() -> &'static str {
5448 r#"(
5449 tasks: [
5450 (
5451 id: "imu",
5452 type: "demo::ImuTask",
5453 config: {
5454 "sample_hz": 200,
5455 },
5456 ),
5457 ],
5458 resources: [
5459 (
5460 id: "board",
5461 provider: "demo::BoardBundle",
5462 config: {
5463 "bus": "i2c-1",
5464 },
5465 ),
5466 ],
5467 bridges: [
5468 (
5469 id: "radio",
5470 type: "demo::RadioBridge",
5471 config: {
5472 "mtu": 32,
5473 },
5474 channels: [
5475 Tx(id: "tx"),
5476 Rx(id: "rx"),
5477 ],
5478 ),
5479 ],
5480 cnx: [
5481 (src: "imu", dst: "radio/tx", msg: "demo::Packet"),
5482 (src: "radio/rx", dst: "imu", msg: "demo::Packet"),
5483 ],
5484 )"#
5485 }
5486
5487 #[cfg(feature = "std")]
5488 #[test]
5489 fn test_read_multi_configuration_assigns_stable_subsystem_codes() {
5490 let dir = multi_config_test_dir("stable_ids");
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: "beta", config: "beta.ron"),
5499 (id: "alpha", config: "alpha.ron"),
5500 ],
5501 interconnects: [
5502 (from: "alpha/zenoh/ping", to: "beta/zenoh/ping", msg: "demo::Ping"),
5503 (from: "beta/zenoh/pong", to: "alpha/zenoh/pong", msg: "demo::Pong"),
5504 ],
5505 )"#,
5506 );
5507
5508 let config =
5509 read_multi_configuration(network_path.to_str().expect("network path utf8")).unwrap();
5510
5511 let alpha = config.subsystem("alpha").expect("alpha subsystem missing");
5512 let beta = config.subsystem("beta").expect("beta subsystem missing");
5513 assert_eq!(alpha.subsystem_code, 0);
5514 assert_eq!(beta.subsystem_code, 1);
5515 assert_eq!(config.interconnects.len(), 2);
5516 assert_eq!(config.interconnects[0].bridge_type, "demo::ZenohBridge");
5517 }
5518
5519 #[cfg(feature = "std")]
5520 #[test]
5521 fn test_read_multi_configuration_rejects_wrong_direction() {
5522 let dir = multi_config_test_dir("wrong_direction");
5523 write_multi_config_file(&dir, "alpha.ron", alpha_subsystem_config());
5524 write_multi_config_file(&dir, "beta.ron", beta_subsystem_config());
5525 let network_path = write_multi_config_file(
5526 &dir,
5527 "network.ron",
5528 r#"(
5529 subsystems: [
5530 (id: "alpha", config: "alpha.ron"),
5531 (id: "beta", config: "beta.ron"),
5532 ],
5533 interconnects: [
5534 (from: "alpha/zenoh/pong", to: "beta/zenoh/ping", msg: "demo::Pong"),
5535 ],
5536 )"#,
5537 );
5538
5539 let err = read_multi_configuration(network_path.to_str().expect("network path utf8"))
5540 .expect_err("direction mismatch should fail");
5541
5542 assert!(
5543 err.to_string()
5544 .contains("must reference a Tx bridge channel"),
5545 "unexpected error: {err}"
5546 );
5547 }
5548
5549 #[cfg(feature = "std")]
5550 #[test]
5551 fn test_read_multi_configuration_rejects_declared_message_mismatch() {
5552 let dir = multi_config_test_dir("msg_mismatch");
5553 write_multi_config_file(&dir, "alpha.ron", alpha_subsystem_config());
5554 write_multi_config_file(&dir, "beta.ron", beta_subsystem_config());
5555 let network_path = write_multi_config_file(
5556 &dir,
5557 "network.ron",
5558 r#"(
5559 subsystems: [
5560 (id: "alpha", config: "alpha.ron"),
5561 (id: "beta", config: "beta.ron"),
5562 ],
5563 interconnects: [
5564 (from: "alpha/zenoh/ping", to: "beta/zenoh/ping", msg: "demo::Wrong"),
5565 ],
5566 )"#,
5567 );
5568
5569 let err = read_multi_configuration(network_path.to_str().expect("network path utf8"))
5570 .expect_err("message mismatch should fail");
5571
5572 assert!(
5573 err.to_string()
5574 .contains("declares message type 'demo::Wrong'"),
5575 "unexpected error: {err}"
5576 );
5577 }
5578
5579 #[cfg(feature = "std")]
5580 #[test]
5581 fn test_read_multi_configuration_resolves_instance_override_root() {
5582 let dir = multi_config_test_dir("instance_root");
5583 write_multi_config_file(&dir, "robot.ron", instance_override_subsystem_config());
5584 let network_path = write_multi_config_file(
5585 &dir,
5586 "multi_copper.ron",
5587 r#"(
5588 subsystems: [
5589 (id: "robot", config: "robot.ron"),
5590 ],
5591 interconnects: [],
5592 instance_overrides_root: "instances",
5593 )"#,
5594 );
5595
5596 let config =
5597 read_multi_configuration(network_path.to_str().expect("network path utf8")).unwrap();
5598
5599 assert_eq!(
5600 config.instance_overrides_root.as_deref().map(Path::new),
5601 Some(dir.join("instances").as_path())
5602 );
5603 }
5604
5605 #[cfg(feature = "std")]
5606 #[test]
5607 fn test_resolve_subsystem_config_for_instance_applies_overrides() {
5608 let dir = multi_config_test_dir("instance_apply");
5609 write_multi_config_file(&dir, "robot.ron", instance_override_subsystem_config());
5610 let instances_dir = dir.join("instances").join("17");
5611 std::fs::create_dir_all(&instances_dir).expect("create instance dir");
5612 write_multi_config_file(
5613 &instances_dir,
5614 "robot.ron",
5615 r#"(
5616 set: [
5617 (
5618 path: "tasks/imu/config",
5619 value: {
5620 "gyro_bias": [0.1, -0.2, 0.3],
5621 },
5622 ),
5623 (
5624 path: "resources/board/config",
5625 value: {
5626 "bus": "robot17-imu",
5627 },
5628 ),
5629 (
5630 path: "bridges/radio/config",
5631 value: {
5632 "mtu": 64,
5633 },
5634 ),
5635 ],
5636 )"#,
5637 );
5638 let network_path = write_multi_config_file(
5639 &dir,
5640 "multi_copper.ron",
5641 r#"(
5642 subsystems: [
5643 (id: "robot", config: "robot.ron"),
5644 ],
5645 interconnects: [],
5646 instance_overrides_root: "instances",
5647 )"#,
5648 );
5649
5650 let multi =
5651 read_multi_configuration(network_path.to_str().expect("network path utf8")).unwrap();
5652 let effective = multi
5653 .resolve_subsystem_config_for_instance("robot", 17)
5654 .expect("effective config");
5655
5656 let graph = effective.get_graph(None).expect("graph");
5657 let imu_id = graph.get_node_id_by_name("imu").expect("imu node");
5658 let imu = graph.get_node(imu_id).expect("imu weight");
5659 let imu_cfg = imu.get_instance_config().expect("imu config");
5660 assert_eq!(imu_cfg.get::<u64>("sample_hz").unwrap(), Some(200));
5661 let gyro_bias: Vec<f64> = imu_cfg
5662 .get_value("gyro_bias")
5663 .expect("gyro_bias deserialize")
5664 .expect("gyro_bias value");
5665 assert_eq!(gyro_bias, vec![0.1, -0.2, 0.3]);
5666
5667 let board = effective
5668 .resources
5669 .iter()
5670 .find(|resource| resource.id == "board")
5671 .expect("board resource");
5672 assert_eq!(
5673 board.config.as_ref().unwrap().get::<String>("bus").unwrap(),
5674 Some("robot17-imu".to_string())
5675 );
5676
5677 let radio = effective
5678 .bridges
5679 .iter()
5680 .find(|bridge| bridge.id == "radio")
5681 .expect("radio bridge");
5682 assert_eq!(
5683 radio.config.as_ref().unwrap().get::<u64>("mtu").unwrap(),
5684 Some(64)
5685 );
5686
5687 let radio_id = graph.get_node_id_by_name("radio").expect("radio node");
5688 let radio_node = graph.get_node(radio_id).expect("radio weight");
5689 assert_eq!(
5690 radio_node
5691 .get_instance_config()
5692 .unwrap()
5693 .get::<u64>("mtu")
5694 .unwrap(),
5695 Some(64)
5696 );
5697 }
5698
5699 #[cfg(feature = "std")]
5700 #[test]
5701 fn test_resolve_subsystem_config_for_instance_rejects_unknown_path() {
5702 let dir = multi_config_test_dir("instance_unknown");
5703 write_multi_config_file(&dir, "robot.ron", instance_override_subsystem_config());
5704 let instances_dir = dir.join("instances").join("17");
5705 std::fs::create_dir_all(&instances_dir).expect("create instance dir");
5706 write_multi_config_file(
5707 &instances_dir,
5708 "robot.ron",
5709 r#"(
5710 set: [
5711 (
5712 path: "tasks/missing/config",
5713 value: {
5714 "gyro_bias": [1.0, 2.0, 3.0],
5715 },
5716 ),
5717 ],
5718 )"#,
5719 );
5720 let network_path = write_multi_config_file(
5721 &dir,
5722 "multi_copper.ron",
5723 r#"(
5724 subsystems: [
5725 (id: "robot", config: "robot.ron"),
5726 ],
5727 interconnects: [],
5728 instance_overrides_root: "instances",
5729 )"#,
5730 );
5731
5732 let multi =
5733 read_multi_configuration(network_path.to_str().expect("network path utf8")).unwrap();
5734 let err = multi
5735 .resolve_subsystem_config_for_instance("robot", 17)
5736 .expect_err("unknown task override should fail");
5737
5738 assert!(
5739 err.to_string().contains("targets unknown task 'missing'"),
5740 "unexpected error: {err}"
5741 );
5742 }
5743
5744 #[test]
5745 fn test_thread_pools_parse_and_round_trip() {
5746 let txt = r#"(
5747 runtime: (
5748 rate_target_hz: 1000,
5749 thread_pools: [
5750 ( id: "rt", threads: 4, affinity: [2, 3, 4, 5], policy: Fifo(priority: 80) ),
5751 ( id: "background", threads: 2, affinity: [0, 1] ),
5752 ( id: "vision", threads: 2, policy: Nice(10), on_error: Strict ),
5753 ],
5754 ),
5755 tasks: [ ( id: "t", type: "tasks::Foo" ) ],
5756 )"#;
5757 let config = CuConfig::deserialize_ron(txt).unwrap();
5758 let runtime = config.runtime.as_ref().expect("runtime config");
5759 assert_eq!(runtime.thread_pools.len(), 3);
5760
5761 let rt = &runtime.thread_pools[0];
5762 assert_eq!(rt.id, "rt");
5763 assert_eq!(rt.threads, 4);
5764 assert_eq!(rt.affinity.as_deref(), Some([2, 3, 4, 5].as_slice()));
5765 assert_eq!(rt.policy, SchedulingPolicy::Fifo { priority: 80 });
5766 assert_eq!(rt.on_error, OnError::Warn);
5767
5768 let bg = &runtime.thread_pools[1];
5769 assert_eq!(bg.id, "background");
5770 assert_eq!(bg.policy, SchedulingPolicy::Fair);
5771
5772 let vision = &runtime.thread_pools[2];
5773 assert_eq!(vision.policy, SchedulingPolicy::Nice(10));
5774 assert_eq!(vision.affinity, None);
5775 assert_eq!(vision.on_error, OnError::Strict);
5776
5777 let serialized = config.serialize_ron().unwrap();
5779 let reparsed = CuConfig::deserialize_ron(&serialized).unwrap();
5780 assert_eq!(
5781 reparsed.runtime.as_ref().unwrap().thread_pools,
5782 runtime.thread_pools
5783 );
5784 }
5785
5786 #[test]
5787 fn test_background_flag_and_pool_forms() {
5788 let txt = r#"(
5789 tasks: [
5790 ( id: "a", type: "tasks::Foo", background: true ),
5791 ( id: "b", type: "tasks::Foo", background: (pool: "vision") ),
5792 ( id: "c", type: "tasks::Foo" ),
5793 ],
5794 cnx: [],
5795 )"#;
5796 let config = CuConfig::deserialize_ron(txt).unwrap();
5797 let graph = config.get_graph(None).unwrap();
5798
5799 let a = graph.get_node(0).unwrap();
5800 assert!(a.is_background());
5801 assert_eq!(a.background_pool(), DEFAULT_BACKGROUND_POOL);
5802
5803 let b = graph.get_node(1).unwrap();
5804 assert!(b.is_background());
5805 assert_eq!(b.background_pool(), "vision");
5806
5807 let c = graph.get_node(2).unwrap();
5808 assert!(!c.is_background());
5809 assert_eq!(c.background_pool(), DEFAULT_BACKGROUND_POOL);
5810 }
5811
5812 #[test]
5813 fn test_thread_pool_validation_rejects_bad_configs() {
5814 let cases = [
5815 (
5816 r#"( runtime: ( thread_pools: [ ( id: "rt", threads: 0 ) ] ), tasks: [] )"#,
5817 "at least 1 thread",
5818 ),
5819 (
5820 r#"( runtime: ( thread_pools: [ ( id: "a", threads: 1 ), ( id: "a", threads: 1 ) ] ), tasks: [] )"#,
5821 "Duplicate thread pool id",
5822 ),
5823 (
5824 r#"( runtime: ( thread_pools: [ ( id: "rt", threads: 1, policy: Fifo(priority: 200) ) ] ), tasks: [] )"#,
5825 "out of range",
5826 ),
5827 (
5828 r#"( runtime: ( thread_pools: [ ( id: "rt", threads: 1, affinity: [] ) ] ), tasks: [] )"#,
5829 "empty affinity",
5830 ),
5831 ];
5832
5833 for (txt, expected) in cases {
5834 let err = CuConfig::deserialize_ron(txt)
5835 .expect_err("expected thread pool validation to fail");
5836 assert!(
5837 err.to_string().contains(expected),
5838 "error '{err}' did not contain '{expected}'"
5839 );
5840 }
5841 }
5842
5843 #[cfg(feature = "std")]
5844 #[test]
5845 fn test_default_background_pool_injected_for_background_tasks() {
5846 let txt = r#"(
5847 tasks: [
5848 ( id: "src", type: "tasks::Src" ),
5849 ( id: "bg", type: "tasks::Task", background: true ),
5850 ],
5851 cnx: [
5852 ( src: "src", dst: "bg", msg: "i32" ),
5853 ( src: "bg", dst: "__nc__", msg: "i32" ),
5854 ],
5855 )"#;
5856 let config = read_configuration_str(txt.to_string(), None).unwrap();
5857 let pools = &config.runtime.as_ref().unwrap().thread_pools;
5858 let background: Vec<_> = pools
5859 .iter()
5860 .filter(|p| p.id == DEFAULT_BACKGROUND_POOL)
5861 .collect();
5862 assert_eq!(background.len(), 1);
5863 assert_eq!(background[0].threads, 2);
5864 assert!(!config.resources.iter().any(|b| b.id == "threadpool"));
5867 }
5868}