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