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